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/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..fc2027ccf 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,86 @@ 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, + }); + 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 +540,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 +598,6 @@ describe("runHeartbeatOnce", () => { heartbeat: { every: "5m", target: "whatsapp", - to: "+1555", includeReasoning: true, }, }, @@ -534,6 +614,7 @@ describe("runHeartbeatOnce", () => { [sessionKey]: { sessionId: "sid", updatedAt: Date.now(), + lastChannel: "whatsapp", lastProvider: "whatsapp", lastTo: "+1555", }, @@ -588,7 +669,6 @@ describe("runHeartbeatOnce", () => { heartbeat: { every: "5m", target: "whatsapp", - to: "+1555", includeReasoning: true, }, }, @@ -605,6 +685,7 @@ describe("runHeartbeatOnce", () => { [sessionKey]: { sessionId: "sid", updatedAt: Date.now(), + lastChannel: "whatsapp", lastProvider: "whatsapp", lastTo: "+1555", }, @@ -672,6 +753,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..5a66714fd 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -1,5 +1,4 @@ import { resolveAgentConfig, resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { resolveUserTimezone } from "../agents/date-time.js"; import { resolveEffectiveMessagesConfig } from "../agents/identity.js"; import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, @@ -16,6 +15,8 @@ import type { ClawdbotConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import { loadSessionStore, + canonicalizeMainSessionAlias, + resolveAgentIdFromSessionKey, resolveAgentMainSessionKey, resolveStorePath, saveSessionStore, @@ -25,9 +26,8 @@ import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js"; import { formatErrorMessage } from "../infra/errors.js"; 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, @@ -70,94 +70,6 @@ export type HeartbeatSummary = { }; const DEFAULT_HEARTBEAT_TARGET = "last"; -const ACTIVE_HOURS_TIME_PATTERN = /^([01]\d|2[0-3]|24):([0-5]\d)$/; - -function resolveActiveHoursTimezone(cfg: ClawdbotConfig, raw?: string): string { - const trimmed = raw?.trim(); - if (!trimmed || trimmed === "user") { - return resolveUserTimezone(cfg.agents?.defaults?.userTimezone); - } - if (trimmed === "local") { - const host = Intl.DateTimeFormat().resolvedOptions().timeZone; - return host?.trim() || "UTC"; - } - try { - new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).format(new Date()); - return trimmed; - } catch { - return resolveUserTimezone(cfg.agents?.defaults?.userTimezone); - } -} - -function parseActiveHoursTime(opts: { allow24: boolean }, raw?: string): number | null { - if (!raw || !ACTIVE_HOURS_TIME_PATTERN.test(raw)) return null; - const [hourStr, minuteStr] = raw.split(":"); - const hour = Number(hourStr); - const minute = Number(minuteStr); - if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null; - if (hour === 24) { - if (!opts.allow24 || minute !== 0) return null; - return 24 * 60; - } - return hour * 60 + minute; -} - -function resolveMinutesInTimeZone(nowMs: number, timeZone: string): number | null { - try { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone, - hour: "2-digit", - minute: "2-digit", - hourCycle: "h23", - }).formatToParts(new Date(nowMs)); - const map: Record = {}; - for (const part of parts) { - if (part.type !== "literal") map[part.type] = part.value; - } - const hour = Number(map.hour); - const minute = Number(map.minute); - if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null; - return hour * 60 + minute; - } catch { - return null; - } -} - -function isWithinActiveHours( - cfg: ClawdbotConfig, - heartbeat?: HeartbeatConfig, - nowMs?: number, -): boolean { - const active = heartbeat?.activeHours; - if (!active) return true; - - const startMin = parseActiveHoursTime({ allow24: false }, active.start); - const endMin = parseActiveHoursTime({ allow24: true }, active.end); - if (startMin === null || endMin === null) return true; - if (startMin === endMin) return true; - - const timeZone = resolveActiveHoursTimezone(cfg, active.timezone); - const currentMin = resolveMinutesInTimeZone(nowMs ?? Date.now(), timeZone); - if (currentMin === null) return true; - - if (endMin > startMin) { - return currentMin >= startMin && currentMin < endMin; - } - return currentMin >= startMin || currentMin < endMin; -} - -type HeartbeatAgentState = { - agentId: string; - heartbeat?: HeartbeatConfig; - intervalMs: number; - lastRunMs?: number; - nextDueMs: number; -}; - -export type HeartbeatRunner = { - stop: () => void; - updateConfig: (cfg: ClawdbotConfig) => void; -}; function hasExplicitHeartbeatAgents(cfg: ClawdbotConfig) { const list = cfg.agents?.list ?? []; @@ -286,17 +198,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( @@ -417,17 +365,13 @@ export async function runHeartbeatOnce(opts: { return { status: "skipped", reason: "disabled" }; } - const startedAt = opts.deps?.nowMs?.() ?? Date.now(); - if (!isWithinActiveHours(cfg, heartbeat, startedAt)) { - return { status: "skipped", reason: "quiet-hours" }; - } - - const queueSize = (opts.deps?.getQueueSize ?? getQueueSize)(CommandLane.Main); + const queueSize = (opts.deps?.getQueueSize ?? getQueueSize)("main"); if (queueSize > 0) { return { status: "skipped", reason: "requests-in-flight" }; } - const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId); + const startedAt = opts.deps?.nowMs?.() ?? Date.now(); + const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId, heartbeat); const previousUpdatedAt = entry?.updatedAt; const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat }); const lastChannel = delivery.lastChannel; @@ -632,97 +576,24 @@ export function startHeartbeatRunner(opts: { cfg?: ClawdbotConfig; runtime?: RuntimeEnv; abortSignal?: AbortSignal; - runOnce?: typeof runHeartbeatOnce; -}): HeartbeatRunner { +}) { + const cfg = opts.cfg ?? loadConfig(); + const heartbeatAgents = resolveHeartbeatAgents(cfg); + const intervals = heartbeatAgents + .map((agent) => resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat)) + .filter((value): value is number => typeof value === "number"); + const intervalMs = intervals.length > 0 ? Math.min(...intervals) : null; + if (!intervalMs) { + log.info("heartbeat: disabled", { enabled: false }); + } + const runtime = opts.runtime ?? defaultRuntime; - const runOnce = opts.runOnce ?? runHeartbeatOnce; - const state = { - cfg: opts.cfg ?? loadConfig(), - runtime, - agents: new Map(), - timer: null as NodeJS.Timeout | null, - stopped: false, - }; - let initialized = false; - - const resolveNextDue = (now: number, intervalMs: number, prevState?: HeartbeatAgentState) => { - if (typeof prevState?.lastRunMs === "number") { - return prevState.lastRunMs + intervalMs; - } - if (prevState && prevState.intervalMs === intervalMs && prevState.nextDueMs > now) { - return prevState.nextDueMs; - } - return now + intervalMs; - }; - - const scheduleNext = () => { - if (state.stopped) return; - if (state.timer) { - clearTimeout(state.timer); - state.timer = null; - } - if (state.agents.size === 0) return; - const now = Date.now(); - let nextDue = Number.POSITIVE_INFINITY; - for (const agent of state.agents.values()) { - if (agent.nextDueMs < nextDue) nextDue = agent.nextDueMs; - } - if (!Number.isFinite(nextDue)) return; - const delay = Math.max(0, nextDue - now); - state.timer = setTimeout(() => { - requestHeartbeatNow({ reason: "interval", coalesceMs: 0 }); - }, delay); - state.timer.unref?.(); - }; - - const updateConfig = (cfg: ClawdbotConfig) => { - if (state.stopped) return; - const now = Date.now(); - const prevAgents = state.agents; - const prevEnabled = prevAgents.size > 0; - const nextAgents = new Map(); - const intervals: number[] = []; - for (const agent of resolveHeartbeatAgents(cfg)) { - const intervalMs = resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat); - if (!intervalMs) continue; - intervals.push(intervalMs); - const prevState = prevAgents.get(agent.agentId); - const nextDueMs = resolveNextDue(now, intervalMs, prevState); - nextAgents.set(agent.agentId, { - agentId: agent.agentId, - heartbeat: agent.heartbeat, - intervalMs, - lastRunMs: prevState?.lastRunMs, - nextDueMs, - }); - } - - state.cfg = cfg; - state.agents = nextAgents; - const nextEnabled = nextAgents.size > 0; - if (!initialized) { - if (!nextEnabled) { - log.info("heartbeat: disabled", { enabled: false }); - } else { - log.info("heartbeat: started", { intervalMs: Math.min(...intervals) }); - } - initialized = true; - } else if (prevEnabled !== nextEnabled) { - if (!nextEnabled) { - log.info("heartbeat: disabled", { enabled: false }); - } else { - log.info("heartbeat: started", { intervalMs: Math.min(...intervals) }); - } - } - - scheduleNext(); - }; - + const lastRunByAgent = new Map(); const run: HeartbeatWakeHandler = async (params) => { if (!heartbeatsEnabled) { return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult; } - if (state.agents.size === 0) { + if (heartbeatAgents.length === 0) { return { status: "skipped", reason: "disabled" } satisfies HeartbeatRunResult; } @@ -732,44 +603,52 @@ export function startHeartbeatRunner(opts: { const now = startedAt; let ran = false; - for (const agent of state.agents.values()) { - if (isInterval && now < agent.nextDueMs) { + for (const agent of heartbeatAgents) { + const agentIntervalMs = resolveHeartbeatIntervalMs(cfg, undefined, agent.heartbeat); + if (!agentIntervalMs) continue; + const lastRun = lastRunByAgent.get(agent.agentId); + if (isInterval && typeof lastRun === "number" && now - lastRun < agentIntervalMs) { continue; } - const res = await runOnce({ - cfg: state.cfg, + const res = await runHeartbeatOnce({ + cfg, agentId: agent.agentId, heartbeat: agent.heartbeat, reason, - deps: { runtime: state.runtime }, + deps: { runtime }, }); if (res.status === "skipped" && res.reason === "requests-in-flight") { return res; } if (res.status !== "skipped" || res.reason !== "disabled") { - agent.lastRunMs = now; - agent.nextDueMs = now + agent.intervalMs; + lastRunByAgent.set(agent.agentId, now); } if (res.status === "ran") ran = true; } - scheduleNext(); if (ran) return { status: "ran", durationMs: Date.now() - startedAt }; return { status: "skipped", reason: isInterval ? "not-due" : "disabled" }; }; setHeartbeatWakeHandler(async (params) => run({ reason: params.reason })); - updateConfig(state.cfg); + + let timer: NodeJS.Timeout | null = null; + if (intervalMs) { + timer = setInterval(() => { + requestHeartbeatNow({ reason: "interval", coalesceMs: 0 }); + }, intervalMs); + timer.unref?.(); + log.info("heartbeat: started", { intervalMs }); + } const cleanup = () => { - state.stopped = true; setHeartbeatWakeHandler(null); - if (state.timer) clearTimeout(state.timer); - state.timer = null; + if (timer) clearInterval(timer); + timer = null; }; opts.abortSignal?.addEventListener("abort", cleanup, { once: true }); - return { stop: cleanup, updateConfig }; + return { stop: cleanup }; }