feat: refine subagents + add chat.inject

Co-authored-by: Tyler Yust <tyler6204@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-01-15 23:06:58 +00:00
parent 688a0ce439
commit a4b347b454
22 changed files with 632 additions and 533 deletions

View File

@@ -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.<provider>.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 <command> --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.<provider>.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

View File

@@ -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<string, string>();
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);
});
});

View File

@@ -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<string, string>();
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);
});
});

View File

@@ -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<string, string>();
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();

View File

@@ -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,

View File

@@ -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,

View File

@@ -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),

View File

@@ -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 {

View File

@@ -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");
});
});

View File

@@ -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: <summary>",
"Notes: <extra context>",
'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.

View File

@@ -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();
});
});

View File

@@ -13,7 +13,9 @@ type PersistedSubagentRegistry = {
const REGISTRY_VERSION = 1 as const;
type PersistedSubagentRunRecord = Omit<SubagentRunRecord, "announceHandled">;
type PersistedSubagentRunRecord = Omit<SubagentRunRecord, "announceHandled"> & {
announceHandled?: boolean;
};
export function resolveSubagentRegistryPath(): string {
return path.join(STATE_DIR_CLAWDBOT, "subagents", "runs.json");
@@ -32,12 +34,27 @@ export function loadSubagentRegistryFromDisk(): Map<string, SubagentRunRecord> {
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;

View File

@@ -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<string, SubagentRunRecord>();
@@ -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

View File

@@ -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:<id>]] replies to a specific message id when you have it.",
"Whitespace inside the tag is allowed (e.g. [[ reply_to_current ]] / [[ reply_to: 123 ]]).",
"Tags are stripped before sending; support depends on the current channel config.",
"",
"## 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:<id>]] replies to a specific message id when you have it.",
"Whitespace inside the tag is allowed (e.g. [[ reply_to_current ]] / [[ reply_to: 123 ]]).",
"Tags are stripped before sending; support depends on the current channel config.",
"",
"### message tool",
"- Use `message` for proactive sends + channel actions (polls, reactions, etc.).",
"- For `action=send`, include `to` and `message`.",
`- If multiple channels are configured, pass \`channel\` (${messageChannelOptions}).`,
inlineButtonsEnabled
? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)."
: runtimeChannel
? `- Inline buttons not enabled for ${runtimeChannel}. If you need them, ask to add "inlineButtons" to ${runtimeChannel}.capabilities or ${runtimeChannel}.accounts.<id>.capabilities.`
: "",
]
.filter(Boolean)
.join("\n")
: "",
"",
]),
// Skip messaging section for subagent/none modes
...(isMinimal
? []
: [
"## Messaging",
"- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)",
"- Cross-session messaging → use sessions_send(sessionKey, message)",
"- Never use exec/curl for provider messaging; Clawdbot handles all routing internally.",
availableTools.has("message")
? [
"",
"### message tool",
"- Use `message` for proactive sends + channel actions (polls, reactions, etc.).",
"- For `action=send`, include `to` and `message`.",
`- If multiple channels are configured, pass \`channel\` (${messageChannelOptions}).`,
inlineButtonsEnabled
? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)."
: runtimeChannel
? `- Inline buttons not enabled for ${runtimeChannel}. If you need them, ask to add "inlineButtons" to ${runtimeChannel}.capabilities or ${runtimeChannel}.accounts.<id>.capabilities.`
: "",
]
.filter(Boolean)
.join("\n")
: "",
"",
]),
];
if (extraSystemPrompt) {
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}` : "",

View File

@@ -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;

View File

@@ -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>(LogsTailParams
export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema);
export const validateChatSendParams = ajv.compile(ChatSendParamsSchema);
export const validateChatAbortParams = ajv.compile<ChatAbortParams>(ChatAbortParamsSchema);
export const validateChatInjectParams = ajv.compile<ChatInjectParams>(ChatInjectParamsSchema);
export const validateChatEvent = ajv.compile(ChatEventSchema);
export const validateUpdateRunParams = ajv.compile<UpdateRunParams>(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,
};

View File

@@ -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,

View File

@@ -62,6 +62,7 @@ import {
ChatAbortParamsSchema,
ChatEventSchema,
ChatHistoryParamsSchema,
ChatInjectParamsSchema,
ChatSendParamsSchema,
LogsTailParamsSchema,
LogsTailResultSchema,
@@ -172,6 +173,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
ChatHistoryParams: ChatHistoryParamsSchema,
ChatSendParams: ChatSendParamsSchema,
ChatAbortParams: ChatAbortParamsSchema,
ChatInjectParams: ChatInjectParamsSchema,
ChatEvent: ChatEventSchema,
UpdateRunParams: UpdateRunParamsSchema,
TickEvent: TickEventSchema,

View File

@@ -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<typeof CronRunLogEntrySchema>;
export type LogsTailParams = Static<typeof LogsTailParamsSchema>;
export type LogsTailResult = Static<typeof LogsTailResultSchema>;
export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
export type ChatInjectParams = Static<typeof ChatInjectParamsSchema>;
export type ChatEvent = Static<typeof ChatEventSchema>;
export type UpdateRunParams = Static<typeof UpdateRunParamsSchema>;
export type TickEvent = Static<typeof TickEventSchema>;

View File

@@ -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<string, unknown> = {
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 = {

View File

@@ -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<string, unknown> = {
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 });
},
};

View File

@@ -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 = [