Skip to content

Commit 2806f2b

Browse files
odysseus0claude
andauthored
Heartbeat: add isolatedSession option for fresh session per heartbeat run (openclaw#46634)
Reuses the cron isolated session pattern (resolveCronSession with forceNew) to give each heartbeat a fresh session with no prior conversation history. Reduces per-heartbeat token cost from ~100K to ~2-5K tokens. Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 9e8df16 commit 2806f2b

File tree

9 files changed

+148
-13
lines changed

9 files changed

+148
-13
lines changed

docs/.generated/config-baseline.json

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1484,6 +1484,16 @@
14841484
"tags": [],
14851485
"hasChildren": false
14861486
},
1487+
{
1488+
"path": "agents.defaults.heartbeat.isolatedSession",
1489+
"kind": "core",
1490+
"type": "boolean",
1491+
"required": false,
1492+
"deprecated": false,
1493+
"sensitive": false,
1494+
"tags": [],
1495+
"hasChildren": false
1496+
},
14871497
{
14881498
"path": "agents.defaults.heartbeat.lightContext",
14891499
"kind": "core",
@@ -1544,7 +1554,7 @@
15441554
"deprecated": false,
15451555
"sensitive": false,
15461556
"tags": ["automation"],
1547-
"help": "Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, synology-chat, tlon, twitch, zalo, zalouser.",
1557+
"help": "Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, zalouser, zalo, tlon, feishu, nextcloud-talk, msteams, bluebubbles, synology-chat, mattermost, twitch, matrix, nostr.",
15481558
"hasChildren": false
15491559
},
15501560
{
@@ -3647,6 +3657,16 @@
36473657
"tags": [],
36483658
"hasChildren": false
36493659
},
3660+
{
3661+
"path": "agents.list.*.heartbeat.isolatedSession",
3662+
"kind": "core",
3663+
"type": "boolean",
3664+
"required": false,
3665+
"deprecated": false,
3666+
"sensitive": false,
3667+
"tags": [],
3668+
"hasChildren": false
3669+
},
36503670
{
36513671
"path": "agents.list.*.heartbeat.lightContext",
36523672
"kind": "core",
@@ -3707,7 +3727,7 @@
37073727
"deprecated": false,
37083728
"sensitive": false,
37093729
"tags": ["automation"],
3710-
"help": "Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, synology-chat, tlon, twitch, zalo, zalouser.",
3730+
"help": "Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, zalouser, zalo, tlon, feishu, nextcloud-talk, msteams, bluebubbles, synology-chat, mattermost, twitch, matrix, nostr.",
37113731
"hasChildren": false
37123732
},
37133733
{

docs/.generated/config-baseline.jsonl

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":4731}
1+
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":4733}
22
{"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true}
33
{"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true}
44
{"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -137,12 +137,13 @@
137137
{"recordType":"path","path":"agents.defaults.heartbeat.directPolicy","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["access","automation","storage"],"label":"Heartbeat Direct Policy","help":"Controls whether heartbeat delivery may target direct/DM chats: \"allow\" (default) permits DM delivery and \"block\" suppresses direct-target sends.","hasChildren":false}
138138
{"recordType":"path","path":"agents.defaults.heartbeat.every","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
139139
{"recordType":"path","path":"agents.defaults.heartbeat.includeReasoning","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
140+
{"recordType":"path","path":"agents.defaults.heartbeat.isolatedSession","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
140141
{"recordType":"path","path":"agents.defaults.heartbeat.lightContext","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
141142
{"recordType":"path","path":"agents.defaults.heartbeat.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
142143
{"recordType":"path","path":"agents.defaults.heartbeat.prompt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
143144
{"recordType":"path","path":"agents.defaults.heartbeat.session","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
144145
{"recordType":"path","path":"agents.defaults.heartbeat.suppressToolErrorWarnings","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"label":"Heartbeat Suppress Tool Error Warnings","help":"Suppress tool error warning payloads during heartbeat runs.","hasChildren":false}
145-
{"recordType":"path","path":"agents.defaults.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, synology-chat, tlon, twitch, zalo, zalouser.","hasChildren":false}
146+
{"recordType":"path","path":"agents.defaults.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, zalouser, zalo, tlon, feishu, nextcloud-talk, msteams, bluebubbles, synology-chat, mattermost, twitch, matrix, nostr.","hasChildren":false}
146147
{"recordType":"path","path":"agents.defaults.heartbeat.to","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
147148
{"recordType":"path","path":"agents.defaults.humanDelay","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
148149
{"recordType":"path","path":"agents.defaults.humanDelay.maxMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Human Delay Max (ms)","help":"Maximum delay in ms for custom humanDelay (default: 2500).","hasChildren":false}
@@ -340,12 +341,13 @@
340341
{"recordType":"path","path":"agents.list.*.heartbeat.directPolicy","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["access","automation","storage"],"label":"Heartbeat Direct Policy","help":"Per-agent override for heartbeat direct/DM delivery policy; use \"block\" for agents that should only send heartbeat alerts to non-DM destinations.","hasChildren":false}
341342
{"recordType":"path","path":"agents.list.*.heartbeat.every","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
342343
{"recordType":"path","path":"agents.list.*.heartbeat.includeReasoning","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
344+
{"recordType":"path","path":"agents.list.*.heartbeat.isolatedSession","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
343345
{"recordType":"path","path":"agents.list.*.heartbeat.lightContext","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
344346
{"recordType":"path","path":"agents.list.*.heartbeat.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
345347
{"recordType":"path","path":"agents.list.*.heartbeat.prompt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
346348
{"recordType":"path","path":"agents.list.*.heartbeat.session","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
347349
{"recordType":"path","path":"agents.list.*.heartbeat.suppressToolErrorWarnings","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"label":"Agent Heartbeat Suppress Tool Error Warnings","help":"Suppress tool error warning payloads during heartbeat runs.","hasChildren":false}
348-
{"recordType":"path","path":"agents.list.*.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, synology-chat, tlon, twitch, zalo, zalouser.","hasChildren":false}
350+
{"recordType":"path","path":"agents.list.*.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, zalouser, zalo, tlon, feishu, nextcloud-talk, msteams, bluebubbles, synology-chat, mattermost, twitch, matrix, nostr.","hasChildren":false}
349351
{"recordType":"path","path":"agents.list.*.heartbeat.to","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
350352
{"recordType":"path","path":"agents.list.*.humanDelay","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
351353
{"recordType":"path","path":"agents.list.*.humanDelay.maxMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}

docs/gateway/configuration-reference.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -975,6 +975,7 @@ Periodic heartbeat runs.
975975
model: "openai/gpt-5.2-mini",
976976
includeReasoning: false,
977977
lightContext: false, // default: false; true keeps only HEARTBEAT.md from workspace bootstrap files
978+
isolatedSession: false, // default: false; true runs each heartbeat in a fresh session (no conversation history)
978979
session: "main",
979980
to: "+15555550123",
980981
directPolicy: "allow", // allow (default) | block
@@ -992,6 +993,7 @@ Periodic heartbeat runs.
992993
- `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs.
993994
- `directPolicy`: direct/DM delivery policy. `allow` (default) permits direct-target delivery. `block` suppresses direct-target delivery and emits `reason=dm-blocked`.
994995
- `lightContext`: when true, heartbeat runs use lightweight bootstrap context and keep only `HEARTBEAT.md` from workspace bootstrap files.
996+
- `isolatedSession`: when true, each heartbeat runs in a fresh session with no prior conversation history. Same isolation pattern as cron `sessionTarget: "isolated"`. Reduces per-heartbeat token cost from ~100K to ~2-5K tokens.
995997
- Per-agent: set `agents.list[].heartbeat`. When any agent defines `heartbeat`, **only those agents** run heartbeats.
996998
- Heartbeats run full agent turns — shorter intervals burn more tokens.
997999

docs/gateway/heartbeat.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting)
2222
3. Decide where heartbeat messages should go (`target: "none"` is the default; set `target: "last"` to route to the last contact).
2323
4. Optional: enable heartbeat reasoning delivery for transparency.
2424
5. Optional: use lightweight bootstrap context if heartbeat runs only need `HEARTBEAT.md`.
25-
6. Optional: restrict heartbeats to active hours (local time).
25+
6. Optional: enable isolated sessions to avoid sending full conversation history each heartbeat.
26+
7. Optional: restrict heartbeats to active hours (local time).
2627

2728
Example config:
2829

@@ -35,6 +36,7 @@ Example config:
3536
target: "last", // explicit delivery to last contact (default is "none")
3637
directPolicy: "allow", // default: allow direct/DM targets; set "block" to suppress
3738
lightContext: true, // optional: only inject HEARTBEAT.md from bootstrap files
39+
isolatedSession: true, // optional: fresh session each run (no conversation history)
3840
// activeHours: { start: "08:00", end: "24:00" },
3941
// includeReasoning: true, // optional: send separate `Reasoning:` message too
4042
},
@@ -91,6 +93,7 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
9193
model: "anthropic/claude-opus-4-6",
9294
includeReasoning: false, // default: false (deliver separate Reasoning: message when available)
9395
lightContext: false, // default: false; true keeps only HEARTBEAT.md from workspace bootstrap files
96+
isolatedSession: false, // default: false; true runs each heartbeat in a fresh session (no conversation history)
9497
target: "last", // default: none | options: last | none | <channel id> (core or plugin, e.g. "bluebubbles")
9598
to: "+15551234567", // optional channel-specific override
9699
accountId: "ops-bot", // optional multi-account channel id
@@ -212,6 +215,7 @@ Use `accountId` to target a specific account on multi-account channels like Tele
212215
- `model`: optional model override for heartbeat runs (`provider/model`).
213216
- `includeReasoning`: when enabled, also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`).
214217
- `lightContext`: when true, heartbeat runs use lightweight bootstrap context and keep only `HEARTBEAT.md` from workspace bootstrap files.
218+
- `isolatedSession`: when true, each heartbeat runs in a fresh session with no prior conversation history. Uses the same isolation pattern as cron `sessionTarget: "isolated"`. Dramatically reduces per-heartbeat token cost. Combine with `lightContext: true` for maximum savings. Delivery routing still uses the main session context.
215219
- `session`: optional session key for heartbeat runs.
216220
- `main` (default): agent main session.
217221
- Explicit session key (copy from `openclaw sessions --json` or the [sessions CLI](/cli/sessions)).
@@ -380,6 +384,10 @@ off in group chats.
380384

381385
## Cost awareness
382386

383-
Heartbeats run full agent turns. Shorter intervals burn more tokens. Keep
384-
`HEARTBEAT.md` small and consider a cheaper `model` or `target: "none"` if you
385-
only want internal state updates.
387+
Heartbeats run full agent turns. Shorter intervals burn more tokens. To reduce cost:
388+
389+
- Use `isolatedSession: true` to avoid sending full conversation history (~100K tokens down to ~2-5K per run).
390+
- Use `lightContext: true` to limit bootstrap files to just `HEARTBEAT.md`.
391+
- Set a cheaper `model` (e.g. `ollama/llama3.2:1b`).
392+
- Keep `HEARTBEAT.md` small.
393+
- Use `target: "none"` if you only want internal state updates.

src/config/legacy.migrations.part-3.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const AGENT_HEARTBEAT_KEYS = new Set([
3131
"ackMaxChars",
3232
"suppressToolErrorWarnings",
3333
"lightContext",
34+
"isolatedSession",
3435
]);
3536

3637
const CHANNEL_HEARTBEAT_KEYS = new Set(["showOk", "showAlerts", "useIndicator"]);

src/config/types.agent-defaults.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,13 @@ export type AgentDefaultsConfig = {
253253
* Lightweight mode keeps only HEARTBEAT.md from workspace bootstrap files.
254254
*/
255255
lightContext?: boolean;
256+
/**
257+
* If true, run heartbeat turns in an isolated session with no prior
258+
* conversation history. The heartbeat only sees its bootstrap context
259+
* (HEARTBEAT.md when lightContext is also enabled). Dramatically reduces
260+
* per-heartbeat token cost by avoiding the full session transcript.
261+
*/
262+
isolatedSession?: boolean;
256263
/**
257264
* When enabled, deliver the model's reasoning payload for heartbeat runs (when available)
258265
* as a separate message prefixed with `Reasoning:` (same as `/reasoning on`).

src/config/zod-schema.agent-runtime.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export const HeartbeatSchema = z
3434
ackMaxChars: z.number().int().nonnegative().optional(),
3535
suppressToolErrorWarnings: z.boolean().optional(),
3636
lightContext: z.boolean().optional(),
37+
isolatedSession: z.boolean().optional(),
3738
})
3839
.strict()
3940
.superRefine((val, ctx) => {

src/infra/heartbeat-runner.model-override.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ describe("runHeartbeatOnce – heartbeat model override", () => {
6565
model?: string;
6666
suppressToolErrorWarnings?: boolean;
6767
lightContext?: boolean;
68+
isolatedSession?: boolean;
6869
}) {
6970
return withHeartbeatFixture(async ({ tmpDir, storePath, seedSession }) => {
7071
const cfg: OpenClawConfig = {
@@ -77,6 +78,7 @@ describe("runHeartbeatOnce – heartbeat model override", () => {
7778
model: params.model,
7879
suppressToolErrorWarnings: params.suppressToolErrorWarnings,
7980
lightContext: params.lightContext,
81+
isolatedSession: params.isolatedSession,
8082
},
8183
},
8284
},
@@ -133,6 +135,72 @@ describe("runHeartbeatOnce – heartbeat model override", () => {
133135
);
134136
});
135137

138+
it("uses isolated session key when isolatedSession is enabled", async () => {
139+
await withHeartbeatFixture(async ({ tmpDir, storePath, seedSession }) => {
140+
const cfg: OpenClawConfig = {
141+
agents: {
142+
defaults: {
143+
workspace: tmpDir,
144+
heartbeat: {
145+
every: "5m",
146+
target: "whatsapp",
147+
isolatedSession: true,
148+
},
149+
},
150+
},
151+
channels: { whatsapp: { allowFrom: ["*"] } },
152+
session: { store: storePath },
153+
};
154+
const sessionKey = resolveMainSessionKey(cfg);
155+
await seedSession(sessionKey, { lastChannel: "whatsapp", lastTo: "+1555" });
156+
157+
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
158+
replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" });
159+
160+
await runHeartbeatOnce({
161+
cfg,
162+
deps: { getQueueSize: () => 0, nowMs: () => 0 },
163+
});
164+
165+
expect(replySpy).toHaveBeenCalledTimes(1);
166+
const ctx = replySpy.mock.calls[0]?.[0];
167+
// Isolated heartbeat runs use a dedicated session key with :heartbeat suffix
168+
expect(ctx.SessionKey).toBe(`${sessionKey}:heartbeat`);
169+
});
170+
});
171+
172+
it("uses main session key when isolatedSession is not set", async () => {
173+
await withHeartbeatFixture(async ({ tmpDir, storePath, seedSession }) => {
174+
const cfg: OpenClawConfig = {
175+
agents: {
176+
defaults: {
177+
workspace: tmpDir,
178+
heartbeat: {
179+
every: "5m",
180+
target: "whatsapp",
181+
},
182+
},
183+
},
184+
channels: { whatsapp: { allowFrom: ["*"] } },
185+
session: { store: storePath },
186+
};
187+
const sessionKey = resolveMainSessionKey(cfg);
188+
await seedSession(sessionKey, { lastChannel: "whatsapp", lastTo: "+1555" });
189+
190+
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
191+
replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" });
192+
193+
await runHeartbeatOnce({
194+
cfg,
195+
deps: { getQueueSize: () => 0, nowMs: () => 0 },
196+
});
197+
198+
expect(replySpy).toHaveBeenCalledTimes(1);
199+
const ctx = replySpy.mock.calls[0]?.[0];
200+
expect(ctx.SessionKey).toBe(sessionKey);
201+
});
202+
});
203+
136204
it("passes per-agent heartbeat model override (merged with defaults)", async () => {
137205
await withHeartbeatFixture(async ({ tmpDir, storePath, seedSession }) => {
138206
const cfg: OpenClawConfig = {

0 commit comments

Comments
 (0)