Merge pull request #1256 from zknicker/feat/heartbeat-session-target

feat: configurable heartbeat session
This commit is contained in:
Peter Steinberger
2026-01-22 01:50:53 +00:00
committed by GitHub
15 changed files with 256 additions and 182 deletions

View File

@@ -14,6 +14,7 @@ Docs: https://docs.clawd.bot
## 2026.1.21 ## 2026.1.21
### Changes ### 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: 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. - CLI: exec approvals mutations render tables instead of raw JSON.
- Exec approvals: support wildcard agent allowlists (`*`) across all agents. - Exec approvals: support wildcard agent allowlists (`*`) across all agents.

View File

@@ -334,6 +334,7 @@ WhatsApp sends audio as **voice notes** (PTT bubble).
- `agents.defaults.heartbeat.model` (optional override) - `agents.defaults.heartbeat.model` (optional override)
- `agents.defaults.heartbeat.target` - `agents.defaults.heartbeat.target`
- `agents.defaults.heartbeat.to` - `agents.defaults.heartbeat.to`
- `agents.defaults.heartbeat.session`
- `agents.list[].heartbeat.*` (per-agent overrides) - `agents.list[].heartbeat.*` (per-agent overrides)
- `session.*` (scope, idle, store, mainKey) - `session.*` (scope, idle, store, mainKey)
- `web.enabled` (disable channel startup when false) - `web.enabled` (disable channel startup when false)

View File

@@ -24,7 +24,7 @@ Unknown keys, malformed types, or invalid values cause the Gateway to **refuse t
When validation fails: When validation fails:
- The Gateway does not boot. - 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` to see the exact issues.
- Run `clawdbot doctor --fix` (or `--yes`) to apply migrations/repairs. - 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`). - `alias` (optional model shortcut, e.g. `/opus`).
- `params` (optional provider-specific API params passed through to the model request). - `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 models 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 models defaults and need a change.
Example: 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. `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). It does **not** modify the session history on disk (`*.jsonl` remains complete).
@@ -1580,9 +1580,11 @@ High level:
- Never touches user/assistant messages. - Never touches user/assistant messages.
- Protects the last `keepLastAssistants` assistant messages (no tool results after that point are pruned). - 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). - Protects the bootstrap prefix (nothing before the first user message is pruned).
- Mode: - Modes:
- `cache-ttl`: pruning only runs when the last Anthropic call for the session is **older** than `ttl`. - `adaptive`: soft-trims oversized tool results (keep head/tail) when the estimated context ratio crosses `softTrimRatio`.
When it runs, it uses the same soft-trim + hard-clear behavior as before. Then hard-clears the oldest eligible tool results when the estimated context ratio crosses `hardClearRatio` **and**
theres 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 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. - **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. - Tool results containing **image blocks are skipped** (never trimmed/cleared) right now.
- The estimated “context ratio” is based on **characters** (approximate), not exact tokens. - The estimated “context ratio” is based on **characters** (approximate), not exact tokens.
- If the session doesnt contain at least `keepLastAssistants` assistant messages yet, pruning is skipped. - If the session doesnt contain at least `keepLastAssistants` assistant messages yet, pruning is skipped.
- `cache-ttl` only activates for Anthropic API calls (and OpenRouter Anthropic models). - In `aggressive` mode, `hardClear.enabled` is ignored (eligible tool results are always replaced with `hardClear.placeholder`).
- 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`.
Default (off, unless Anthropic auth profiles are detected): Default (adaptive):
```json5
{
agents: { defaults: { contextPruning: { mode: "adaptive" } } }
}
```
To disable:
```json5 ```json5
{ {
agents: { defaults: { contextPruning: { mode: "off" } } } 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 ```json5
{ {
agents: { defaults: { contextPruning: { mode: "cache-ttl" } } } agents: { defaults: { contextPruning: { mode: "aggressive" } } }
} }
``` ```
Defaults (when `mode` is `"cache-ttl"`): Example (adaptive tuned):
- `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):
```json5 ```json5
{ {
agents: { agents: {
defaults: { defaults: {
contextPruning: { contextPruning: {
mode: "cache-ttl", mode: "adaptive",
ttl: "5m",
keepLastAssistants: 3, keepLastAssistants: 3,
softTrimRatio: 0.3, softTrimRatio: 0.3,
hardClearRatio: 0.5, hardClearRatio: 0.5,
@@ -1737,12 +1742,9 @@ Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
`30m`. Set `0m` to disable. `30m`. Set `0m` to disable.
- `model`: optional override model for heartbeat runs (`provider/model`). - `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`. - `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. - `session`: optional session key to control which session the heartbeat runs in. Default: `main`.
- `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`.
- `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp, chat id for Telegram). - `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. - `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). - `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.maxChars` (default 50000)
- `tools.web.fetch.timeoutSeconds` (default 30) - `tools.web.fetch.timeoutSeconds` (default 30)
- `tools.web.fetch.cacheTtlMinutes` (default 15) - `tools.web.fetch.cacheTtlMinutes` (default 15)
- `tools.web.fetch.maxRedirects` (default 3)
- `tools.web.fetch.userAgent` (optional override) - `tools.web.fetch.userAgent` (optional override)
- `tools.web.fetch.readability` (default true; disable to use basic HTML cleanup only) - `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) - `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: `agents.defaults.subagents` configures sub-agent defaults:
- `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the callers model unless overridden per agent or per call. - `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the callers 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) - `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) - 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 `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 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` ### `agents.defaults.sandbox`
@@ -2452,9 +2453,6 @@ Controls session scoping, reset policy, reset triggers, and where the session st
dm: { mode: "idle", idleMinutes: 240 }, dm: { mode: "idle", idleMinutes: 240 },
group: { mode: "idle", idleMinutes: 120 } group: { mode: "idle", idleMinutes: 120 }
}, },
resetByChannel: {
discord: { mode: "idle", idleMinutes: 10080 }
},
resetTriggers: ["/new", "/reset"], resetTriggers: ["/new", "/reset"],
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json // Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
// You can override with {agentId} templating: // 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. - `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`. - `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. - 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 (05, default 5). - `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (05, default 5).
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches. - `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. - `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, // noSandbox: false,
// executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", // executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
// attachOnly: false, // set true when tunneling a remote CDP to localhost // 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) ### `ui` (Appearance)
Optional accent color used by the native apps for UI chrome (e.g. Talk Mode bubble tint). Optional accent color used by the native apps for UI chrome (e.g. Talk Mode bubble tint).
@@ -2647,13 +2642,6 @@ Defaults:
- bind: `loopback` - bind: `loopback`
- port: `18789` (single port for WS + HTTP) - 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 ```json5
{ {
gateway: { gateway: {
@@ -2684,15 +2672,14 @@ Notes:
- `clawdbot gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag). - `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). - `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`. - 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`. - 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). - 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. - `gateway.remote.token` is **only** for remote CLI calls; it does not enable local gateway auth. `gateway.token` is ignored.
Auth and Tailscale: Auth and Tailscale:
- `gateway.auth.mode` sets the handshake requirements (`token` or `password`). - `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). - 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.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended).
- `gateway.auth.allowTailscale` allows Tailscale Serve identity headers - `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 `true`, Serve requests do not need a token/password; set `false` to require
explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and
auth mode is not `password`. 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: "serve"` uses Tailscale Serve (tailnet only, loopback bind).
- `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth. - `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth.
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown. - `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.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.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.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: macOS app behavior:
- Clawdbot.app watches `~/.clawdbot/clawdbot.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes. - 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: { remote: {
url: "ws://gateway.tailnet:18789", url: "ws://gateway.tailnet:18789",
token: "your-token", token: "your-token",
password: "your-password", password: "your-password"
tlsFingerprint: "sha256:ab12cd34..."
} }
} }
} }
``` ```
### `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) ### `gateway.reload` (Config hot reload)
The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically. 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 DNSSD) ### `discovery.wideArea` (Wide-Area Bonjour / unicast DNSSD)
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: 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) - 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) | | `{{From}}` | Sender identifier (E.164 for WhatsApp; may differ per channel) |
| `{{To}}` | Destination identifier | | `{{To}}` | Destination identifier |
| `{{MessageSid}}` | Channel message id (when available) | | `{{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 | | `{{SessionId}}` | Current session UUID |
| `{{IsNewSession}}` | `"true"` when a new session was created | | `{{IsNewSession}}` | `"true"` when a new session was created |
| `{{MediaUrl}}` | Inbound media pseudo-URL (if present) | | `{{MediaUrl}}` | Inbound media pseudo-URL (if present) |

View File

@@ -127,18 +127,26 @@ Example: two agents, only the second agent runs heartbeats.
- `every`: heartbeat interval (duration string; default unit = minutes). - `every`: heartbeat interval (duration string; default unit = minutes).
- `model`: optional model override for heartbeat runs (`provider/model`). - `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`). - `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`: - `target`:
- `last` (default): deliver to the last used external channel. - `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. - `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). - `prompt`: overrides the default prompt body (not merged).
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery. - `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery.
## Delivery behavior ## Delivery behavior
- Heartbeats run in each agents **main session** (`agent:<id>:<mainKey>`), or `global` - Heartbeats run in the agents main session by default (`agent:<id>:<mainKey>`),
when `session.scope = "global"`. 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 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 - If `target` resolves to no external destination, the run still happens but no
outbound message is sent. outbound message is sent.

