#contextPruning heartbeat sessions

32 messages Ā· Page 1 of 1 (latest)

cerulean jungle
#

Does contextPruning in agents.defaults apply to isolated heartbeat sessions, or only to the main (telegram) session?
I manually prune and see my heartbeat session increase 2K per heartbeat continously even with "keepLastAssistants": 6.

Maybe to put it another way, my simple heartbeats grow the session to 128K tokens over time which is quite expensive with sonnet. I'd hoped contextpruning and cache-ttl would limit this to base+12K

sharp snowBOT
sterile cradleBOT
#

To help others find answers, you can mark your question as solved via Right click solution message -> Apps -> āœ… Mark Solution

sharp snowBOT
simple questBOT
#

Just checking the docs and source so I don’t guess.

simple questBOT
# cerulean jungle Does contextPruning in agents.defaults apply to isolated heartbeat sessions, or ...

I found the relevant bits in docs and source. Short version, yes, agents.defaults.contextPruning applies to heartbeat runs for that agent too, not just the main Telegram session.

But the important catch is: it does not cap the session the way you’re expecting.

  • contextPruning only prunes old tool results from the in-memory prompt
  • it does not trim normal user/assistant messages
  • it does not rewrite the on-disk transcript
  • keepLastAssistants: 6 means ā€œdon’t prune tool results newer than the last 6 assistant turnsā€, not ā€œkeep only 6 assistant turns totalā€

Also, cache-ttl is about pruning after the cache TTL expires. If your heartbeat runs more often than the TTL, OpenClaw is intentionally keeping the cache warm, so pruning may not fire.

For heartbeat cost, the real lever is:

agents: {
  defaults: {
#
    heartbeat: {
      isolatedSession: true,
      lightContext: true
    }
  }
}

On current docs/source, isolatedSession: true should run heartbeats in a fresh :heartbeat session each time, with no prior conversation history, so per-run cost stays roughly flat. If you’re seeing the prompt itself grow every tick anyway, that does not match current behavior and may be version-specific.

One more nuance, if you’re looking at cumulative token spend, then +2K per heartbeat over time is expected. Pruning/caching lowers per-run cost, it does not stop total billed tokens from increasing linearly.

Can you paste:

cerulean jungle
#

openclaw --version
OpenClaw 2026.4.10 (44e5b62)

{
"heartbeat": null,
"contextPruning": {
"mode": "cache-ttl",
"ttl": "1h",
"keepLastAssistants": 6
}
}

Note: heartbeat is null at agents.defaults — it's configured per-agent instead. Here's Robin's as an
example:

{
"every": "55m",
"activeHours": { "start": "08:00", "end": "23:00" },
"target": "last",
"lightContext": true,
"isolatedSession": true
}

simple questBOT
#

Yep, with that config, agents.defaults.contextPruning still applies to Robin too, because it inherits agent defaults unless Robin overrides them.

But in this exact setup, it’s basically not the thing doing the work:

  • isolatedSession: true means each heartbeat runs in a fresh heartbeat session
  • lightContext: true keeps that run small
  • contextPruning only trims old tool results
  • keepLastAssistants: 6 means pruning is skipped unless there are at least 6 assistant turns in that session

So for Robin’s isolated heartbeat session, pruning will usually do almost nothing, because there usually isn’t much old history to prune in the first place.

The key distinction is:

  • If you mean cumulative usage: +2K-ish per heartbeat adding up to 128K over time is expected. That is just many separate billed heartbeat calls.
  • If you mean a single heartbeat prompt is growing from 2K to 128K: that would be wrong for isolatedSession: true, and I’d suspect a bug/regression.

So the short answer is:

#
  1. Yes, defaults contextPruning applies to isolated heartbeat sessions too.
  2. No, it won’t cap heartbeat cost to ā€œbase + 12Kā€.
  3. In your config, the main cost control is already isolatedSession + lightContext, not pruning.

If cost is still too high, the levers that matter most are:

  • longer heartbeat interval
  • a cheaper heartbeat model override
  • simplifying HEARTBEAT.md / bootstrap inputs

Docs:

If you want, paste the metric/screen you’re looking at for that ā€œ128Kā€, because I think the likely confusion is cumulative spend vs per-run prompt size.

cerulean jungle
#

The 128K was the cumulative session size after days of heartbeat runs accumulating in a persistent
session — that's the problem we already fixed. Fresh sessions now start at ~16K.

Per-run cost on the fresh sessions:

  • Call 1 (cache miss): 16,820 tokens, cacheWrite=16,729, cost=$0.064
  • Call 2 (cache hit): 17,105 tokens, cacheWrite=352, cacheRead=16,729, cost=$0.007

