Heartbeat: optional reasoning delivery (#690)
* feat: expose heartbeat reasoning output * docs(changelog): mention heartbeat reasoning toggle
This commit is contained in:
committed by
GitHub
parent
5adbeb1bad
commit
3166cc911b
@@ -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.
|
- 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.
|
- 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: 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.
|
- Docker: allow optional home volume + extra bind mounts in `docker-setup.sh`. (#679) — thanks @gabriel-trigo.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|||||||
@@ -1227,6 +1227,7 @@ Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
|
|||||||
- `every`: duration string (`ms`, `s`, `m`, `h`); default unit minutes. Default:
|
- `every`: duration string (`ms`, `s`, `m`, `h`); default unit minutes. Default:
|
||||||
`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`.
|
||||||
- `target`: optional delivery provider (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `none`). Default: `last`.
|
- `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).
|
- `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.
|
- `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.
|
||||||
|
|||||||
@@ -8,6 +8,29 @@ read_when:
|
|||||||
Heartbeat runs **periodic agent turns** in the main session so the model can
|
Heartbeat runs **periodic agent turns** in the main session so the model can
|
||||||
surface anything that needs attention without spamming you.
|
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
|
## Defaults
|
||||||
|
|
||||||
- Interval: `30m` (set `agents.defaults.heartbeat.every`; use `0m` to disable).
|
- 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
|
- The heartbeat prompt is sent **verbatim** as the user message. The system
|
||||||
prompt includes a “Heartbeat” section and the run is flagged internally.
|
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
|
## Response contract
|
||||||
|
|
||||||
- If nothing needs attention, reply with **`HEARTBEAT_OK`**.
|
- If nothing needs attention, reply with **`HEARTBEAT_OK`**.
|
||||||
@@ -38,6 +74,7 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
|
|||||||
heartbeat: {
|
heartbeat: {
|
||||||
every: "30m", // default: 30m (0m disables)
|
every: "30m", // default: 30m (0m disables)
|
||||||
model: "anthropic/claude-opus-4-5",
|
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
|
target: "last", // last | whatsapp | telegram | discord | slack | signal | imessage | none
|
||||||
to: "+15551234567", // optional provider-specific override
|
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.",
|
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).
|
- `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`).
|
||||||
- `target`:
|
- `target`:
|
||||||
- `last` (default): deliver to the last used external provider.
|
- `last` (default): deliver to the last used external provider.
|
||||||
- explicit provider: `whatsapp` / `telegram` / `discord` / `slack` / `signal` / `imessage`.
|
- 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)
|
## HEARTBEAT.md (optional)
|
||||||
|
|
||||||
If a `HEARTBEAT.md` file exists in the workspace, the default prompt tells the
|
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
|
agent to read it. Think of it as your “heartbeat checklist”: small, stable, and
|
||||||
bloat.
|
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)
|
## 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.
|
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
|
## Cost awareness
|
||||||
|
|
||||||
Heartbeats run full agent turns. Shorter intervals burn more tokens. Keep
|
Heartbeats run full agent turns. Shorter intervals burn more tokens. Keep
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ read_when:
|
|||||||
|
|
||||||
## Heartbeats
|
## 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 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
|
## Web chat UI
|
||||||
- The web chat thinking selector mirrors the session's stored level from the inbound session store/config when the page loads.
|
- The web chat thinking selector mirrors the session's stored level from the inbound session store/config when the page loads.
|
||||||
|
|||||||
@@ -1396,6 +1396,13 @@ export type AgentDefaultsConfig = {
|
|||||||
prompt?: string;
|
prompt?: string;
|
||||||
/** Max chars allowed after HEARTBEAT_OK before delivery (default: 30). */
|
/** Max chars allowed after HEARTBEAT_OK before delivery (default: 30). */
|
||||||
ackMaxChars?: number;
|
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). */
|
/** Max concurrent agent runs across all conversations. Default: 1 (sequential). */
|
||||||
maxConcurrent?: number;
|
maxConcurrent?: number;
|
||||||
|
|||||||
@@ -646,6 +646,7 @@ const HeartbeatSchema = z
|
|||||||
.object({
|
.object({
|
||||||
every: z.string().optional(),
|
every: z.string().optional(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
|
includeReasoning: z.boolean().optional(),
|
||||||
target: z
|
target: z
|
||||||
.union([
|
.union([
|
||||||
z.literal("last"),
|
z.literal("last"),
|
||||||
|
|||||||
@@ -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 () => {
|
it("loads the default agent session from templated stores", async () => {
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||||
const storeTemplate = path.join(
|
const storeTemplate = path.join(
|
||||||
|
|||||||
@@ -112,6 +112,20 @@ function resolveHeartbeatReplyPayload(
|
|||||||
return undefined;
|
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: {
|
function resolveHeartbeatSender(params: {
|
||||||
allowFrom: Array<string | number>;
|
allowFrom: Array<string | number>;
|
||||||
lastTo?: string;
|
lastTo?: string;
|
||||||
@@ -246,6 +260,8 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
const replyPayload = resolveHeartbeatReplyPayload(replyResult);
|
const replyPayload = resolveHeartbeatReplyPayload(replyResult);
|
||||||
|
const includeReasoning =
|
||||||
|
cfg.agents?.defaults?.heartbeat?.includeReasoning === true;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!replyPayload ||
|
!replyPayload ||
|
||||||
@@ -294,6 +310,12 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
replyPayload.mediaUrls ??
|
replyPayload.mediaUrls ??
|
||||||
(replyPayload.mediaUrl ? [replyPayload.mediaUrl] : []);
|
(replyPayload.mediaUrl ? [replyPayload.mediaUrl] : []);
|
||||||
|
|
||||||
|
const reasoningPayloads = includeReasoning
|
||||||
|
? resolveHeartbeatReasoningPayloads(replyResult).filter(
|
||||||
|
(payload) => payload !== replyPayload,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
if (delivery.provider === "none" || !delivery.to) {
|
if (delivery.provider === "none" || !delivery.to) {
|
||||||
emitHeartbeatEvent({
|
emitHeartbeatEvent({
|
||||||
status: "skipped",
|
status: "skipped",
|
||||||
@@ -327,6 +349,7 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
provider: delivery.provider,
|
provider: delivery.provider,
|
||||||
to: delivery.to,
|
to: delivery.to,
|
||||||
payloads: [
|
payloads: [
|
||||||
|
...reasoningPayloads,
|
||||||
{
|
{
|
||||||
text: normalized.text,
|
text: normalized.text,
|
||||||
mediaUrls,
|
mediaUrls,
|
||||||
|
|||||||
Reference in New Issue
Block a user