Rebase onto upstream (a4d95fd) #12

Closed
wylab wants to merge 144 commits from rebase-onto-upstream into main
2 changed files with 25 additions and 20 deletions
Showing only changes of commit d5283e9b10 - Show all commits
+21 -16
View File
@@ -1,11 +1,16 @@
"""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
if TYPE_CHECKING:
from nanobot.session.manager import SessionManager
# Default interval: 30 minutes
DEFAULT_HEARTBEAT_INTERVAL_S = 30 * 60
@@ -22,34 +27,34 @@ 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("<!--") or line in skip_patterns:
continue
return False # Found actionable content
return True
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.
"""
def __init__(
self,
workspace: Path,
on_heartbeat: Callable[[str, dict[str, Any] | None], Coroutine[Any, Any, str]] | None = None,
interval_s: int = DEFAULT_HEARTBEAT_INTERVAL_S,
enabled: bool = True,
session_manager: "SessionManager | None" = None,
session_manager: SessionManager | None = None,
target_session_key: str = "telegram:239824268",
idle_threshold_s: int = 30 * 60, # 30 minutes
):
@@ -62,11 +67,11 @@ class HeartbeatService:
self.idle_threshold_s = idle_threshold_s
self._running = False
self._task: asyncio.Task | None = None
@property
def heartbeat_file(self) -> Path:
return self.workspace / "HEARTBEAT.md"
def _read_heartbeat_file(self) -> str | None:
"""Read HEARTBEAT.md content."""
if self.heartbeat_file.exists():
@@ -75,24 +80,24 @@ class HeartbeatService:
except Exception:
return None
return None
async def start(self) -> None:
"""Start the heartbeat service."""
if not self.enabled:
logger.info("Heartbeat disabled")
return
self._running = True
self._task = asyncio.create_task(self._run_loop())
logger.info(f"Heartbeat started (every {self.interval_s}s)")
def stop(self) -> None:
"""Stop the heartbeat service."""
self._running = False
if self._task:
self._task.cancel()
self._task = None
async def _run_loop(self) -> None:
"""Main heartbeat loop."""
while self._running:
@@ -104,7 +109,7 @@ class HeartbeatService:
break
except Exception as e:
logger.error(f"Heartbeat error: {e}")
async def _tick(self) -> None:
"""Execute a single heartbeat tick."""
@@ -145,7 +150,7 @@ class HeartbeatService:
if self.on_heartbeat:
try:
# Call with suppress_output metadata
response = await self.on_heartbeat(
await self.on_heartbeat(
HEARTBEAT_PROMPT,
metadata={"suppress_output": True}
)
@@ -155,7 +160,7 @@ class HeartbeatService:
except Exception as e:
logger.error(f"Heartbeat execution failed: {e}")
async def trigger_now(self) -> str | None:
"""Manually trigger a heartbeat."""
if self.on_heartbeat:
+4 -4
View File
@@ -1,11 +1,11 @@
# tests/test_heartbeat_idle.py
import pytest
import asyncio
from pathlib import Path
from datetime import datetime, timedelta
from pathlib import Path
import pytest
from nanobot.heartbeat.service import HeartbeatService
from nanobot.session.manager import SessionManager
from unittest.mock import AsyncMock, MagicMock
@pytest.mark.asyncio