Merge PR #1054 to deliver agent response to user and fix HEARTBEAT_OK detection

fix(heartbeat): deliver agent response to user and fix HEARTBEAT_OK detection
This commit is contained in:
Xubin Ren
2026-02-23 21:52:08 +08:00
committed by GitHub
2 changed files with 32 additions and 20 deletions

View File

@@ -372,13 +372,21 @@ def gateway(
session_key="heartbeat", session_key="heartbeat",
channel=channel, channel=channel,
chat_id=chat_id, chat_id=chat_id,
# suppress: heartbeat should not push progress to external channels on_progress=_silent, # suppress: heartbeat should not push progress to external channels
on_progress=_silent,
) )
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( heartbeat = HeartbeatService(
workspace=config.workspace_path, workspace=config.workspace_path,
on_heartbeat=on_heartbeat, on_heartbeat=on_heartbeat,
on_notify=on_heartbeat_notify,
interval_s=30 * 60, # 30 minutes interval_s=30 * 60, # 30 minutes
enabled=True enabled=True
) )

View File

@@ -9,14 +9,15 @@ from loguru import logger
# Default interval: 30 minutes # Default interval: 30 minutes
DEFAULT_HEARTBEAT_INTERVAL_S = 30 * 60 DEFAULT_HEARTBEAT_INTERVAL_S = 30 * 60
# The prompt sent to agent during heartbeat # Token the agent replies with when there is nothing to report
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"
HEARTBEAT_OK_TOKEN = "HEARTBEAT_OK" 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: def _is_heartbeat_empty(content: str | None) -> bool:
"""Check if HEARTBEAT.md has no actionable content.""" """Check if HEARTBEAT.md has no actionable content."""
@@ -38,20 +39,24 @@ def _is_heartbeat_empty(content: str | None) -> bool:
class HeartbeatService: class HeartbeatService:
""" """
Periodic heartbeat service that wakes the agent to check for tasks. Periodic heartbeat service that wakes the agent to check for tasks.
The agent reads HEARTBEAT.md from the workspace and executes any The agent reads HEARTBEAT.md from the workspace and executes any tasks
tasks listed there. If nothing needs attention, it replies HEARTBEAT_OK. 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__( def __init__(
self, self,
workspace: Path, workspace: Path,
on_heartbeat: Callable[[str], Coroutine[Any, Any, str]] | None = None, 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, interval_s: int = DEFAULT_HEARTBEAT_INTERVAL_S,
enabled: bool = True, enabled: bool = True,
): ):
self.workspace = workspace self.workspace = workspace
self.on_heartbeat = on_heartbeat self.on_heartbeat = on_heartbeat
self.on_notify = on_notify
self.interval_s = interval_s self.interval_s = interval_s
self.enabled = enabled self.enabled = enabled
self._running = False self._running = False
@@ -113,15 +118,14 @@ class HeartbeatService:
if self.on_heartbeat: if self.on_heartbeat:
try: try:
response = await self.on_heartbeat(HEARTBEAT_PROMPT) response = await self.on_heartbeat(HEARTBEAT_PROMPT)
if HEARTBEAT_OK_TOKEN in response.upper():
# Check if agent said "nothing to do" logger.info("Heartbeat: OK (nothing to report)")
if HEARTBEAT_OK_TOKEN.replace("_", "") in response.upper().replace("_", ""):
logger.info("Heartbeat: OK (no action needed)")
else: else:
logger.info("Heartbeat: completed task") logger.info("Heartbeat: completed, delivering response")
if self.on_notify:
except Exception as e: await self.on_notify(response)
logger.error("Heartbeat execution failed: {}", e) except Exception:
logger.exception("Heartbeat execution failed")
async def trigger_now(self) -> str | None: async def trigger_now(self) -> str | None:
"""Manually trigger a heartbeat.""" """Manually trigger a heartbeat."""