So $0.07/run total — same pattern every 55 minutes. Two API calls per heartbeat run, first writes
cache, second hits it immediately. Growing ~300 tokens per run (session accumulating history).

Two questions for Krill:

  1. Is the two-call-per-run pattern expected behavior?
  2. With isolatedSession: true, should the session history accumulate across runs, or start truly fresh
    each time?
simple questBOT
#

Let me check the source on the two-call part so I don’t hand-wave it.

#

Based on the current docs + runner source:

  1. Two-call-per-run is not the expected heartbeat core behavior.
    The heartbeat runner itself does one getReplyFromConfig(...) turn per heartbeat. So a stable pattern of:

    • call 1: big cache write
    • call 2: immediate cache hit

    suggests something else is causing a second model turn in that same isolated heartbeat session.

  2. With isolatedSession: true, history should be fresh across runs.
    Important nuance: the :heartbeat session key stays stable, but the runner mints a fresh underlying session/transcript each tick and archives the stale isolated one. So from the model’s point of view, it should not keep accumulating prior heartbeat history across runs.

So if you’re seeing ~300 tokens of growth, I’d interpret that as:

  • probably extra history within the same heartbeat run, not
  • cross-run carryover from previous heartbeats
#

In other words, isolatedSession: true should be ā€œfresh each runā€, not ā€œpersistent history under a different nameā€.

If you want a quick sanity check, watch the :heartbeat entry across two ticks:

  • if the underlying sessionId changes each run, isolation is working
  • if the same transcript/sessionId keeps growing, that smells like a bug

At this point I’d look at:

  • gateway logs around one heartbeat tick
  • whether a plugin/hook is triggering a second model turn
  • the :heartbeat session entry in openclaw sessions --json

Docs:

If you paste one heartbeat log slice (just around one tick), I can help narrow down what’s making that second call.

cerulean jungle
#

[21:50:16] ERROR [tools] web_fetch failed (401): SECURITY NOTICE: The following content is from an
EXTERNAL, UNTRUSTED source
[21:50] heartbeat call 1: total=16,820 cacheWrite=16,729 cacheRead=0 cost=$0.064
[21:50] heartbeat call 2: total=17,105 cacheWrite=352 cacheRead=16,729 cost=$0.007
Pattern repeats every 55 minutes. Looks like the second turn is Robin recovering from the web_fetch
401.

Given this we will update HEARTBEAT.md to explicitly use exec/curl instead of web_fetch

Does isolatedSession: true create a new sessionId each tick or reuse the same one?

simple questBOT
#

Yep, that log pattern makes sense.

  • Two model calls is expected when a heartbeat uses a tool.
    • call 1: model decides to call web_fetch
    • tool returns the 401/security error
    • call 2: model sees that tool result and recovers / finishes the turn

So that is not ā€œheartbeat ran twiceā€, it is one heartbeat turn with a tool round-trip.

For your direct question:

  • isolatedSession: true uses the same stable :heartbeat session key
  • but it should create a fresh underlying sessionId / transcript each tick

So, new sessionId each tick, not reuse of the old isolated transcript.

That matches the current runner logic: it forces a new isolated session, stores it under the :heartbeat key, and archives the stale isolated transcript.

#

One small caution, I would not jump to exec/curl just to bypass web_fetch safety. If the issue is untrusted input wrapping or auth, exec is a pretty blunt workaround. Better fix is usually:

  • make the heartbeat pass a clean literal URL to web_fetch
  • or avoid fetching content that needs auth with web_fetch

Docs: https://docs.openclaw.ai/gateway/heartbeat

If you want, paste the exact HEARTBEAT.md fetch line and I can help rewrite it so it stays on web_fetch cleanly.

cerulean jungle
#

The relevant line is step 1:

GET https://api.agentmail.to/v0/inboxes/[agent-inbox]/threads?label=unread

This needs an Authorization: Bearer $AGENTMAIL_API_KEY header. Robin is hitting it with web_fetch which
doesn't pass auth headers, hence the 401. The old HEARTBEAT.md used exec with curl -H "Authorization:
Bearer $AGENTMAIL_API_KEY" which worked fine.

Is there a way to pass custom headers to web_fetch, or is exec/curl the right tool for authenticated
API calls? We're happy to keep it on web_fetch if there's a clean way to do it.

simple questBOT
#

I’m checking the docs/source for web_fetch headers so I don’t tell you the wrong thing.

