Skip to content

Commit 186642e

Browse files
committed
feat(heartbeat): add model override for heartbeat phases
Add gateway.heartbeat.model config option that lets operators run heartbeat on a different (typically cheaper) model than the agent's primary model. When set, both Phase 1 (decision LLM call) and Phase 2 (agent execution loop) use the override model. When unset, behavior is unchanged — heartbeat uses the agent's configured model. This enables cost optimization: e.g. run chat on claude-opus-4 but heartbeat on claude-sonnet-4 or claude-haiku-3.5. Config example: { "gateway": { "heartbeat": { "model": "anthropic/claude-haiku-3.5" } } }
1 parent f5b8ee9 commit 186642e

5 files changed

Lines changed: 120 additions & 8 deletions

File tree

nanobot/cli/commands.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -791,13 +791,21 @@ async def on_heartbeat_execute(tasks: str) -> str:
791791
async def _silent(*_args, **_kwargs):
792792
pass
793793

794-
resp = await agent.process_direct(
795-
tasks,
796-
session_key="heartbeat",
797-
channel=channel,
798-
chat_id=chat_id,
799-
on_progress=_silent,
800-
)
794+
# Temporarily swap agent model if heartbeat has a model override.
795+
hb_model = hb_cfg.model
796+
original_model = agent.model
797+
if hb_model:
798+
agent.model = hb_model
799+
try:
800+
resp = await agent.process_direct(
801+
tasks,
802+
session_key="heartbeat",
803+
channel=channel,
804+
chat_id=chat_id,
805+
on_progress=_silent,
806+
)
807+
finally:
808+
agent.model = original_model
801809

802810
# Keep a small tail of heartbeat history so the loop stays bounded
803811
# without losing all short-term context between runs.
@@ -825,6 +833,7 @@ async def on_heartbeat_notify(response: str) -> None:
825833
interval_s=hb_cfg.interval_s,
826834
enabled=hb_cfg.enabled,
827835
timezone=config.agents.defaults.timezone,
836+
heartbeat_model=hb_cfg.model,
828837
)
829838

830839
if channels.enabled_channels:

nanobot/config/schema.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ class HeartbeatConfig(Base):
146146
enabled: bool = True
147147
interval_s: int = 30 * 60 # 30 minutes
148148
keep_recent_messages: int = 8
149+
model: str | None = None # Override model for heartbeat (Phase 1 + Phase 2)
149150

150151

151152
class ApiConfig(Base):

nanobot/heartbeat/service.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,12 @@ def __init__(
6060
interval_s: int = 30 * 60,
6161
enabled: bool = True,
6262
timezone: str | None = None,
63+
heartbeat_model: str | None = None,
6364
):
6465
self.workspace = workspace
6566
self.provider = provider
66-
self.model = model
67+
self.model = heartbeat_model or model
68+
self._agent_model = model
6769
self.on_execute = on_execute
6870
self.on_notify = on_notify
6971
self.interval_s = interval_s

tests/heartbeat/__init__.py

Whitespace-only changes.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Tests for heartbeat model override feature."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
from unittest.mock import AsyncMock, MagicMock
7+
8+
import pytest
9+
10+
from nanobot.config.schema import HeartbeatConfig
11+
from nanobot.heartbeat.service import HeartbeatService
12+
13+
14+
@pytest.fixture
15+
def mock_provider():
16+
provider = MagicMock()
17+
provider.chat_with_retry = AsyncMock()
18+
return provider
19+
20+
21+
@pytest.fixture
22+
def tmp_workspace(tmp_path: Path) -> Path:
23+
hb_file = tmp_path / "HEARTBEAT.md"
24+
hb_file.write_text("Check email.\n")
25+
return tmp_path
26+
27+
28+
class TestHeartbeatModelOverride:
29+
"""Verify that heartbeat.model overrides the agent model for both phases."""
30+
31+
def test_config_default_model_is_none(self):
32+
cfg = HeartbeatConfig()
33+
assert cfg.model is None
34+
35+
def test_config_accepts_model_string(self):
36+
cfg = HeartbeatConfig(model="anthropic/claude-haiku-3.5")
37+
assert cfg.model == "anthropic/claude-haiku-3.5"
38+
39+
def test_service_uses_agent_model_when_no_override(self, mock_provider, tmp_workspace):
40+
svc = HeartbeatService(
41+
workspace=tmp_workspace,
42+
provider=mock_provider,
43+
model="anthropic/claude-opus-4",
44+
)
45+
assert svc.model == "anthropic/claude-opus-4"
46+
assert svc._agent_model == "anthropic/claude-opus-4"
47+
48+
def test_service_uses_heartbeat_model_when_set(self, mock_provider, tmp_workspace):
49+
svc = HeartbeatService(
50+
workspace=tmp_workspace,
51+
provider=mock_provider,
52+
model="anthropic/claude-opus-4",
53+
heartbeat_model="anthropic/claude-haiku-3.5",
54+
)
55+
assert svc.model == "anthropic/claude-haiku-3.5"
56+
assert svc._agent_model == "anthropic/claude-opus-4"
57+
58+
@pytest.mark.asyncio
59+
async def test_phase1_decide_uses_heartbeat_model(self, mock_provider, tmp_workspace):
60+
"""Phase 1 LLM call should use the heartbeat model, not agent model."""
61+
response = MagicMock()
62+
response.should_execute_tools = True
63+
response.has_tool_calls = True
64+
response.tool_calls = [MagicMock(arguments={"action": "skip", "tasks": ""})]
65+
mock_provider.chat_with_retry.return_value = response
66+
67+
svc = HeartbeatService(
68+
workspace=tmp_workspace,
69+
provider=mock_provider,
70+
model="anthropic/claude-opus-4",
71+
heartbeat_model="anthropic/claude-haiku-3.5",
72+
)
73+
74+
action, tasks = await svc._decide("Check email.")
75+
76+
call_kwargs = mock_provider.chat_with_retry.call_args
77+
assert call_kwargs.kwargs.get("model") == "anthropic/claude-haiku-3.5"
78+
assert action == "skip"
79+
80+
@pytest.mark.asyncio
81+
async def test_phase1_decide_uses_agent_model_without_override(
82+
self, mock_provider, tmp_workspace
83+
):
84+
"""Without override, Phase 1 should use the agent model."""
85+
response = MagicMock()
86+
response.should_execute_tools = True
87+
response.has_tool_calls = True
88+
response.tool_calls = [MagicMock(arguments={"action": "skip", "tasks": ""})]
89+
mock_provider.chat_with_retry.return_value = response
90+
91+
svc = HeartbeatService(
92+
workspace=tmp_workspace,
93+
provider=mock_provider,
94+
model="anthropic/claude-opus-4",
95+
)
96+
97+
await svc._decide("Check email.")
98+
99+
call_kwargs = mock_provider.chat_with_retry.call_args
100+
assert call_kwargs.kwargs.get("model") == "anthropic/claude-opus-4"

0 commit comments

Comments
 (0)