View File

@@ -60,7 +60,7 @@ describe("directive behavior", () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it("moves /model list to /models", async () => { it("aliases /model list to /models", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset(); vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json"); const storePath = path.join(home, "sessions.json");
@@ -84,13 +84,15 @@ describe("directive behavior", () => {
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Model listing moved."); expect(text).toContain("Providers:");
expect(text).toContain("Use: /models (providers) or /models <provider> (models)"); expect(text).toContain("- anthropic");
expect(text).toContain("- openai");
expect(text).toContain("Use: /models <provider>");
expect(text).toContain("Switch: /model <provider/model>"); expect(text).toContain("Switch: /model <provider/model>");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); 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) => { await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset(); vi.mocked(runEmbeddedPiAgent).mockReset();
vi.mocked(loadModelCatalog).mockResolvedValueOnce([]); vi.mocked(loadModelCatalog).mockResolvedValueOnce([]);
@@ -122,10 +124,10 @@ describe("directive behavior", () => {
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); 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) => { await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset(); vi.mocked(runEmbeddedPiAgent).mockReset();
vi.mocked(loadModelCatalog).mockResolvedValueOnce([ vi.mocked(loadModelCatalog).mockResolvedValue([
{ id: "claude-opus-4-5", name: "Opus 4.5", provider: "anthropic" }, { id: "claude-opus-4-5", name: "Opus 4.5", provider: "anthropic" },
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" },
{ id: "grok-4", name: "Grok 4", provider: "xai" }, { 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; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Model listing moved."); expect(text).toContain("Providers:");
expect(text).toContain("Use: /models (providers) or /models <provider> (models)"); expect(text).toContain("- anthropic");
expect(text).toContain("Switch: /model <provider/model>"); expect(text).toContain("- openai");
expect(text).toContain("- xai");
expect(text).toContain("Use: /models <provider>");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); 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) => { await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset(); vi.mocked(runEmbeddedPiAgent).mockReset();
// Catalog present but missing custom providers: /model should still include // 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 storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig( const res = await getReplyFromConfig(
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true }, { Body: "/models minimax", From: "+1222", To: "+1222", CommandAuthorized: true },
{}, {},
{ {
agents: { agents: {
@@ -202,13 +206,12 @@ describe("directive behavior", () => {
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Model listing moved."); expect(text).toContain("Model set to minimax");
expect(text).toContain("Use: /models (providers) or /models <provider> (models)"); expect(text).toContain("minimax/MiniMax-M2.1");
expect(text).toContain("Switch: /model <provider/model>");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); 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) => { await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset(); vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json"); const storePath = path.join(home, "sessions.json");
@@ -231,9 +234,7 @@ describe("directive behavior", () => {
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Model listing moved."); expect(text).toContain("Providers:");
expect(text).toContain("Use: /models (providers) or /models <provider> (models)");
expect(text).toContain("Switch: /model <provider/model>");
expect(text).not.toContain("missing (missing)"); expect(text).not.toContain("missing (missing)");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });

View File

@@ -215,6 +215,7 @@ describe("directive behavior", () => {
expect(text).toContain("Switch: /model <provider/model>"); expect(text).toContain("Switch: /model <provider/model>");
expect(text).toContain("Browse: /models (providers) or /models <provider> (models)"); expect(text).toContain("Browse: /models (providers) or /models <provider> (models)");
expect(text).toContain("More: /model status"); expect(text).toContain("More: /model status");
expect(text).not.toContain("openai/gpt-4.1-mini");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -123,7 +123,7 @@ describe("trigger handling", () => {
expect(normalized).not.toContain("image"); expect(normalized).not.toContain("image");
}); });
}); });
it("moves /model list to /models", async () => { it("aliases /model list to /models", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const cfg = makeCfg(home); const cfg = makeCfg(home);
const res = await getReplyFromConfig( const res = await getReplyFromConfig(
@@ -143,8 +143,8 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
const normalized = normalizeTestText(text ?? ""); const normalized = normalizeTestText(text ?? "");
expect(normalized).toContain("Model listing moved."); expect(normalized).toContain("Providers:");
expect(normalized).toContain("Use: /models (providers) or /models <provider> (models)"); expect(normalized).toContain("Use: /models <provider>");
expect(normalized).toContain("Switch: /model <provider/model>"); expect(normalized).toContain("Switch: /model <provider/model>");
}); });
}); });

View File

@@ -7,6 +7,7 @@ import {
resolveModelRefFromString, resolveModelRefFromString,
} from "../../agents/model-selection.js"; } from "../../agents/model-selection.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.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 { ReplyPayload } from "../types.js";
import type { CommandHandler } from "./commands-types.js"; import type { CommandHandler } from "./commands-types.js";
@@ -68,10 +69,11 @@ function parseModelsArgs(raw: string): {
}; };
} }
export const handleModelsCommand: CommandHandler = async (params, allowTextCommands) => { export async function resolveModelsCommandReply(params: {
if (!allowTextCommands) return null; cfg: ClawdbotConfig;
commandBodyNormalized: string;
const body = params.command.commandBodyNormalized.trim(); }): Promise<ReplyPayload | null> {
const body = params.commandBodyNormalized.trim();
if (!body.startsWith("/models")) return null; if (!body.startsWith("/models")) return null;
const argText = body.replace(/^\/models\b/i, "").trim(); const argText = body.replace(/^\/models\b/i, "").trim();
@@ -164,7 +166,7 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
"Use: /models <provider>", "Use: /models <provider>",
"Switch: /model <provider/model>", "Switch: /model <provider/model>",
]; ];
return { reply: { text: lines.join("\n") }, shouldContinue: false }; return { text: lines.join("\n") };
} }
if (!byProvider.has(provider)) { if (!byProvider.has(provider)) {
@@ -176,7 +178,7 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
"", "",
"Use: /models <provider>", "Use: /models <provider>",
]; ];
return { reply: { text: lines.join("\n") }, shouldContinue: false }; return { text: lines.join("\n") };
} }
const models = [...(byProvider.get(provider) ?? new Set<string>())].sort(); const models = [...(byProvider.get(provider) ?? new Set<string>())].sort();
@@ -189,7 +191,7 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
"Browse: /models", "Browse: /models",
"Switch: /model <provider/model>", "Switch: /model <provider/model>",
]; ];
return { reply: { text: lines.join("\n") }, shouldContinue: false }; return { text: lines.join("\n") };
} }
const effectivePageSize = all ? total : pageSize; const effectivePageSize = all ? total : pageSize;
@@ -203,7 +205,7 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
`Try: /models ${provider} ${safePage}`, `Try: /models ${provider} ${safePage}`,
`All: /models ${provider} all`, `All: /models ${provider} all`,
]; ];
return { reply: { text: lines.join("\n") }, shouldContinue: false }; return { text: lines.join("\n") };
} }
const startIndex = (safePage - 1) * effectivePageSize; const startIndex = (safePage - 1) * effectivePageSize;
@@ -226,5 +228,16 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
} }
const payload: ReplyPayload = { text: lines.join("\n") }; 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 };
}; };

View File

@@ -36,7 +36,7 @@ describe("/model chat UX", () => {
expect(reply?.text).toContain("Switch: /model <provider/model>"); expect(reply?.text).toContain("Switch: /model <provider/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 directives = parseInlineDirectives("/model anthropic/claud-opus-4-5");
const cfg = { commands: { text: true } } as unknown as ClawdbotConfig; const cfg = { commands: { text: true } } as unknown as ClawdbotConfig;
@@ -52,9 +52,11 @@ describe("/model chat UX", () => {
provider: "anthropic", provider: "anthropic",
}); });
expect(resolved.modelSelection).toBeUndefined(); expect(resolved.modelSelection).toEqual({
expect(resolved.errorText).toContain("Did you mean:"); provider: "anthropic",
expect(resolved.errorText).toContain("anthropic/claude-opus-4-5"); model: "claude-opus-4-5",
expect(resolved.errorText).toContain("Try: /model anthropic/claude-opus-4-5"); isDefault: true,
});
expect(resolved.errorText).toBeUndefined();
}); });
}); });

View File

@@ -20,6 +20,7 @@ import {
resolveProviderEndpointLabel, resolveProviderEndpointLabel,
} from "./directive-handling.model-picker.js"; } from "./directive-handling.model-picker.js";
import type { InlineDirectives } from "./directive-handling.parse.js"; import type { InlineDirectives } from "./directive-handling.parse.js";
import { resolveModelsCommandReply } from "./commands-models.js";
import { type ModelDirectiveSelection, resolveModelDirectiveSelection } from "./model-selection.js"; import { type ModelDirectiveSelection, resolveModelDirectiveSelection } from "./model-selection.js";
function buildModelPickerCatalog(params: { function buildModelPickerCatalog(params: {
@@ -185,14 +186,11 @@ export async function maybeHandleModelDirectiveInfo(params: {
}); });
if (wantsLegacyList) { if (wantsLegacyList) {
return { const reply = await resolveModelsCommandReply({
text: [ cfg: params.cfg,
"Model listing moved.", commandBodyNormalized: "/models",
"", });
"Use: /models (providers) or /models <provider> (models)", return reply ?? { text: "No models available." };
"Switch: /model <provider/model>",
].join("\n"),
};
} }
if (wantsSummary) { if (wantsSummary) {
@@ -340,42 +338,7 @@ export function resolveModelSelectionFromDirective(params: {
} }
if (resolved.selection) { if (resolved.selection) {
const suggestion = `${resolved.selection.provider}/${resolved.selection.model}`; modelSelection = resolved.selection;
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 <provider>",
].join("\n"),
};
}
} }
} }

