Merge branch 'main' into pr-1098

This commit is contained in:
Re-bin
2026-02-24 11:14:37 +00:00
3 changed files with 117 additions and 70 deletions

View File

@@ -360,19 +360,19 @@ def gateway(
return "cli", "direct" return "cli", "direct"
# Create heartbeat service # Create heartbeat service
async def on_heartbeat(prompt: str) -> str: async def on_heartbeat_execute(tasks: str) -> str:
"""Execute heartbeat through the agent.""" """Phase 2: execute heartbeat tasks through the full agent loop."""
channel, chat_id = _pick_heartbeat_target() channel, chat_id = _pick_heartbeat_target()
async def _silent(*_args, **_kwargs): async def _silent(*_args, **_kwargs):
pass pass
return await agent.process_direct( return await agent.process_direct(
prompt, tasks,
session_key="heartbeat", session_key="heartbeat",
channel=channel, channel=channel,
chat_id=chat_id, 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: async def on_heartbeat_notify(response: str) -> None:
@@ -383,12 +383,15 @@ def gateway(
return # No external channel available to deliver to return # No external channel available to deliver to
await bus.publish_outbound(OutboundMessage(channel=channel, chat_id=chat_id, content=response)) await bus.publish_outbound(OutboundMessage(channel=channel, chat_id=chat_id, content=response))
hb_cfg = config.gateway.heartbeat
heartbeat = HeartbeatService( heartbeat = HeartbeatService(
workspace=config.workspace_path, workspace=config.workspace_path,
on_heartbeat=on_heartbeat, provider=provider,
model=agent.model,
on_execute=on_heartbeat_execute,
on_notify=on_heartbeat_notify, on_notify=on_heartbeat_notify,
interval_s=30 * 60, # 30 minutes interval_s=hb_cfg.interval_s,
enabled=True enabled=hb_cfg.enabled,
) )
if channels.enabled_channels: if channels.enabled_channels:

View File

@@ -228,11 +228,19 @@ class ProvidersConfig(Base):
github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth) 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): class GatewayConfig(Base):
"""Gateway/server configuration.""" """Gateway/server configuration."""
host: str = "0.0.0.0" host: str = "0.0.0.0"
port: int = 18790 port: int = 18790
heartbeat: HeartbeatConfig = Field(default_factory=HeartbeatConfig)
class WebSearchConfig(Base): class WebSearchConfig(Base):

View File

@@ -1,80 +1,110 @@
"""Heartbeat service - periodic agent wake-up to check for tasks.""" """Heartbeat service - periodic agent wake-up to check for tasks."""
from __future__ import annotations
import asyncio import asyncio
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Coroutine from typing import TYPE_CHECKING, Any, Callable, Coroutine
from loguru import logger from loguru import logger
# Default interval: 30 minutes if TYPE_CHECKING:
DEFAULT_HEARTBEAT_INTERVAL_S = 30 * 60 from nanobot.providers.base import LLMProvider
# Token the agent replies with when there is nothing to report _HEARTBEAT_TOOL = [
HEARTBEAT_OK_TOKEN = "HEARTBEAT_OK" {
"type": "function",
# The prompt sent to agent during heartbeat "function": {
HEARTBEAT_PROMPT = ( "name": "heartbeat",
"Read HEARTBEAT.md in your workspace and follow any instructions listed there. " "description": "Report heartbeat decision after reviewing tasks.",
f"If nothing needs attention, reply with exactly: {HEARTBEAT_OK_TOKEN}" "parameters": {
) "type": "object",
"properties": {
"action": {
def _is_heartbeat_empty(content: str | None) -> bool: "type": "string",
"""Check if HEARTBEAT.md has no actionable content.""" "enum": ["skip", "run"],
if not content: "description": "skip = nothing to do, run = has active tasks",
return True },
"tasks": {
# Lines to skip: empty, headers, HTML comments, empty checkboxes "type": "string",
skip_patterns = {"- [ ]", "* [ ]", "- [x]", "* [x]"} "description": "Natural-language summary of active tasks (required for run)",
},
for line in content.split("\n"): },
line = line.strip() "required": ["action"],
if not line or line.startswith("#") or line.startswith("<!--") or line in skip_patterns: },
continue },
return False # Found actionable content }
]
return True
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 tasks Phase 1 (decision): reads HEARTBEAT.md and asks the LLM — via a virtual
listed there. If it has something to report, the response is forwarded tool call — whether there are active tasks. This avoids free-text parsing
to the user via on_notify. If nothing needs attention, the agent replies and the unreliable HEARTBEAT_OK token.
HEARTBEAT_OK and the response is silently dropped.
Phase 2 (execution): only triggered when Phase 1 returns ``run``. The
``on_execute`` callback runs the task through the full agent loop and
returns the result to deliver.
""" """
def __init__( def __init__(
self, self,
workspace: Path, workspace: Path,
on_heartbeat: Callable[[str], Coroutine[Any, Any, str]] | None = None, provider: LLMProvider,
model: str,
on_execute: Callable[[str], Coroutine[Any, Any, str]] | None = None,
on_notify: Callable[[str], Coroutine[Any, Any, None]] | None = None, on_notify: Callable[[str], Coroutine[Any, Any, None]] | None = None,
interval_s: int = DEFAULT_HEARTBEAT_INTERVAL_S, interval_s: int = 30 * 60,
enabled: bool = True, enabled: bool = True,
): ):
self.workspace = workspace self.workspace = workspace
self.on_heartbeat = on_heartbeat self.provider = provider
self.model = model
self.on_execute = on_execute
self.on_notify = on_notify 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
self._task: asyncio.Task | None = None self._task: asyncio.Task | None = None
@property @property
def heartbeat_file(self) -> Path: def heartbeat_file(self) -> Path:
return self.workspace / "HEARTBEAT.md" return self.workspace / "HEARTBEAT.md"
def _read_heartbeat_file(self) -> str | None: def _read_heartbeat_file(self) -> str | None:
"""Read HEARTBEAT.md content."""
if self.heartbeat_file.exists(): if self.heartbeat_file.exists():
try: try:
return self.heartbeat_file.read_text(encoding="utf-8") return self.heartbeat_file.read_text(encoding="utf-8")
except Exception: except Exception:
return None return None
return None return None
async def _decide(self, content: str) -> tuple[str, str]:
"""Phase 1: ask LLM to decide skip/run via virtual tool call.
Returns (action, tasks) where action is 'skip' or 'run'.
"""
response = await self.provider.chat(
messages=[
{"role": "system", "content": "You are a heartbeat agent. Call the heartbeat tool to report your decision."},
{"role": "user", "content": (
"Review the following HEARTBEAT.md and decide whether there are active tasks.\n\n"
f"{content}"
)},
],
tools=_HEARTBEAT_TOOL,
model=self.model,
)
if not response.has_tool_calls:
return "skip", ""
args = response.tool_calls[0].arguments
return args.get("action", "skip"), args.get("tasks", "")
async def start(self) -> None: async def start(self) -> None:
"""Start the heartbeat service.""" """Start the heartbeat service."""
if not self.enabled: if not self.enabled:
@@ -83,18 +113,18 @@ class HeartbeatService:
if self._running: if self._running:
logger.warning("Heartbeat already running") logger.warning("Heartbeat already running")
return return
self._running = True self._running = True
self._task = asyncio.create_task(self._run_loop()) self._task = asyncio.create_task(self._run_loop())
logger.info("Heartbeat started (every {}s)", self.interval_s) logger.info("Heartbeat started (every {}s)", self.interval_s)
def stop(self) -> None: def stop(self) -> None:
"""Stop the heartbeat service.""" """Stop the heartbeat service."""
self._running = False self._running = False
if self._task: if self._task:
self._task.cancel() self._task.cancel()
self._task = None self._task = None
async def _run_loop(self) -> None: async def _run_loop(self) -> None:
"""Main heartbeat loop.""" """Main heartbeat loop."""
while self._running: while self._running:
@@ -106,32 +136,38 @@ class HeartbeatService:
break break
except Exception as e: except Exception as e:
logger.error("Heartbeat error: {}", e) logger.error("Heartbeat error: {}", e)
async def _tick(self) -> None: async def _tick(self) -> None:
"""Execute a single heartbeat tick.""" """Execute a single heartbeat tick."""
content = self._read_heartbeat_file() content = self._read_heartbeat_file()
if not content:
# Skip if HEARTBEAT.md is empty or doesn't exist logger.debug("Heartbeat: HEARTBEAT.md missing or empty")
if _is_heartbeat_empty(content):
logger.debug("Heartbeat: no tasks (HEARTBEAT.md empty)")
return return
logger.info("Heartbeat: checking for tasks...") logger.info("Heartbeat: checking for tasks...")
if self.on_heartbeat: try:
try: action, tasks = await self._decide(content)
response = await self.on_heartbeat(HEARTBEAT_PROMPT)
if HEARTBEAT_OK_TOKEN in response.upper(): if action != "run":
logger.info("Heartbeat: OK (nothing to report)") logger.info("Heartbeat: OK (nothing to report)")
else: return
logger.info("Heartbeat: tasks found, executing...")
if self.on_execute:
response = await self.on_execute(tasks)
if response and self.on_notify:
logger.info("Heartbeat: completed, delivering response") logger.info("Heartbeat: completed, delivering response")
if self.on_notify: await self.on_notify(response)
await self.on_notify(response) except Exception:
except Exception: logger.exception("Heartbeat execution failed")
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."""
if self.on_heartbeat: content = self._read_heartbeat_file()
return await self.on_heartbeat(HEARTBEAT_PROMPT) if not content:
return None return None
action, tasks = await self._decide(content)
if action != "run" or not self.on_execute:
return None
return await self.on_execute(tasks)