refactor(heartbeat): migrate heartbeat service to cron-based auto-registration

Remove standalone nanobot/heartbeat/ service and replace it with an
auto-registered system cron job on gateway startup. Key behaviors preserved:

- HeartbeatConfig (enabled, interval_s, keep_recent_messages) remains in
  GatewayConfig for backward compatibility.
- On startup, if enabled, a system cron job "heartbeat" is registered with
  schedule derived from interval_s.
- HEARTBEAT.md is checked on each tick; empty/template-identical files skip
  to avoid wasting LLM calls.
- Post-run evaluate_response and session history truncation
  (keep_recent_messages) are retained.
- Delivery target selection, deliverable filtering, and preamble guidance
  are preserved.

Files removed:
- nanobot/heartbeat/__init__.py
- nanobot/heartbeat/service.py
- tests/heartbeat/*
- tests/agent/test_heartbeat_service.py

Templates and docs updated to reflect cron-based usage.
This commit is contained in:
chengyongru
2026-05-28 20:20:28 +08:00
committed by Xubin Ren
parent 7d09f1cd9e
commit fe2af64e04
19 changed files with 122 additions and 1048 deletions
-336
View File
@@ -1,336 +0,0 @@
import asyncio
import pytest
from nanobot.heartbeat.service import HeartbeatService
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.utils.llm_runtime import LLMRuntime
class DummyProvider(LLMProvider):
def __init__(self, responses: list[LLMResponse]):
super().__init__()
self._responses = list(responses)
self.calls = 0
self.models: list[str | None] = []
async def chat(self, *args, **kwargs) -> LLMResponse:
self.calls += 1
self.models.append(kwargs.get("model"))
if self._responses:
return self._responses.pop(0)
return LLMResponse(content="", tool_calls=[])
def get_default_model(self) -> str:
return "test-model"
@pytest.mark.asyncio
async def test_start_is_idempotent(tmp_path) -> None:
provider = DummyProvider([])
service = HeartbeatService(
workspace=tmp_path,
provider=provider,
model="openai/gpt-4o-mini",
interval_s=9999,
enabled=True,
)
await service.start()
first_task = service._task
await service.start()
assert service._task is first_task
service.stop()
await asyncio.sleep(0)
@pytest.mark.asyncio
async def test_decide_returns_skip_when_no_tool_call(tmp_path) -> None:
provider = DummyProvider([LLMResponse(content="no tool call", tool_calls=[])])
service = HeartbeatService(
workspace=tmp_path,
provider=provider,
model="openai/gpt-4o-mini",
)
action, tasks = await service._decide("heartbeat content")
assert action == "skip"
assert tasks == ""
@pytest.mark.asyncio
async def test_trigger_now_executes_when_decision_is_run(tmp_path) -> None:
(tmp_path / "HEARTBEAT.md").write_text("- [ ] do thing", encoding="utf-8")
provider = DummyProvider([
LLMResponse(
content="",
tool_calls=[
ToolCallRequest(
id="hb_1",
name="heartbeat",
arguments={"action": "run", "tasks": "check open tasks"},
)
],
)
])
called_with: list[str] = []
async def _on_execute(tasks: str) -> str:
called_with.append(tasks)
return "done"
service = HeartbeatService(
workspace=tmp_path,
provider=provider,
model="openai/gpt-4o-mini",
on_execute=_on_execute,
)
result = await service.trigger_now()
assert result == "done"
assert called_with == ["check open tasks"]
@pytest.mark.asyncio
async def test_trigger_now_returns_none_when_decision_is_skip(tmp_path) -> None:
(tmp_path / "HEARTBEAT.md").write_text("- [ ] do thing", encoding="utf-8")
provider = DummyProvider([
LLMResponse(
content="",
tool_calls=[
ToolCallRequest(
id="hb_1",
name="heartbeat",
arguments={"action": "skip"},
)
],
)
])
async def _on_execute(tasks: str) -> str:
return tasks
service = HeartbeatService(
workspace=tmp_path,
provider=provider,
model="openai/gpt-4o-mini",
on_execute=_on_execute,
)
assert await service.trigger_now() is None
@pytest.mark.asyncio
async def test_tick_notifies_when_evaluator_says_yes(tmp_path, monkeypatch) -> None:
"""Phase 1 run -> Phase 2 execute -> Phase 3 evaluate=notify -> on_notify called."""
(tmp_path / "HEARTBEAT.md").write_text("- [ ] check deployments", encoding="utf-8")
provider = DummyProvider([
LLMResponse(
content="",
tool_calls=[
ToolCallRequest(
id="hb_1",
name="heartbeat",
arguments={"action": "run", "tasks": "check deployments"},
)
],
),
])
executed: list[str] = []
notified: list[str] = []
async def _on_execute(tasks: str) -> str:
executed.append(tasks)
return "deployment failed on staging"
async def _on_notify(response: str) -> None:
notified.append(response)
service = HeartbeatService(
workspace=tmp_path,
provider=provider,
model="openai/gpt-4o-mini",
on_execute=_on_execute,
on_notify=_on_notify,
)
async def _eval_notify(*a, **kw):
return True
monkeypatch.setattr("nanobot.utils.evaluator.evaluate_response", _eval_notify)
await service._tick()
assert executed == ["check deployments"]
assert notified == ["deployment failed on staging"]
@pytest.mark.asyncio
async def test_tick_suppresses_when_evaluator_says_no(tmp_path, monkeypatch) -> None:
"""Phase 1 run -> Phase 2 execute -> Phase 3 evaluate=silent -> on_notify NOT called."""
(tmp_path / "HEARTBEAT.md").write_text("- [ ] check status", encoding="utf-8")
provider = DummyProvider([
LLMResponse(
content="",
tool_calls=[
ToolCallRequest(
id="hb_1",
name="heartbeat",
arguments={"action": "run", "tasks": "check status"},
)
],
),
])
executed: list[str] = []
notified: list[str] = []
async def _on_execute(tasks: str) -> str:
executed.append(tasks)
return "everything is fine, no issues"
async def _on_notify(response: str) -> None:
notified.append(response)
service = HeartbeatService(
workspace=tmp_path,
provider=provider,
model="openai/gpt-4o-mini",
on_execute=_on_execute,
on_notify=_on_notify,
)
async def _eval_silent(*a, **kw):
return False
monkeypatch.setattr("nanobot.utils.evaluator.evaluate_response", _eval_silent)
await service._tick()
assert executed == ["check status"]
assert notified == []
def test_tick_uses_runtime_provider_and_model(tmp_path, monkeypatch) -> None:
"""Preset changes must apply to heartbeat decision and post-run evaluation."""
(tmp_path / "HEARTBEAT.md").write_text("- [ ] check runtime model", encoding="utf-8")
runtime_provider = DummyProvider([
LLMResponse(
content="",
tool_calls=[
ToolCallRequest(
id="hb_1",
name="heartbeat",
arguments={"action": "run", "tasks": "check runtime model"},
)
],
),
])
runtime_model = "openai/gpt-4.1"
executed: list[str] = []
evaluated: list[tuple[LLMProvider, str]] = []
async def _on_execute(tasks: str) -> str:
executed.append(tasks)
return "runtime model produced a user-facing update"
async def _eval_capture(response, tasks, provider, model):
evaluated.append((provider, model))
return False
service = HeartbeatService(
workspace=tmp_path,
llm_runtime=lambda: LLMRuntime(runtime_provider, runtime_model),
on_execute=_on_execute,
)
monkeypatch.setattr("nanobot.utils.evaluator.evaluate_response", _eval_capture)
asyncio.run(service._tick())
assert runtime_provider.calls == 1
assert runtime_provider.models == [runtime_model]
assert executed == ["check runtime model"]
assert evaluated == [(runtime_provider, runtime_model)]
@pytest.mark.asyncio
async def test_decide_retries_transient_error_then_succeeds(tmp_path, monkeypatch) -> None:
provider = DummyProvider([
LLMResponse(content="429 rate limit", finish_reason="error"),
LLMResponse(
content="",
tool_calls=[
ToolCallRequest(
id="hb_1",
name="heartbeat",
arguments={"action": "run", "tasks": "check open tasks"},
)
],
),
])
delays: list[int] = []
async def _fake_sleep(delay: int) -> None:
delays.append(delay)
monkeypatch.setattr(asyncio, "sleep", _fake_sleep)
service = HeartbeatService(
workspace=tmp_path,
provider=provider,
model="openai/gpt-4o-mini",
)
action, tasks = await service._decide("heartbeat content")
assert action == "run"
assert tasks == "check open tasks"
assert provider.calls == 2
assert delays == [1]
@pytest.mark.asyncio
async def test_decide_prompt_includes_current_time(tmp_path) -> None:
"""Phase 1 user prompt must contain current time so the LLM can judge task urgency."""
captured_messages: list[dict] = []
class CapturingProvider(LLMProvider):
async def chat(self, *, messages=None, **kwargs) -> LLMResponse:
if messages:
captured_messages.extend(messages)
return LLMResponse(
content="",
tool_calls=[
ToolCallRequest(
id="hb_1", name="heartbeat",
arguments={"action": "skip"},
)
],
)
def get_default_model(self) -> str:
return "test-model"
service = HeartbeatService(
workspace=tmp_path,
provider=CapturingProvider(),
model="test-model",
)
await service._decide("- [ ] check servers at 10:00 UTC")
user_msg = captured_messages[1]
assert user_msg["role"] == "user"
assert "Current Time:" in user_msg["content"]
+7 -7
View File
@@ -602,17 +602,17 @@ async def test_process_message_uses_explicit_session_metadata_for_goal_context(
chat_session = loop.sessions.get_or_create("websocket:chat-with-goal")
chat_session.metadata[GOAL_STATE_KEY] = {
"status": "active",
"objective": "This chat goal must not leak into heartbeat.",
"objective": "This chat goal must not leak into system.",
}
loop.sessions.save(chat_session)
system_session = loop.sessions.get_or_create("heartbeat")
system_session = loop.sessions.get_or_create("system")
system_session.metadata = {}
loop.sessions.save(system_session)
loop.context.build_messages = MagicMock( # type: ignore[method-assign]
return_value=[
{"role": "system", "content": "system"},
{"role": "user", "content": "runtime + heartbeat"},
{"role": "user", "content": "runtime + system"},
]
)
loop._run_agent_loop = AsyncMock(return_value=( # type: ignore[method-assign]
@@ -620,7 +620,7 @@ async def test_process_message_uses_explicit_session_metadata_for_goal_context(
[],
[
{"role": "system", "content": "system"},
{"role": "user", "content": "runtime + heartbeat"},
{"role": "user", "content": "runtime + system"},
{"role": "assistant", "content": "ok"},
],
"stop",
@@ -630,11 +630,11 @@ async def test_process_message_uses_explicit_session_metadata_for_goal_context(
result = await loop._process_message(
InboundMessage(
channel="websocket",
sender_id="heartbeat",
sender_id="system",
chat_id="chat-with-goal",
content="heartbeat work",
content="system work",
),
session_key="heartbeat",
session_key="system",
)
assert result is not None
-11
View File
@@ -1589,16 +1589,6 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
def register_system_job(self, _job) -> None:
return None
class _FakeHeartbeatService:
def __init__(self, **_kwargs) -> None:
return None
async def start(self) -> None:
return None
def stop(self) -> None:
return None
class _FakeServer:
async def __aenter__(self):
return self
@@ -1645,7 +1635,6 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCronService)
monkeypatch.setattr("nanobot.heartbeat.service.HeartbeatService", _FakeHeartbeatService)
monkeypatch.setattr("asyncio.start_server", _fake_start_server)
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
@@ -1,120 +0,0 @@
"""Tests for heartbeat context bridge — injecting delivered messages into channel session."""
from nanobot.session.manager import SessionManager
class TestHeartbeatContextBridge:
"""Verify that on_heartbeat_notify injects the assistant message into the
channel session so user replies have conversational context."""
def test_notify_injects_into_channel_session(self, tmp_path):
"""After notify, the target channel session should contain the
heartbeat response as an assistant turn."""
session_mgr = SessionManager(tmp_path / "sessions")
target_key = "telegram:12345"
# Simulate: session exists with one user message
target_session = session_mgr.get_or_create(target_key)
target_session.add_message("user", "hello earlier")
session_mgr.save(target_session)
# Simulate what on_heartbeat_notify does
target_session = session_mgr.get_or_create(target_key)
target_session.add_message(
"assistant",
"3 new emails — invoice, meeting, proposal.",
_channel_delivery=True,
)
session_mgr.save(target_session)
# Reload and verify
reloaded = session_mgr.get_or_create(target_key)
messages = reloaded.get_history(max_messages=0)
roles = [m["role"] for m in messages]
assert roles == ["user", "assistant"]
assert "3 new emails" in messages[-1]["content"]
def test_reply_after_injection_has_context(self, tmp_path):
"""Simulates the full flow: prior conversation exists, heartbeat
injects, then user replies. The session should have the heartbeat
message visible in get_history so the model sees the context."""
session_mgr = SessionManager(tmp_path / "sessions")
target_key = "telegram:12345"
# Pre-existing conversation (user has chatted before)
session = session_mgr.get_or_create(target_key)
session.add_message("user", "Hey")
session.add_message("assistant", "Hi there!")
session_mgr.save(session)
# Step 1: heartbeat injects assistant message
session = session_mgr.get_or_create(target_key)
session.add_message(
"assistant",
"If you want, I can mark that email as read.",
_channel_delivery=True,
)
session_mgr.save(session)
# Step 2: user replies "Sure"
session = session_mgr.get_or_create(target_key)
session.add_message("user", "Sure")
session_mgr.save(session)
# Verify: get_history includes the heartbeat injection
reloaded = session_mgr.get_or_create(target_key)
history = reloaded.get_history(max_messages=0)
roles = [m["role"] for m in history]
assert roles == ["user", "assistant", "assistant", "user"]
assert "mark that email" in history[2]["content"]
assert history[3]["content"] == "Sure"
def test_injection_does_not_duplicate_on_existing_history(self, tmp_path):
"""If the channel session already has messages, the injection
appends cleanly without corruption."""
session_mgr = SessionManager(tmp_path / "sessions")
target_key = "telegram:12345"
# Pre-existing conversation
session = session_mgr.get_or_create(target_key)
session.add_message("user", "What time is it?")
session.add_message("assistant", "It's 2pm.")
session.add_message("user", "Thanks")
session_mgr.save(session)
# Heartbeat injects
session = session_mgr.get_or_create(target_key)
session.add_message(
"assistant",
"You have a meeting in 30 minutes.",
_channel_delivery=True,
)
session_mgr.save(session)
# Verify
reloaded = session_mgr.get_or_create(target_key)
history = reloaded.get_history(max_messages=0)
roles = [m["role"] for m in history]
assert roles == ["user", "assistant", "user", "assistant"]
assert "meeting in 30 minutes" in history[-1]["content"]
def test_reply_after_injection_to_empty_session_keeps_context(self, tmp_path):
"""A user replying to the first delivered message still sees that context."""
session_mgr = SessionManager(tmp_path / "sessions")
target_key = "telegram:99999"
session = session_mgr.get_or_create(target_key)
session.add_message(
"assistant",
"Weather alert: sandstorm expected at 4pm.",
_channel_delivery=True,
)
session.add_message("user", "Sure")
session_mgr.save(session)
reloaded = session_mgr.get_or_create(target_key)
history = reloaded.get_history(max_messages=0)
assert len(history) == 2
assert history[0]["role"] == "assistant"
assert "sandstorm" in history[0]["content"]
assert history[1] == {"role": "user", "content": "Sure"}
@@ -1,230 +0,0 @@
"""Tests for HeartbeatService._is_deliverable and _tick suppression."""
import pytest
from nanobot.heartbeat.service import HeartbeatService
from nanobot.providers.base import LLMResponse, ToolCallRequest
# ---------------------------------------------------------------------------
# _is_deliverable unit tests
# ---------------------------------------------------------------------------
class TestIsDeliverable:
"""Verify the pre-evaluator deliverability filter."""
def test_normal_report_is_deliverable(self):
assert HeartbeatService._is_deliverable(
"2 new emails — invoice from Zain, meeting rescheduled to 3pm."
)
def test_short_dismissal_is_deliverable(self):
assert HeartbeatService._is_deliverable("All clear.")
def test_finalization_fallback_blocked(self):
assert not HeartbeatService._is_deliverable(
"I completed the tool steps but couldn't produce a final answer. "
"Please try again or narrow the task."
)
def test_leaked_heartbeat_md_reference_blocked(self):
assert not HeartbeatService._is_deliverable(
"Yes — HEARTBEAT.md has active tasks listed. They are: "
"Check Gmail for important messages, Check Calendar."
)
def test_leaked_awareness_md_reference_blocked(self):
assert not HeartbeatService._is_deliverable(
"I reviewed AWARENESS.md and found no new signals."
)
def test_leaked_judgment_call_blocked(self):
assert not HeartbeatService._is_deliverable(
"Best judgment call: stay quiet."
)
def test_leaked_decision_logic_blocked(self):
assert not HeartbeatService._is_deliverable(
"Strict HEARTBEAT interpretation. Decision logic says SHORT UPDATE."
)
def test_leaked_valid_options_blocked(self):
assert not HeartbeatService._is_deliverable(
"The valid options are FULL REPORT, SHORT UPDATE, or SILENT."
)
def test_leaked_my_instructions_blocked(self):
assert not HeartbeatService._is_deliverable(
"My instructions say to check Gmail and Calendar."
)
def test_leaked_supposed_to_blocked(self):
assert not HeartbeatService._is_deliverable(
"I am supposed to scan for urgent emails."
)
def test_case_insensitive(self):
assert not HeartbeatService._is_deliverable(
"HEARTBEAT.MD has tasks listed."
)
def test_empty_string_is_deliverable(self):
"""Empty string won't reach _is_deliverable in practice (caught earlier),
but should not crash."""
assert HeartbeatService._is_deliverable("")
# ---------------------------------------------------------------------------
# _tick integration: non-deliverable responses never reach evaluator/notify
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_tick_suppresses_finalization_fallback(tmp_path, monkeypatch) -> None:
"""Finalization fallback should be caught before the evaluator runs."""
(tmp_path / "HEARTBEAT.md").write_text("- [ ] check inbox", encoding="utf-8")
from nanobot.providers.base import LLMProvider
class StubProvider(LLMProvider):
async def chat(self, **kwargs) -> LLMResponse:
return LLMResponse(
content="",
tool_calls=[
ToolCallRequest(
id="hb_1", name="heartbeat",
arguments={"action": "run", "tasks": "check inbox"},
)
],
)
def get_default_model(self) -> str:
return "test-model"
notified: list[str] = []
evaluator_called = False
async def _on_execute(tasks: str) -> str:
return (
"I completed the tool steps but couldn't produce a final answer. "
"Please try again or narrow the task."
)
async def _on_notify(response: str) -> None:
notified.append(response)
async def _eval_always_notify(*a, **kw):
nonlocal evaluator_called
evaluator_called = True
return True
monkeypatch.setattr("nanobot.utils.evaluator.evaluate_response", _eval_always_notify)
service = HeartbeatService(
workspace=tmp_path,
provider=StubProvider(),
model="test-model",
on_execute=_on_execute,
on_notify=_on_notify,
)
await service._tick()
assert notified == [], "Finalization fallback should not reach the user"
assert not evaluator_called, "Evaluator should not be called for non-deliverable responses"
@pytest.mark.asyncio
async def test_tick_suppresses_leaked_reasoning(tmp_path, monkeypatch) -> None:
"""Leaked internal reasoning should be caught before the evaluator runs."""
(tmp_path / "HEARTBEAT.md").write_text("- [ ] check status", encoding="utf-8")
from nanobot.providers.base import LLMProvider
class StubProvider(LLMProvider):
async def chat(self, **kwargs) -> LLMResponse:
return LLMResponse(
content="",
tool_calls=[
ToolCallRequest(
id="hb_1", name="heartbeat",
arguments={"action": "run", "tasks": "check status"},
)
],
)
def get_default_model(self) -> str:
return "test-model"
notified: list[str] = []
async def _on_execute(tasks: str) -> str:
return "HEARTBEAT.md has active tasks listed. They are: Check Gmail."
async def _on_notify(response: str) -> None:
notified.append(response)
async def _eval_always_notify(*a, **kw):
return True
monkeypatch.setattr("nanobot.utils.evaluator.evaluate_response", _eval_always_notify)
service = HeartbeatService(
workspace=tmp_path,
provider=StubProvider(),
model="test-model",
on_execute=_on_execute,
on_notify=_on_notify,
)
await service._tick()
assert notified == [], "Leaked reasoning should not reach the user"
@pytest.mark.asyncio
async def test_tick_delivers_normal_report(tmp_path, monkeypatch) -> None:
"""Normal reports should pass through deliverability and evaluator."""
(tmp_path / "HEARTBEAT.md").write_text("- [ ] check inbox", encoding="utf-8")
from nanobot.providers.base import LLMProvider
class StubProvider(LLMProvider):
async def chat(self, **kwargs) -> LLMResponse:
return LLMResponse(
content="",
tool_calls=[
ToolCallRequest(
id="hb_1", name="heartbeat",
arguments={"action": "run", "tasks": "check inbox"},
)
],
)
def get_default_model(self) -> str:
return "test-model"
notified: list[str] = []
async def _on_execute(tasks: str) -> str:
return "3 new emails — client proposal from Zain, invoice, meeting reminder."
async def _on_notify(response: str) -> None:
notified.append(response)
async def _eval_always_notify(*a, **kw):
return True
monkeypatch.setattr("nanobot.utils.evaluator.evaluate_response", _eval_always_notify)
service = HeartbeatService(
workspace=tmp_path,
provider=StubProvider(),
model="test-model",
on_execute=_on_execute,
on_notify=_on_notify,
)
await service._tick()
assert notified == ["3 new emails — client proposal from Zain, invoice, meeting reminder."]