View File

@@ -173,6 +173,8 @@ export type AgentDefaultsConfig = {
}; };
/** Heartbeat model override (provider/model). */ /** Heartbeat model override (provider/model). */
model?: string; 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). */ /** Delivery target (last|whatsapp|telegram|discord|slack|msteams|signal|imessage|none). */
target?: target?:
| "last" | "last"

View File

@@ -20,6 +20,7 @@ export const HeartbeatSchema = z
.strict() .strict()
.optional(), .optional(),
model: z.string().optional(), model: z.string().optional(),
session: z.string().optional(),
includeReasoning: z.boolean().optional(), includeReasoning: z.boolean().optional(),
target: z target: z
.union([ .union([

View File

@@ -41,7 +41,6 @@ describe("resolveHeartbeatIntervalMs", () => {
heartbeat: { heartbeat: {
every: "5m", every: "5m",
target: "whatsapp", target: "whatsapp",
to: "+1555",
ackMaxChars: 0, ackMaxChars: 0,
}, },
}, },
@@ -58,6 +57,7 @@ describe("resolveHeartbeatIntervalMs", () => {
[sessionKey]: { [sessionKey]: {
sessionId: "sid", sessionId: "sid",
updatedAt: Date.now(), updatedAt: Date.now(),
lastChannel: "whatsapp",
lastProvider: "whatsapp", lastProvider: "whatsapp",
lastTo: "+1555", lastTo: "+1555",
}, },
@@ -102,7 +102,6 @@ describe("resolveHeartbeatIntervalMs", () => {
heartbeat: { heartbeat: {
every: "5m", every: "5m",
target: "whatsapp", target: "whatsapp",
to: "+1555",
}, },
}, },
}, },
@@ -118,6 +117,7 @@ describe("resolveHeartbeatIntervalMs", () => {
[sessionKey]: { [sessionKey]: {
sessionId: "sid", sessionId: "sid",
updatedAt: Date.now(), updatedAt: Date.now(),
lastChannel: "whatsapp",
lastProvider: "whatsapp", lastProvider: "whatsapp",
lastTo: "+1555", lastTo: "+1555",
}, },
@@ -164,7 +164,6 @@ describe("resolveHeartbeatIntervalMs", () => {
heartbeat: { heartbeat: {
every: "5m", every: "5m",
target: "whatsapp", target: "whatsapp",
to: "+1555",
}, },
}, },
}, },
@@ -180,6 +179,7 @@ describe("resolveHeartbeatIntervalMs", () => {
[sessionKey]: { [sessionKey]: {
sessionId: "sid", sessionId: "sid",
updatedAt: originalUpdatedAt, updatedAt: originalUpdatedAt,
lastChannel: "whatsapp",
lastProvider: "whatsapp", lastProvider: "whatsapp",
lastTo: "+1555", lastTo: "+1555",
}, },
@@ -231,7 +231,7 @@ describe("resolveHeartbeatIntervalMs", () => {
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
agents: { agents: {
defaults: { defaults: {
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" }, heartbeat: { every: "5m", target: "whatsapp" },
}, },
}, },
channels: { whatsapp: { allowFrom: ["*"] } }, channels: { whatsapp: { allowFrom: ["*"] } },
@@ -246,6 +246,7 @@ describe("resolveHeartbeatIntervalMs", () => {
[sessionKey]: { [sessionKey]: {
sessionId: "sid", sessionId: "sid",
updatedAt: Date.now(), updatedAt: Date.now(),
lastChannel: "whatsapp",
lastProvider: "whatsapp", lastProvider: "whatsapp",
lastTo: "+1555", lastTo: "+1555",
}, },
@@ -291,7 +292,7 @@ describe("resolveHeartbeatIntervalMs", () => {
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
agents: { agents: {
defaults: { defaults: {
heartbeat: { every: "5m", target: "telegram", to: "123456" }, heartbeat: { every: "5m", target: "telegram" },
}, },
}, },
channels: { telegram: { botToken: "test-bot-token-123" } }, channels: { telegram: { botToken: "test-bot-token-123" } },
@@ -306,6 +307,7 @@ describe("resolveHeartbeatIntervalMs", () => {
[sessionKey]: { [sessionKey]: {
sessionId: "sid", sessionId: "sid",
updatedAt: Date.now(), updatedAt: Date.now(),
lastChannel: "telegram",
lastProvider: "telegram", lastProvider: "telegram",
lastTo: "123456", lastTo: "123456",
}, },
@@ -357,7 +359,7 @@ describe("resolveHeartbeatIntervalMs", () => {
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
agents: { agents: {
defaults: { defaults: {
heartbeat: { every: "5m", target: "telegram", to: "123456" }, heartbeat: { every: "5m", target: "telegram" },
}, },
}, },
channels: { channels: {
@@ -378,6 +380,7 @@ describe("resolveHeartbeatIntervalMs", () => {
[sessionKey]: { [sessionKey]: {
sessionId: "sid", sessionId: "sid",
updatedAt: Date.now(), updatedAt: Date.now(),
lastChannel: "telegram",
lastProvider: "telegram", lastProvider: "telegram",
lastTo: "123456", lastTo: "123456",
}, },

View File

@@ -11,6 +11,7 @@ import {
resolveMainSessionKey, resolveMainSessionKey,
resolveStorePath, resolveStorePath,
} from "../config/sessions.js"; } from "../config/sessions.js";
import { buildAgentPeerSessionKey } from "../routing/session-key.js";
import { import {
isHeartbeatEnabledForAgent, isHeartbeatEnabledForAgent,
resolveHeartbeatIntervalMs, resolveHeartbeatIntervalMs,
@@ -332,7 +333,7 @@ describe("runHeartbeatOnce", () => {
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
agents: { agents: {
defaults: { defaults: {
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" }, heartbeat: { every: "5m", target: "whatsapp" },
}, },
}, },
channels: { whatsapp: { allowFrom: ["*"] } }, channels: { whatsapp: { allowFrom: ["*"] } },
@@ -395,7 +396,7 @@ describe("runHeartbeatOnce", () => {
{ id: "main", default: true }, { id: "main", default: true },
{ {
id: "ops", 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 () => { it("suppresses duplicate heartbeat payloads within 24h", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-")); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
const storePath = path.join(tmpDir, "sessions.json"); const storePath = path.join(tmpDir, "sessions.json");
@@ -459,7 +542,7 @@ describe("runHeartbeatOnce", () => {
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
agents: { agents: {
defaults: { defaults: {
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" }, heartbeat: { every: "5m", target: "whatsapp" },
}, },
}, },
channels: { whatsapp: { allowFrom: ["*"] } }, channels: { whatsapp: { allowFrom: ["*"] } },
@@ -517,7 +600,6 @@ describe("runHeartbeatOnce", () => {
heartbeat: { heartbeat: {
every: "5m", every: "5m",
target: "whatsapp", target: "whatsapp",
to: "+1555",
includeReasoning: true, includeReasoning: true,
}, },
}, },
@@ -534,6 +616,7 @@ describe("runHeartbeatOnce", () => {
[sessionKey]: { [sessionKey]: {
sessionId: "sid", sessionId: "sid",
updatedAt: Date.now(), updatedAt: Date.now(),
lastChannel: "whatsapp",
lastProvider: "whatsapp", lastProvider: "whatsapp",
lastTo: "+1555", lastTo: "+1555",
}, },
@@ -588,7 +671,6 @@ describe("runHeartbeatOnce", () => {
heartbeat: { heartbeat: {
every: "5m", every: "5m",
target: "whatsapp", target: "whatsapp",
to: "+1555",
includeReasoning: true, includeReasoning: true,
}, },
}, },
@@ -605,6 +687,7 @@ describe("runHeartbeatOnce", () => {
[sessionKey]: { [sessionKey]: {
sessionId: "sid", sessionId: "sid",
updatedAt: Date.now(), updatedAt: Date.now(),
lastChannel: "whatsapp",
lastProvider: "whatsapp", lastProvider: "whatsapp",
lastTo: "+1555", lastTo: "+1555",
}, },
@@ -672,6 +755,7 @@ describe("runHeartbeatOnce", () => {
[sessionKey]: { [sessionKey]: {
sessionId: "sid", sessionId: "sid",
updatedAt: Date.now(), updatedAt: Date.now(),
lastChannel: "whatsapp",
lastProvider: "whatsapp", lastProvider: "whatsapp",
lastTo: "+1555", lastTo: "+1555",
}, },

View File

@@ -15,7 +15,9 @@ import { parseDurationMs } from "../cli/parse-duration.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { import {
canonicalizeMainSessionAlias,
loadSessionStore, loadSessionStore,
resolveAgentIdFromSessionKey,
resolveAgentMainSessionKey, resolveAgentMainSessionKey,
resolveStorePath, resolveStorePath,
saveSessionStore, saveSessionStore,
@@ -27,7 +29,7 @@ import { createSubsystemLogger } from "../logging/subsystem.js";
import { getQueueSize } from "../process/command-queue.js"; import { getQueueSize } from "../process/command-queue.js";
import { CommandLane } from "../process/lanes.js"; import { CommandLane } from "../process/lanes.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.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 { emitHeartbeatEvent } from "./heartbeat-events.js";
import { import {
type HeartbeatRunResult, 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 sessionCfg = cfg.session;
const scope = sessionCfg?.scope ?? "per-sender"; const scope = sessionCfg?.scope ?? "per-sender";
const resolvedAgentId = normalizeAgentId(agentId ?? resolveDefaultAgentId(cfg)); const resolvedAgentId = normalizeAgentId(agentId ?? resolveDefaultAgentId(cfg));
const sessionKey = const mainSessionKey =
scope === "global" ? "global" : resolveAgentMainSessionKey({ cfg, agentId: resolvedAgentId }); scope === "global" ? "global" : resolveAgentMainSessionKey({ cfg, agentId: resolvedAgentId });
const storeAgentId = scope === "global" ? resolveDefaultAgentId(cfg) : resolvedAgentId; const storeAgentId = scope === "global" ? resolveDefaultAgentId(cfg) : resolvedAgentId;
const storePath = resolveStorePath(sessionCfg?.store, { agentId: storeAgentId }); const storePath = resolveStorePath(sessionCfg?.store, { agentId: storeAgentId });
const store = loadSessionStore(storePath); const store = loadSessionStore(storePath);
const entry = store[sessionKey]; const mainEntry = store[mainSessionKey];
return { sessionKey, storePath, store, entry };
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( function resolveHeartbeatReplyPayload(
@@ -427,7 +465,7 @@ export async function runHeartbeatOnce(opts: {
return { status: "skipped", reason: "requests-in-flight" }; 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 previousUpdatedAt = entry?.updatedAt;
const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat }); const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat });
const lastChannel = delivery.lastChannel; const lastChannel = delivery.lastChannel;