diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a7af418e..8a6ae14d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.clawd.bot ## 2026.1.21 ### Changes +- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker. - CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output. - CLI: exec approvals mutations render tables instead of raw JSON. - Exec approvals: support wildcard agent allowlists (`*`) across all agents. diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index 45a4798a2..a496d1654 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -334,6 +334,7 @@ WhatsApp sends audio as **voice notes** (PTT bubble). - `agents.defaults.heartbeat.model` (optional override) - `agents.defaults.heartbeat.target` - `agents.defaults.heartbeat.to` +- `agents.defaults.heartbeat.session` - `agents.list[].heartbeat.*` (per-agent overrides) - `session.*` (scope, idle, store, mainKey) - `web.enabled` (disable channel startup when false) diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 00fd9e30f..6cdc39394 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -24,7 +24,7 @@ Unknown keys, malformed types, or invalid values cause the Gateway to **refuse t When validation fails: - The Gateway does not boot. -- Only diagnostic commands are allowed (for example: `clawdbot doctor`, `clawdbot logs`, `clawdbot health`, `clawdbot status`, `clawdbot gateway status`, `clawdbot gateway probe`, `clawdbot help`). +- Only diagnostic commands are allowed (for example: `clawdbot doctor`, `clawdbot logs`, `clawdbot health`, `clawdbot status`, `clawdbot service`, `clawdbot help`). - Run `clawdbot doctor` to see the exact issues. - Run `clawdbot doctor --fix` (or `--yes`) to apply migrations/repairs. @@ -1414,7 +1414,7 @@ Each `agents.defaults.models` entry can include: - `alias` (optional model shortcut, e.g. `/opus`). - `params` (optional provider-specific API params passed through to the model request). -`params` is also applied to streaming runs (embedded agent + compaction). Supported keys today: `temperature`, `maxTokens`, `cacheControlTtl` (`"5m"` or `"1h"`, Anthropic API + OpenRouter Anthropic models only; ignored for Anthropic OAuth/Claude Code tokens). These merge with call-time options; caller-supplied values win. `temperature` is an advanced knob—leave unset unless you know the model’s defaults and need a change. Clawdbot includes the `extended-cache-ttl-2025-04-11` beta flag for Anthropic API; keep it if you override provider headers. +`params` is also applied to streaming runs (embedded agent + compaction). Supported keys today: `temperature`, `maxTokens`. These merge with call-time options; caller-supplied values win. `temperature` is an advanced knob—leave unset unless you know the model’s defaults and need a change. Example: @@ -1569,7 +1569,7 @@ Example: } ``` -#### `agents.defaults.contextPruning` (TTL-aware tool-result pruning) +#### `agents.defaults.contextPruning` (tool-result pruning) `agents.defaults.contextPruning` prunes **old tool results** from the in-memory context right before a request is sent to the LLM. It does **not** modify the session history on disk (`*.jsonl` remains complete). @@ -1580,9 +1580,11 @@ High level: - Never touches user/assistant messages. - Protects the last `keepLastAssistants` assistant messages (no tool results after that point are pruned). - Protects the bootstrap prefix (nothing before the first user message is pruned). -- Mode: - - `cache-ttl`: pruning only runs when the last Anthropic call for the session is **older** than `ttl`. - When it runs, it uses the same soft-trim + hard-clear behavior as before. +- Modes: + - `adaptive`: soft-trims oversized tool results (keep head/tail) when the estimated context ratio crosses `softTrimRatio`. + Then hard-clears the oldest eligible tool results when the estimated context ratio crosses `hardClearRatio` **and** + there’s enough prunable tool-result bulk (`minPrunableToolChars`). + - `aggressive`: always replaces eligible tool results before the cutoff with the `hardClear.placeholder` (no ratio checks). Soft vs hard pruning (what changes in the context sent to the LLM): - **Soft-trim**: only for *oversized* tool results. Keeps the beginning + end and inserts `...` in the middle. @@ -1596,41 +1598,44 @@ Notes / current limitations: - Tool results containing **image blocks are skipped** (never trimmed/cleared) right now. - The estimated “context ratio” is based on **characters** (approximate), not exact tokens. - If the session doesn’t contain at least `keepLastAssistants` assistant messages yet, pruning is skipped. -- `cache-ttl` only activates for Anthropic API calls (and OpenRouter Anthropic models). -- After a prune, the TTL window resets so subsequent requests keep cache until `ttl` expires again. -- For best results, match `contextPruning.ttl` to the model `cacheControlTtl` you set in `agents.defaults.models.*.params`. +- In `aggressive` mode, `hardClear.enabled` is ignored (eligible tool results are always replaced with `hardClear.placeholder`). -Default (off, unless Anthropic auth profiles are detected): +Default (adaptive): +```json5 +{ + agents: { defaults: { contextPruning: { mode: "adaptive" } } } +} +``` + +To disable: ```json5 { agents: { defaults: { contextPruning: { mode: "off" } } } } ``` -Enable TTL-aware pruning: +Defaults (when `mode` is `"adaptive"` or `"aggressive"`): +- `keepLastAssistants`: `3` +- `softTrimRatio`: `0.3` (adaptive only) +- `hardClearRatio`: `0.5` (adaptive only) +- `minPrunableToolChars`: `50000` (adaptive only) +- `softTrim`: `{ maxChars: 4000, headChars: 1500, tailChars: 1500 }` (adaptive only) +- `hardClear`: `{ enabled: true, placeholder: "[Old tool result content cleared]" }` + +Example (aggressive, minimal): ```json5 { - agents: { defaults: { contextPruning: { mode: "cache-ttl" } } } + agents: { defaults: { contextPruning: { mode: "aggressive" } } } } ``` -Defaults (when `mode` is `"cache-ttl"`): -- `ttl`: `"5m"` -- `keepLastAssistants`: `3` -- `softTrimRatio`: `0.3` -- `hardClearRatio`: `0.5` -- `minPrunableToolChars`: `50000` -- `softTrim`: `{ maxChars: 4000, headChars: 1500, tailChars: 1500 }` -- `hardClear`: `{ enabled: true, placeholder: "[Old tool result content cleared]" }` - -Example (cache-ttl tuned): +Example (adaptive tuned): ```json5 { agents: { defaults: { contextPruning: { - mode: "cache-ttl", - ttl: "5m", + mode: "adaptive", keepLastAssistants: 3, softTrimRatio: 0.3, hardClearRatio: 0.5, @@ -1737,12 +1742,9 @@ Z.AI models are available as `zai/` (e.g. `zai/glm-4.7`) and require `30m`. Set `0m` to disable. - `model`: optional override model for heartbeat runs (`provider/model`). - `includeReasoning`: when `true`, heartbeats will also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`). Default: `false`. -- `activeHours`: optional local-time window that controls when heartbeats run. - - `start`: start time (HH:MM, 24h). Inclusive. - - `end`: end time (HH:MM, 24h). Exclusive. Use `"24:00"` for end-of-day. - - `timezone`: `"user"` (default), `"local"`, or an IANA timezone id. -- `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `none`). Default: `last`. +- `session`: optional session key to control which session the heartbeat runs in. Default: `main`. - `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp, chat id for Telegram). +- `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `msteams`, `signal`, `imessage`, `none`). Default: `last`. - `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md` line if you still want the file read. - `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 300). @@ -1773,7 +1775,6 @@ Note: `applyPatch` is only under `tools.exec`. - `tools.web.fetch.maxChars` (default 50000) - `tools.web.fetch.timeoutSeconds` (default 30) - `tools.web.fetch.cacheTtlMinutes` (default 15) -- `tools.web.fetch.maxRedirects` (default 3) - `tools.web.fetch.userAgent` (optional override) - `tools.web.fetch.readability` (default true; disable to use basic HTML cleanup only) - `tools.web.fetch.firecrawl.enabled` (default true when an API key is set) @@ -1840,7 +1841,7 @@ Example: `agents.defaults.subagents` configures sub-agent defaults: - `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the caller’s model unless overridden per agent or per call. -- `maxConcurrent`: max concurrent sub-agent runs (default 8) +- `maxConcurrent`: max concurrent sub-agent runs (default 1) - `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable) - Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny` (deny wins) @@ -1974,7 +1975,7 @@ Notes: `agents.defaults.maxConcurrent` sets the maximum number of embedded agent runs that can execute in parallel across sessions. Each session is still serialized (one run -per session key at a time). Default: 4. +per session key at a time). Default: 1. ### `agents.defaults.sandbox` @@ -2452,9 +2453,6 @@ Controls session scoping, reset policy, reset triggers, and where the session st dm: { mode: "idle", idleMinutes: 240 }, group: { mode: "idle", idleMinutes: 120 } }, - resetByChannel: { - discord: { mode: "idle", idleMinutes: 10080 } - }, resetTriggers: ["/new", "/reset"], // Default is already per-agent under ~/.clawdbot/agents//sessions/sessions.json // You can override with {agentId} templating: @@ -2490,7 +2488,7 @@ Fields: - `idleMinutes`: sliding idle window in minutes. When daily + idle are both configured, whichever expires first wins. - `resetByType`: per-session overrides for `dm`, `group`, and `thread`. - If you only set legacy `session.idleMinutes` without any `reset`/`resetByType`, Clawdbot stays in idle-only mode for backward compatibility. -- `resetByChannel`: channel-specific reset policy overrides (keyed by channel id, applies to all session types for that channel; overrides `reset`/`resetByType`). +- `heartbeatIdleMinutes`: optional idle override for heartbeat checks (daily reset still applies when enabled). - `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5). - `sendPolicy.default`: `allow` or `deny` fallback when no rule matches. - `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow. @@ -2617,13 +2615,10 @@ Defaults: // noSandbox: false, // executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", // attachOnly: false, // set true when tunneling a remote CDP to localhost - // snapshotDefaults: { mode: "efficient" }, // tool/CLI default snapshot preset } } ``` -Note: `browser.snapshotDefaults` only affects Clawdbot's browser tool + CLI. Direct HTTP clients must pass `mode` explicitly. - ### `ui` (Appearance) Optional accent color used by the native apps for UI chrome (e.g. Talk Mode bubble tint). @@ -2647,13 +2642,6 @@ Defaults: - bind: `loopback` - port: `18789` (single port for WS + HTTP) -Bind modes: -- `loopback`: `127.0.0.1` (local-only) -- `lan`: `0.0.0.0` (all interfaces) -- `tailnet`: Tailscale IPv4 address (100.64.0.0/10) -- `auto`: prefer loopback, fall back to LAN if loopback cannot bind -- `custom`: `gateway.customBindHost` (IPv4), fallback to LAN if unavailable - ```json5 { gateway: { @@ -2684,15 +2672,14 @@ Notes: - `clawdbot gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag). - `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI). - OpenAI Chat Completions endpoint: **disabled by default**; enable with `gateway.http.endpoints.chatCompletions.enabled: true`. -- OpenResponses endpoint: **disabled by default**; enable with `gateway.http.endpoints.responses.enabled: true`. - Precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`. -- Non-loopback binds (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) require auth. Use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`). +- Non-loopback binds (`lan`/`tailnet`/`auto`) require auth. Use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`). - The onboarding wizard generates a gateway token by default (even on loopback). - `gateway.remote.token` is **only** for remote CLI calls; it does not enable local gateway auth. `gateway.token` is ignored. Auth and Tailscale: - `gateway.auth.mode` sets the handshake requirements (`token` or `password`). -- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine and as the bootstrap credential for device pairing). +- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine). - When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers). - `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended). - `gateway.auth.allowTailscale` allows Tailscale Serve identity headers @@ -2701,9 +2688,6 @@ Auth and Tailscale: `true`, Serve requests do not need a token/password; set `false` to require explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and auth mode is not `password`. -- After pairing, the Gateway issues **device tokens** scoped to the device role + scopes. - These are returned in `hello-ok.auth.deviceToken`; clients should persist and reuse them - instead of the shared token. Rotate/revoke via `device.token.rotate`/`device.token.revoke`. - `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind). - `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth. - `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown. @@ -2712,7 +2696,6 @@ Remote client defaults (CLI): - `gateway.remote.url` sets the default Gateway WebSocket URL for CLI calls when `gateway.mode = "remote"`. - `gateway.remote.token` supplies the token for remote calls (leave unset for no auth). - `gateway.remote.password` supplies the password for remote calls (leave unset for no auth). -- `gateway.remote.tlsFingerprint` pins the gateway TLS cert fingerprint (sha256). macOS app behavior: - Clawdbot.app watches `~/.clawdbot/clawdbot.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes. @@ -2726,36 +2709,12 @@ macOS app behavior: remote: { url: "ws://gateway.tailnet:18789", token: "your-token", - password: "your-password", - tlsFingerprint: "sha256:ab12cd34..." + password: "your-password" } } } ``` -### `gateway.nodes` (Node command allowlist) - -The Gateway enforces a per-platform command allowlist for `node.invoke`. Nodes must both -**declare** a command and have it **allowed** by the Gateway to run it. - -Use this section to extend or deny commands: - -```json5 -{ - gateway: { - nodes: { - allowCommands: ["custom.vendor.command"], // extra commands beyond defaults - denyCommands: ["sms.send"] // block a command even if declared - } - } -} -``` - -Notes: -- `allowCommands` extends the built-in per-platform defaults. -- `denyCommands` always wins (even if the node claims the command). -- `node.invoke` rejects commands that are not declared by the node. - ### `gateway.reload` (Config hot reload) The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically. @@ -3003,7 +2962,7 @@ Auto-generated certs require `openssl` on PATH; if generation fails, the bridge ### `discovery.wideArea` (Wide-Area Bonjour / unicast DNS‑SD) -When enabled, the Gateway writes a unicast DNS-SD zone for `_clawdbot-gw._tcp` under `~/.clawdbot/dns/` using the standard discovery domain `clawdbot.internal.` +When enabled, the Gateway writes a unicast DNS-SD zone for `_clawdbot-bridge._tcp` under `~/.clawdbot/dns/` using the standard discovery domain `clawdbot.internal.` To make iOS/Android discover across networks (Vienna ⇄ London), pair this with: - a DNS server on the gateway host serving `clawdbot.internal.` (CoreDNS is recommended) @@ -3033,9 +2992,6 @@ Template placeholders are expanded in `tools.media.*.models[].args` and `tools.m | `{{From}}` | Sender identifier (E.164 for WhatsApp; may differ per channel) | | `{{To}}` | Destination identifier | | `{{MessageSid}}` | Channel message id (when available) | -| `{{MessageSidFull}}` | Provider-specific full message id when `MessageSid` is shortened | -| `{{ReplyToId}}` | Reply-to message id (when available) | -| `{{ReplyToIdFull}}` | Provider-specific full reply-to id when `ReplyToId` is shortened | | `{{SessionId}}` | Current session UUID | | `{{IsNewSession}}` | `"true"` when a new session was created | | `{{MediaUrl}}` | Inbound media pseudo-URL (if present) | diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index 3c5740da3..cc6c2a7d5 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -127,18 +127,26 @@ Example: two agents, only the second agent runs heartbeats. - `every`: heartbeat interval (duration string; default unit = minutes). - `model`: optional model override for heartbeat runs (`provider/model`). - `includeReasoning`: when enabled, also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`). +- `session`: optional session key for heartbeat runs. + - `main` (default): agent main session. + - Explicit session key (copy from `clawdbot sessions --json` or the [sessions CLI](/cli/sessions)). + - Session key formats: see [Sessions](/concepts/session) and [Groups](/concepts/groups). - `target`: - `last` (default): deliver to the last used external channel. - - explicit channel: `whatsapp` / `telegram` / `discord` / `slack` / `signal` / `imessage`. + - explicit channel: `whatsapp` / `telegram` / `discord` / `slack` / `msteams` / `signal` / `imessage`. - `none`: run the heartbeat but **do not deliver** externally. -- `to`: optional recipient override (E.164 for WhatsApp, chat id for Telegram, etc.). +- `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp or a Telegram chat id). - `prompt`: overrides the default prompt body (not merged). - `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery. ## Delivery behavior -- Heartbeats run in each agent’s **main session** (`agent::`), or `global` - when `session.scope = "global"`. +- Heartbeats run in the agent’s main session by default (`agent::`), + or `global` when `session.scope = "global"`. Set `session` to override to a + specific channel session (Discord/WhatsApp/etc.). +- `session` only affects the run context; delivery is controlled by `target` and `to`. +- To deliver to a specific channel/recipient, set `target` + `to`. With + `target: "last"`, delivery uses the last external channel for that session. - If the main queue is busy, the heartbeat is skipped and retried later. - If `target` resolves to no external destination, the run still happens but no outbound message is sent. diff --git a/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts b/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts index 2b42977cb..66f4ebee1 100644 --- a/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts @@ -60,7 +60,7 @@ describe("directive behavior", () => { vi.restoreAllMocks(); }); - it("moves /model list to /models", async () => { + it("aliases /model list to /models", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); const storePath = path.join(home, "sessions.json"); @@ -84,13 +84,15 @@ describe("directive behavior", () => { ); const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Model listing moved."); - expect(text).toContain("Use: /models (providers) or /models (models)"); + expect(text).toContain("Providers:"); + expect(text).toContain("- anthropic"); + expect(text).toContain("- openai"); + expect(text).toContain("Use: /models "); expect(text).toContain("Switch: /model "); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); - it("shows summary on /model when catalog is unavailable", async () => { + it("shows current model when catalog is unavailable", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); vi.mocked(loadModelCatalog).mockResolvedValueOnce([]); @@ -122,10 +124,10 @@ describe("directive behavior", () => { expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); - it("moves /model list to /models even when no allowlist is set", async () => { + it("includes catalog providers when no allowlist is set", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); - vi.mocked(loadModelCatalog).mockResolvedValueOnce([ + vi.mocked(loadModelCatalog).mockResolvedValue([ { id: "claude-opus-4-5", name: "Opus 4.5", provider: "anthropic" }, { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, { id: "grok-4", name: "Grok 4", provider: "xai" }, @@ -151,13 +153,15 @@ describe("directive behavior", () => { ); const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Model listing moved."); - expect(text).toContain("Use: /models (providers) or /models (models)"); - expect(text).toContain("Switch: /model "); + expect(text).toContain("Providers:"); + expect(text).toContain("- anthropic"); + expect(text).toContain("- openai"); + expect(text).toContain("- xai"); + expect(text).toContain("Use: /models "); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); - it("moves /model list to /models even when catalog is present", async () => { + it("lists config-only providers when catalog is present", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); // Catalog present but missing custom providers: /model should still include @@ -173,7 +177,7 @@ describe("directive behavior", () => { const storePath = path.join(home, "sessions.json"); const res = await getReplyFromConfig( - { Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true }, + { Body: "/models minimax", From: "+1222", To: "+1222", CommandAuthorized: true }, {}, { agents: { @@ -202,13 +206,12 @@ describe("directive behavior", () => { ); const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Model listing moved."); - expect(text).toContain("Use: /models (providers) or /models (models)"); - expect(text).toContain("Switch: /model "); + expect(text).toContain("Model set to minimax"); + expect(text).toContain("minimax/MiniMax-M2.1"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); - it("moves /model list to /models without listing auth labels", async () => { + it("does not repeat missing auth labels on /model list", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); const storePath = path.join(home, "sessions.json"); @@ -231,9 +234,7 @@ describe("directive behavior", () => { ); const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Model listing moved."); - expect(text).toContain("Use: /models (providers) or /models (models)"); - expect(text).toContain("Switch: /model "); + expect(text).toContain("Providers:"); expect(text).not.toContain("missing (missing)"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); diff --git a/src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.test.ts b/src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.test.ts index c3d6a5fdd..74cd2b13e 100644 --- a/src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.test.ts @@ -215,6 +215,7 @@ describe("directive behavior", () => { expect(text).toContain("Switch: /model "); expect(text).toContain("Browse: /models (providers) or /models (models)"); expect(text).toContain("More: /model status"); + expect(text).not.toContain("openai/gpt-4.1-mini"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); diff --git a/src/auto-reply/reply.triggers.trigger-handling.shows-quick-model-picker-grouped-by-model.test.ts b/src/auto-reply/reply.triggers.trigger-handling.shows-quick-model-picker-grouped-by-model.test.ts index 0de7dedb6..876225295 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.shows-quick-model-picker-grouped-by-model.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.shows-quick-model-picker-grouped-by-model.test.ts @@ -123,7 +123,7 @@ describe("trigger handling", () => { expect(normalized).not.toContain("image"); }); }); - it("moves /model list to /models", async () => { + it("aliases /model list to /models", async () => { await withTempHome(async (home) => { const cfg = makeCfg(home); const res = await getReplyFromConfig( @@ -143,8 +143,8 @@ describe("trigger handling", () => { const text = Array.isArray(res) ? res[0]?.text : res?.text; const normalized = normalizeTestText(text ?? ""); - expect(normalized).toContain("Model listing moved."); - expect(normalized).toContain("Use: /models (providers) or /models (models)"); + expect(normalized).toContain("Providers:"); + expect(normalized).toContain("Use: /models "); expect(normalized).toContain("Switch: /model "); }); }); diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index 652c8d0e8..c7761a8be 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -7,6 +7,7 @@ import { resolveModelRefFromString, } from "../../agents/model-selection.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js"; +import type { ClawdbotConfig } from "../../config/config.js"; import type { ReplyPayload } from "../types.js"; import type { CommandHandler } from "./commands-types.js"; @@ -68,10 +69,11 @@ function parseModelsArgs(raw: string): { }; } -export const handleModelsCommand: CommandHandler = async (params, allowTextCommands) => { - if (!allowTextCommands) return null; - - const body = params.command.commandBodyNormalized.trim(); +export async function resolveModelsCommandReply(params: { + cfg: ClawdbotConfig; + commandBodyNormalized: string; +}): Promise { + const body = params.commandBodyNormalized.trim(); if (!body.startsWith("/models")) return null; const argText = body.replace(/^\/models\b/i, "").trim(); @@ -164,7 +166,7 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma "Use: /models ", "Switch: /model ", ]; - return { reply: { text: lines.join("\n") }, shouldContinue: false }; + return { text: lines.join("\n") }; } if (!byProvider.has(provider)) { @@ -176,7 +178,7 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma "", "Use: /models ", ]; - return { reply: { text: lines.join("\n") }, shouldContinue: false }; + return { text: lines.join("\n") }; } const models = [...(byProvider.get(provider) ?? new Set())].sort(); @@ -189,7 +191,7 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma "Browse: /models", "Switch: /model ", ]; - return { reply: { text: lines.join("\n") }, shouldContinue: false }; + return { text: lines.join("\n") }; } const effectivePageSize = all ? total : pageSize; @@ -203,7 +205,7 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma `Try: /models ${provider} ${safePage}`, `All: /models ${provider} all`, ]; - return { reply: { text: lines.join("\n") }, shouldContinue: false }; + return { text: lines.join("\n") }; } const startIndex = (safePage - 1) * effectivePageSize; @@ -226,5 +228,16 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma } const payload: ReplyPayload = { text: lines.join("\n") }; - return { reply: payload, shouldContinue: false }; + return payload; +} + +export const handleModelsCommand: CommandHandler = async (params, allowTextCommands) => { + if (!allowTextCommands) return null; + + const reply = await resolveModelsCommandReply({ + cfg: params.cfg, + commandBodyNormalized: params.command.commandBodyNormalized, + }); + if (!reply) return null; + return { reply, shouldContinue: false }; }; diff --git a/src/auto-reply/reply/directive-handling.model.chat-ux.test.ts b/src/auto-reply/reply/directive-handling.model.chat-ux.test.ts index 1e8b2dc7b..c1e2ab7d9 100644 --- a/src/auto-reply/reply/directive-handling.model.chat-ux.test.ts +++ b/src/auto-reply/reply/directive-handling.model.chat-ux.test.ts @@ -36,7 +36,7 @@ describe("/model chat UX", () => { expect(reply?.text).toContain("Switch: /model "); }); - it("suggests closest match for typos without switching", () => { + it("auto-applies closest match for typos", () => { const directives = parseInlineDirectives("/model anthropic/claud-opus-4-5"); const cfg = { commands: { text: true } } as unknown as ClawdbotConfig; @@ -52,9 +52,11 @@ describe("/model chat UX", () => { provider: "anthropic", }); - expect(resolved.modelSelection).toBeUndefined(); - expect(resolved.errorText).toContain("Did you mean:"); - expect(resolved.errorText).toContain("anthropic/claude-opus-4-5"); - expect(resolved.errorText).toContain("Try: /model anthropic/claude-opus-4-5"); + expect(resolved.modelSelection).toEqual({ + provider: "anthropic", + model: "claude-opus-4-5", + isDefault: true, + }); + expect(resolved.errorText).toBeUndefined(); }); }); diff --git a/src/auto-reply/reply/directive-handling.model.ts b/src/auto-reply/reply/directive-handling.model.ts index dd2f98914..667eb4b90 100644 --- a/src/auto-reply/reply/directive-handling.model.ts +++ b/src/auto-reply/reply/directive-handling.model.ts @@ -20,6 +20,7 @@ import { resolveProviderEndpointLabel, } from "./directive-handling.model-picker.js"; import type { InlineDirectives } from "./directive-handling.parse.js"; +import { resolveModelsCommandReply } from "./commands-models.js"; import { type ModelDirectiveSelection, resolveModelDirectiveSelection } from "./model-selection.js"; function buildModelPickerCatalog(params: { @@ -185,14 +186,11 @@ export async function maybeHandleModelDirectiveInfo(params: { }); if (wantsLegacyList) { - return { - text: [ - "Model listing moved.", - "", - "Use: /models (providers) or /models (models)", - "Switch: /model ", - ].join("\n"), - }; + const reply = await resolveModelsCommandReply({ + cfg: params.cfg, + commandBodyNormalized: "/models", + }); + return reply ?? { text: "No models available." }; } if (wantsSummary) { @@ -340,42 +338,7 @@ export function resolveModelSelectionFromDirective(params: { } if (resolved.selection) { - const suggestion = `${resolved.selection.provider}/${resolved.selection.model}`; - const rawHasSlash = raw.includes("/"); - const shouldAutoSelect = (() => { - if (!rawHasSlash) return true; - const slash = raw.indexOf("/"); - if (slash <= 0) return true; - const rawProvider = normalizeProviderId(raw.slice(0, slash)); - const rawFragment = raw - .slice(slash + 1) - .trim() - .toLowerCase(); - if (!rawFragment) return false; - const resolvedProvider = normalizeProviderId(resolved.selection.provider); - if (rawProvider !== resolvedProvider) return false; - const resolvedModel = resolved.selection.model.toLowerCase(); - return ( - resolvedModel.startsWith(rawFragment) || - resolvedModel.includes(rawFragment) || - rawFragment.startsWith(resolvedModel) - ); - })(); - - if (shouldAutoSelect) { - modelSelection = resolved.selection; - } else { - return { - errorText: [ - `Unrecognized model: ${raw}`, - "", - `Did you mean: ${suggestion}`, - `Try: /model ${suggestion}`, - "", - "Browse: /models or /models ", - ].join("\n"), - }; - } + modelSelection = resolved.selection; } } diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 35d7548ab..11f7cf10d 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -173,6 +173,8 @@ export type AgentDefaultsConfig = { }; /** Heartbeat model override (provider/model). */ model?: string; + /** Session key for heartbeat runs ("main" or explicit session key). */ + session?: string; /** Delivery target (last|whatsapp|telegram|discord|slack|msteams|signal|imessage|none). */ target?: | "last" diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index b01fd264b..d34165907 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -20,6 +20,7 @@ export const HeartbeatSchema = z .strict() .optional(), model: z.string().optional(), + session: z.string().optional(), includeReasoning: z.boolean().optional(), target: z .union([ diff --git a/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts b/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts index aa961a301..7e7ae0b78 100644 --- a/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts +++ b/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts @@ -41,7 +41,6 @@ describe("resolveHeartbeatIntervalMs", () => { heartbeat: { every: "5m", target: "whatsapp", - to: "+1555", ackMaxChars: 0, }, }, @@ -58,6 +57,7 @@ describe("resolveHeartbeatIntervalMs", () => { [sessionKey]: { sessionId: "sid", updatedAt: Date.now(), + lastChannel: "whatsapp", lastProvider: "whatsapp", lastTo: "+1555", }, @@ -102,7 +102,6 @@ describe("resolveHeartbeatIntervalMs", () => { heartbeat: { every: "5m", target: "whatsapp", - to: "+1555", }, }, }, @@ -118,6 +117,7 @@ describe("resolveHeartbeatIntervalMs", () => { [sessionKey]: { sessionId: "sid", updatedAt: Date.now(), + lastChannel: "whatsapp", lastProvider: "whatsapp", lastTo: "+1555", }, @@ -164,7 +164,6 @@ describe("resolveHeartbeatIntervalMs", () => { heartbeat: { every: "5m", target: "whatsapp", - to: "+1555", }, }, }, @@ -180,6 +179,7 @@ describe("resolveHeartbeatIntervalMs", () => { [sessionKey]: { sessionId: "sid", updatedAt: originalUpdatedAt, + lastChannel: "whatsapp", lastProvider: "whatsapp", lastTo: "+1555", }, @@ -231,7 +231,7 @@ describe("resolveHeartbeatIntervalMs", () => { const cfg: ClawdbotConfig = { agents: { defaults: { - heartbeat: { every: "5m", target: "whatsapp", to: "+1555" }, + heartbeat: { every: "5m", target: "whatsapp" }, }, }, channels: { whatsapp: { allowFrom: ["*"] } }, @@ -246,6 +246,7 @@ describe("resolveHeartbeatIntervalMs", () => { [sessionKey]: { sessionId: "sid", updatedAt: Date.now(), + lastChannel: "whatsapp", lastProvider: "whatsapp", lastTo: "+1555", }, @@ -291,7 +292,7 @@ describe("resolveHeartbeatIntervalMs", () => { const cfg: ClawdbotConfig = { agents: { defaults: { - heartbeat: { every: "5m", target: "telegram", to: "123456" }, + heartbeat: { every: "5m", target: "telegram" }, }, }, channels: { telegram: { botToken: "test-bot-token-123" } }, @@ -306,6 +307,7 @@ describe("resolveHeartbeatIntervalMs", () => { [sessionKey]: { sessionId: "sid", updatedAt: Date.now(), + lastChannel: "telegram", lastProvider: "telegram", lastTo: "123456", }, @@ -357,7 +359,7 @@ describe("resolveHeartbeatIntervalMs", () => { const cfg: ClawdbotConfig = { agents: { defaults: { - heartbeat: { every: "5m", target: "telegram", to: "123456" }, + heartbeat: { every: "5m", target: "telegram" }, }, }, channels: { @@ -378,6 +380,7 @@ describe("resolveHeartbeatIntervalMs", () => { [sessionKey]: { sessionId: "sid", updatedAt: Date.now(), + lastChannel: "telegram", lastProvider: "telegram", lastTo: "123456", }, diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index c65475d13..e52c578e7 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -11,6 +11,7 @@ import { resolveMainSessionKey, resolveStorePath, } from "../config/sessions.js"; +import { buildAgentPeerSessionKey } from "../routing/session-key.js"; import { isHeartbeatEnabledForAgent, resolveHeartbeatIntervalMs, @@ -332,7 +333,7 @@ describe("runHeartbeatOnce", () => { const cfg: ClawdbotConfig = { agents: { defaults: { - heartbeat: { every: "5m", target: "whatsapp", to: "+1555" }, + heartbeat: { every: "5m", target: "whatsapp" }, }, }, channels: { whatsapp: { allowFrom: ["*"] } }, @@ -395,7 +396,7 @@ describe("runHeartbeatOnce", () => { { id: "main", default: true }, { id: "ops", - heartbeat: { every: "5m", target: "whatsapp", to: "+1555", prompt: "Ops check" }, + heartbeat: { every: "5m", target: "whatsapp", prompt: "Ops check" }, }, ], }, @@ -451,6 +452,88 @@ describe("runHeartbeatOnce", () => { } }); + it("runs heartbeats in the explicit session key when configured", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-")); + const storePath = path.join(tmpDir, "sessions.json"); + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + try { + const groupId = "120363401234567890@g.us"; + const cfg: ClawdbotConfig = { + agents: { + defaults: { + heartbeat: { + every: "5m", + target: "last", + }, + }, + }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: storePath }, + }; + const mainSessionKey = resolveMainSessionKey(cfg); + const agentId = resolveAgentIdFromSessionKey(mainSessionKey); + const groupSessionKey = buildAgentPeerSessionKey({ + agentId, + channel: "whatsapp", + peerKind: "group", + peerId: groupId, + }); + if (cfg.agents?.defaults?.heartbeat) { + cfg.agents.defaults.heartbeat.session = groupSessionKey; + } + + await fs.writeFile( + storePath, + JSON.stringify( + { + [mainSessionKey]: { + sessionId: "sid-main", + updatedAt: Date.now(), + lastChannel: "whatsapp", + lastTo: "+1555", + }, + [groupSessionKey]: { + sessionId: "sid-group", + updatedAt: Date.now() + 10_000, + lastChannel: "whatsapp", + lastTo: groupId, + }, + }, + null, + 2, + ), + ); + + replySpy.mockResolvedValue([{ text: "Group alert" }]); + const sendWhatsApp = vi.fn().mockResolvedValue({ + messageId: "m1", + toJid: "jid", + }); + + await runHeartbeatOnce({ + cfg, + deps: { + sendWhatsApp, + getQueueSize: () => 0, + nowMs: () => 0, + webAuthExists: async () => true, + hasActiveWebListener: () => true, + }, + }); + + expect(sendWhatsApp).toHaveBeenCalledTimes(1); + expect(sendWhatsApp).toHaveBeenCalledWith(groupId, "Group alert", expect.any(Object)); + expect(replySpy).toHaveBeenCalledWith( + expect.objectContaining({ SessionKey: groupSessionKey }), + { isHeartbeat: true }, + cfg, + ); + } finally { + replySpy.mockRestore(); + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + it("suppresses duplicate heartbeat payloads within 24h", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-")); const storePath = path.join(tmpDir, "sessions.json"); @@ -459,7 +542,7 @@ describe("runHeartbeatOnce", () => { const cfg: ClawdbotConfig = { agents: { defaults: { - heartbeat: { every: "5m", target: "whatsapp", to: "+1555" }, + heartbeat: { every: "5m", target: "whatsapp" }, }, }, channels: { whatsapp: { allowFrom: ["*"] } }, @@ -517,7 +600,6 @@ describe("runHeartbeatOnce", () => { heartbeat: { every: "5m", target: "whatsapp", - to: "+1555", includeReasoning: true, }, }, @@ -534,6 +616,7 @@ describe("runHeartbeatOnce", () => { [sessionKey]: { sessionId: "sid", updatedAt: Date.now(), + lastChannel: "whatsapp", lastProvider: "whatsapp", lastTo: "+1555", }, @@ -588,7 +671,6 @@ describe("runHeartbeatOnce", () => { heartbeat: { every: "5m", target: "whatsapp", - to: "+1555", includeReasoning: true, }, }, @@ -605,6 +687,7 @@ describe("runHeartbeatOnce", () => { [sessionKey]: { sessionId: "sid", updatedAt: Date.now(), + lastChannel: "whatsapp", lastProvider: "whatsapp", lastTo: "+1555", }, @@ -672,6 +755,7 @@ describe("runHeartbeatOnce", () => { [sessionKey]: { sessionId: "sid", updatedAt: Date.now(), + lastChannel: "whatsapp", lastProvider: "whatsapp", lastTo: "+1555", }, diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 92853a1f0..c39ac6923 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -15,7 +15,9 @@ import { parseDurationMs } from "../cli/parse-duration.js"; import type { ClawdbotConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import { + canonicalizeMainSessionAlias, loadSessionStore, + resolveAgentIdFromSessionKey, resolveAgentMainSessionKey, resolveStorePath, saveSessionStore, @@ -27,7 +29,7 @@ import { createSubsystemLogger } from "../logging/subsystem.js"; import { getQueueSize } from "../process/command-queue.js"; import { CommandLane } from "../process/lanes.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; -import { normalizeAgentId } from "../routing/session-key.js"; +import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.js"; import { emitHeartbeatEvent } from "./heartbeat-events.js"; import { type HeartbeatRunResult, @@ -286,17 +288,53 @@ function resolveHeartbeatAckMaxChars(cfg: ClawdbotConfig, heartbeat?: HeartbeatC ); } -function resolveHeartbeatSession(cfg: ClawdbotConfig, agentId?: string) { +function resolveHeartbeatSession( + cfg: ClawdbotConfig, + agentId?: string, + heartbeat?: HeartbeatConfig, +) { const sessionCfg = cfg.session; const scope = sessionCfg?.scope ?? "per-sender"; const resolvedAgentId = normalizeAgentId(agentId ?? resolveDefaultAgentId(cfg)); - const sessionKey = + const mainSessionKey = scope === "global" ? "global" : resolveAgentMainSessionKey({ cfg, agentId: resolvedAgentId }); const storeAgentId = scope === "global" ? resolveDefaultAgentId(cfg) : resolvedAgentId; const storePath = resolveStorePath(sessionCfg?.store, { agentId: storeAgentId }); const store = loadSessionStore(storePath); - const entry = store[sessionKey]; - return { sessionKey, storePath, store, entry }; + const mainEntry = store[mainSessionKey]; + + if (scope === "global") { + return { sessionKey: mainSessionKey, storePath, store, entry: mainEntry }; + } + + const trimmed = heartbeat?.session?.trim() ?? ""; + if (!trimmed) { + return { sessionKey: mainSessionKey, storePath, store, entry: mainEntry }; + } + + const normalized = trimmed.toLowerCase(); + if (normalized === "main" || normalized === "global") { + return { sessionKey: mainSessionKey, storePath, store, entry: mainEntry }; + } + + const candidate = toAgentStoreSessionKey({ + agentId: resolvedAgentId, + requestKey: trimmed, + mainKey: cfg.session?.mainKey, + }); + const canonical = canonicalizeMainSessionAlias({ + cfg, + agentId: resolvedAgentId, + sessionKey: candidate, + }); + if (canonical !== "global") { + const sessionAgentId = resolveAgentIdFromSessionKey(canonical); + if (sessionAgentId === normalizeAgentId(resolvedAgentId)) { + return { sessionKey: canonical, storePath, store, entry: store[canonical] }; + } + } + + return { sessionKey: mainSessionKey, storePath, store, entry: mainEntry }; } function resolveHeartbeatReplyPayload( @@ -427,7 +465,7 @@ export async function runHeartbeatOnce(opts: { return { status: "skipped", reason: "requests-in-flight" }; } - const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId); + const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId, heartbeat); const previousUpdatedAt = entry?.updatedAt; const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat }); const lastChannel = delivery.lastChannel;