From ec55f7791256cfcec28d947296247aac72f5701b Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 24 Feb 2026 11:04:56 +0000 Subject: [PATCH] fix(heartbeat): replace HEARTBEAT_OK token with virtual tool-call decision --- nanobot/cli/commands.py | 17 ++-- nanobot/config/schema.py | 8 ++ nanobot/heartbeat/service.py | 162 +++++++++++++++++++++-------------- 3 files changed, 117 insertions(+), 70 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 90b9f44..ca71694 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -360,19 +360,19 @@ def gateway( return "cli", "direct" # Create heartbeat service - async def on_heartbeat(prompt: str) -> str: - """Execute heartbeat through the agent.""" + async def on_heartbeat_execute(tasks: str) -> str: + """Phase 2: execute heartbeat tasks through the full agent loop.""" channel, chat_id = _pick_heartbeat_target() async def _silent(*_args, **_kwargs): pass return await agent.process_direct( - prompt, + tasks, session_key="heartbeat", channel=channel, chat_id=chat_id, - on_progress=_silent, # suppress: heartbeat should not push progress to external channels + on_progress=_silent, ) async def on_heartbeat_notify(response: str) -> None: @@ -383,12 +383,15 @@ def gateway( return # No external channel available to deliver to await bus.publish_outbound(OutboundMessage(channel=channel, chat_id=chat_id, content=response)) + hb_cfg = config.gateway.heartbeat heartbeat = HeartbeatService( workspace=config.workspace_path, - on_heartbeat=on_heartbeat, + provider=provider, + model=agent.model, + on_execute=on_heartbeat_execute, on_notify=on_heartbeat_notify, - interval_s=30 * 60, # 30 minutes - enabled=True + interval_s=hb_cfg.interval_s, + enabled=hb_cfg.enabled, ) if channels.enabled_channels: diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index fe8dd83..215f38d 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -228,11 +228,19 @@ class ProvidersConfig(Base): github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth) +class HeartbeatConfig(Base): + """Heartbeat service configuration.""" + + enabled: bool = True + interval_s: int = 30 * 60 # 30 minutes + + class GatewayConfig(Base): """Gateway/server configuration.""" host: str = "0.0.0.0" port: int = 18790 + heartbeat: HeartbeatConfig = Field(default_factory=HeartbeatConfig) class WebSearchConfig(Base): diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py index cb1a1c7..e534017 100644 --- a/nanobot/heartbeat/service.py +++ b/nanobot/heartbeat/service.py @@ -1,80 +1,110 @@ """Heartbeat service - periodic agent wake-up to check for tasks.""" +from __future__ import annotations + import asyncio from pathlib import Path -from typing import Any, Callable, Coroutine +from typing import TYPE_CHECKING, Any, Callable, Coroutine from loguru import logger -# Default interval: 30 minutes -DEFAULT_HEARTBEAT_INTERVAL_S = 30 * 60 +if TYPE_CHECKING: + from nanobot.providers.base import LLMProvider -# Token the agent replies with when there is nothing to report -HEARTBEAT_OK_TOKEN = "HEARTBEAT_OK" - -# The prompt sent to agent during heartbeat -HEARTBEAT_PROMPT = ( - "Read HEARTBEAT.md in your workspace and follow any instructions listed there. " - f"If nothing needs attention, reply with exactly: {HEARTBEAT_OK_TOKEN}" -) - - -def _is_heartbeat_empty(content: str | None) -> bool: - """Check if HEARTBEAT.md has no actionable content.""" - if not content: - return True - - # Lines to skip: empty, headers, HTML comments, empty checkboxes - skip_patterns = {"- [ ]", "* [ ]", "- [x]", "* [x]"} - - for line in content.split("\n"): - line = line.strip() - if not line or line.startswith("#") or line.startswith("