feat: refine subagents + add chat.inject
Co-authored-by: Tyler Yust <tyler6204@users.noreply.github.com>
This commit is contained in:
144
CHANGELOG.md
144
CHANGELOG.md
@@ -11,6 +11,7 @@
|
|||||||
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
|
- 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.
|
- 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: 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.
|
- 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: 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.
|
- 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.
|
- Security: expanded `clawdbot security audit` (+ `--fix`), detect-secrets CI scan, and a `SECURITY.md` reporting policy.
|
||||||
|
|
||||||
### Changes
|
### 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: 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.
|
- 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: expand gateway security hardening guidance and incident response checklist.
|
||||||
- Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.
|
- 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)
|
- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.
|
||||||
- Docs: add per-command CLI doc pages and link them from `clawdbot <command> --help`.
|
- Tools: add `web_search`/`web_fetch` (Brave API), auto-enable `web_fetch` for sandboxed sessions, and remove the `brave-search` skill.
|
||||||
- Docs: add multi-gateway guide (sidebar + nav).
|
- 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
|
### Fixes
|
||||||
|
- Sessions: refactor session store updates to lock + mutate per-entry, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
||||||
#### 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.
|
|
||||||
- Browser: add tests for snapshot labels/efficient query params and labeled image responses.
|
- 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.
|
- 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.
|
- 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: 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
|
||||||
- Heartbeat: keep `updatedAt` monotonic when restoring heartbeat sessions. (#934) — thanks @ronak-guliani.
|
- Sandbox: restore `docker.binds` config validation for custom bind mounts. (#873) — thanks @akonyer.
|
||||||
- Agent: clear run context after CLI runs (`clearAgentRunContext`) to avoid runaway contexts. (#934) — thanks @ronak-guliani.
|
- Sandbox: preserve configured PATH for `docker exec` so custom tools remain available. (#873) — thanks @akonyer.
|
||||||
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`).
|
- 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
|
## 2026.1.14
|
||||||
- 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.
|
|
||||||
|
|
||||||
#### Control UI / TUI
|
### Changes
|
||||||
- Control UI: load cron run history on job selection and clarify empty-state messaging. (#866)
|
- Usage: add MiniMax coding plan usage tracking.
|
||||||
- UI: use application-defined WebSocket close code and fix dashboard auth query items. (#918) — thanks @rahthakor.
|
- Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.
|
||||||
- UI: always apply `?token=` from URL (fixes unauthorized after re-onboard).
|
- Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)
|
||||||
- Browser: add tests for snapshot labels/efficient query params and labeled image responses.
|
- 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: 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: 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)`.
|
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`).
|
||||||
|
|
||||||
#### 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.
|
|
||||||
- macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis.
|
- 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: 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: split long captions into media + follow-up text messages. (#907) - thanks @jalehman.
|
||||||
- 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: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.
|
- 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.
|
- 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: 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: 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
|
## 2026.1.13
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
resetSubagentRegistryForTests();
|
||||||
callGatewayMock.mockReset();
|
callGatewayMock.mockReset();
|
||||||
const calls: Array<{ method?: string; params?: unknown }> = [];
|
const calls: Array<{ method?: string; params?: unknown }> = [];
|
||||||
let agentCallCount = 0;
|
let agentCallCount = 0;
|
||||||
let sendParams: { to?: string; channel?: string; message?: string } = {};
|
|
||||||
let deletedKey: string | undefined;
|
let deletedKey: string | undefined;
|
||||||
let childRunId: string | undefined;
|
let childRunId: string | undefined;
|
||||||
let childSessionKey: string | undefined;
|
let childSessionKey: string | undefined;
|
||||||
const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = [];
|
const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = [];
|
||||||
const sessionLastAssistantText = new Map<string, string>();
|
|
||||||
|
|
||||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||||
const request = opts as { method?: string; params?: unknown };
|
const request = opts as { method?: string; params?: unknown };
|
||||||
@@ -57,15 +55,12 @@ describe("clawdbot-tools: subagents", () => {
|
|||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
channel?: string;
|
channel?: string;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
|
lane?: string;
|
||||||
};
|
};
|
||||||
const message = params?.message ?? "";
|
// Only capture the first agent call (subagent spawn, not main agent trigger)
|
||||||
const sessionKey = params?.sessionKey ?? "";
|
if (params?.lane === "subagent") {
|
||||||
if (message === "Sub-agent announce step.") {
|
|
||||||
sessionLastAssistantText.set(sessionKey, "announce now");
|
|
||||||
} else {
|
|
||||||
childRunId = runId;
|
childRunId = runId;
|
||||||
childSessionKey = sessionKey;
|
childSessionKey = params?.sessionKey ?? "";
|
||||||
sessionLastAssistantText.set(sessionKey, "result");
|
|
||||||
expect(params?.channel).toBe("discord");
|
expect(params?.channel).toBe("discord");
|
||||||
expect(params?.timeout).toBe(1);
|
expect(params?.timeout).toBe(1);
|
||||||
}
|
}
|
||||||
@@ -85,24 +80,6 @@ describe("clawdbot-tools: subagents", () => {
|
|||||||
endedAt: 4000,
|
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") {
|
if (request.method === "sessions.delete") {
|
||||||
const params = request.params as { key?: string } | undefined;
|
const params = request.params as { key?: string } | undefined;
|
||||||
deletedKey = params?.key;
|
deletedKey = params?.key;
|
||||||
@@ -135,19 +112,24 @@ describe("clawdbot-tools: subagents", () => {
|
|||||||
expect(childWait?.timeoutMs).toBe(1000);
|
expect(childWait?.timeoutMs).toBe(1000);
|
||||||
expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||||
|
|
||||||
|
// Two agent calls: subagent spawn + main agent trigger
|
||||||
const agentCalls = calls.filter((call) => call.method === "agent");
|
const agentCalls = calls.filter((call) => call.method === "agent");
|
||||||
expect(agentCalls).toHaveLength(2);
|
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");
|
// First call: subagent spawn
|
||||||
expect(sendParams.to).toBe("channel:req");
|
const first = agentCalls[0]?.params as { lane?: string } | undefined;
|
||||||
expect(sendParams.message ?? "").toContain("announce now");
|
expect(first?.lane).toBe("subagent");
|
||||||
expect(sendParams.message ?? "").toContain("Stats:");
|
|
||||||
|
// 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);
|
expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
resetSubagentRegistryForTests();
|
||||||
callGatewayMock.mockReset();
|
callGatewayMock.mockReset();
|
||||||
const calls: Array<{ method?: string; params?: unknown }> = [];
|
const calls: Array<{ method?: string; params?: unknown }> = [];
|
||||||
let agentCallCount = 0;
|
let agentCallCount = 0;
|
||||||
let sendParams: { to?: string; channel?: string; message?: string } = {};
|
|
||||||
let deletedKey: string | undefined;
|
let deletedKey: string | undefined;
|
||||||
let childRunId: string | undefined;
|
let childRunId: string | undefined;
|
||||||
let childSessionKey: string | undefined;
|
let childSessionKey: string | undefined;
|
||||||
const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = [];
|
const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = [];
|
||||||
const sessionLastAssistantText = new Map<string, string>();
|
|
||||||
|
|
||||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||||
const request = opts as { method?: string; params?: unknown };
|
const request = opts as { method?: string; params?: unknown };
|
||||||
@@ -58,15 +56,12 @@ describe("clawdbot-tools: subagents", () => {
|
|||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
channel?: string;
|
channel?: string;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
|
lane?: string;
|
||||||
};
|
};
|
||||||
const message = params?.message ?? "";
|
// Only capture the first agent call (subagent spawn, not main agent trigger)
|
||||||
const sessionKey = params?.sessionKey ?? "";
|
if (params?.lane === "subagent") {
|
||||||
if (message === "Sub-agent announce step.") {
|
|
||||||
sessionLastAssistantText.set(sessionKey, "announce now");
|
|
||||||
} else {
|
|
||||||
childRunId = runId;
|
childRunId = runId;
|
||||||
childSessionKey = sessionKey;
|
childSessionKey = params?.sessionKey ?? "";
|
||||||
sessionLastAssistantText.set(sessionKey, "result");
|
|
||||||
expect(params?.channel).toBe("discord");
|
expect(params?.channel).toBe("discord");
|
||||||
expect(params?.timeout).toBe(1);
|
expect(params?.timeout).toBe(1);
|
||||||
}
|
}
|
||||||
@@ -79,26 +74,8 @@ describe("clawdbot-tools: subagents", () => {
|
|||||||
if (request.method === "agent.wait") {
|
if (request.method === "agent.wait") {
|
||||||
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
|
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
|
||||||
waitCalls.push(params ?? {});
|
waitCalls.push(params ?? {});
|
||||||
const status = params?.runId === childRunId ? "timeout" : "ok";
|
// Return "ok" with timing info for the child run
|
||||||
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: "m-announce" };
|
|
||||||
}
|
}
|
||||||
if (request.method === "sessions.delete") {
|
if (request.method === "sessions.delete") {
|
||||||
const params = request.params as { key?: string } | undefined;
|
const params = request.params as { key?: string } | undefined;
|
||||||
@@ -141,8 +118,12 @@ describe("clawdbot-tools: subagents", () => {
|
|||||||
|
|
||||||
const childWait = waitCalls.find((call) => call.runId === childRunId);
|
const childWait = waitCalls.find((call) => call.runId === childRunId);
|
||||||
expect(childWait?.timeoutMs).toBe(1000);
|
expect(childWait?.timeoutMs).toBe(1000);
|
||||||
|
|
||||||
|
// Two agent calls: subagent spawn + main agent trigger
|
||||||
const agentCalls = calls.filter((call) => call.method === "agent");
|
const agentCalls = calls.filter((call) => call.method === "agent");
|
||||||
expect(agentCalls).toHaveLength(2);
|
expect(agentCalls).toHaveLength(2);
|
||||||
|
|
||||||
|
// First call: subagent spawn
|
||||||
const first = agentCalls[0]?.params as
|
const first = agentCalls[0]?.params as
|
||||||
| {
|
| {
|
||||||
lane?: string;
|
lane?: string;
|
||||||
@@ -156,17 +137,24 @@ describe("clawdbot-tools: subagents", () => {
|
|||||||
expect(first?.channel).toBe("discord");
|
expect(first?.channel).toBe("discord");
|
||||||
expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||||
expect(childSessionKey?.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");
|
// Second call: main agent trigger with announce message
|
||||||
expect(sendParams.to).toBe("channel:req");
|
const second = agentCalls[1]?.params as
|
||||||
expect(sendParams.message ?? "").toContain("announce now");
|
| {
|
||||||
expect(sendParams.message ?? "").toContain("Stats:");
|
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);
|
expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
resetSubagentRegistryForTests();
|
||||||
callGatewayMock.mockReset();
|
callGatewayMock.mockReset();
|
||||||
const calls: Array<{ method?: string; params?: unknown }> = [];
|
const calls: Array<{ method?: string; params?: unknown }> = [];
|
||||||
let agentCallCount = 0;
|
let agentCallCount = 0;
|
||||||
let sendParams: { to?: string; channel?: string; message?: string } = {};
|
|
||||||
let childRunId: string | undefined;
|
let childRunId: string | undefined;
|
||||||
let childSessionKey: string | undefined;
|
let childSessionKey: string | undefined;
|
||||||
const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = [];
|
const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = [];
|
||||||
const sessionLastAssistantText = new Map<string, string>();
|
let patchParams: { key?: string; label?: string } = {};
|
||||||
|
|
||||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||||
const request = opts as { method?: string; params?: unknown };
|
const request = opts as { method?: string; params?: unknown };
|
||||||
@@ -66,15 +65,12 @@ describe("clawdbot-tools: subagents", () => {
|
|||||||
const params = request.params as {
|
const params = request.params as {
|
||||||
message?: string;
|
message?: string;
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
|
lane?: string;
|
||||||
};
|
};
|
||||||
const message = params?.message ?? "";
|
// Only capture the first agent call (subagent spawn, not main agent trigger)
|
||||||
const sessionKey = params?.sessionKey ?? "";
|
if (params?.lane === "subagent") {
|
||||||
if (message === "Sub-agent announce step.") {
|
|
||||||
sessionLastAssistantText.set(sessionKey, "hello from sub");
|
|
||||||
} else {
|
|
||||||
childRunId = runId;
|
childRunId = runId;
|
||||||
childSessionKey = sessionKey;
|
childSessionKey = params?.sessionKey ?? "";
|
||||||
sessionLastAssistantText.set(sessionKey, "done");
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
runId,
|
runId,
|
||||||
@@ -85,26 +81,12 @@ describe("clawdbot-tools: subagents", () => {
|
|||||||
if (request.method === "agent.wait") {
|
if (request.method === "agent.wait") {
|
||||||
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
|
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
|
||||||
waitCalls.push(params ?? {});
|
waitCalls.push(params ?? {});
|
||||||
const status = params?.runId === childRunId ? "timeout" : "ok";
|
return { runId: params?.runId ?? "run-1", status: "ok", startedAt: 1000, endedAt: 2000 };
|
||||||
return { runId: params?.runId ?? "run-1", status };
|
|
||||||
}
|
}
|
||||||
if (request.method === "chat.history") {
|
if (request.method === "sessions.patch") {
|
||||||
const params = request.params as { sessionKey?: string } | undefined;
|
const params = request.params as { key?: string; label?: string } | undefined;
|
||||||
const text = sessionLastAssistantText.get(params?.sessionKey ?? "") ?? "";
|
patchParams = { key: params?.key, label: params?.label };
|
||||||
return {
|
return { ok: true };
|
||||||
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.delete") {
|
if (request.method === "sessions.delete") {
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
@@ -121,6 +103,7 @@ describe("clawdbot-tools: subagents", () => {
|
|||||||
const result = await tool.execute("call2", {
|
const result = await tool.execute("call2", {
|
||||||
task: "do thing",
|
task: "do thing",
|
||||||
runTimeoutSeconds: 1,
|
runTimeoutSeconds: 1,
|
||||||
|
label: "my-task",
|
||||||
});
|
});
|
||||||
expect(result.details).toMatchObject({
|
expect(result.details).toMatchObject({
|
||||||
status: "accepted",
|
status: "accepted",
|
||||||
@@ -144,12 +127,29 @@ describe("clawdbot-tools: subagents", () => {
|
|||||||
|
|
||||||
const childWait = waitCalls.find((call) => call.runId === childRunId);
|
const childWait = waitCalls.find((call) => call.runId === childRunId);
|
||||||
expect(childWait?.timeoutMs).toBe(1000);
|
expect(childWait?.timeoutMs).toBe(1000);
|
||||||
expect(sendParams.channel).toBe("whatsapp");
|
// Cleanup should patch the label
|
||||||
expect(sendParams.to).toBe("+123");
|
expect(patchParams.key).toBe(childSessionKey);
|
||||||
expect(sendParams.message ?? "").toContain("hello from sub");
|
expect(patchParams.label).toBe("my-task");
|
||||||
expect(sendParams.message ?? "").toContain("Stats:");
|
|
||||||
|
// 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);
|
expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sessions_spawn only allows same-agent by default", async () => {
|
it("sessions_spawn only allows same-agent by default", async () => {
|
||||||
resetSubagentRegistryForTests();
|
resetSubagentRegistryForTests();
|
||||||
callGatewayMock.mockReset();
|
callGatewayMock.mockReset();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { ClawdbotConfig } from "../../config/config.js";
|
|||||||
import { getMachineDisplayName } from "../../infra/machine-name.js";
|
import { getMachineDisplayName } from "../../infra/machine-name.js";
|
||||||
import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js";
|
import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js";
|
||||||
import { normalizeMessageChannel } from "../../utils/message-channel.js";
|
import { normalizeMessageChannel } from "../../utils/message-channel.js";
|
||||||
|
import { isSubagentSessionKey } from "../../routing/session-key.js";
|
||||||
import { isReasoningTagProvider } from "../../utils/provider-utils.js";
|
import { isReasoningTagProvider } from "../../utils/provider-utils.js";
|
||||||
import { resolveUserPath } from "../../utils.js";
|
import { resolveUserPath } from "../../utils.js";
|
||||||
import { resolveClawdbotAgentDir } from "../agent-paths.js";
|
import { resolveClawdbotAgentDir } from "../agent-paths.js";
|
||||||
@@ -230,6 +231,7 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
config: params.config,
|
config: params.config,
|
||||||
});
|
});
|
||||||
const isDefaultAgent = sessionAgentId === defaultAgentId;
|
const isDefaultAgent = sessionAgentId === defaultAgentId;
|
||||||
|
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
|
||||||
const appendPrompt = buildEmbeddedSystemPrompt({
|
const appendPrompt = buildEmbeddedSystemPrompt({
|
||||||
workspaceDir: effectiveWorkspace,
|
workspaceDir: effectiveWorkspace,
|
||||||
defaultThinkLevel: params.thinkLevel,
|
defaultThinkLevel: params.thinkLevel,
|
||||||
@@ -241,6 +243,7 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
|
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
|
||||||
: undefined,
|
: undefined,
|
||||||
skillsPrompt,
|
skillsPrompt,
|
||||||
|
promptMode,
|
||||||
runtimeInfo,
|
runtimeInfo,
|
||||||
sandboxInfo,
|
sandboxInfo,
|
||||||
tools,
|
tools,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { getMachineDisplayName } from "../../../infra/machine-name.js";
|
|||||||
import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js";
|
import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js";
|
||||||
import { normalizeMessageChannel } from "../../../utils/message-channel.js";
|
import { normalizeMessageChannel } from "../../../utils/message-channel.js";
|
||||||
import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
|
import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
|
||||||
|
import { isSubagentSessionKey } from "../../../routing/session-key.js";
|
||||||
import { resolveUserPath } from "../../../utils.js";
|
import { resolveUserPath } from "../../../utils.js";
|
||||||
import { resolveClawdbotAgentDir } from "../../agent-paths.js";
|
import { resolveClawdbotAgentDir } from "../../agent-paths.js";
|
||||||
import { resolveSessionAgentIds } from "../../agent-scope.js";
|
import { resolveSessionAgentIds } from "../../agent-scope.js";
|
||||||
@@ -189,6 +190,7 @@ export async function runEmbeddedAttempt(
|
|||||||
config: params.config,
|
config: params.config,
|
||||||
});
|
});
|
||||||
const isDefaultAgent = sessionAgentId === defaultAgentId;
|
const isDefaultAgent = sessionAgentId === defaultAgentId;
|
||||||
|
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
|
||||||
|
|
||||||
const appendPrompt = buildEmbeddedSystemPrompt({
|
const appendPrompt = buildEmbeddedSystemPrompt({
|
||||||
workspaceDir: effectiveWorkspace,
|
workspaceDir: effectiveWorkspace,
|
||||||
@@ -202,6 +204,7 @@ export async function runEmbeddedAttempt(
|
|||||||
: undefined,
|
: undefined,
|
||||||
skillsPrompt,
|
skillsPrompt,
|
||||||
reactionGuidance,
|
reactionGuidance,
|
||||||
|
promptMode,
|
||||||
runtimeInfo,
|
runtimeInfo,
|
||||||
sandboxInfo,
|
sandboxInfo,
|
||||||
tools,
|
tools,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||||
import type { ResolvedTimeFormat } from "../date-time.js";
|
import type { ResolvedTimeFormat } from "../date-time.js";
|
||||||
import type { EmbeddedContextFile } from "../pi-embedded-helpers.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 { buildToolSummaryMap } from "../tool-summaries.js";
|
||||||
import type { EmbeddedSandboxInfo } from "./types.js";
|
import type { EmbeddedSandboxInfo } from "./types.js";
|
||||||
import type { ReasoningLevel, ThinkLevel } from "./utils.js";
|
import type { ReasoningLevel, ThinkLevel } from "./utils.js";
|
||||||
@@ -19,6 +19,8 @@ export function buildEmbeddedSystemPrompt(params: {
|
|||||||
level: "minimal" | "extensive";
|
level: "minimal" | "extensive";
|
||||||
channel: string;
|
channel: string;
|
||||||
};
|
};
|
||||||
|
/** Controls which hardcoded sections to include. Defaults to "full". */
|
||||||
|
promptMode?: PromptMode;
|
||||||
runtimeInfo: {
|
runtimeInfo: {
|
||||||
host: string;
|
host: string;
|
||||||
os: string;
|
os: string;
|
||||||
@@ -47,6 +49,7 @@ export function buildEmbeddedSystemPrompt(params: {
|
|||||||
heartbeatPrompt: params.heartbeatPrompt,
|
heartbeatPrompt: params.heartbeatPrompt,
|
||||||
skillsPrompt: params.skillsPrompt,
|
skillsPrompt: params.skillsPrompt,
|
||||||
reactionGuidance: params.reactionGuidance,
|
reactionGuidance: params.reactionGuidance,
|
||||||
|
promptMode: params.promptMode,
|
||||||
runtimeInfo: params.runtimeInfo,
|
runtimeInfo: params.runtimeInfo,
|
||||||
sandboxInfo: params.sandboxInfo,
|
sandboxInfo: params.sandboxInfo,
|
||||||
toolNames: params.tools.map((tool) => tool.name),
|
toolNames: params.tools.map((tool) => tool.name),
|
||||||
|
|||||||
@@ -5,10 +5,22 @@ import type { SandboxToolPolicy } from "./sandbox.js";
|
|||||||
import { expandToolGroups, normalizeToolName } from "./tool-policy.js";
|
import { expandToolGroups, normalizeToolName } from "./tool-policy.js";
|
||||||
|
|
||||||
const DEFAULT_SUBAGENT_TOOL_DENY = [
|
const DEFAULT_SUBAGENT_TOOL_DENY = [
|
||||||
|
// Session management - main agent orchestrates
|
||||||
"sessions_list",
|
"sessions_list",
|
||||||
"sessions_history",
|
"sessions_history",
|
||||||
"sessions_send",
|
"sessions_send",
|
||||||
"sessions_spawn",
|
"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 {
|
export function resolveSubagentToolPolicy(cfg?: ClawdbotConfig): SandboxToolPolicy {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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", () => ({
|
vi.mock("../gateway/call.js", () => ({
|
||||||
callGateway: vi.fn(async (req: unknown) => {
|
callGateway: vi.fn(async (req: unknown) => {
|
||||||
const typed = req as { method?: string; params?: { message?: string } };
|
const typed = req as { method?: string; params?: { message?: string; sessionKey?: string } };
|
||||||
if (typed.method === "send") {
|
if (typed.method === "agent") {
|
||||||
return await sendSpy(typed);
|
return await agentSpy(typed);
|
||||||
}
|
}
|
||||||
if (typed.method === "agent.wait") {
|
if (typed.method === "agent.wait") {
|
||||||
return { status: "error", startedAt: 10, endedAt: 20, error: "boom" };
|
return { status: "error", startedAt: 10, endedAt: 20, error: "boom" };
|
||||||
@@ -18,24 +18,11 @@ vi.mock("../gateway/call.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./tools/agent-step.js", () => ({
|
vi.mock("./tools/agent-step.js", () => ({
|
||||||
runAgentStep: vi.fn(async () => "did some stuff"),
|
|
||||||
readLatestAssistantReply: vi.fn(async () => "raw subagent reply"),
|
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", () => ({
|
vi.mock("../config/sessions.js", () => ({
|
||||||
loadSessionStore: vi.fn(async () => ({ entries: {} })),
|
loadSessionStore: vi.fn(() => ({})),
|
||||||
resolveAgentIdFromSessionKey: () => "main",
|
resolveAgentIdFromSessionKey: () => "main",
|
||||||
resolveStorePath: () => "/tmp/sessions.json",
|
resolveStorePath: () => "/tmp/sessions.json",
|
||||||
}));
|
}));
|
||||||
@@ -48,10 +35,10 @@ vi.mock("../config/config.js", () => ({
|
|||||||
|
|
||||||
describe("subagent announce formatting", () => {
|
describe("subagent announce formatting", () => {
|
||||||
beforeEach(() => {
|
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");
|
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||||
await runSubagentAnnounceFlow({
|
await runSubagentAnnounceFlow({
|
||||||
childSessionKey: "agent:main:subagent:test",
|
childSessionKey: "agent:main:subagent:test",
|
||||||
@@ -66,22 +53,21 @@ describe("subagent announce formatting", () => {
|
|||||||
endedAt: 20,
|
endedAt: 20,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(sendSpy).toHaveBeenCalled();
|
expect(agentSpy).toHaveBeenCalled();
|
||||||
const msg = sendSpy.mock.calls[0]?.[0]?.params?.message as string;
|
const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string; sessionKey?: string } };
|
||||||
expect(msg).toContain("Status:");
|
const msg = call?.params?.message as string;
|
||||||
expect(msg).toContain("Status: error");
|
expect(call?.params?.sessionKey).toBe("agent:main:main");
|
||||||
expect(msg).toContain("Result:");
|
expect(msg).toContain("background task");
|
||||||
expect(msg).toContain("Notes:");
|
expect(msg).toContain("failed");
|
||||||
expect(msg).toContain("boom");
|
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 () => {
|
it("includes success status when outcome is ok", 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",
|
|
||||||
);
|
|
||||||
|
|
||||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||||
|
// Use waitForCompletion: false so it uses the provided outcome instead of calling agent.wait
|
||||||
await runSubagentAnnounceFlow({
|
await runSubagentAnnounceFlow({
|
||||||
childSessionKey: "agent:main:subagent:test",
|
childSessionKey: "agent:main:subagent:test",
|
||||||
childRunId: "run-456",
|
childRunId: "run-456",
|
||||||
@@ -90,14 +76,14 @@ describe("subagent announce formatting", () => {
|
|||||||
task: "do thing",
|
task: "do thing",
|
||||||
timeoutMs: 1000,
|
timeoutMs: 1000,
|
||||||
cleanup: "keep",
|
cleanup: "keep",
|
||||||
waitForCompletion: true,
|
waitForCompletion: false,
|
||||||
startedAt: 10,
|
startedAt: 10,
|
||||||
endedAt: 20,
|
endedAt: 20,
|
||||||
|
outcome: { status: "ok" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const msg = sendSpy.mock.calls[0]?.[0]?.params?.message as string;
|
const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } };
|
||||||
expect(msg).toContain("Status: error");
|
const msg = call?.params?.message as string;
|
||||||
expect(msg).toContain("Result:");
|
expect(msg).toContain("completed successfully");
|
||||||
expect(msg).toContain("Notes:");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,11 +8,7 @@ import {
|
|||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
import { callGateway } from "../gateway/call.js";
|
import { callGateway } from "../gateway/call.js";
|
||||||
import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js";
|
import { readLatestAssistantReply } from "./tools/agent-step.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";
|
|
||||||
|
|
||||||
function formatDurationShort(valueMs?: number) {
|
function formatDurationShort(valueMs?: number) {
|
||||||
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) return undefined;
|
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) return undefined;
|
||||||
@@ -149,27 +145,27 @@ export function buildSubagentSystemPrompt(params: {
|
|||||||
"",
|
"",
|
||||||
"## Your Role",
|
"## Your Role",
|
||||||
`- You were created to handle: ${taskText}`,
|
`- 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.",
|
"- You are NOT the main agent. Don't try to be.",
|
||||||
"",
|
"",
|
||||||
"## Rules",
|
"## Rules",
|
||||||
"1. **Stay focused** - Do your assigned task, nothing else",
|
"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",
|
"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",
|
"4. **Be ephemeral** - You may be terminated after task completion. That's fine.",
|
||||||
"5. **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",
|
"## What You DON'T Do",
|
||||||
"- NO user conversations (that's main agent's job)",
|
"- NO user conversations (that's main agent's job)",
|
||||||
"- NO external messages (email, tweets, etc.) unless explicitly tasked",
|
"- NO external messages (email, tweets, etc.) unless explicitly tasked",
|
||||||
"- NO cron jobs or persistent state",
|
"- NO cron jobs or persistent state",
|
||||||
"- NO pretending to be the main agent",
|
"- NO pretending to be the main agent",
|
||||||
"",
|
"- NO using the `message` tool directly",
|
||||||
"## Output Format",
|
|
||||||
"When complete, respond with:",
|
|
||||||
"- **Status:** success | failed | blocked",
|
|
||||||
"- **Result:** [what you accomplished]",
|
|
||||||
"- **Notes:** [anything the main agent should know] - discuss gimme options",
|
|
||||||
"",
|
"",
|
||||||
"## Session Context",
|
"## Session Context",
|
||||||
params.label ? `- Label: ${params.label}` : undefined,
|
params.label ? `- Label: ${params.label}` : undefined,
|
||||||
@@ -177,8 +173,6 @@ export function buildSubagentSystemPrompt(params: {
|
|||||||
params.requesterChannel ? `- Requester channel: ${params.requesterChannel}.` : undefined,
|
params.requesterChannel ? `- Requester channel: ${params.requesterChannel}.` : undefined,
|
||||||
`- Your session: ${params.childSessionKey}.`,
|
`- 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);
|
].filter((line): line is string => line !== undefined);
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
@@ -188,109 +182,6 @@ export type SubagentRunOutcome = {
|
|||||||
error?: string;
|
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: {
|
export async function runSubagentAnnounceFlow(params: {
|
||||||
childSessionKey: string;
|
childSessionKey: string;
|
||||||
childRunId: string;
|
childRunId: string;
|
||||||
@@ -340,8 +231,6 @@ export async function runSubagentAnnounceFlow(params: {
|
|||||||
params.endedAt = wait.endedAt;
|
params.endedAt = wait.endedAt;
|
||||||
}
|
}
|
||||||
if (wait?.status === "timeout") {
|
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" };
|
if (!outcome) outcome = { status: "timeout" };
|
||||||
}
|
}
|
||||||
reply = await readLatestAssistantReply({
|
reply = await readLatestAssistantReply({
|
||||||
@@ -357,53 +246,50 @@ export async function runSubagentAnnounceFlow(params: {
|
|||||||
|
|
||||||
if (!outcome) outcome = { status: "unknown" };
|
if (!outcome) outcome = { status: "unknown" };
|
||||||
|
|
||||||
const announceTarget = await resolveAnnounceTarget({
|
// Build stats
|
||||||
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;
|
|
||||||
|
|
||||||
const statsLine = await buildSubagentStatsLine({
|
const statsLine = await buildSubagentStatsLine({
|
||||||
sessionKey: params.childSessionKey,
|
sessionKey: params.childSessionKey,
|
||||||
startedAt: params.startedAt,
|
startedAt: params.startedAt,
|
||||||
endedAt: params.endedAt,
|
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({
|
await callGateway({
|
||||||
method: "send",
|
method: "agent",
|
||||||
params: {
|
params: {
|
||||||
to: announceTarget.to,
|
sessionKey: params.requesterSessionKey,
|
||||||
message,
|
message: triggerMessage,
|
||||||
channel: announceTarget.channel,
|
deliver: true,
|
||||||
accountId: announceTarget.accountId,
|
|
||||||
idempotencyKey: crypto.randomUUID(),
|
idempotencyKey: crypto.randomUUID(),
|
||||||
},
|
},
|
||||||
timeoutMs: 10_000,
|
timeoutMs: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
didAnnounce = true;
|
didAnnounce = true;
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort follow-ups; ignore failures to avoid breaking the caller response.
|
// Best-effort follow-ups; ignore failures to avoid breaking the caller response.
|
||||||
|
|||||||
@@ -68,21 +68,24 @@ describe("subagent registry persistence", () => {
|
|||||||
const mod2 = await import("./subagent-registry.js");
|
const mod2 = await import("./subagent-registry.js");
|
||||||
mod2.initSubagentRegistry();
|
mod2.initSubagentRegistry();
|
||||||
|
|
||||||
// allow queued async wait/announce to execute
|
// allow queued async wait/cleanup to execute
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
expect(announceSpy).toHaveBeenCalled();
|
expect(announceSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
type AnnounceParams = {
|
type AnnounceParams = {
|
||||||
childRunId: string;
|
|
||||||
childSessionKey: string;
|
childSessionKey: string;
|
||||||
|
childRunId: string;
|
||||||
|
requesterSessionKey: string;
|
||||||
|
task: string;
|
||||||
|
cleanup: string;
|
||||||
|
label?: string;
|
||||||
};
|
};
|
||||||
const first = announceSpy.mock.calls[0]?.[0] as unknown as AnnounceParams;
|
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");
|
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-"));
|
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-subagent-"));
|
||||||
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
|
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
|
||||||
|
|
||||||
@@ -100,7 +103,7 @@ describe("subagent registry persistence", () => {
|
|||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
startedAt: 1,
|
startedAt: 1,
|
||||||
endedAt: 2,
|
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));
|
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 calls = announceSpy.mock.calls.map((call) => call[0]);
|
||||||
const match = calls.find(
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ type PersistedSubagentRegistry = {
|
|||||||
|
|
||||||
const REGISTRY_VERSION = 1 as const;
|
const REGISTRY_VERSION = 1 as const;
|
||||||
|
|
||||||
type PersistedSubagentRunRecord = Omit<SubagentRunRecord, "announceHandled">;
|
type PersistedSubagentRunRecord = Omit<SubagentRunRecord, "announceHandled"> & {
|
||||||
|
announceHandled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export function resolveSubagentRegistryPath(): string {
|
export function resolveSubagentRegistryPath(): string {
|
||||||
return path.join(STATE_DIR_CLAWDBOT, "subagents", "runs.json");
|
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;
|
if (!entry || typeof entry !== "object") continue;
|
||||||
const typed = entry as PersistedSubagentRunRecord;
|
const typed = entry as PersistedSubagentRunRecord;
|
||||||
if (!typed.runId || typeof typed.runId !== "string") continue;
|
if (!typed.runId || typeof typed.runId !== "string") continue;
|
||||||
|
// Back-compat: map legacy announce fields into cleanup fields.
|
||||||
const announceCompletedAt =
|
const announceCompletedAt =
|
||||||
typeof typed.announceCompletedAt === "number" ? typed.announceCompletedAt : undefined;
|
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, {
|
out.set(runId, {
|
||||||
...typed,
|
...typed,
|
||||||
announceCompletedAt,
|
announceCompletedAt,
|
||||||
announceHandled: Boolean(announceCompletedAt),
|
announceHandled,
|
||||||
|
cleanupCompletedAt,
|
||||||
|
cleanupHandled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -22,8 +22,12 @@ export type SubagentRunRecord = {
|
|||||||
endedAt?: number;
|
endedAt?: number;
|
||||||
outcome?: SubagentRunOutcome;
|
outcome?: SubagentRunOutcome;
|
||||||
archiveAtMs?: number;
|
archiveAtMs?: number;
|
||||||
|
/** @deprecated Use cleanupCompletedAt instead */
|
||||||
announceCompletedAt?: number;
|
announceCompletedAt?: number;
|
||||||
announceHandled: boolean;
|
/** @deprecated Use cleanupHandled instead */
|
||||||
|
announceHandled?: boolean;
|
||||||
|
cleanupCompletedAt?: number;
|
||||||
|
cleanupHandled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const subagentRuns = new Map<string, SubagentRunRecord>();
|
const subagentRuns = new Map<string, SubagentRunRecord>();
|
||||||
@@ -46,11 +50,11 @@ function resumeSubagentRun(runId: string) {
|
|||||||
if (!runId || resumedRuns.has(runId)) return;
|
if (!runId || resumedRuns.has(runId)) return;
|
||||||
const entry = subagentRuns.get(runId);
|
const entry = subagentRuns.get(runId);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
if (entry.announceCompletedAt) return;
|
if (entry.cleanupCompletedAt) return;
|
||||||
|
|
||||||
if (typeof entry.endedAt === "number" && entry.endedAt > 0) {
|
if (typeof entry.endedAt === "number" && entry.endedAt > 0) {
|
||||||
if (!beginSubagentAnnounce(runId)) return;
|
if (!beginSubagentCleanup(runId)) return;
|
||||||
const announce = runSubagentAnnounceFlow({
|
void runSubagentAnnounceFlow({
|
||||||
childSessionKey: entry.childSessionKey,
|
childSessionKey: entry.childSessionKey,
|
||||||
childRunId: entry.runId,
|
childRunId: entry.runId,
|
||||||
requesterSessionKey: entry.requesterSessionKey,
|
requesterSessionKey: entry.requesterSessionKey,
|
||||||
@@ -64,9 +68,8 @@ function resumeSubagentRun(runId: string) {
|
|||||||
endedAt: entry.endedAt,
|
endedAt: entry.endedAt,
|
||||||
label: entry.label,
|
label: entry.label,
|
||||||
outcome: entry.outcome,
|
outcome: entry.outcome,
|
||||||
});
|
}).then((didAnnounce) => {
|
||||||
void announce.then((didAnnounce) => {
|
finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce);
|
||||||
finalizeSubagentAnnounce(runId, entry.cleanup, didAnnounce);
|
|
||||||
});
|
});
|
||||||
resumedRuns.add(runId);
|
resumedRuns.add(runId);
|
||||||
return;
|
return;
|
||||||
@@ -156,7 +159,9 @@ async function sweepSubagentRuns() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureListener() {
|
function ensureListener() {
|
||||||
if (listenerStarted) return;
|
if (listenerStarted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
listenerStarted = true;
|
listenerStarted = true;
|
||||||
listenerStop = onAgentEvent((evt) => {
|
listenerStop = onAgentEvent((evt) => {
|
||||||
if (!evt || evt.stream !== "lifecycle") return;
|
if (!evt || evt.stream !== "lifecycle") return;
|
||||||
@@ -186,10 +191,10 @@ function ensureListener() {
|
|||||||
}
|
}
|
||||||
persistSubagentRuns();
|
persistSubagentRuns();
|
||||||
|
|
||||||
if (!beginSubagentAnnounce(evt.runId)) {
|
if (!beginSubagentCleanup(evt.runId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const announce = runSubagentAnnounceFlow({
|
void runSubagentAnnounceFlow({
|
||||||
childSessionKey: entry.childSessionKey,
|
childSessionKey: entry.childSessionKey,
|
||||||
childRunId: entry.runId,
|
childRunId: entry.runId,
|
||||||
requesterSessionKey: entry.requesterSessionKey,
|
requesterSessionKey: entry.requesterSessionKey,
|
||||||
@@ -203,14 +208,17 @@ function ensureListener() {
|
|||||||
endedAt: entry.endedAt,
|
endedAt: entry.endedAt,
|
||||||
label: entry.label,
|
label: entry.label,
|
||||||
outcome: entry.outcome,
|
outcome: entry.outcome,
|
||||||
});
|
}).then((didAnnounce) => {
|
||||||
void announce.then((didAnnounce) => {
|
finalizeSubagentCleanup(evt.runId, entry.cleanup, didAnnounce);
|
||||||
finalizeSubagentAnnounce(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);
|
const entry = subagentRuns.get(runId);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
if (cleanup === "delete") {
|
if (cleanup === "delete") {
|
||||||
@@ -218,17 +226,23 @@ function finalizeSubagentAnnounce(runId: string, cleanup: "delete" | "keep", did
|
|||||||
persistSubagentRuns();
|
persistSubagentRuns();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!didAnnounce) return;
|
if (!didAnnounce) {
|
||||||
entry.announceCompletedAt = Date.now();
|
// Allow retry on the next wake if the announce failed.
|
||||||
|
entry.cleanupHandled = false;
|
||||||
|
persistSubagentRuns();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entry.cleanupCompletedAt = Date.now();
|
||||||
persistSubagentRuns();
|
persistSubagentRuns();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function beginSubagentAnnounce(runId: string) {
|
function beginSubagentCleanup(runId: string) {
|
||||||
const entry = subagentRuns.get(runId);
|
const entry = subagentRuns.get(runId);
|
||||||
if (!entry) return false;
|
if (!entry) return false;
|
||||||
if (entry.announceCompletedAt) return false;
|
// Support legacy field names for backward compatibility
|
||||||
if (entry.announceHandled) return false;
|
if (entry.cleanupCompletedAt || entry.announceCompletedAt) return false;
|
||||||
entry.announceHandled = true;
|
if (entry.cleanupHandled || entry.announceHandled) return false;
|
||||||
|
entry.cleanupHandled = true;
|
||||||
persistSubagentRuns();
|
persistSubagentRuns();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -261,7 +275,7 @@ export function registerSubagentRun(params: {
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
startedAt: now,
|
startedAt: now,
|
||||||
archiveAtMs,
|
archiveAtMs,
|
||||||
announceHandled: false,
|
cleanupHandled: false,
|
||||||
});
|
});
|
||||||
ensureListener();
|
ensureListener();
|
||||||
persistSubagentRuns();
|
persistSubagentRuns();
|
||||||
@@ -302,8 +316,8 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) {
|
|||||||
wait.status === "error" ? { status: "error", error: wait.error } : { status: "ok" };
|
wait.status === "error" ? { status: "error", error: wait.error } : { status: "ok" };
|
||||||
mutated = true;
|
mutated = true;
|
||||||
if (mutated) persistSubagentRuns();
|
if (mutated) persistSubagentRuns();
|
||||||
if (!beginSubagentAnnounce(runId)) return;
|
if (!beginSubagentCleanup(runId)) return;
|
||||||
const announce = runSubagentAnnounceFlow({
|
void runSubagentAnnounceFlow({
|
||||||
childSessionKey: entry.childSessionKey,
|
childSessionKey: entry.childSessionKey,
|
||||||
childRunId: entry.runId,
|
childRunId: entry.runId,
|
||||||
requesterSessionKey: entry.requesterSessionKey,
|
requesterSessionKey: entry.requesterSessionKey,
|
||||||
@@ -317,9 +331,8 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) {
|
|||||||
endedAt: entry.endedAt,
|
endedAt: entry.endedAt,
|
||||||
label: entry.label,
|
label: entry.label,
|
||||||
outcome: entry.outcome,
|
outcome: entry.outcome,
|
||||||
});
|
}).then((didAnnounce) => {
|
||||||
void announce.then((didAnnounce) => {
|
finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce);
|
||||||
finalizeSubagentAnnounce(runId, entry.cleanup, didAnnounce);
|
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import { listDeliverableMessageChannels } from "../utils/message-channel.js";
|
|||||||
import type { ResolvedTimeFormat } from "./date-time.js";
|
import type { ResolvedTimeFormat } from "./date-time.js";
|
||||||
import type { EmbeddedContextFile } from "./pi-embedded-helpers.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: {
|
export function buildAgentSystemPrompt(params: {
|
||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
defaultThinkLevel?: ThinkLevel;
|
defaultThinkLevel?: ThinkLevel;
|
||||||
@@ -20,6 +28,8 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
contextFiles?: EmbeddedContextFile[];
|
contextFiles?: EmbeddedContextFile[];
|
||||||
skillsPrompt?: string;
|
skillsPrompt?: string;
|
||||||
heartbeatPrompt?: string;
|
heartbeatPrompt?: string;
|
||||||
|
/** Controls which hardcoded sections to include. Defaults to "full". */
|
||||||
|
promptMode?: PromptMode;
|
||||||
runtimeInfo?: {
|
runtimeInfo?: {
|
||||||
host?: string;
|
host?: string;
|
||||||
os?: string;
|
os?: string;
|
||||||
@@ -179,17 +189,22 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
const runtimeCapabilitiesLower = new Set(runtimeCapabilities.map((cap) => cap.toLowerCase()));
|
const runtimeCapabilitiesLower = new Set(runtimeCapabilities.map((cap) => cap.toLowerCase()));
|
||||||
const inlineButtonsEnabled = runtimeCapabilitiesLower.has("inlinebuttons");
|
const inlineButtonsEnabled = runtimeCapabilitiesLower.has("inlinebuttons");
|
||||||
const messageChannelOptions = listDeliverableMessageChannels().join("|");
|
const messageChannelOptions = listDeliverableMessageChannels().join("|");
|
||||||
|
const promptMode = params.promptMode ?? "full";
|
||||||
|
const isMinimal = promptMode === "minimal" || promptMode === "none";
|
||||||
const skillsLines = skillsPrompt ? [skillsPrompt, ""] : [];
|
const skillsLines = skillsPrompt ? [skillsPrompt, ""] : [];
|
||||||
const skillsSection = skillsPrompt
|
// Skip skills section for subagent/none modes
|
||||||
? [
|
const skillsSection =
|
||||||
"## Skills",
|
skillsPrompt && !isMinimal
|
||||||
`Skills provide task-specific instructions. Use \`${readToolName}\` to load the SKILL.md at the location listed for that skill.`,
|
? [
|
||||||
...skillsLines,
|
"## 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 =
|
const memorySection =
|
||||||
availableTools.has("memory_search") || availableTools.has("memory_get")
|
!isMinimal && (availableTools.has("memory_search") || availableTools.has("memory_get"))
|
||||||
? [
|
? [
|
||||||
"## Memory Recall",
|
"## 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.",
|
"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 = [
|
const lines = [
|
||||||
"You are a personal assistant running inside Clawdbot.",
|
"You are a personal assistant running inside Clawdbot.",
|
||||||
"",
|
"",
|
||||||
@@ -235,8 +255,9 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
"",
|
"",
|
||||||
...skillsSection,
|
...skillsSection,
|
||||||
...memorySection,
|
...memorySection,
|
||||||
hasGateway ? "## Clawdbot Self-Update" : "",
|
// Skip self-update for subagent/none modes
|
||||||
hasGateway
|
hasGateway && !isMinimal ? "## Clawdbot Self-Update" : "",
|
||||||
|
hasGateway && !isMinimal
|
||||||
? [
|
? [
|
||||||
"Get Updates (self-update) is ONLY allowed when the user explicitly asks for it.",
|
"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.",
|
"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.",
|
"After restart, Clawdbot pings the last active session automatically.",
|
||||||
].join("\n")
|
].join("\n")
|
||||||
: "",
|
: "",
|
||||||
hasGateway ? "" : "",
|
hasGateway && !isMinimal ? "" : "",
|
||||||
"",
|
"",
|
||||||
params.modelAliasLines && params.modelAliasLines.length > 0 ? "## Model Aliases" : "",
|
// Skip model aliases for subagent/none modes
|
||||||
params.modelAliasLines && params.modelAliasLines.length > 0
|
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."
|
? "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.join("\n")
|
||||||
: "",
|
: "",
|
||||||
params.modelAliasLines && params.modelAliasLines.length > 0 ? "" : "",
|
params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal ? "" : "",
|
||||||
"## Workspace",
|
"## Workspace",
|
||||||
`Your working directory is: ${params.workspaceDir}`,
|
`Your working directory is: ${params.workspaceDir}`,
|
||||||
"Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.",
|
"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")
|
.join("\n")
|
||||||
: "",
|
: "",
|
||||||
params.sandboxInfo?.enabled ? "" : "",
|
params.sandboxInfo?.enabled ? "" : "",
|
||||||
ownerLine ? "## User Identity" : "",
|
// Skip user identity for subagent/none modes
|
||||||
ownerLine ?? "",
|
ownerLine && !isMinimal ? "## User Identity" : "",
|
||||||
ownerLine ? "" : "",
|
ownerLine && !isMinimal ? ownerLine : "",
|
||||||
|
ownerLine && !isMinimal ? "" : "",
|
||||||
...(userTimezone || userTime
|
...(userTimezone || userTime
|
||||||
? [
|
? [
|
||||||
"## Current Date & Time",
|
"## Current Date & Time",
|
||||||
@@ -329,38 +354,50 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
"## Workspace Files (injected)",
|
"## Workspace Files (injected)",
|
||||||
"These user-editable files are loaded by Clawdbot and included below in Project Context.",
|
"These user-editable files are loaded by Clawdbot and included below in Project Context.",
|
||||||
"",
|
"",
|
||||||
"## Reply Tags",
|
// Skip reply tags for subagent/none modes
|
||||||
"To request a native reply/quote on supported surfaces, include one tag in your reply:",
|
...(isMinimal
|
||||||
"- [[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 ]]).",
|
"## Reply Tags",
|
||||||
"Tags are stripped before sending; support depends on the current channel config.",
|
"To request a native reply/quote on supported surfaces, include one tag in your reply:",
|
||||||
"",
|
"- [[reply_to_current]] replies to the triggering message.",
|
||||||
"## Messaging",
|
"- [[reply_to:<id>]] replies to a specific message id when you have it.",
|
||||||
"- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)",
|
"Whitespace inside the tag is allowed (e.g. [[ reply_to_current ]] / [[ reply_to: 123 ]]).",
|
||||||
"- Cross-session messaging → use sessions_send(sessionKey, message)",
|
"Tags are stripped before sending; support depends on the current channel config.",
|
||||||
"- 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.).",
|
// Skip messaging section for subagent/none modes
|
||||||
"- For `action=send`, include `to` and `message`.",
|
...(isMinimal
|
||||||
`- 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)."
|
"## Messaging",
|
||||||
: runtimeChannel
|
"- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)",
|
||||||
? `- Inline buttons not enabled for ${runtimeChannel}. If you need them, ask to add "inlineButtons" to ${runtimeChannel}.capabilities or ${runtimeChannel}.accounts.<id>.capabilities.`
|
"- Cross-session messaging → use sessions_send(sessionKey, message)",
|
||||||
: "",
|
"- Never use exec/curl for provider messaging; Clawdbot handles all routing internally.",
|
||||||
]
|
availableTools.has("message")
|
||||||
.filter(Boolean)
|
? [
|
||||||
.join("\n")
|
"",
|
||||||
: "",
|
"### message tool",
|
||||||
"",
|
"- Use `message` for proactive sends + channel actions (polls, reactions, etc.).",
|
||||||
|
"- For `action=send`, include `to` and `message`.",
|
||||||
|
`- If multiple channels are configured, pass \`channel\` (${messageChannelOptions}).`,
|
||||||
|
inlineButtonsEnabled
|
||||||
|
? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)."
|
||||||
|
: runtimeChannel
|
||||||
|
? `- Inline buttons not enabled for ${runtimeChannel}. If you need them, ask to add "inlineButtons" to ${runtimeChannel}.capabilities or ${runtimeChannel}.accounts.<id>.capabilities.`
|
||||||
|
: "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
|
: "",
|
||||||
|
"",
|
||||||
|
]),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (extraSystemPrompt) {
|
if (extraSystemPrompt) {
|
||||||
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) {
|
if (params.reactionGuidance) {
|
||||||
const { level, channel } = 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(
|
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",
|
||||||
`Runtime: ${[
|
`Runtime: ${[
|
||||||
runtimeInfo?.host ? `host=${runtimeInfo.host}` : "",
|
runtimeInfo?.host ? `host=${runtimeInfo.host}` : "",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export async function resolveAnnounceTarget(params: {
|
|||||||
const match =
|
const match =
|
||||||
sessions.find((entry) => entry?.key === params.sessionKey) ??
|
sessions.find((entry) => entry?.key === params.sessionKey) ??
|
||||||
sessions.find((entry) => entry?.key === params.displayKey);
|
sessions.find((entry) => entry?.key === params.displayKey);
|
||||||
|
|
||||||
const channel = typeof match?.lastChannel === "string" ? match.lastChannel : undefined;
|
const channel = typeof match?.lastChannel === "string" ? match.lastChannel : undefined;
|
||||||
const to = typeof match?.lastTo === "string" ? match.lastTo : undefined;
|
const to = typeof match?.lastTo === "string" ? match.lastTo : undefined;
|
||||||
const accountId = typeof match?.lastAccountId === "string" ? match.lastAccountId : undefined;
|
const accountId = typeof match?.lastAccountId === "string" ? match.lastAccountId : undefined;
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import {
|
|||||||
type ChatEvent,
|
type ChatEvent,
|
||||||
ChatEventSchema,
|
ChatEventSchema,
|
||||||
ChatHistoryParamsSchema,
|
ChatHistoryParamsSchema,
|
||||||
|
type ChatInjectParams,
|
||||||
|
ChatInjectParamsSchema,
|
||||||
ChatSendParamsSchema,
|
ChatSendParamsSchema,
|
||||||
type ConfigApplyParams,
|
type ConfigApplyParams,
|
||||||
ConfigApplyParamsSchema,
|
ConfigApplyParamsSchema,
|
||||||
@@ -232,6 +234,7 @@ export const validateLogsTailParams = ajv.compile<LogsTailParams>(LogsTailParams
|
|||||||
export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema);
|
export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema);
|
||||||
export const validateChatSendParams = ajv.compile(ChatSendParamsSchema);
|
export const validateChatSendParams = ajv.compile(ChatSendParamsSchema);
|
||||||
export const validateChatAbortParams = ajv.compile<ChatAbortParams>(ChatAbortParamsSchema);
|
export const validateChatAbortParams = ajv.compile<ChatAbortParams>(ChatAbortParamsSchema);
|
||||||
|
export const validateChatInjectParams = ajv.compile<ChatInjectParams>(ChatInjectParamsSchema);
|
||||||
export const validateChatEvent = ajv.compile(ChatEventSchema);
|
export const validateChatEvent = ajv.compile(ChatEventSchema);
|
||||||
export const validateUpdateRunParams = ajv.compile<UpdateRunParams>(UpdateRunParamsSchema);
|
export const validateUpdateRunParams = ajv.compile<UpdateRunParams>(UpdateRunParamsSchema);
|
||||||
export const validateWebLoginStartParams =
|
export const validateWebLoginStartParams =
|
||||||
@@ -310,6 +313,7 @@ export {
|
|||||||
LogsTailResultSchema,
|
LogsTailResultSchema,
|
||||||
ChatHistoryParamsSchema,
|
ChatHistoryParamsSchema,
|
||||||
ChatSendParamsSchema,
|
ChatSendParamsSchema,
|
||||||
|
ChatInjectParamsSchema,
|
||||||
UpdateRunParamsSchema,
|
UpdateRunParamsSchema,
|
||||||
TickEventSchema,
|
TickEventSchema,
|
||||||
ShutdownEventSchema,
|
ShutdownEventSchema,
|
||||||
@@ -388,4 +392,5 @@ export type {
|
|||||||
LogsTailResult,
|
LogsTailResult,
|
||||||
PollParams,
|
PollParams,
|
||||||
UpdateRunParams,
|
UpdateRunParams,
|
||||||
|
ChatInjectParams,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -53,6 +53,15 @@ export const ChatAbortParamsSchema = Type.Object(
|
|||||||
{ additionalProperties: false },
|
{ 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(
|
export const ChatEventSchema = Type.Object(
|
||||||
{
|
{
|
||||||
runId: NonEmptyString,
|
runId: NonEmptyString,
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import {
|
|||||||
ChatAbortParamsSchema,
|
ChatAbortParamsSchema,
|
||||||
ChatEventSchema,
|
ChatEventSchema,
|
||||||
ChatHistoryParamsSchema,
|
ChatHistoryParamsSchema,
|
||||||
|
ChatInjectParamsSchema,
|
||||||
ChatSendParamsSchema,
|
ChatSendParamsSchema,
|
||||||
LogsTailParamsSchema,
|
LogsTailParamsSchema,
|
||||||
LogsTailResultSchema,
|
LogsTailResultSchema,
|
||||||
@@ -172,6 +173,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
|||||||
ChatHistoryParams: ChatHistoryParamsSchema,
|
ChatHistoryParams: ChatHistoryParamsSchema,
|
||||||
ChatSendParams: ChatSendParamsSchema,
|
ChatSendParams: ChatSendParamsSchema,
|
||||||
ChatAbortParams: ChatAbortParamsSchema,
|
ChatAbortParams: ChatAbortParamsSchema,
|
||||||
|
ChatInjectParams: ChatInjectParamsSchema,
|
||||||
ChatEvent: ChatEventSchema,
|
ChatEvent: ChatEventSchema,
|
||||||
UpdateRunParams: UpdateRunParamsSchema,
|
UpdateRunParams: UpdateRunParamsSchema,
|
||||||
TickEvent: TickEventSchema,
|
TickEvent: TickEventSchema,
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
ChatAbortParamsSchema,
|
ChatAbortParamsSchema,
|
||||||
ChatEventSchema,
|
ChatEventSchema,
|
||||||
|
ChatInjectParamsSchema,
|
||||||
LogsTailParamsSchema,
|
LogsTailParamsSchema,
|
||||||
LogsTailResultSchema,
|
LogsTailResultSchema,
|
||||||
} from "./logs-chat.js";
|
} from "./logs-chat.js";
|
||||||
@@ -163,6 +164,7 @@ export type CronRunLogEntry = Static<typeof CronRunLogEntrySchema>;
|
|||||||
export type LogsTailParams = Static<typeof LogsTailParamsSchema>;
|
export type LogsTailParams = Static<typeof LogsTailParamsSchema>;
|
||||||
export type LogsTailResult = Static<typeof LogsTailResultSchema>;
|
export type LogsTailResult = Static<typeof LogsTailResultSchema>;
|
||||||
export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
|
export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
|
||||||
|
export type ChatInjectParams = Static<typeof ChatInjectParamsSchema>;
|
||||||
export type ChatEvent = Static<typeof ChatEventSchema>;
|
export type ChatEvent = Static<typeof ChatEventSchema>;
|
||||||
export type UpdateRunParams = Static<typeof UpdateRunParamsSchema>;
|
export type UpdateRunParams = Static<typeof UpdateRunParamsSchema>;
|
||||||
export type TickEvent = Static<typeof TickEventSchema>;
|
export type TickEvent = Static<typeof TickEventSchema>;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
import { resolveThinkingDefault } from "../agents/model-selection.js";
|
import { resolveThinkingDefault } from "../agents/model-selection.js";
|
||||||
import { resolveAgentTimeoutMs } from "../agents/timeout.js";
|
import { resolveAgentTimeoutMs } from "../agents/timeout.js";
|
||||||
import { agentCommand } from "../commands/agent.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 { registerAgentRunContext } from "../infra/agent-events.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +19,7 @@ import {
|
|||||||
errorShape,
|
errorShape,
|
||||||
formatValidationErrors,
|
formatValidationErrors,
|
||||||
validateChatAbortParams,
|
validateChatAbortParams,
|
||||||
|
validateChatInjectParams,
|
||||||
validateChatHistoryParams,
|
validateChatHistoryParams,
|
||||||
validateChatSendParams,
|
validateChatSendParams,
|
||||||
} from "./protocol/index.js";
|
} from "./protocol/index.js";
|
||||||
@@ -31,6 +34,84 @@ import {
|
|||||||
|
|
||||||
export const handleChatBridgeMethods: BridgeMethodHandler = async (ctx, nodeId, method, params) => {
|
export const handleChatBridgeMethods: BridgeMethodHandler = async (ctx, nodeId, method, params) => {
|
||||||
switch (method) {
|
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": {
|
case "chat.history": {
|
||||||
if (!validateChatHistoryParams(params)) {
|
if (!validateChatHistoryParams(params)) {
|
||||||
return {
|
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({
|
const timeoutMs = resolveAgentTimeoutMs({
|
||||||
cfg,
|
cfg,
|
||||||
overrideMs: p.timeoutMs,
|
overrideMs: p.timeoutMs,
|
||||||
@@ -294,11 +375,10 @@ export const handleChatBridgeMethods: BridgeMethodHandler = async (ctx, nodeId,
|
|||||||
clientRunId,
|
clientRunId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (store) {
|
if (storePath) {
|
||||||
store[canonicalKey] = sessionEntry;
|
await updateSessionStore(storePath, (store) => {
|
||||||
if (storePath) {
|
store[canonicalKey] = sessionEntry;
|
||||||
await saveSessionStore(storePath, store);
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ackPayload = {
|
const ackPayload = {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
import { resolveThinkingDefault } from "../../agents/model-selection.js";
|
import { resolveThinkingDefault } from "../../agents/model-selection.js";
|
||||||
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
|
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
|
||||||
import { agentCommand } from "../../commands/agent.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 { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||||
@@ -21,6 +23,7 @@ import {
|
|||||||
formatValidationErrors,
|
formatValidationErrors,
|
||||||
validateChatAbortParams,
|
validateChatAbortParams,
|
||||||
validateChatHistoryParams,
|
validateChatHistoryParams,
|
||||||
|
validateChatInjectParams,
|
||||||
validateChatSendParams,
|
validateChatSendParams,
|
||||||
} from "../protocol/index.js";
|
} from "../protocol/index.js";
|
||||||
import { MAX_CHAT_HISTORY_MESSAGES_BYTES } from "../server-constants.js";
|
import { MAX_CHAT_HISTORY_MESSAGES_BYTES } from "../server-constants.js";
|
||||||
@@ -205,7 +208,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const { cfg, storePath, store, entry, canonicalKey } = loadSessionEntry(p.sessionKey);
|
const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(p.sessionKey);
|
||||||
const timeoutMs = resolveAgentTimeoutMs({
|
const timeoutMs = resolveAgentTimeoutMs({
|
||||||
cfg,
|
cfg,
|
||||||
overrideMs: p.timeoutMs,
|
overrideMs: p.timeoutMs,
|
||||||
@@ -284,11 +287,10 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
clientRunId,
|
clientRunId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (store) {
|
if (storePath) {
|
||||||
store[canonicalKey] = sessionEntry;
|
await updateSessionStore(storePath, (store) => {
|
||||||
if (storePath) {
|
store[canonicalKey] = sessionEntry;
|
||||||
await saveSessionStore(storePath, store);
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ackPayload = {
|
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 });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -381,6 +381,60 @@ describe("gateway server chat", () => {
|
|||||||
await server.close();
|
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 () => {
|
test("chat.history defaults thinking to low for reasoning-capable models", async () => {
|
||||||
piSdkMock.enabled = true;
|
piSdkMock.enabled = true;
|
||||||
piSdkMock.models = [
|
piSdkMock.models = [
|
||||||
|
|||||||
Reference in New Issue
Block a user