diff --git a/CHANGELOG.md b/CHANGELOG.md index 57c8ebff5..d336b8af1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Providers: unify group history context wrappers across providers with per-provider/per-account `historyLimit` overrides (fallback to `messages.groupChat.historyLimit`). Set `0` to disable. (#672) — thanks @steipete. - CLI: add `clawdbot update` (safe-ish git checkout update) + `--update` shorthand. (#673) — thanks @fm1randa. - Gateway: add OpenAI-compatible `/v1/chat/completions` HTTP endpoint (auth, SSE streaming, per-agent routing). (#680) — thanks @steipete. +- Gateway/Heartbeat: optionally deliver heartbeat `Reasoning:` output (`agents.defaults.heartbeat.includeReasoning`). (#690) — thanks @steipete. - Docker: allow optional home volume + extra bind mounts in `docker-setup.sh`. (#679) — thanks @gabriel-trigo. ### Fixes diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index dca0921c3..c92c5d327 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1227,6 +1227,7 @@ Z.AI models are available as `zai/` (e.g. `zai/glm-4.7`) and require - `every`: duration string (`ms`, `s`, `m`, `h`); default unit minutes. Default: `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`. - `target`: optional delivery provider (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `none`). Default: `last`. - `to`: optional recipient override (provider-specific id, e.g. E.164 for WhatsApp, chat id for Telegram). - `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md if exists` line if you still want the file read. diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index 42002d169..0cb109216 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -8,6 +8,29 @@ read_when: Heartbeat runs **periodic agent turns** in the main session so the model can surface anything that needs attention without spamming you. +## Quick start (beginner) + +1. Leave heartbeats enabled (default is `30m`) or set your own cadence. +2. Create a tiny `HEARTBEAT.md` checklist in the agent workspace (optional but recommended). +3. Decide where heartbeat messages should go (`target: "last"` is the default). +4. Optional: enable heartbeat reasoning delivery for transparency. + +Example config: + +```json5 +{ + agents: { + defaults: { + heartbeat: { + every: "30m", + target: "last", + // includeReasoning: true, // optional: send separate `Reasoning:` message too + } + } + } +} +``` + ## Defaults - Interval: `30m` (set `agents.defaults.heartbeat.every`; use `0m` to disable). @@ -16,6 +39,19 @@ surface anything that needs attention without spamming you. - The heartbeat prompt is sent **verbatim** as the user message. The system prompt includes a “Heartbeat” section and the run is flagged internally. +## What the heartbeat prompt is for + +The default prompt is intentionally broad: +- **Background tasks**: “Consider outstanding tasks” nudges the agent to review + follow-ups (inbox, calendar, reminders, queued work) and surface anything urgent. +- **Human check-in**: “Checkup sometimes on your human during day time” nudges an + occasional lightweight “anything you need?” message, but avoids night-time spam + by using your configured local timezone (see [/concepts/timezone](/concepts/timezone)). + +If you want a heartbeat to do something very specific (e.g. “check Gmail PubSub +stats” or “verify gateway health”), set `agents.defaults.heartbeat.prompt` to a +custom body (sent verbatim). + ## Response contract - If nothing needs attention, reply with **`HEARTBEAT_OK`**. @@ -38,6 +74,7 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped. heartbeat: { every: "30m", // default: 30m (0m disables) model: "anthropic/claude-opus-4-5", + includeReasoning: false, // default: false (deliver separate Reasoning: message when available) target: "last", // last | whatsapp | telegram | discord | slack | signal | imessage | none to: "+15551234567", // optional provider-specific override prompt: "Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.", @@ -52,6 +89,7 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped. - `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`). - `target`: - `last` (default): deliver to the last used external provider. - explicit provider: `whatsapp` / `telegram` / `discord` / `slack` / `signal` / `imessage`. @@ -72,8 +110,36 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped. ## HEARTBEAT.md (optional) If a `HEARTBEAT.md` file exists in the workspace, the default prompt tells the -agent to read it. Keep it tiny (short checklist or reminders) to avoid prompt -bloat. +agent to read it. Think of it as your “heartbeat checklist”: small, stable, and +safe to include every 30 minutes. + +Keep it tiny (short checklist or reminders) to avoid prompt bloat. + +Example `HEARTBEAT.md`: + +```md +# Heartbeat checklist + +- Quick scan: anything urgent in inboxes? +- If it’s daytime, do a lightweight check-in if nothing else is pending. +- If a task is blocked, write down *what is missing* and ask Peter next time. +``` + +### Can the agent update HEARTBEAT.md? + +Yes — if you ask it to. + +`HEARTBEAT.md` is just a normal file in the agent workspace, so you can tell the +agent (in a normal chat) something like: +- “Update `HEARTBEAT.md` to add a daily calendar check.” +- “Rewrite `HEARTBEAT.md` so it’s shorter and focused on inbox follow-ups.” + +If you want this to happen proactively, you can also include an explicit line in +your heartbeat prompt like: “If the checklist becomes stale, update HEARTBEAT.md +with a better one.” + +Safety note: don’t put secrets (API keys, phone numbers, private tokens) into +`HEARTBEAT.md` — it becomes part of the prompt context. ## Manual wake (on-demand) @@ -85,6 +151,19 @@ clawdbot wake --text "Check for urgent follow-ups" --mode now Use `--mode next-heartbeat` to wait for the next scheduled tick. +## Reasoning delivery (optional) + +By default, heartbeats deliver only the final “answer” payload. + +If you want transparency, enable: +- `agents.defaults.heartbeat.includeReasoning: true` + +When enabled, heartbeats will also deliver a separate message prefixed +`Reasoning:` (same shape as `/reasoning on`). This can be useful when the agent +is managing multiple sessions/codexes and you want to see why it decided to ping +you — but it can also leak more internal detail than you want. Prefer keeping it +off in group chats. + ## Cost awareness Heartbeats run full agent turns. Shorter intervals burn more tokens. Keep diff --git a/docs/tools/thinking.md b/docs/tools/thinking.md index e966662a7..96c40e7ef 100644 --- a/docs/tools/thinking.md +++ b/docs/tools/thinking.md @@ -50,6 +50,7 @@ read_when: ## Heartbeats - Heartbeat probe body is the configured heartbeat prompt (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`). Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats). +- Heartbeat delivery defaults to the final payload only. To also send the separate `Reasoning:` message (when available), set `agents.defaults.heartbeat.includeReasoning: true`. ## Web chat UI - The web chat thinking selector mirrors the session's stored level from the inbound session store/config when the page loads. diff --git a/src/config/types.ts b/src/config/types.ts index 4de23e5d9..22a317500 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1396,6 +1396,13 @@ export type AgentDefaultsConfig = { prompt?: string; /** Max chars allowed after HEARTBEAT_OK before delivery (default: 30). */ ackMaxChars?: number; + /** + * When enabled, deliver the model's reasoning payload for heartbeat runs (when available) + * as a separate message prefixed with `Reasoning:` (same as `/reasoning on`). + * + * Default: false (only the final heartbeat payload is delivered). + */ + includeReasoning?: boolean; }; /** Max concurrent agent runs across all conversations. Default: 1 (sequential). */ maxConcurrent?: number; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 50db5c8d9..dfbd97c43 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -646,6 +646,7 @@ const HeartbeatSchema = z .object({ every: z.string().optional(), model: z.string().optional(), + includeReasoning: z.boolean().optional(), target: z .union([ z.literal("last"), diff --git a/src/infra/heartbeat-runner.test.ts b/src/infra/heartbeat-runner.test.ts index cbad5f025..24f882e94 100644 --- a/src/infra/heartbeat-runner.test.ts +++ b/src/infra/heartbeat-runner.test.ts @@ -246,6 +246,81 @@ describe("runHeartbeatOnce", () => { } }); + it("can include reasoning payloads when enabled", 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 { + await fs.writeFile( + storePath, + JSON.stringify( + { + main: { + sessionId: "sid", + updatedAt: Date.now(), + lastProvider: "whatsapp", + lastTo: "+1555", + }, + }, + null, + 2, + ), + ); + + const cfg: ClawdbotConfig = { + agents: { + defaults: { + heartbeat: { + every: "5m", + target: "whatsapp", + to: "+1555", + includeReasoning: true, + }, + }, + }, + whatsapp: { allowFrom: ["*"] }, + session: { store: storePath }, + }; + + replySpy.mockResolvedValue([ + { text: "Reasoning:\nBecause it helps" }, + { text: "Final 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(2); + expect(sendWhatsApp).toHaveBeenNthCalledWith( + 1, + "+1555", + "Reasoning:\nBecause it helps", + expect.any(Object), + ); + expect(sendWhatsApp).toHaveBeenNthCalledWith( + 2, + "+1555", + "Final alert", + expect.any(Object), + ); + } finally { + replySpy.mockRestore(); + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + it("loads the default agent session from templated stores", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-")); const storeTemplate = path.join( diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 4ceeaca63..bbf86f902 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -112,6 +112,20 @@ function resolveHeartbeatReplyPayload( return undefined; } +function resolveHeartbeatReasoningPayloads( + replyResult: ReplyPayload | ReplyPayload[] | undefined, +): ReplyPayload[] { + const payloads = Array.isArray(replyResult) + ? replyResult + : replyResult + ? [replyResult] + : []; + return payloads.filter((payload) => { + const text = typeof payload.text === "string" ? payload.text : ""; + return text.trimStart().startsWith("Reasoning:"); + }); +} + function resolveHeartbeatSender(params: { allowFrom: Array; lastTo?: string; @@ -246,6 +260,8 @@ export async function runHeartbeatOnce(opts: { cfg, ); const replyPayload = resolveHeartbeatReplyPayload(replyResult); + const includeReasoning = + cfg.agents?.defaults?.heartbeat?.includeReasoning === true; if ( !replyPayload || @@ -294,6 +310,12 @@ export async function runHeartbeatOnce(opts: { replyPayload.mediaUrls ?? (replyPayload.mediaUrl ? [replyPayload.mediaUrl] : []); + const reasoningPayloads = includeReasoning + ? resolveHeartbeatReasoningPayloads(replyResult).filter( + (payload) => payload !== replyPayload, + ) + : []; + if (delivery.provider === "none" || !delivery.to) { emitHeartbeatEvent({ status: "skipped", @@ -327,6 +349,7 @@ export async function runHeartbeatOnce(opts: { provider: delivery.provider, to: delivery.to, payloads: [ + ...reasoningPayloads, { text: normalized.text, mediaUrls,