Skip to content

Heartbeat ignores heartbeat.model when session has modelOverride #13009

@mhuttes14

Description

@mhuttes14

Bug Summary

The heartbeat ignores its configured agents.defaults.heartbeat.model when the active session has a modelOverride set (e.g., from a TUI /model switch). The heartbeat ends up using whatever model the user last selected in the TUI instead of the pinned heartbeat model.

Steps to Reproduce

  1. Set agents.defaults.heartbeat.model to "openrouter/openrouter/auto" in openclaw.json
  2. Set heartbeat.target to "last" (the default)
  3. Open the TUI and use /model to switch to a different model (e.g., openrouter/moonshotai/kimi-k2.5)
  4. Wait for the next heartbeat cycle (or trigger with openclaw system event --mode now)
  5. Check gateway logs: grep -E "(heartbeat|agent model)" ~/.openclaw/logs/gateway.log | tail -10

Expected: Heartbeat uses openrouter/openrouter/auto (from heartbeat.model config)
Actual: Heartbeat uses moonshotai/kimi-k2.5 (from session's modelOverride)

Root Cause

In getReplyFromConfig() (dist/reply-DptDUVRg.js, minified), the heartbeat model is correctly resolved from agentCfg.heartbeat.model when opts.isHeartbeat is true. However, resolveReplyDirectives() is called afterward and returns the session's modelOverride, which then unconditionally overwrites the heartbeat model:

// Heartbeat model correctly resolved here ✓
if (opts?.isHeartbeat) {
    const heartbeatRaw = agentCfg?.heartbeat?.model?.trim() ?? "";
    const heartbeatRef = heartbeatRaw ? resolveModelRefFromString({ ... }) : null;
    if (heartbeatRef) {
        provider = heartbeatRef.ref.provider;
        model = heartbeatRef.ref.model;
    }
}

// ... later, after resolveReplyDirectives():
provider = resolvedProvider;  // ← overwrites heartbeat model
model = resolvedModel;        // ← with session's modelOverride

Inside resolveReplyDirectives()createModelSelectionState(), the session's stored modelOverride is applied unconditionally:

if (sessionEntry?.providerOverride) provider = sessionEntry.providerOverride;
if (sessionEntry?.modelOverride) model = sessionEntry.modelOverride;

There is no isHeartbeat guard at either of these override points.

Why It Matters

The heartbeat is meant to be a background process that runs on a cheap/fast model (openrouter/auto). When it inherits the user's chat model (e.g., Claude Opus, Gemini Pro), it:

  • Uses more expensive models unnecessarily
  • May behave differently due to model-specific quirks
  • Defeats the purpose of having a separate heartbeat.model config

Suggested Fix

Skip session modelOverride application when opts.isHeartbeat is true and heartbeat.model is explicitly configured:

if (!opts?.isHeartbeat && sessionEntry?.modelOverride) {
    model = sessionEntry.modelOverride;
}

Or apply the heartbeat model after directive resolution so it takes final precedence.

Current Workaround

Manually edit ~/.openclaw/agents/main/sessions/sessions.json to remove the providerOverride and modelOverride fields, then restart the gateway. This needs to be repeated after every TUI /model switch.

Environment

  • OpenClaw version: 2026.2.9
  • OS: macOS (Darwin 25.2.0, Apple Silicon)
  • Model routing: OpenRouter
  • Heartbeat config: { every: "30m", model: "openrouter/openrouter/auto", target: "last" }

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions