From 7671239902f479c8c0b60aac53454e6ef8f28146 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 23 Feb 2026 13:45:09 +0000 Subject: [PATCH] fix(heartbeat): suppress progress messages and deliver agent response to user --- nanobot/cli/commands.py | 12 +++++++++-- nanobot/heartbeat/service.py | 40 ++++++++++++++++++++---------------- 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index e1df6ad..90b9f44 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -372,13 +372,21 @@ def gateway( session_key="heartbeat", channel=channel, chat_id=chat_id, - # suppress: heartbeat should not push progress to external channels - on_progress=_silent, + on_progress=_silent, # suppress: heartbeat should not push progress to external channels ) + async def on_heartbeat_notify(response: str) -> None: + """Deliver a heartbeat response to the user's channel.""" + from nanobot.bus.events import OutboundMessage + channel, chat_id = _pick_heartbeat_target() + if channel == "cli": + return # No external channel available to deliver to + await bus.publish_outbound(OutboundMessage(channel=channel, chat_id=chat_id, content=response)) + heartbeat = HeartbeatService( workspace=config.workspace_path, on_heartbeat=on_heartbeat, + on_notify=on_heartbeat_notify, interval_s=30 * 60, # 30 minutes enabled=True ) diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py index 3c1a6aa..7dbdc03 100644 --- a/nanobot/heartbeat/service.py +++ b/nanobot/heartbeat/service.py @@ -9,14 +9,15 @@ from loguru import logger # Default interval: 30 minutes DEFAULT_HEARTBEAT_INTERVAL_S = 30 * 60 -# The prompt sent to agent during heartbeat -HEARTBEAT_PROMPT = """Read HEARTBEAT.md in your workspace (if it exists). -Follow any instructions or tasks listed there. -If nothing needs attention, reply with just: HEARTBEAT_OK""" - -# Token that indicates "nothing to do" +# 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.""" @@ -38,20 +39,24 @@ def _is_heartbeat_empty(content: str | None) -> bool: class HeartbeatService: """ Periodic heartbeat service that wakes the agent to check for tasks. - - The agent reads HEARTBEAT.md from the workspace and executes any - tasks listed there. If nothing needs attention, it replies HEARTBEAT_OK. + + The agent reads HEARTBEAT.md from the workspace and executes any tasks + listed there. If it has something to report, the response is forwarded + to the user via on_notify. If nothing needs attention, the agent replies + HEARTBEAT_OK and the response is silently dropped. """ - + def __init__( self, workspace: Path, on_heartbeat: Callable[[str], Coroutine[Any, Any, str]] | None = None, + on_notify: Callable[[str], Coroutine[Any, Any, None]] | None = None, interval_s: int = DEFAULT_HEARTBEAT_INTERVAL_S, enabled: bool = True, ): self.workspace = workspace self.on_heartbeat = on_heartbeat + self.on_notify = on_notify self.interval_s = interval_s self.enabled = enabled self._running = False @@ -113,15 +118,14 @@ class HeartbeatService: if self.on_heartbeat: try: response = await self.on_heartbeat(HEARTBEAT_PROMPT) - - # Check if agent said "nothing to do" - if HEARTBEAT_OK_TOKEN.replace("_", "") in response.upper().replace("_", ""): - logger.info("Heartbeat: OK (no action needed)") + if HEARTBEAT_OK_TOKEN in response.upper(): + logger.info("Heartbeat: OK (nothing to report)") else: - logger.info("Heartbeat: completed task") - - except Exception as e: - logger.error("Heartbeat execution failed: {}", e) + logger.info("Heartbeat: completed, delivering response") + if self.on_notify: + await self.on_notify(response) + except Exception: + logger.exception("Heartbeat execution failed") async def trigger_now(self) -> str | None: """Manually trigger a heartbeat."""