#

web_fetch does not support custom headers right now.

Per docs/tool shape, it only takes:

  • url
  • extractMode
  • maxChars

So for this AgentMail endpoint, web_fetch is the wrong fit anyway, because it is built for plain GET + readable extraction, not authenticated API calls.

For your case, the right options are:

  1. exec + curl for Bearer-auth JSON APIs
  2. a dedicated plugin/tool if this is a recurring integration
  3. browser only if you need cookie/login-based access rather than API-token auth

So yes, for:

#

Authorization: Bearer $AGENTMAIL_API_KEY

using exec/curl is the correct approach here.

One safety note, keep the token in env/config and only reference $AGENTMAIL_API_KEY, don’t paste the raw secret into HEARTBEAT.md.

Docs:

And to your earlier session question, isolatedSession: true should still mean new underlying sessionId per tick, even though the :heartbeat session key stays stable.

cerulean jungle
#

Great. I'll monitor actual costs over the next day with the recent changes. Thanks for your help, Krill

simple questBOT
#

Glad we pinned it down 🦐

That sounds like the right next step, watch the real per-run cost now that the persistent session growth and web_fetch auth issue are out of the way.

If this fixed it, please right-click the solution message → Apps → āœ… Mark Solution. That helps Answer Overflow index it for the next person.

cerulean jungle
#

Didn't fix it. Here's what we are seeing:

What uses fresh sessions?
Only crons with sessionTarget: "isolated". Everything else accumulates:

  • Cron jobs (sessionTarget: "isolated") → truly fresh per run, terminates when done āœ…
  • Heartbeat (isolatedSession: true) → misleadingly named. Despite sounding like "isolated," it's a
    persistent session that accumulates every run āŒ
  • Telegram chat → persistent by design — this is what makes conversation context work āœ…

The naming collision between isolatedSession (heartbeat config, doesn't reset) and sessionTarget:
"isolated" (cron config, does reset) is genuinely confusing. Krill told us isolatedSession: true should
create a new sessionId per tick — but we observed it doesn't. That gap between documentation and
behaviour is what burned us.

The practical upshot: the only true "fire and forget" sessions are cron jobs. Everything else needs monitoring.

simple questBOT
#

Oof, thanks for circling back. If you reproduced that on 2026.4.10, then I steered you wrong earlier. I’m checking the release code now instead of assuming current docs match your build.

#

I checked the 2026.4.10 source itself. The intent is definitely ā€œfresh isolated heartbeat session each tickā€, so if your runtime still accumulates, that’s bug territory, not just docs drift. Let me sanity check one more piece so I don’t overstate it.

#

Aha, I found the subtle bit. The release does mint a new sessionId, but it also carries forward the old session entry metadata. That would absolutely make the stable :heartbeat key look cumulative even after rollover. Let me confirm which fields live on the entry.

#

Nice catch, and you were right to push back.

I checked your exact release (2026.4.10, commit 44e5b62), and the behavior is more subtle than I said earlier:

  • In src/infra/heartbeat-runner.ts, heartbeat isolatedSession: true does call resolveCronSession(... forceNew: true)
  • In src/cron/isolated-agent/session.ts, forceNew: true does mint a new sessionId
  • But that same helper also does ...entry, so it preserves most of the old stable session entry metadata

And SessionEntry includes things like:

  • inputTokens
  • outputTokens
  • totalTokens
  • cacheRead
  • cacheWrite
  • messageCount
  • etc.
#

So from an operator point of view, your complaint is fair:

  • the underlying transcript/session may roll
  • but the stable :heartbeat session entry can still look cumulative across runs

That means heartbeat.isolatedSession: true is not equivalent in practice to cron sessionTarget: "isolated" if what you want is truly clean, fire-and-forget behavior with fresh-looking session state.

So I’d phrase it like this:

  • Cron sessionTarget: "isolated" = true one-shot isolated runs āœ…
  • Heartbeat isolatedSession: true = fresh run path, but stable session entry still carries metadata forward, so it can appear persistent āŒ/confusing

I think this is a real docs/UX bug, and maybe a runtime-state bug depending on intended semantics. Please file it here with your repro, version, and this distinction:

https://github.com/openclaw/openclaw/issues

If you include:

#
  • version 2026.4.10 (44e5b62)
  • heartbeat config
  • before/after sessionId
  • before/after totalTokens/cacheRead/cacheWrite

that should make it very actionable.

And for now, yeah, if you need guaranteed fresh isolated runs, cron sessionTarget: "isolated" looks like the safer tool.

cerulean jungle