From a4b347b4548336298b08c32666aa0f3cc817cf84 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 15 Jan 2026 23:06:58 +0000 Subject: [PATCH] feat: refine subagents + add chat.inject Co-authored-by: Tyler Yust --- CHANGELOG.md | 144 +++---------- ...ounces-agent-wait-lifecycle-events.test.ts | 58 ++--- ...unces-back-requester-group-channel.test.ts | 68 +++--- ...resolves-main-announce-target-from.test.ts | 66 +++--- src/agents/pi-embedded-runner/compact.ts | 3 + src/agents/pi-embedded-runner/run/attempt.ts | 3 + .../pi-embedded-runner/system-prompt.ts | 5 +- src/agents/pi-tools.policy.ts | 12 ++ src/agents/subagent-announce.format.test.ts | 60 ++---- src/agents/subagent-announce.ts | 202 ++++-------------- .../subagent-registry.persistence.test.ts | 18 +- src/agents/subagent-registry.store.ts | 21 +- src/agents/subagent-registry.ts | 65 +++--- src/agents/system-prompt.ts | 181 ++++++++++------ src/agents/tools/sessions-announce-target.ts | 1 + src/gateway/protocol/index.ts | 5 + src/gateway/protocol/schema/logs-chat.ts | 9 + .../protocol/schema/protocol-schemas.ts | 2 + src/gateway/protocol/schema/types.ts | 2 + src/gateway/server-bridge-methods-chat.ts | 94 +++++++- src/gateway/server-methods/chat.ts | 92 +++++++- .../server.chat.gateway-server-chat.test.ts | 54 +++++ 22 files changed, 632 insertions(+), 533 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eda031b0..9ed563487 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Docs: add Date & Time guide and update prompt/timezone configuration docs. - Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc. - Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4. +- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204. - Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors. - Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`. - Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors. @@ -28,141 +29,62 @@ - Security: expanded `clawdbot security audit` (+ `--fix`), detect-secrets CI scan, and a `SECURITY.md` reporting policy. ### Changes -#### Web Tools -- Tools: add `web_search`/`web_fetch` (Brave API), including helpful setup hints when the key is missing. -- Tools: enable `web_fetch` by default (unless explicitly disabled in config). -- CLI/Docs: add `clawdbot configure --section web` for storing Brave API keys and update onboarding tips. - -#### Browser / Control UI -- Browser: add Chrome extension relay takeover mode (toolbar button) + `clawdbot browser serve` remote control + `browser.controlToken`. -- Browser: ship a built-in `chrome` profile for extension relay and start the relay automatically when running locally. -- Browser: default `browser.defaultProfile` to `chrome` (existing Chrome takeover mode). -- Browser: add `clawdbot browser extension install/path` and copy extension path to clipboard. -- Control UI: show raw any-map entries in config views; move Docs link into the left nav. - -#### Plugins -- Plugins: add plugin HTTP hooks + loader updates to support channel plugins. (#854) — thanks @longmaba. -- Plugins: add onboarding plugin install flow. (#854) — thanks @longmaba. -- Channels: add Matrix plugin (external) with docs + onboarding hooks. -- Voice Call: add Plivo provider (no SDK dependency). (#846) — thanks @vrknetha. - -#### Security -- Security: expand `clawdbot security audit` checks and publish a `SECURITY.md` reporting policy. -- Security: extend `clawdbot security audit --fix` to tighten more sensitive state paths. -- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia. - -#### Onboarding / Daemon -- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`. -- Daemon: support profile-aware service names for multi-gateway setups. (#671) — thanks @bjesuiter. - -#### Auth / Usage / Config -- Usage: add MiniMax coding plan usage tracking. -- Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR. -- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging. -- Auth: add dynamic template variables to `messages.responsePrefix`. (#928) — thanks @sebslight. -- Config: add `channels..configWrites` gating for channel-initiated config writes; migrate Slack channel IDs. - -#### Channels -- Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko. -- WhatsApp: add `channels.whatsapp.sendReadReceipts` to disable auto read receipts. (#882) — thanks @chrisrodz. - -#### Docs - Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics. - Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors. +- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging. +- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor`. +- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor` (includes browser control exposure checks). +- Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba. +- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`. - Docs: expand gateway security hardening guidance and incident response checklist. - Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf. -- Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915) -- Docs: add per-command CLI doc pages and link them from `clawdbot --help`. -- Docs: add multi-gateway guide (sidebar + nav). +- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia. +- Tools: add `web_search`/`web_fetch` (Brave API), auto-enable `web_fetch` for sandboxed sessions, and remove the `brave-search` skill. +- CLI/Docs: add a web tools configure section for storing Brave API keys and update onboarding tips. +- Browser: add Chrome extension relay takeover mode (toolbar button), plus `clawdbot browser extension install/path` and remote browser control via `clawdbot browser serve` + `browser.controlToken`. ### Fixes - -#### Gateway / Daemon / Sessions -- Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4. -- Gateway/UI: ship session defaults in the hello snapshot so the Control UI canonicalizes main session keys (no bare `main` alias). -- Agents: skip thinking/final tag stripping inside Markdown code spans. (#939) — thanks @ngutman. +- Sessions: refactor session store updates to lock + mutate per-entry, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204. - Browser: add tests for snapshot labels/efficient query params and labeled image responses. -- Browser: persist role snapshot refs per CDP target so `snapshot` → `act` clicks work even if Playwright returns a different Page instance. -- macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4. -- macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75. -- Packaging: run `pnpm build` on `prepack` so npm publishes include fresh `dist/` output. -- Telegram: register dock native commands with underscores to avoid `BOT_COMMAND_INVALID` (#929, fixes #901) — thanks @grp06. - Google: downgrade unsigned thinking blocks before send to avoid missing signature errors. -- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams. -- Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994. -- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli. - Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06. - Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06. - Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4. -- Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare `main` sessions. - Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06. - Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight. - Daemon: clear persisted launchd disabled state before bootstrap (fixes `daemon install` after uninstall). (#849) — thanks @ndraiman. -- Sessions: return deep clones (`structuredClone`) so cached session entries can't be mutated. (#934) — thanks @ronak-guliani. -- Heartbeat: keep `updatedAt` monotonic when restoring heartbeat sessions. (#934) — thanks @ronak-guliani. -- Agent: clear run context after CLI runs (`clearAgentRunContext`) to avoid runaway contexts. (#934) — thanks @ronak-guliani. -- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`). +- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06. +- Sandbox: restore `docker.binds` config validation for custom bind mounts. (#873) — thanks @akonyer. +- Sandbox: preserve configured PATH for `docker exec` so custom tools remain available. (#873) — thanks @akonyer. +- Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr. +- Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”). +- Auto-reply: treat trailing `NO_REPLY` tokens as silent replies. +- Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves). -#### CLI / Onboarding -- Onboarding: show web search setup at the end (not the beginning). -- Onboarding: show daemon install/restart progress (avoid “blinking cursor”) and fix daemon install output formatting. -- Health: colorize “not configured” provider lines for easier scanning. +## 2026.1.14 -#### Control UI / TUI -- Control UI: load cron run history on job selection and clarify empty-state messaging. (#866) -- UI: use application-defined WebSocket close code and fix dashboard auth query items. (#918) — thanks @rahthakor. -- UI: always apply `?token=` from URL (fixes unauthorized after re-onboard). -- Browser: add tests for snapshot labels/efficient query params and labeled image responses. +### Changes +- Usage: add MiniMax coding plan usage tracking. +- Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR. +- Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915) +- Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko. +- Config: add `channels..configWrites` gating for channel-initiated config writes; migrate Slack channel IDs. + +### Fixes + - Mac: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor. + - UI: use application-defined WebSocket close code (browser compatibility). (#918) — thanks @rahthakor. - TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank. - TUI: add a bright spinner + elapsed time in the status line for send/stream/run states. -- TUI: show LLM error messages (rate limits, auth, etc.) instead of `(no output)`. - -#### Agents / Auth / Tools / Sandbox -- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams. -- Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994. -- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli. -- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06. -- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4. -- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight. -- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06. -- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06. -- Sandbox: restore `docker.binds` config validation and preserve configured PATH for `docker exec`. (#873) — thanks @akonyer. -- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors. -- Agents: preserve Antigravity Claude signatures and skip Gemini downgrades. (#959) — thanks @rdev. - -#### macOS / Apps -- macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4. -- macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75. -- macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor. -- macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917) -- Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare `main` sessions. +- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`). - macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis. -- macOS: update cron testing channel arg. (#896) — thanks @ngutman. - -#### Channels / Messaging -- Slack: isolate thread history and avoid inheriting channel transcripts for new threads by default. (#758) -- Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr. -- Slack: drop Socket Mode events with mismatched `api_app_id`/`team_id`. (#889) — thanks @roshanasingh4. -- Commands: add native command argument menus across Discord/Slack/Telegram. (#936) — thanks @thewilloftheshadow. -- Discord: isolate autoThread thread context. (#856) — thanks @davidguttman. - Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver. -- Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”). -- Telegram: let control commands bypass per-chat sequentialization; always allow abort triggers. -- Telegram: split long captions into media + follow-up text messages. (#907) — thanks @jalehman. +- Telegram: split long captions into media + follow-up text messages. (#907) - thanks @jalehman. - Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko. -- Telegram: register dock native commands with underscores to avoid `BOT_COMMAND_INVALID` (#929, fixes #901) — thanks @grp06. - Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose. -- iMessage: prefer handle routing for direct-message replies; include imsg RPC error details. (#935) +- Slack: drop Socket Mode events with mismatched `api_app_id`/`team_id`. (#889) — thanks @roshanasingh4. +- Discord: isolate autoThread thread context. (#856) — thanks @davidguttman. - WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester. - WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee. -- WhatsApp: harden owner command auth. -- Auto-reply: treat trailing `NO_REPLY` tokens as silent replies. - -#### Config / Doctor / Packaging -- Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves). -- Config/Doctor: remove legacy Clawdis env fallbacks and config/service migrations (Clawdbot-only). -- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06. -- Packaging: run `pnpm build` on `prepack` so npm publishes include fresh `dist/` output. ## 2026.1.13 diff --git a/src/agents/clawdbot-tools.subagents.sessions-spawn-announces-agent-wait-lifecycle-events.test.ts b/src/agents/clawdbot-tools.subagents.sessions-spawn-announces-agent-wait-lifecycle-events.test.ts index e8793fa6c..814e021d8 100644 --- a/src/agents/clawdbot-tools.subagents.sessions-spawn-announces-agent-wait-lifecycle-events.test.ts +++ b/src/agents/clawdbot-tools.subagents.sessions-spawn-announces-agent-wait-lifecycle-events.test.ts @@ -34,17 +34,15 @@ describe("clawdbot-tools: subagents", () => { }; }); - it("sessions_spawn announces via agent.wait when lifecycle events are missing", async () => { + it("sessions_spawn deletes session when cleanup=delete via agent.wait", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); const calls: Array<{ method?: string; params?: unknown }> = []; let agentCallCount = 0; - let sendParams: { to?: string; channel?: string; message?: string } = {}; let deletedKey: string | undefined; let childRunId: string | undefined; let childSessionKey: string | undefined; const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = []; - const sessionLastAssistantText = new Map(); callGatewayMock.mockImplementation(async (opts: unknown) => { const request = opts as { method?: string; params?: unknown }; @@ -57,15 +55,12 @@ describe("clawdbot-tools: subagents", () => { sessionKey?: string; channel?: string; timeout?: number; + lane?: string; }; - const message = params?.message ?? ""; - const sessionKey = params?.sessionKey ?? ""; - if (message === "Sub-agent announce step.") { - sessionLastAssistantText.set(sessionKey, "announce now"); - } else { + // Only capture the first agent call (subagent spawn, not main agent trigger) + if (params?.lane === "subagent") { childRunId = runId; - childSessionKey = sessionKey; - sessionLastAssistantText.set(sessionKey, "result"); + childSessionKey = params?.sessionKey ?? ""; expect(params?.channel).toBe("discord"); expect(params?.timeout).toBe(1); } @@ -85,24 +80,6 @@ describe("clawdbot-tools: subagents", () => { endedAt: 4000, }; } - if (request.method === "chat.history") { - const params = request.params as { sessionKey?: string } | undefined; - const text = sessionLastAssistantText.get(params?.sessionKey ?? "") ?? ""; - return { - messages: [{ role: "assistant", content: [{ type: "text", text }] }], - }; - } - if (request.method === "send") { - const params = request.params as - | { to?: string; channel?: string; message?: string } - | undefined; - sendParams = { - to: params?.to, - channel: params?.channel, - message: params?.message, - }; - return { messageId: "m-announce" }; - } if (request.method === "sessions.delete") { const params = request.params as { key?: string } | undefined; deletedKey = params?.key; @@ -135,19 +112,24 @@ describe("clawdbot-tools: subagents", () => { expect(childWait?.timeoutMs).toBe(1000); expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true); + // Two agent calls: subagent spawn + main agent trigger const agentCalls = calls.filter((call) => call.method === "agent"); expect(agentCalls).toHaveLength(2); - const second = agentCalls[1]?.params as - | { channel?: string; deliver?: boolean; lane?: string } - | undefined; - expect(second?.lane).toBe("nested"); - expect(second?.deliver).toBe(false); - expect(second?.channel).toBe("webchat"); - expect(sendParams.channel).toBe("discord"); - expect(sendParams.to).toBe("channel:req"); - expect(sendParams.message ?? "").toContain("announce now"); - expect(sendParams.message ?? "").toContain("Stats:"); + // First call: subagent spawn + const first = agentCalls[0]?.params as { lane?: string } | undefined; + expect(first?.lane).toBe("subagent"); + + // Second call: main agent trigger + const second = agentCalls[1]?.params as { sessionKey?: string; deliver?: boolean } | undefined; + expect(second?.sessionKey).toBe("discord:group:req"); + expect(second?.deliver).toBe(true); + + // No direct send to external channel (main agent handles delivery) + const sendCalls = calls.filter((c) => c.method === "send"); + expect(sendCalls.length).toBe(0); + + // Session should be deleted expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true); }); }); diff --git a/src/agents/clawdbot-tools.subagents.sessions-spawn-announces-back-requester-group-channel.test.ts b/src/agents/clawdbot-tools.subagents.sessions-spawn-announces-back-requester-group-channel.test.ts index 890f4cba7..008025fde 100644 --- a/src/agents/clawdbot-tools.subagents.sessions-spawn-announces-back-requester-group-channel.test.ts +++ b/src/agents/clawdbot-tools.subagents.sessions-spawn-announces-back-requester-group-channel.test.ts @@ -35,17 +35,15 @@ describe("clawdbot-tools: subagents", () => { }; }); - it("sessions_spawn announces back to the requester group channel", async () => { + it("sessions_spawn runs cleanup via lifecycle events", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); const calls: Array<{ method?: string; params?: unknown }> = []; let agentCallCount = 0; - let sendParams: { to?: string; channel?: string; message?: string } = {}; let deletedKey: string | undefined; let childRunId: string | undefined; let childSessionKey: string | undefined; const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = []; - const sessionLastAssistantText = new Map(); callGatewayMock.mockImplementation(async (opts: unknown) => { const request = opts as { method?: string; params?: unknown }; @@ -58,15 +56,12 @@ describe("clawdbot-tools: subagents", () => { sessionKey?: string; channel?: string; timeout?: number; + lane?: string; }; - const message = params?.message ?? ""; - const sessionKey = params?.sessionKey ?? ""; - if (message === "Sub-agent announce step.") { - sessionLastAssistantText.set(sessionKey, "announce now"); - } else { + // Only capture the first agent call (subagent spawn, not main agent trigger) + if (params?.lane === "subagent") { childRunId = runId; - childSessionKey = sessionKey; - sessionLastAssistantText.set(sessionKey, "result"); + childSessionKey = params?.sessionKey ?? ""; expect(params?.channel).toBe("discord"); expect(params?.timeout).toBe(1); } @@ -79,26 +74,8 @@ describe("clawdbot-tools: subagents", () => { if (request.method === "agent.wait") { const params = request.params as { runId?: string; timeoutMs?: number } | undefined; waitCalls.push(params ?? {}); - const status = params?.runId === childRunId ? "timeout" : "ok"; - return { runId: params?.runId ?? "run-1", status }; - } - if (request.method === "chat.history") { - const params = request.params as { sessionKey?: string } | undefined; - const text = sessionLastAssistantText.get(params?.sessionKey ?? "") ?? ""; - return { - messages: [{ role: "assistant", content: [{ type: "text", text }] }], - }; - } - if (request.method === "send") { - const params = request.params as - | { to?: string; channel?: string; message?: string } - | undefined; - sendParams = { - to: params?.to, - channel: params?.channel, - message: params?.message, - }; - return { messageId: "m-announce" }; + // Return "ok" with timing info for the child run + return { runId: params?.runId ?? "run-1", status: "ok", startedAt: 1000, endedAt: 2000 }; } if (request.method === "sessions.delete") { const params = request.params as { key?: string } | undefined; @@ -141,8 +118,12 @@ describe("clawdbot-tools: subagents", () => { const childWait = waitCalls.find((call) => call.runId === childRunId); expect(childWait?.timeoutMs).toBe(1000); + + // Two agent calls: subagent spawn + main agent trigger const agentCalls = calls.filter((call) => call.method === "agent"); expect(agentCalls).toHaveLength(2); + + // First call: subagent spawn const first = agentCalls[0]?.params as | { lane?: string; @@ -156,17 +137,24 @@ describe("clawdbot-tools: subagents", () => { expect(first?.channel).toBe("discord"); expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true); expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true); - const second = agentCalls[1]?.params as - | { channel?: string; deliver?: boolean; lane?: string } - | undefined; - expect(second?.lane).toBe("nested"); - expect(second?.deliver).toBe(false); - expect(second?.channel).toBe("webchat"); - expect(sendParams.channel).toBe("discord"); - expect(sendParams.to).toBe("channel:req"); - expect(sendParams.message ?? "").toContain("announce now"); - expect(sendParams.message ?? "").toContain("Stats:"); + // Second call: main agent trigger with announce message + const second = agentCalls[1]?.params as + | { + sessionKey?: string; + message?: string; + deliver?: boolean; + } + | undefined; + expect(second?.sessionKey).toBe("discord:group:req"); + expect(second?.deliver).toBe(true); + expect(second?.message).toContain("background task"); + + // No direct send to external channel (main agent handles delivery) + const sendCalls = calls.filter((c) => c.method === "send"); + expect(sendCalls.length).toBe(0); + + // Session should be deleted since cleanup=delete expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true); }); }); diff --git a/src/agents/clawdbot-tools.subagents.sessions-spawn-resolves-main-announce-target-from.test.ts b/src/agents/clawdbot-tools.subagents.sessions-spawn-resolves-main-announce-target-from.test.ts index 8f74e46a1..8a094fb6d 100644 --- a/src/agents/clawdbot-tools.subagents.sessions-spawn-resolves-main-announce-target-from.test.ts +++ b/src/agents/clawdbot-tools.subagents.sessions-spawn-resolves-main-announce-target-from.test.ts @@ -35,16 +35,15 @@ describe("clawdbot-tools: subagents", () => { }; }); - it("sessions_spawn resolves main announce target from sessions.list", async () => { + it("sessions_spawn runs cleanup flow after subagent completion", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); const calls: Array<{ method?: string; params?: unknown }> = []; let agentCallCount = 0; - let sendParams: { to?: string; channel?: string; message?: string } = {}; let childRunId: string | undefined; let childSessionKey: string | undefined; const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = []; - const sessionLastAssistantText = new Map(); + let patchParams: { key?: string; label?: string } = {}; callGatewayMock.mockImplementation(async (opts: unknown) => { const request = opts as { method?: string; params?: unknown }; @@ -66,15 +65,12 @@ describe("clawdbot-tools: subagents", () => { const params = request.params as { message?: string; sessionKey?: string; + lane?: string; }; - const message = params?.message ?? ""; - const sessionKey = params?.sessionKey ?? ""; - if (message === "Sub-agent announce step.") { - sessionLastAssistantText.set(sessionKey, "hello from sub"); - } else { + // Only capture the first agent call (subagent spawn, not main agent trigger) + if (params?.lane === "subagent") { childRunId = runId; - childSessionKey = sessionKey; - sessionLastAssistantText.set(sessionKey, "done"); + childSessionKey = params?.sessionKey ?? ""; } return { runId, @@ -85,26 +81,12 @@ describe("clawdbot-tools: subagents", () => { if (request.method === "agent.wait") { const params = request.params as { runId?: string; timeoutMs?: number } | undefined; waitCalls.push(params ?? {}); - const status = params?.runId === childRunId ? "timeout" : "ok"; - return { runId: params?.runId ?? "run-1", status }; + return { runId: params?.runId ?? "run-1", status: "ok", startedAt: 1000, endedAt: 2000 }; } - if (request.method === "chat.history") { - const params = request.params as { sessionKey?: string } | undefined; - const text = sessionLastAssistantText.get(params?.sessionKey ?? "") ?? ""; - return { - messages: [{ role: "assistant", content: [{ type: "text", text }] }], - }; - } - if (request.method === "send") { - const params = request.params as - | { to?: string; channel?: string; message?: string } - | undefined; - sendParams = { - to: params?.to, - channel: params?.channel, - message: params?.message, - }; - return { messageId: "m1" }; + if (request.method === "sessions.patch") { + const params = request.params as { key?: string; label?: string } | undefined; + patchParams = { key: params?.key, label: params?.label }; + return { ok: true }; } if (request.method === "sessions.delete") { return { ok: true }; @@ -121,6 +103,7 @@ describe("clawdbot-tools: subagents", () => { const result = await tool.execute("call2", { task: "do thing", runTimeoutSeconds: 1, + label: "my-task", }); expect(result.details).toMatchObject({ status: "accepted", @@ -144,12 +127,29 @@ describe("clawdbot-tools: subagents", () => { const childWait = waitCalls.find((call) => call.runId === childRunId); expect(childWait?.timeoutMs).toBe(1000); - expect(sendParams.channel).toBe("whatsapp"); - expect(sendParams.to).toBe("+123"); - expect(sendParams.message ?? "").toContain("hello from sub"); - expect(sendParams.message ?? "").toContain("Stats:"); + // Cleanup should patch the label + expect(patchParams.key).toBe(childSessionKey); + expect(patchParams.label).toBe("my-task"); + + // Two agent calls: subagent spawn + main agent trigger + const agentCalls = calls.filter((c) => c.method === "agent"); + expect(agentCalls).toHaveLength(2); + + // First call: subagent spawn + const first = agentCalls[0]?.params as { lane?: string } | undefined; + expect(first?.lane).toBe("subagent"); + + // Second call: main agent trigger (not "Sub-agent announce step." anymore) + const second = agentCalls[1]?.params as { sessionKey?: string; message?: string } | undefined; + expect(second?.sessionKey).toBe("main"); + expect(second?.message).toContain("background task"); + + // No direct send to external channel (main agent handles delivery) + const sendCalls = calls.filter((c) => c.method === "send"); + expect(sendCalls.length).toBe(0); expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true); }); + it("sessions_spawn only allows same-agent by default", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 56dc84ba8..3382266df 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -10,6 +10,7 @@ import type { ClawdbotConfig } from "../../config/config.js"; import { getMachineDisplayName } from "../../infra/machine-name.js"; import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js"; import { normalizeMessageChannel } from "../../utils/message-channel.js"; +import { isSubagentSessionKey } from "../../routing/session-key.js"; import { isReasoningTagProvider } from "../../utils/provider-utils.js"; import { resolveUserPath } from "../../utils.js"; import { resolveClawdbotAgentDir } from "../agent-paths.js"; @@ -230,6 +231,7 @@ export async function compactEmbeddedPiSession(params: { config: params.config, }); const isDefaultAgent = sessionAgentId === defaultAgentId; + const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full"; const appendPrompt = buildEmbeddedSystemPrompt({ workspaceDir: effectiveWorkspace, defaultThinkLevel: params.thinkLevel, @@ -241,6 +243,7 @@ export async function compactEmbeddedPiSession(params: { ? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt) : undefined, skillsPrompt, + promptMode, runtimeInfo, sandboxInfo, tools, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index ca7a38adb..f0205507b 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -12,6 +12,7 @@ import { getMachineDisplayName } from "../../../infra/machine-name.js"; import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js"; import { normalizeMessageChannel } from "../../../utils/message-channel.js"; import { isReasoningTagProvider } from "../../../utils/provider-utils.js"; +import { isSubagentSessionKey } from "../../../routing/session-key.js"; import { resolveUserPath } from "../../../utils.js"; import { resolveClawdbotAgentDir } from "../../agent-paths.js"; import { resolveSessionAgentIds } from "../../agent-scope.js"; @@ -189,6 +190,7 @@ export async function runEmbeddedAttempt( config: params.config, }); const isDefaultAgent = sessionAgentId === defaultAgentId; + const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full"; const appendPrompt = buildEmbeddedSystemPrompt({ workspaceDir: effectiveWorkspace, @@ -202,6 +204,7 @@ export async function runEmbeddedAttempt( : undefined, skillsPrompt, reactionGuidance, + promptMode, runtimeInfo, sandboxInfo, tools, diff --git a/src/agents/pi-embedded-runner/system-prompt.ts b/src/agents/pi-embedded-runner/system-prompt.ts index ea0163f44..e869c75d6 100644 --- a/src/agents/pi-embedded-runner/system-prompt.ts +++ b/src/agents/pi-embedded-runner/system-prompt.ts @@ -1,7 +1,7 @@ import type { AgentTool } from "@mariozechner/pi-agent-core"; import type { ResolvedTimeFormat } from "../date-time.js"; import type { EmbeddedContextFile } from "../pi-embedded-helpers.js"; -import { buildAgentSystemPrompt } from "../system-prompt.js"; +import { buildAgentSystemPrompt, type PromptMode } from "../system-prompt.js"; import { buildToolSummaryMap } from "../tool-summaries.js"; import type { EmbeddedSandboxInfo } from "./types.js"; import type { ReasoningLevel, ThinkLevel } from "./utils.js"; @@ -19,6 +19,8 @@ export function buildEmbeddedSystemPrompt(params: { level: "minimal" | "extensive"; channel: string; }; + /** Controls which hardcoded sections to include. Defaults to "full". */ + promptMode?: PromptMode; runtimeInfo: { host: string; os: string; @@ -47,6 +49,7 @@ export function buildEmbeddedSystemPrompt(params: { heartbeatPrompt: params.heartbeatPrompt, skillsPrompt: params.skillsPrompt, reactionGuidance: params.reactionGuidance, + promptMode: params.promptMode, runtimeInfo: params.runtimeInfo, sandboxInfo: params.sandboxInfo, toolNames: params.tools.map((tool) => tool.name), diff --git a/src/agents/pi-tools.policy.ts b/src/agents/pi-tools.policy.ts index 6cbd667fc..a25bd0c2b 100644 --- a/src/agents/pi-tools.policy.ts +++ b/src/agents/pi-tools.policy.ts @@ -5,10 +5,22 @@ import type { SandboxToolPolicy } from "./sandbox.js"; import { expandToolGroups, normalizeToolName } from "./tool-policy.js"; const DEFAULT_SUBAGENT_TOOL_DENY = [ + // Session management - main agent orchestrates "sessions_list", "sessions_history", "sessions_send", "sessions_spawn", + // System admin - dangerous from subagent + "gateway", + "agents_list", + // Interactive setup - not a task + "whatsapp_login", + // Status/scheduling - main agent coordinates + "session_status", + "cron", + // Memory - pass relevant info in spawn prompt instead + "memory_search", + "memory_get", ]; export function resolveSubagentToolPolicy(cfg?: ClawdbotConfig): SandboxToolPolicy { diff --git a/src/agents/subagent-announce.format.test.ts b/src/agents/subagent-announce.format.test.ts index b8adc0ca8..515e39254 100644 --- a/src/agents/subagent-announce.format.test.ts +++ b/src/agents/subagent-announce.format.test.ts @@ -1,12 +1,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const sendSpy = vi.fn(async () => ({})); +const agentSpy = vi.fn(async () => ({ runId: "run-main", status: "ok" })); vi.mock("../gateway/call.js", () => ({ callGateway: vi.fn(async (req: unknown) => { - const typed = req as { method?: string; params?: { message?: string } }; - if (typed.method === "send") { - return await sendSpy(typed); + const typed = req as { method?: string; params?: { message?: string; sessionKey?: string } }; + if (typed.method === "agent") { + return await agentSpy(typed); } if (typed.method === "agent.wait") { return { status: "error", startedAt: 10, endedAt: 20, error: "boom" }; @@ -18,24 +18,11 @@ vi.mock("../gateway/call.js", () => ({ })); vi.mock("./tools/agent-step.js", () => ({ - runAgentStep: vi.fn(async () => "did some stuff"), readLatestAssistantReply: vi.fn(async () => "raw subagent reply"), })); -vi.mock("./tools/sessions-announce-target.js", () => ({ - resolveAnnounceTarget: vi.fn(async () => ({ - provider: "telegram", - to: "+15550001111", - accountId: "default", - })), -})); - -vi.mock("./tools/sessions-send-helpers.js", () => ({ - isAnnounceSkip: () => false, -})); - vi.mock("../config/sessions.js", () => ({ - loadSessionStore: vi.fn(async () => ({ entries: {} })), + loadSessionStore: vi.fn(() => ({})), resolveAgentIdFromSessionKey: () => "main", resolveStorePath: () => "/tmp/sessions.json", })); @@ -48,10 +35,10 @@ vi.mock("../config/config.js", () => ({ describe("subagent announce formatting", () => { beforeEach(() => { - sendSpy.mockClear(); + agentSpy.mockClear(); }); - it("wraps unstructured announce into Status/Result/Notes", async () => { + it("sends instructional message to main agent with status and findings", async () => { const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); await runSubagentAnnounceFlow({ childSessionKey: "agent:main:subagent:test", @@ -66,22 +53,21 @@ describe("subagent announce formatting", () => { endedAt: 20, }); - expect(sendSpy).toHaveBeenCalled(); - const msg = sendSpy.mock.calls[0]?.[0]?.params?.message as string; - expect(msg).toContain("Status:"); - expect(msg).toContain("Status: error"); - expect(msg).toContain("Result:"); - expect(msg).toContain("Notes:"); + expect(agentSpy).toHaveBeenCalled(); + const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string; sessionKey?: string } }; + const msg = call?.params?.message as string; + expect(call?.params?.sessionKey).toBe("agent:main:main"); + expect(msg).toContain("background task"); + expect(msg).toContain("failed"); expect(msg).toContain("boom"); + expect(msg).toContain("Findings:"); + expect(msg).toContain("raw subagent reply"); + expect(msg).toContain("Stats:"); }); - it("keeps runtime status even when announce reply is structured", async () => { - const agentStep = await import("./tools/agent-step.js"); - vi.mocked(agentStep.runAgentStep).mockResolvedValueOnce( - "- **Status:** success\n\n- **Result:** did some stuff\n\n- **Notes:** all good", - ); - + it("includes success status when outcome is ok", async () => { const { runSubagentAnnounceFlow } = await import("./subagent-announce.js"); + // Use waitForCompletion: false so it uses the provided outcome instead of calling agent.wait await runSubagentAnnounceFlow({ childSessionKey: "agent:main:subagent:test", childRunId: "run-456", @@ -90,14 +76,14 @@ describe("subagent announce formatting", () => { task: "do thing", timeoutMs: 1000, cleanup: "keep", - waitForCompletion: true, + waitForCompletion: false, startedAt: 10, endedAt: 20, + outcome: { status: "ok" }, }); - const msg = sendSpy.mock.calls[0]?.[0]?.params?.message as string; - expect(msg).toContain("Status: error"); - expect(msg).toContain("Result:"); - expect(msg).toContain("Notes:"); + const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } }; + const msg = call?.params?.message as string; + expect(msg).toContain("completed successfully"); }); }); diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index 80197ecc8..71615306a 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -8,11 +8,7 @@ import { resolveStorePath, } from "../config/sessions.js"; import { callGateway } from "../gateway/call.js"; -import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js"; -import { AGENT_LANE_NESTED } from "./lanes.js"; -import { readLatestAssistantReply, runAgentStep } from "./tools/agent-step.js"; -import { resolveAnnounceTarget } from "./tools/sessions-announce-target.js"; -import { isAnnounceSkip } from "./tools/sessions-send-helpers.js"; +import { readLatestAssistantReply } from "./tools/agent-step.js"; function formatDurationShort(valueMs?: number) { if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) return undefined; @@ -149,27 +145,27 @@ export function buildSubagentSystemPrompt(params: { "", "## Your Role", `- You were created to handle: ${taskText}`, - "- Complete this task and report back. That's your entire purpose.", + "- Complete this task. That's your entire purpose.", "- You are NOT the main agent. Don't try to be.", "", "## Rules", "1. **Stay focused** - Do your assigned task, nothing else", - "2. **Report completion** - When done, summarize results clearly", + "2. **Complete the task** - Your final message will be automatically reported to the main agent", "3. **Don't initiate** - No heartbeats, no proactive actions, no side quests", - "4. **Ask the spawner** - If blocked or confused, report back rather than improvising", - "5. **Be ephemeral** - You may be terminated after task completion. That's fine.", + "4. **Be ephemeral** - You may be terminated after task completion. That's fine.", + "", + "## Output Format", + "When complete, your final response should include:", + "- What you accomplished or found", + "- Any relevant details the main agent should know", + "- Keep it concise but informative", "", "## What You DON'T Do", "- NO user conversations (that's main agent's job)", "- NO external messages (email, tweets, etc.) unless explicitly tasked", "- NO cron jobs or persistent state", "- NO pretending to be the main agent", - "", - "## Output Format", - "When complete, respond with:", - "- **Status:** success | failed | blocked", - "- **Result:** [what you accomplished]", - "- **Notes:** [anything the main agent should know] - discuss gimme options", + "- NO using the `message` tool directly", "", "## Session Context", params.label ? `- Label: ${params.label}` : undefined, @@ -177,8 +173,6 @@ export function buildSubagentSystemPrompt(params: { params.requesterChannel ? `- Requester channel: ${params.requesterChannel}.` : undefined, `- Your session: ${params.childSessionKey}.`, "", - "Run the task. Provide a clear final answer (plain text).", - 'After you finish, you may be asked to produce an "announce" message to post back to the requester chat.', ].filter((line): line is string => line !== undefined); return lines.join("\n"); } @@ -188,109 +182,6 @@ export type SubagentRunOutcome = { error?: string; }; -const ANNOUNCE_SECTION_RE = /^\s*[-*]?\s*(?:\*\*)?(status|result|notes)(?:\*\*)?\s*:\s*(.*)$/i; - -function parseAnnounceSections(announce: string) { - const sections = { - status: [] as string[], - result: [] as string[], - notes: [] as string[], - }; - let current: keyof typeof sections | null = null; - let sawSection = false; - - for (const line of announce.split(/\r?\n/)) { - const match = line.match(ANNOUNCE_SECTION_RE); - if (match) { - const key = match[1]?.toLowerCase() as keyof typeof sections; - current = key; - sawSection = true; - const rest = match[2]?.trim(); - if (rest) sections[key].push(rest); - continue; - } - if (current) sections[current].push(line); - } - - const normalize = (lines: string[]) => { - const joined = lines.join("\n").trim(); - return joined.length > 0 ? joined : undefined; - }; - - return { - sawSection, - status: normalize(sections.status), - result: normalize(sections.result), - notes: normalize(sections.notes), - }; -} - -function normalizeAnnounceBody(params: { - outcome: SubagentRunOutcome; - announceReply: string; - statsLine?: string; -}) { - const announce = params.announceReply.trim(); - const statsLine = params.statsLine?.trim(); - - const statusLabel = - params.outcome.status === "ok" - ? "success" - : params.outcome.status === "timeout" - ? "timeout" - : params.outcome.status === "unknown" - ? "unknown" - : "error"; - - const parsed = parseAnnounceSections(announce); - const resultText = parsed.result ?? (announce || "(not available)"); - const notesParts: string[] = []; - if (parsed.notes) notesParts.push(parsed.notes); - if (params.outcome.error) notesParts.push(`- Error: ${params.outcome.error}`); - const notesBlock = notesParts.length ? notesParts.join("\n") : "- (none)"; - - const message = [ - `Status: ${statusLabel}`, - "", - "Result:", - resultText, - "", - "Notes:", - notesBlock, - ].join("\n"); - - return statsLine ? `${message}\n\n${statsLine}` : message; -} - -function buildSubagentAnnouncePrompt(params: { - requesterSessionKey?: string; - requesterChannel?: string; - announceChannel: string; - task: string; - subagentReply?: string; -}) { - const lines = [ - "Sub-agent announce step:", - params.requesterSessionKey ? `Requester session: ${params.requesterSessionKey}.` : undefined, - params.requesterChannel ? `Requester channel: ${params.requesterChannel}.` : undefined, - `Post target channel: ${params.announceChannel}.`, - `Original task: ${params.task}`, - params.subagentReply - ? `Sub-agent result: ${params.subagentReply}` - : "Sub-agent result: (not available).", - "", - "**You MUST announce your result.** The requester is waiting for your response.", - "Provide a brief, useful summary of what you accomplished.", - "Reply with Result and Notes only (no Status line; status is added by the system).", - "Format:", - "Result: ", - "Notes: ", - 'Only reply "ANNOUNCE_SKIP" if the task completely failed with no useful output.', - "Your reply will be posted to the requester chat.", - ].filter(Boolean); - return lines.join("\n"); -} - export async function runSubagentAnnounceFlow(params: { childSessionKey: string; childRunId: string; @@ -340,8 +231,6 @@ export async function runSubagentAnnounceFlow(params: { params.endedAt = wait.endedAt; } if (wait?.status === "timeout") { - // No lifecycle end seen before timeout. Still attempt an announce so - // requesters are not left hanging. if (!outcome) outcome = { status: "timeout" }; } reply = await readLatestAssistantReply({ @@ -357,53 +246,50 @@ export async function runSubagentAnnounceFlow(params: { if (!outcome) outcome = { status: "unknown" }; - const announceTarget = await resolveAnnounceTarget({ - sessionKey: params.requesterSessionKey, - displayKey: params.requesterDisplayKey, - }); - if (!announceTarget) return false; - - const announcePrompt = buildSubagentAnnouncePrompt({ - requesterSessionKey: params.requesterSessionKey, - requesterChannel: params.requesterChannel, - announceChannel: announceTarget.channel, - task: params.task, - subagentReply: reply, - }); - - const announceReply = await runAgentStep({ - sessionKey: params.childSessionKey, - message: "Sub-agent announce step.", - extraSystemPrompt: announcePrompt, - timeoutMs: params.timeoutMs, - channel: INTERNAL_MESSAGE_CHANNEL, - lane: AGENT_LANE_NESTED, - }); - - if (!announceReply || !announceReply.trim() || isAnnounceSkip(announceReply)) return false; - + // Build stats const statsLine = await buildSubagentStatsLine({ sessionKey: params.childSessionKey, startedAt: params.startedAt, endedAt: params.endedAt, }); - const message = normalizeAnnounceBody({ - outcome, - announceReply, - statsLine, - }); + // Build status label + const statusLabel = + outcome.status === "ok" + ? "completed successfully" + : outcome.status === "timeout" + ? "timed out" + : outcome.status === "error" + ? `failed: ${outcome.error || "unknown error"}` + : "finished with unknown status"; + + // Build instructional message for main agent + const taskLabel = params.label || params.task || "background task"; + const triggerMessage = [ + `A background task "${taskLabel}" just ${statusLabel}.`, + "", + "Findings:", + reply || "(no output)", + "", + statsLine, + "", + "Summarize this naturally for the user. Keep it brief (1-2 sentences). Flow it into the conversation naturally.", + "Do not mention technical details like tokens, stats, or that this was a background task.", + "You can respond with NO_REPLY if no announcement is needed (e.g., internal task with no user-facing result).", + ].join("\n"); + + // Send to main agent - it will respond in its own voice await callGateway({ - method: "send", + method: "agent", params: { - to: announceTarget.to, - message, - channel: announceTarget.channel, - accountId: announceTarget.accountId, + sessionKey: params.requesterSessionKey, + message: triggerMessage, + deliver: true, idempotencyKey: crypto.randomUUID(), }, - timeoutMs: 10_000, + timeoutMs: 60_000, }); + didAnnounce = true; } catch { // Best-effort follow-ups; ignore failures to avoid breaking the caller response. diff --git a/src/agents/subagent-registry.persistence.test.ts b/src/agents/subagent-registry.persistence.test.ts index 995e202cc..bb34489bf 100644 --- a/src/agents/subagent-registry.persistence.test.ts +++ b/src/agents/subagent-registry.persistence.test.ts @@ -68,21 +68,24 @@ describe("subagent registry persistence", () => { const mod2 = await import("./subagent-registry.js"); mod2.initSubagentRegistry(); - // allow queued async wait/announce to execute + // allow queued async wait/cleanup to execute await new Promise((r) => setTimeout(r, 0)); expect(announceSpy).toHaveBeenCalled(); type AnnounceParams = { - childRunId: string; childSessionKey: string; + childRunId: string; + requesterSessionKey: string; + task: string; + cleanup: string; + label?: string; }; const first = announceSpy.mock.calls[0]?.[0] as unknown as AnnounceParams; - expect(first.childRunId).toBe("run-1"); expect(first.childSessionKey).toBe("agent:main:subagent:test"); }); - it("retries announce even when announceHandled was persisted", async () => { + it("skips cleanup when cleanupHandled/announceHandled was persisted", async () => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-subagent-")); process.env.CLAWDBOT_STATE_DIR = tempStateDir; @@ -100,7 +103,7 @@ describe("subagent registry persistence", () => { createdAt: 1, startedAt: 1, endedAt: 2, - announceHandled: true, + cleanupHandled: true, // Already handled - should be skipped }, }, }; @@ -113,10 +116,11 @@ describe("subagent registry persistence", () => { await new Promise((r) => setTimeout(r, 0)); + // announce should NOT be called since cleanupHandled was true const calls = announceSpy.mock.calls.map((call) => call[0]); const match = calls.find( - (params) => (params as { childRunId?: string }).childRunId === "run-2", + (params) => (params as { childSessionKey?: string }).childSessionKey === "agent:main:subagent:two", ); - expect(match).toBeTruthy(); + expect(match).toBeFalsy(); }); }); diff --git a/src/agents/subagent-registry.store.ts b/src/agents/subagent-registry.store.ts index 6312698f8..9681738a5 100644 --- a/src/agents/subagent-registry.store.ts +++ b/src/agents/subagent-registry.store.ts @@ -13,7 +13,9 @@ type PersistedSubagentRegistry = { const REGISTRY_VERSION = 1 as const; -type PersistedSubagentRunRecord = Omit; +type PersistedSubagentRunRecord = Omit & { + announceHandled?: boolean; +}; export function resolveSubagentRegistryPath(): string { return path.join(STATE_DIR_CLAWDBOT, "subagents", "runs.json"); @@ -32,12 +34,27 @@ export function loadSubagentRegistryFromDisk(): Map { if (!entry || typeof entry !== "object") continue; const typed = entry as PersistedSubagentRunRecord; if (!typed.runId || typeof typed.runId !== "string") continue; + // Back-compat: map legacy announce fields into cleanup fields. const announceCompletedAt = typeof typed.announceCompletedAt === "number" ? typed.announceCompletedAt : undefined; + const cleanupCompletedAt = + typeof typed.cleanupCompletedAt === "number" + ? typed.cleanupCompletedAt + : announceCompletedAt; + const cleanupHandled = + typeof typed.cleanupHandled === "boolean" + ? typed.cleanupHandled + : Boolean(typed.announceHandled ?? announceCompletedAt ?? cleanupCompletedAt); + const announceHandled = + typeof typed.announceHandled === "boolean" + ? typed.announceHandled + : Boolean(announceCompletedAt); out.set(runId, { ...typed, announceCompletedAt, - announceHandled: Boolean(announceCompletedAt), + announceHandled, + cleanupCompletedAt, + cleanupHandled, }); } return out; diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index b9756500b..596bae930 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -22,8 +22,12 @@ export type SubagentRunRecord = { endedAt?: number; outcome?: SubagentRunOutcome; archiveAtMs?: number; + /** @deprecated Use cleanupCompletedAt instead */ announceCompletedAt?: number; - announceHandled: boolean; + /** @deprecated Use cleanupHandled instead */ + announceHandled?: boolean; + cleanupCompletedAt?: number; + cleanupHandled?: boolean; }; const subagentRuns = new Map(); @@ -46,11 +50,11 @@ function resumeSubagentRun(runId: string) { if (!runId || resumedRuns.has(runId)) return; const entry = subagentRuns.get(runId); if (!entry) return; - if (entry.announceCompletedAt) return; + if (entry.cleanupCompletedAt) return; if (typeof entry.endedAt === "number" && entry.endedAt > 0) { - if (!beginSubagentAnnounce(runId)) return; - const announce = runSubagentAnnounceFlow({ + if (!beginSubagentCleanup(runId)) return; + void runSubagentAnnounceFlow({ childSessionKey: entry.childSessionKey, childRunId: entry.runId, requesterSessionKey: entry.requesterSessionKey, @@ -64,9 +68,8 @@ function resumeSubagentRun(runId: string) { endedAt: entry.endedAt, label: entry.label, outcome: entry.outcome, - }); - void announce.then((didAnnounce) => { - finalizeSubagentAnnounce(runId, entry.cleanup, didAnnounce); + }).then((didAnnounce) => { + finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce); }); resumedRuns.add(runId); return; @@ -156,7 +159,9 @@ async function sweepSubagentRuns() { } function ensureListener() { - if (listenerStarted) return; + if (listenerStarted) { + return; + } listenerStarted = true; listenerStop = onAgentEvent((evt) => { if (!evt || evt.stream !== "lifecycle") return; @@ -186,10 +191,10 @@ function ensureListener() { } persistSubagentRuns(); - if (!beginSubagentAnnounce(evt.runId)) { + if (!beginSubagentCleanup(evt.runId)) { return; } - const announce = runSubagentAnnounceFlow({ + void runSubagentAnnounceFlow({ childSessionKey: entry.childSessionKey, childRunId: entry.runId, requesterSessionKey: entry.requesterSessionKey, @@ -203,14 +208,17 @@ function ensureListener() { endedAt: entry.endedAt, label: entry.label, outcome: entry.outcome, - }); - void announce.then((didAnnounce) => { - finalizeSubagentAnnounce(evt.runId, entry.cleanup, didAnnounce); + }).then((didAnnounce) => { + finalizeSubagentCleanup(evt.runId, entry.cleanup, didAnnounce); }); }); } -function finalizeSubagentAnnounce(runId: string, cleanup: "delete" | "keep", didAnnounce: boolean) { +function finalizeSubagentCleanup( + runId: string, + cleanup: "delete" | "keep", + didAnnounce: boolean, +) { const entry = subagentRuns.get(runId); if (!entry) return; if (cleanup === "delete") { @@ -218,17 +226,23 @@ function finalizeSubagentAnnounce(runId: string, cleanup: "delete" | "keep", did persistSubagentRuns(); return; } - if (!didAnnounce) return; - entry.announceCompletedAt = Date.now(); + if (!didAnnounce) { + // Allow retry on the next wake if the announce failed. + entry.cleanupHandled = false; + persistSubagentRuns(); + return; + } + entry.cleanupCompletedAt = Date.now(); persistSubagentRuns(); } -export function beginSubagentAnnounce(runId: string) { +function beginSubagentCleanup(runId: string) { const entry = subagentRuns.get(runId); if (!entry) return false; - if (entry.announceCompletedAt) return false; - if (entry.announceHandled) return false; - entry.announceHandled = true; + // Support legacy field names for backward compatibility + if (entry.cleanupCompletedAt || entry.announceCompletedAt) return false; + if (entry.cleanupHandled || entry.announceHandled) return false; + entry.cleanupHandled = true; persistSubagentRuns(); return true; } @@ -261,7 +275,7 @@ export function registerSubagentRun(params: { createdAt: now, startedAt: now, archiveAtMs, - announceHandled: false, + cleanupHandled: false, }); ensureListener(); persistSubagentRuns(); @@ -302,8 +316,8 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) { wait.status === "error" ? { status: "error", error: wait.error } : { status: "ok" }; mutated = true; if (mutated) persistSubagentRuns(); - if (!beginSubagentAnnounce(runId)) return; - const announce = runSubagentAnnounceFlow({ + if (!beginSubagentCleanup(runId)) return; + void runSubagentAnnounceFlow({ childSessionKey: entry.childSessionKey, childRunId: entry.runId, requesterSessionKey: entry.requesterSessionKey, @@ -317,9 +331,8 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) { endedAt: entry.endedAt, label: entry.label, outcome: entry.outcome, - }); - void announce.then((didAnnounce) => { - finalizeSubagentAnnounce(runId, entry.cleanup, didAnnounce); + }).then((didAnnounce) => { + finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce); }); } catch { // ignore diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 083f93b1f..b9b352e4b 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -4,6 +4,14 @@ import { listDeliverableMessageChannels } from "../utils/message-channel.js"; import type { ResolvedTimeFormat } from "./date-time.js"; import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; +/** + * Controls which hardcoded sections are included in the system prompt. + * - "full": All sections (default, for main agent) + * - "minimal": Reduced sections (Tooling, Workspace, Runtime) - used for subagents + * - "none": Just basic identity line, no sections + */ +export type PromptMode = "full" | "minimal" | "none"; + export function buildAgentSystemPrompt(params: { workspaceDir: string; defaultThinkLevel?: ThinkLevel; @@ -20,6 +28,8 @@ export function buildAgentSystemPrompt(params: { contextFiles?: EmbeddedContextFile[]; skillsPrompt?: string; heartbeatPrompt?: string; + /** Controls which hardcoded sections to include. Defaults to "full". */ + promptMode?: PromptMode; runtimeInfo?: { host?: string; os?: string; @@ -179,17 +189,22 @@ export function buildAgentSystemPrompt(params: { const runtimeCapabilitiesLower = new Set(runtimeCapabilities.map((cap) => cap.toLowerCase())); const inlineButtonsEnabled = runtimeCapabilitiesLower.has("inlinebuttons"); const messageChannelOptions = listDeliverableMessageChannels().join("|"); + const promptMode = params.promptMode ?? "full"; + const isMinimal = promptMode === "minimal" || promptMode === "none"; 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, - "", - ] - : []; + // 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 = - availableTools.has("memory_search") || availableTools.has("memory_get") + !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.", @@ -197,6 +212,11 @@ export function buildAgentSystemPrompt(params: { ] : []; + // For "none" mode, return just the basic identity line + if (promptMode === "none") { + return "You are a personal assistant running inside Clawdbot."; + } + const lines = [ "You are a personal assistant running inside Clawdbot.", "", @@ -235,8 +255,9 @@ export function buildAgentSystemPrompt(params: { "", ...skillsSection, ...memorySection, - hasGateway ? "## Clawdbot Self-Update" : "", - hasGateway + // Skip self-update for subagent/none modes + hasGateway && !isMinimal ? "## Clawdbot Self-Update" : "", + hasGateway && !isMinimal ? [ "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.", @@ -244,16 +265,19 @@ export function buildAgentSystemPrompt(params: { "After restart, Clawdbot pings the last active session automatically.", ].join("\n") : "", - hasGateway ? "" : "", + hasGateway && !isMinimal ? "" : "", "", - params.modelAliasLines && params.modelAliasLines.length > 0 ? "## Model Aliases" : "", - params.modelAliasLines && params.modelAliasLines.length > 0 + // Skip model aliases for subagent/none modes + params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal + ? "## Model Aliases" + : "", + params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal ? "Prefer aliases when specifying model overrides; full provider/model is also accepted." : "", - params.modelAliasLines && params.modelAliasLines.length > 0 + params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal ? params.modelAliasLines.join("\n") : "", - params.modelAliasLines && params.modelAliasLines.length > 0 ? "" : "", + params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal ? "" : "", "## Workspace", `Your working directory is: ${params.workspaceDir}`, "Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.", @@ -311,9 +335,10 @@ export function buildAgentSystemPrompt(params: { .join("\n") : "", params.sandboxInfo?.enabled ? "" : "", - ownerLine ? "## User Identity" : "", - ownerLine ?? "", - ownerLine ? "" : "", + // Skip user identity for subagent/none modes + ownerLine && !isMinimal ? "## User Identity" : "", + ownerLine && !isMinimal ? ownerLine : "", + ownerLine && !isMinimal ? "" : "", ...(userTimezone || userTime ? [ "## Current Date & Time", @@ -329,38 +354,50 @@ export function buildAgentSystemPrompt(params: { "## Workspace Files (injected)", "These user-editable files are loaded by Clawdbot and included below in Project Context.", "", - "## 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.", - "", - "## 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") - ? [ + // 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.", "", - "### 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") - : "", - "", + ]), + // 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") + : "", + "", + ]), ]; if (extraSystemPrompt) { - lines.push("## Group Chat Context", extraSystemPrompt, ""); + // Use "Subagent Context" header for minimal mode (subagents), otherwise "Group Chat Context" + const contextHeader = promptMode === "minimal" ? "## Subagent Context" : "## Group Chat Context"; + lines.push(contextHeader, extraSystemPrompt, ""); } if (params.reactionGuidance) { const { level, channel } = params.reactionGuidance; @@ -402,26 +439,38 @@ export function buildAgentSystemPrompt(params: { } } + // Skip silent replies for subagent/none modes + if (!isMinimal) { + 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}`, + "", + ); + } + + // Skip heartbeats for subagent/none modes + if (!isMinimal) { + 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).', + 'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.', + "", + ); + } + 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}` : "", diff --git a/src/agents/tools/sessions-announce-target.ts b/src/agents/tools/sessions-announce-target.ts index 0b5d3adb7..06cc4965a 100644 --- a/src/agents/tools/sessions-announce-target.ts +++ b/src/agents/tools/sessions-announce-target.ts @@ -32,6 +32,7 @@ export async function resolveAnnounceTarget(params: { const match = sessions.find((entry) => entry?.key === params.sessionKey) ?? sessions.find((entry) => entry?.key === params.displayKey); + const channel = typeof match?.lastChannel === "string" ? match.lastChannel : undefined; const to = typeof match?.lastTo === "string" ? match.lastTo : undefined; const accountId = typeof match?.lastAccountId === "string" ? match.lastAccountId : undefined; diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index dca6c2862..19faebe55 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -22,6 +22,8 @@ import { type ChatEvent, ChatEventSchema, ChatHistoryParamsSchema, + type ChatInjectParams, + ChatInjectParamsSchema, ChatSendParamsSchema, type ConfigApplyParams, ConfigApplyParamsSchema, @@ -232,6 +234,7 @@ export const validateLogsTailParams = ajv.compile(LogsTailParams export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema); export const validateChatSendParams = ajv.compile(ChatSendParamsSchema); export const validateChatAbortParams = ajv.compile(ChatAbortParamsSchema); +export const validateChatInjectParams = ajv.compile(ChatInjectParamsSchema); export const validateChatEvent = ajv.compile(ChatEventSchema); export const validateUpdateRunParams = ajv.compile(UpdateRunParamsSchema); export const validateWebLoginStartParams = @@ -310,6 +313,7 @@ export { LogsTailResultSchema, ChatHistoryParamsSchema, ChatSendParamsSchema, + ChatInjectParamsSchema, UpdateRunParamsSchema, TickEventSchema, ShutdownEventSchema, @@ -388,4 +392,5 @@ export type { LogsTailResult, PollParams, UpdateRunParams, + ChatInjectParams, }; diff --git a/src/gateway/protocol/schema/logs-chat.ts b/src/gateway/protocol/schema/logs-chat.ts index 7f3a4b470..7b684771a 100644 --- a/src/gateway/protocol/schema/logs-chat.ts +++ b/src/gateway/protocol/schema/logs-chat.ts @@ -53,6 +53,15 @@ export const ChatAbortParamsSchema = Type.Object( { additionalProperties: false }, ); +export const ChatInjectParamsSchema = Type.Object( + { + sessionKey: NonEmptyString, + message: NonEmptyString, + label: Type.Optional(Type.String({ maxLength: 100 })), + }, + { additionalProperties: false }, +); + export const ChatEventSchema = Type.Object( { runId: NonEmptyString, diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index e57313f07..a6cc04750 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -62,6 +62,7 @@ import { ChatAbortParamsSchema, ChatEventSchema, ChatHistoryParamsSchema, + ChatInjectParamsSchema, ChatSendParamsSchema, LogsTailParamsSchema, LogsTailResultSchema, @@ -172,6 +173,7 @@ export const ProtocolSchemas: Record = { ChatHistoryParams: ChatHistoryParamsSchema, ChatSendParams: ChatSendParamsSchema, ChatAbortParams: ChatAbortParamsSchema, + ChatInjectParams: ChatInjectParamsSchema, ChatEvent: ChatEventSchema, UpdateRunParams: UpdateRunParamsSchema, TickEvent: TickEventSchema, diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index 502926561..ee6408a41 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -59,6 +59,7 @@ import type { import type { ChatAbortParamsSchema, ChatEventSchema, + ChatInjectParamsSchema, LogsTailParamsSchema, LogsTailResultSchema, } from "./logs-chat.js"; @@ -163,6 +164,7 @@ export type CronRunLogEntry = Static; export type LogsTailParams = Static; export type LogsTailResult = Static; export type ChatAbortParams = Static; +export type ChatInjectParams = Static; export type ChatEvent = Static; export type UpdateRunParams = Static; export type TickEvent = Static; diff --git a/src/gateway/server-bridge-methods-chat.ts b/src/gateway/server-bridge-methods-chat.ts index 0dd99c48c..d4834a57b 100644 --- a/src/gateway/server-bridge-methods-chat.ts +++ b/src/gateway/server-bridge-methods-chat.ts @@ -1,8 +1,10 @@ import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; import { resolveThinkingDefault } from "../agents/model-selection.js"; import { resolveAgentTimeoutMs } from "../agents/timeout.js"; import { agentCommand } from "../commands/agent.js"; -import { mergeSessionEntry, saveSessionStore } from "../config/sessions.js"; +import { mergeSessionEntry, updateSessionStore } from "../config/sessions.js"; import { registerAgentRunContext } from "../infra/agent-events.js"; import { defaultRuntime } from "../runtime.js"; import { @@ -17,6 +19,7 @@ import { errorShape, formatValidationErrors, validateChatAbortParams, + validateChatInjectParams, validateChatHistoryParams, validateChatSendParams, } from "./protocol/index.js"; @@ -31,6 +34,84 @@ import { export const handleChatBridgeMethods: BridgeMethodHandler = async (ctx, nodeId, method, params) => { switch (method) { + case "chat.inject": { + if (!validateChatInjectParams(params)) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `invalid chat.inject params: ${formatValidationErrors(validateChatInjectParams.errors)}`, + }, + }; + } + const p = params as { + sessionKey: string; + message: string; + label?: string; + }; + + const { storePath, entry } = loadSessionEntry(p.sessionKey); + const sessionId = entry?.sessionId; + if (!sessionId || !storePath) { + return { + ok: false, + error: { code: ErrorCodes.INVALID_REQUEST, message: "session not found" }, + }; + } + + const transcriptPath = entry?.sessionFile + ? entry.sessionFile + : path.join(path.dirname(storePath), `${sessionId}.jsonl`); + + if (!fs.existsSync(transcriptPath)) { + return { + ok: false, + error: { code: ErrorCodes.INVALID_REQUEST, message: "transcript file not found" }, + }; + } + + const now = Date.now(); + const messageId = randomUUID().slice(0, 8); + const labelPrefix = p.label ? `[${p.label}]\n\n` : ""; + const messageBody: Record = { + role: "assistant", + content: [{ type: "text", text: `${labelPrefix}${p.message}` }], + timestamp: now, + stopReason: "injected", + usage: { input: 0, output: 0, totalTokens: 0 }, + }; + const transcriptEntry = { + type: "message", + id: messageId, + timestamp: new Date(now).toISOString(), + message: messageBody, + }; + + try { + fs.appendFileSync(transcriptPath, `${JSON.stringify(transcriptEntry)}\n`, "utf-8"); + } catch (err) { + const errMessage = err instanceof Error ? err.message : String(err); + return { + ok: false, + error: { + code: ErrorCodes.UNAVAILABLE, + message: `failed to write transcript: ${errMessage}`, + }, + }; + } + + const chatPayload = { + runId: `inject-${messageId}`, + sessionKey: p.sessionKey, + seq: 0, + state: "final" as const, + message: transcriptEntry.message, + }; + ctx.broadcast("chat", chatPayload); + ctx.bridgeSendToSession(p.sessionKey, "chat", chatPayload); + + return { ok: true, payloadJSON: JSON.stringify({ ok: true, messageId }) }; + } case "chat.history": { if (!validateChatHistoryParams(params)) { return { @@ -217,7 +298,7 @@ export const handleChatBridgeMethods: BridgeMethodHandler = async (ctx, nodeId, } } - const { cfg, storePath, store, entry, canonicalKey } = loadSessionEntry(p.sessionKey); + const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(p.sessionKey); const timeoutMs = resolveAgentTimeoutMs({ cfg, overrideMs: p.timeoutMs, @@ -294,11 +375,10 @@ export const handleChatBridgeMethods: BridgeMethodHandler = async (ctx, nodeId, clientRunId, }); - if (store) { - store[canonicalKey] = sessionEntry; - if (storePath) { - await saveSessionStore(storePath, store); - } + if (storePath) { + await updateSessionStore(storePath, (store) => { + store[canonicalKey] = sessionEntry; + }); } const ackPayload = { diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index e119753c1..8543a09d8 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -1,9 +1,11 @@ import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; import { resolveThinkingDefault } from "../../agents/model-selection.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; import { agentCommand } from "../../commands/agent.js"; -import { mergeSessionEntry, saveSessionStore } from "../../config/sessions.js"; +import { mergeSessionEntry, updateSessionStore } from "../../config/sessions.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; import { defaultRuntime } from "../../runtime.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; @@ -21,6 +23,7 @@ import { formatValidationErrors, validateChatAbortParams, validateChatHistoryParams, + validateChatInjectParams, validateChatSendParams, } from "../protocol/index.js"; import { MAX_CHAT_HISTORY_MESSAGES_BYTES } from "../server-constants.js"; @@ -205,7 +208,7 @@ export const chatHandlers: GatewayRequestHandlers = { return; } } - const { cfg, storePath, store, entry, canonicalKey } = loadSessionEntry(p.sessionKey); + const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(p.sessionKey); const timeoutMs = resolveAgentTimeoutMs({ cfg, overrideMs: p.timeoutMs, @@ -284,11 +287,10 @@ export const chatHandlers: GatewayRequestHandlers = { clientRunId, }); - if (store) { - store[canonicalKey] = sessionEntry; - if (storePath) { - await saveSessionStore(storePath, store); - } + if (storePath) { + await updateSessionStore(storePath, (store) => { + store[canonicalKey] = sessionEntry; + }); } const ackPayload = { @@ -355,4 +357,80 @@ export const chatHandlers: GatewayRequestHandlers = { }); } }, + "chat.inject": async ({ params, respond, context }) => { + if (!validateChatInjectParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid chat.inject params: ${formatValidationErrors(validateChatInjectParams.errors)}`, + ), + ); + return; + } + const p = params as { + sessionKey: string; + message: string; + label?: string; + }; + + // Load session to find transcript file + const { storePath, entry } = loadSessionEntry(p.sessionKey); + const sessionId = entry?.sessionId; + if (!sessionId || !storePath) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "session not found")); + return; + } + + // Resolve transcript path + const transcriptPath = entry?.sessionFile + ? entry.sessionFile + : path.join(path.dirname(storePath), `${sessionId}.jsonl`); + + if (!fs.existsSync(transcriptPath)) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "transcript file not found")); + return; + } + + // Build transcript entry + const now = Date.now(); + const messageId = randomUUID().slice(0, 8); + const labelPrefix = p.label ? `[${p.label}]\n\n` : ""; + const messageBody: Record = { + role: "assistant", + content: [{ type: "text", text: `${labelPrefix}${p.message}` }], + timestamp: now, + stopReason: "injected", + usage: { input: 0, output: 0, totalTokens: 0 }, + }; + const transcriptEntry = { + type: "message", + id: messageId, + timestamp: new Date(now).toISOString(), + message: messageBody, + }; + + // Append to transcript file + try { + fs.appendFileSync(transcriptPath, `${JSON.stringify(transcriptEntry)}\n`, "utf-8"); + } catch (err) { + const errMessage = err instanceof Error ? err.message : String(err); + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, `failed to write transcript: ${errMessage}`)); + return; + } + + // Broadcast to webchat for immediate UI update + const chatPayload = { + runId: `inject-${messageId}`, + sessionKey: p.sessionKey, + seq: 0, + state: "final" as const, + message: transcriptEntry.message, + }; + context.broadcast("chat", chatPayload); + context.bridgeSendToSession(p.sessionKey, "chat", chatPayload); + + respond(true, { ok: true, messageId }); + }, }; diff --git a/src/gateway/server.chat.gateway-server-chat.test.ts b/src/gateway/server.chat.gateway-server-chat.test.ts index 75b326ec3..147196653 100644 --- a/src/gateway/server.chat.gateway-server-chat.test.ts +++ b/src/gateway/server.chat.gateway-server-chat.test.ts @@ -381,6 +381,60 @@ describe("gateway server chat", () => { await server.close(); }); + test("chat.inject appends to the session transcript", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); + testState.sessionStorePath = path.join(dir, "sessions.json"); + const transcriptPath = path.join(dir, "sess-main.jsonl"); + + await fs.writeFile( + transcriptPath, + `${JSON.stringify({ + type: "message", + id: "m1", + timestamp: new Date().toISOString(), + message: { role: "user", content: [{ type: "text", text: "seed" }], timestamp: Date.now() }, + })}\n`, + "utf-8", + ); + + await fs.writeFile( + testState.sessionStorePath, + JSON.stringify( + { + main: { + sessionId: "sess-main", + updatedAt: Date.now(), + }, + }, + null, + 2, + ), + "utf-8", + ); + + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + const res = await rpcReq<{ messageId?: string }>(ws, "chat.inject", { + sessionKey: "main", + message: "injected text", + label: "note", + }); + expect(res.ok).toBe(true); + + const raw = await fs.readFile(transcriptPath, "utf-8"); + const lines = raw.split(/\r?\n/).filter(Boolean); + expect(lines.length).toBe(2); + const last = JSON.parse(lines[1]) as { + message?: { role?: string; content?: Array<{ text?: string }> }; + }; + expect(last.message?.role).toBe("assistant"); + expect(last.message?.content?.[0]?.text).toContain("injected text"); + + ws.close(); + await server.close(); + }); + test("chat.history defaults thinking to low for reasoning-capable models", async () => { piSdkMock.enabled = true; piSdkMock.models = [