refactor(heartbeat): migrate heartbeat service to cron-based auto-registration
Remove standalone nanobot/heartbeat/ service and replace it with an auto-registered system cron job on gateway startup. Key behaviors preserved: - HeartbeatConfig (enabled, interval_s, keep_recent_messages) remains in GatewayConfig for backward compatibility. - On startup, if enabled, a system cron job "heartbeat" is registered with schedule derived from interval_s. - HEARTBEAT.md is checked on each tick; empty/template-identical files skip to avoid wasting LLM calls. - Post-run evaluate_response and session history truncation (keep_recent_messages) are retained. - Delivery target selection, deliverable filtering, and preamble guidance are preserved. Files removed: - nanobot/heartbeat/__init__.py - nanobot/heartbeat/service.py - tests/heartbeat/* - tests/agent/test_heartbeat_service.py Templates and docs updated to reflect cron-based usage.
This commit is contained in:
@@ -3,8 +3,6 @@
|
||||
import base64
|
||||
import mimetypes
|
||||
import platform
|
||||
from contextlib import suppress
|
||||
from importlib.resources import files as pkg_files
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
@@ -12,12 +10,13 @@ from nanobot.agent.memory import MemoryStore
|
||||
from nanobot.agent.skills import SkillsLoader
|
||||
from nanobot.agent.tools import mcp as mcp_tools
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.apps.cli import utils as cli_app_utils
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.session.goal_state import goal_state_runtime_lines
|
||||
from nanobot.utils.helpers import (
|
||||
current_time_str,
|
||||
detect_image_mime,
|
||||
load_bundled_template,
|
||||
truncate_text,
|
||||
)
|
||||
from nanobot.utils.prompt_templates import render_template
|
||||
@@ -168,10 +167,9 @@ class ContextBuilder:
|
||||
@staticmethod
|
||||
def _is_template_content(content: str, template_path: str) -> bool:
|
||||
"""Check if *content* is identical to the bundled template (user hasn't customized it)."""
|
||||
with suppress(Exception):
|
||||
tpl = pkg_files("nanobot") / "templates" / template_path
|
||||
if tpl.is_file():
|
||||
return content.strip() == tpl.read_text(encoding="utf-8").strip()
|
||||
tpl = load_bundled_template(template_path)
|
||||
if tpl is not None:
|
||||
return content.strip() == tpl.strip()
|
||||
return False
|
||||
|
||||
def build_messages(
|
||||
|
||||
+88
-72
@@ -1,6 +1,7 @@
|
||||
"""CLI commands for nanobot."""
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import os
|
||||
import select
|
||||
import signal
|
||||
@@ -75,6 +76,7 @@ class SafeFileHistory(FileHistory):
|
||||
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner
|
||||
from nanobot.config.paths import get_workspace_path, is_default_workspace
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.utils.evaluator import evaluate_response
|
||||
from nanobot.utils.helpers import sync_workspace_templates
|
||||
from nanobot.utils.restart import (
|
||||
consume_restart_notice_from_env,
|
||||
@@ -94,6 +96,20 @@ EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"}
|
||||
_REASONING_SENTENCE_ENDINGS = (".", "!", "?", "。", "!", "?")
|
||||
_REASONING_FLUSH_CHARS = 60
|
||||
|
||||
_HEARTBEAT_PREAMBLE = (
|
||||
"[Your response will be delivered directly to the user's messaging app. "
|
||||
"Output ONLY the final user-facing message. Never reference internal "
|
||||
"files (HEARTBEAT.md, AWARENESS.md, etc.), your instructions, or your "
|
||||
"decision process. If nothing needs reporting, respond with just "
|
||||
"'All clear.' and nothing else.]\n\n"
|
||||
)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def _heartbeat_template() -> str | None:
|
||||
from nanobot.utils.helpers import load_bundled_template
|
||||
return load_bundled_template("HEARTBEAT.md")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI input: prompt_toolkit for editing, paste, history, and display
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -718,7 +734,6 @@ def _run_gateway(
|
||||
from nanobot.channels.websocket import publish_runtime_model_update
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.cron.types import CronJob
|
||||
from nanobot.heartbeat.service import HeartbeatService
|
||||
from nanobot.providers.factory import build_provider_snapshot, load_provider_snapshot
|
||||
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||
from nanobot.session.manager import SessionManager
|
||||
@@ -810,6 +825,9 @@ def _run_gateway(
|
||||
# Set cron callback (needs agent)
|
||||
async def on_cron_job(job: CronJob) -> str | None:
|
||||
"""Execute a cron job through the agent."""
|
||||
async def _silent(*_args, **_kwargs):
|
||||
pass
|
||||
|
||||
# Dream is an internal job — run directly, not through the agent loop.
|
||||
if job.name == "dream":
|
||||
try:
|
||||
@@ -819,7 +837,56 @@ def _run_gateway(
|
||||
logger.exception("Dream cron job failed")
|
||||
return None
|
||||
|
||||
from nanobot.utils.evaluator import evaluate_response
|
||||
# Heartbeat is a system job that checks HEARTBEAT.md for active tasks.
|
||||
if job.name == "heartbeat":
|
||||
heartbeat_file = config.workspace_path / "HEARTBEAT.md"
|
||||
try:
|
||||
content = heartbeat_file.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
logger.debug("Heartbeat: HEARTBEAT.md missing")
|
||||
return None
|
||||
if not content or content == _heartbeat_template():
|
||||
logger.debug("Heartbeat: HEARTBEAT.md empty or identical to template")
|
||||
return None
|
||||
|
||||
channel, chat_id = _pick_heartbeat_target()
|
||||
if channel == "cli":
|
||||
return None
|
||||
|
||||
prompt = (
|
||||
_HEARTBEAT_PREAMBLE
|
||||
+ f"Review the following HEARTBEAT.md and report any active tasks:\n\n{content}"
|
||||
)
|
||||
|
||||
resp = await agent.process_direct(
|
||||
prompt,
|
||||
session_key="heartbeat",
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
on_progress=_silent,
|
||||
)
|
||||
response = resp.content if resp else ""
|
||||
|
||||
# Keep a small tail of heartbeat history so the loop stays bounded.
|
||||
session = agent.sessions.get_or_create("heartbeat")
|
||||
session.retain_recent_legal_suffix(hb_cfg.keep_recent_messages)
|
||||
agent.sessions.save(session)
|
||||
|
||||
if not response:
|
||||
return None
|
||||
|
||||
should_notify = await evaluate_response(
|
||||
response, prompt, agent.provider, agent.model,
|
||||
)
|
||||
if should_notify:
|
||||
logger.info("Heartbeat: completed, delivering response")
|
||||
await _deliver_to_channel(
|
||||
OutboundMessage(channel=channel, chat_id=chat_id, content=response),
|
||||
record=True,
|
||||
)
|
||||
else:
|
||||
logger.info("Heartbeat: silenced by post-run evaluation")
|
||||
return response
|
||||
|
||||
reminder_note = (
|
||||
"The scheduled time has arrived. Deliver this reminder to the user now, "
|
||||
@@ -834,9 +901,6 @@ def _run_gateway(
|
||||
if isinstance(cron_tool, CronTool):
|
||||
cron_token = cron_tool.set_cron_context(True)
|
||||
|
||||
async def _silent(*_args, **_kwargs):
|
||||
pass
|
||||
|
||||
message_record_token = None
|
||||
if isinstance(message_tool, MessageTool):
|
||||
message_record_token = message_tool.set_record_channel_delivery(True)
|
||||
@@ -898,7 +962,6 @@ def _run_gateway(
|
||||
def _pick_heartbeat_target() -> tuple[str, str]:
|
||||
"""Pick a routable channel/chat target for heartbeat-triggered messages."""
|
||||
enabled = set(channels.enabled_channels)
|
||||
# Prefer the most recently updated non-internal session on an enabled channel.
|
||||
for item in session_manager.list_sessions():
|
||||
key = item.get("key") or ""
|
||||
if ":" not in key:
|
||||
@@ -908,70 +971,8 @@ def _run_gateway(
|
||||
continue
|
||||
if channel in enabled and chat_id:
|
||||
return channel, chat_id
|
||||
# Fallback keeps prior behavior but remains explicit.
|
||||
return "cli", "direct"
|
||||
|
||||
# Create heartbeat service
|
||||
heartbeat_preamble = (
|
||||
"[Your response will be delivered directly to the user's messaging app. "
|
||||
"Output ONLY the final user-facing message. Never reference internal "
|
||||
"files (HEARTBEAT.md, AWARENESS.md, etc.), your instructions, or your "
|
||||
"decision process. If nothing needs reporting, respond with just "
|
||||
"'All clear.' and nothing else.]\n\n"
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
resp = await agent.process_direct(
|
||||
heartbeat_preamble + tasks,
|
||||
session_key="heartbeat",
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
on_progress=_silent,
|
||||
)
|
||||
|
||||
# Keep a small tail of heartbeat history so the loop stays bounded
|
||||
# without losing all short-term context between runs.
|
||||
session = agent.sessions.get_or_create("heartbeat")
|
||||
session.retain_recent_legal_suffix(hb_cfg.keep_recent_messages)
|
||||
agent.sessions.save(session)
|
||||
|
||||
return resp.content if resp else ""
|
||||
|
||||
async def on_heartbeat_notify(response: str) -> None:
|
||||
"""Deliver a heartbeat response to the user's channel.
|
||||
|
||||
In addition to publishing the outbound message, this injects the
|
||||
delivered text as an assistant turn into the *target channel's*
|
||||
session. Without this, a user reply on the channel (e.g. "Sure")
|
||||
lands in a session that has no context about the heartbeat message
|
||||
and the agent cannot follow through.
|
||||
"""
|
||||
channel, chat_id = _pick_heartbeat_target()
|
||||
if channel == "cli":
|
||||
return # No external channel available to deliver to
|
||||
|
||||
await _deliver_to_channel(
|
||||
OutboundMessage(channel=channel, chat_id=chat_id, content=response),
|
||||
record=True,
|
||||
)
|
||||
|
||||
hb_cfg = config.gateway.heartbeat
|
||||
heartbeat = HeartbeatService(
|
||||
workspace=config.workspace_path,
|
||||
llm_runtime=agent.llm_runtime,
|
||||
on_execute=on_heartbeat_execute,
|
||||
on_notify=on_heartbeat_notify,
|
||||
interval_s=hb_cfg.interval_s,
|
||||
enabled=hb_cfg.enabled,
|
||||
timezone=config.agents.defaults.timezone,
|
||||
)
|
||||
|
||||
if channels.enabled_channels:
|
||||
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
|
||||
else:
|
||||
@@ -981,7 +982,11 @@ def _run_gateway(
|
||||
if cron_status["jobs"] > 0:
|
||||
console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs")
|
||||
|
||||
console.print(f"[green]✓[/green] Heartbeat: every {hb_cfg.interval_s}s")
|
||||
hb_cfg = config.gateway.heartbeat
|
||||
if hb_cfg.enabled:
|
||||
console.print(f"[green]✓[/green] Heartbeat: every {hb_cfg.interval_s}s")
|
||||
else:
|
||||
console.print("[yellow]✗[/yellow] Heartbeat: disabled")
|
||||
|
||||
async def _health_server(host: str, health_port: int):
|
||||
"""Lightweight HTTP health endpoint on the gateway port."""
|
||||
@@ -1032,7 +1037,7 @@ def _run_gateway(
|
||||
agent.dream.max_batch_size = dream_cfg.max_batch_size
|
||||
agent.dream.max_iterations = dream_cfg.max_iterations
|
||||
agent.dream.annotate_line_ages = dream_cfg.annotate_line_ages
|
||||
from nanobot.cron.types import CronJob, CronPayload
|
||||
from nanobot.cron.types import CronJob, CronPayload, CronSchedule
|
||||
cron.register_system_job(CronJob(
|
||||
id="dream",
|
||||
name="dream",
|
||||
@@ -1041,6 +1046,19 @@ def _run_gateway(
|
||||
))
|
||||
console.print(f"[green]✓[/green] Dream: {dream_cfg.describe_schedule()}")
|
||||
|
||||
# Register Heartbeat system job (idempotent on restart)
|
||||
if hb_cfg.enabled:
|
||||
cron.register_system_job(CronJob(
|
||||
id="heartbeat",
|
||||
name="heartbeat",
|
||||
schedule=CronSchedule(
|
||||
kind="every",
|
||||
every_ms=hb_cfg.interval_s * 1000,
|
||||
tz=config.agents.defaults.timezone,
|
||||
),
|
||||
payload=CronPayload(kind="system_event"),
|
||||
))
|
||||
|
||||
async def _open_browser_when_ready() -> None:
|
||||
"""Wait for the gateway to bind, then point the user's browser at the webui."""
|
||||
if not open_browser_url:
|
||||
@@ -1067,7 +1085,6 @@ def _run_gateway(
|
||||
async def run():
|
||||
try:
|
||||
await cron.start()
|
||||
await heartbeat.start()
|
||||
tasks = [
|
||||
agent.run(),
|
||||
channels.start_all(),
|
||||
@@ -1085,7 +1102,6 @@ def _run_gateway(
|
||||
console.print(traceback.format_exc())
|
||||
finally:
|
||||
await agent.close_mcp()
|
||||
heartbeat.stop()
|
||||
cron.stop()
|
||||
agent.stop()
|
||||
await channels.stop_all()
|
||||
|
||||
@@ -1155,7 +1155,7 @@ _SETTINGS_SECTIONS: dict[str, tuple[str, str, set[str] | None]] = {
|
||||
"Agent Settings": ("Agent Defaults", "Configure default model, temperature, and behavior", None),
|
||||
"Channel Common": ("Channel Common", "Configure cross-channel behavior: progress, tool hints, retries", None),
|
||||
"API Server": ("API Server", "Configure OpenAI-compatible API endpoint", None),
|
||||
"Gateway": ("Gateway Settings", "Configure server host, port, and heartbeat", None),
|
||||
"Gateway": ("Gateway Settings", "Configure server host, port", None),
|
||||
"Tools": ("Tools Settings", "Configure web search, shell exec, and other tools", {"mcp_servers"}),
|
||||
}
|
||||
|
||||
|
||||
@@ -236,7 +236,7 @@ class ProvidersConfig(Base):
|
||||
|
||||
|
||||
class HeartbeatConfig(Base):
|
||||
"""Heartbeat service configuration."""
|
||||
"""Heartbeat service configuration (now backed by cron)."""
|
||||
|
||||
enabled: bool = True
|
||||
interval_s: int = 30 * 60 # 30 minutes
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Heartbeat service for periodic agent wake-ups."""
|
||||
|
||||
from nanobot.heartbeat.service import HeartbeatService
|
||||
|
||||
__all__ = ["HeartbeatService"]
|
||||
@@ -1,243 +0,0 @@
|
||||
"""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 loguru import logger
|
||||
|
||||
from nanobot.providers.base import LLMProvider
|
||||
from nanobot.utils.llm_runtime import LLMRuntimeResolver, static_llm_runtime
|
||||
|
||||
_HEARTBEAT_TOOL = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "heartbeat",
|
||||
"description": "Report heartbeat decision after reviewing tasks.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["skip", "run"],
|
||||
"description": "skip = nothing to do, run = has active tasks",
|
||||
},
|
||||
"tasks": {
|
||||
"type": "string",
|
||||
"description": "Natural-language summary of active tasks (required for run)",
|
||||
},
|
||||
},
|
||||
"required": ["action"],
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class HeartbeatService:
|
||||
"""
|
||||
Periodic heartbeat service that wakes the agent to check for tasks.
|
||||
|
||||
Phase 1 (decision): reads HEARTBEAT.md and asks the LLM — via a virtual
|
||||
tool call — whether there are active tasks. This avoids free-text parsing
|
||||
and the unreliable HEARTBEAT_OK token.
|
||||
|
||||
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__(
|
||||
self,
|
||||
workspace: Path,
|
||||
provider: LLMProvider | None = None,
|
||||
model: str | None = None,
|
||||
on_execute: Callable[[str], Coroutine[Any, Any, str]] | None = None,
|
||||
on_notify: Callable[[str], Coroutine[Any, Any, None]] | None = None,
|
||||
interval_s: int = 30 * 60,
|
||||
enabled: bool = True,
|
||||
timezone: str | None = None,
|
||||
llm_runtime: LLMRuntimeResolver | None = None,
|
||||
):
|
||||
self.workspace = workspace
|
||||
if llm_runtime is None:
|
||||
if provider is None or model is None:
|
||||
raise ValueError("HeartbeatService requires either llm_runtime or provider/model")
|
||||
llm_runtime = static_llm_runtime(provider, model)
|
||||
self._llm_runtime = llm_runtime
|
||||
self.on_execute = on_execute
|
||||
self.on_notify = on_notify
|
||||
self.interval_s = interval_s
|
||||
self.enabled = enabled
|
||||
self.timezone = timezone
|
||||
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:
|
||||
if self.heartbeat_file.exists():
|
||||
try:
|
||||
return self.heartbeat_file.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
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'.
|
||||
"""
|
||||
from nanobot.utils.helpers import current_time_str
|
||||
|
||||
llm = self._llm_runtime()
|
||||
|
||||
response = await llm.provider.chat_with_retry(
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a heartbeat agent. Call the heartbeat tool to report your decision."},
|
||||
{"role": "user", "content": (
|
||||
f"Current Time: {current_time_str(self.timezone)}\n\n"
|
||||
"Review the following HEARTBEAT.md and decide whether there are active tasks.\n\n"
|
||||
f"{content}"
|
||||
)},
|
||||
],
|
||||
tools=_HEARTBEAT_TOOL,
|
||||
model=llm.model,
|
||||
)
|
||||
|
||||
if not response.should_execute_tools:
|
||||
if response.has_tool_calls:
|
||||
logger.warning(
|
||||
"Ignoring heartbeat tool calls under finish_reason='{}'",
|
||||
response.finish_reason,
|
||||
)
|
||||
return "skip", ""
|
||||
|
||||
args = response.tool_calls[0].arguments
|
||||
return args.get("action", "skip"), args.get("tasks", "")
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the heartbeat service."""
|
||||
if not self.enabled:
|
||||
logger.info("Heartbeat disabled")
|
||||
return
|
||||
if self._running:
|
||||
logger.warning("Heartbeat already running")
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._task = asyncio.create_task(self._run_loop())
|
||||
logger.info("Heartbeat started (every {}s)", self.interval_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:
|
||||
try:
|
||||
await asyncio.sleep(self.interval_s)
|
||||
if self._running:
|
||||
await self._tick()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception:
|
||||
logger.exception("Heartbeat error")
|
||||
|
||||
@staticmethod
|
||||
def _is_deliverable(response: str) -> bool:
|
||||
"""Check if a heartbeat response is suitable for user delivery.
|
||||
|
||||
Filters out two classes of bad output before the evaluator runs:
|
||||
|
||||
1. **Finalization fallback** — the runner hit empty-response retries
|
||||
and produced a canned error message. For heartbeat, empty output
|
||||
is a valid "nothing to report" outcome, not a failure.
|
||||
2. **Leaked reasoning** — the model reflected internal file names,
|
||||
decision logic, or meta-commentary instead of a user-facing report.
|
||||
"""
|
||||
text = response.lower()
|
||||
|
||||
# Runner finalization fallback
|
||||
if "couldn't produce a final answer" in text:
|
||||
return False
|
||||
|
||||
# Leaked internal reasoning patterns
|
||||
leaked_patterns = [
|
||||
"heartbeat.md",
|
||||
"awareness.md",
|
||||
"judgment call:",
|
||||
"decision logic",
|
||||
"valid options are",
|
||||
"my instructions",
|
||||
"i am supposed to",
|
||||
"strict heartbeat interpretation",
|
||||
]
|
||||
if any(pattern in text for pattern in leaked_patterns):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def _tick(self) -> None:
|
||||
"""Execute a single heartbeat tick."""
|
||||
from nanobot.utils.evaluator import evaluate_response
|
||||
|
||||
content = self._read_heartbeat_file()
|
||||
if not content:
|
||||
logger.debug("Heartbeat: HEARTBEAT.md missing or empty")
|
||||
return
|
||||
|
||||
logger.info("Heartbeat: checking for tasks...")
|
||||
|
||||
try:
|
||||
action, tasks = await self._decide(content)
|
||||
|
||||
if action != "run":
|
||||
logger.info("Heartbeat: OK (nothing to report)")
|
||||
return
|
||||
|
||||
logger.info("Heartbeat: tasks found, executing...")
|
||||
if self.on_execute:
|
||||
response = await self.on_execute(tasks)
|
||||
|
||||
if not response:
|
||||
logger.info("Heartbeat: no response from execution")
|
||||
return
|
||||
|
||||
if not self._is_deliverable(response):
|
||||
logger.info(
|
||||
"Heartbeat: suppressed non-deliverable response ({})",
|
||||
response[:80],
|
||||
)
|
||||
return
|
||||
|
||||
llm = self._llm_runtime()
|
||||
should_notify = await evaluate_response(
|
||||
response, tasks, llm.provider, llm.model,
|
||||
)
|
||||
if should_notify and self.on_notify:
|
||||
logger.info("Heartbeat: completed, delivering response")
|
||||
await self.on_notify(response)
|
||||
else:
|
||||
logger.info("Heartbeat: silenced by post-run evaluation")
|
||||
except Exception:
|
||||
logger.exception("Heartbeat execution failed")
|
||||
|
||||
async def trigger_now(self) -> str | None:
|
||||
"""Manually trigger a heartbeat."""
|
||||
content = self._read_heartbeat_file()
|
||||
if not content:
|
||||
return None
|
||||
action, tasks = await self._decide(content)
|
||||
if action != "run" or not self.on_execute:
|
||||
return None
|
||||
return await self.on_execute(tasks)
|
||||
@@ -14,10 +14,10 @@ Get USER_ID and CHANNEL from the current session (e.g., `8281248569` and `telegr
|
||||
|
||||
## Heartbeat Tasks
|
||||
|
||||
`HEARTBEAT.md` is checked on the configured heartbeat interval. Use file tools to manage periodic tasks.
|
||||
`HEARTBEAT.md` is checked periodically when registered as a cron job. Use the built-in `cron` tool to schedule it (e.g. `cron add --name heartbeat --schedule "every 30m" --message "Check HEARTBEAT.md"`).
|
||||
|
||||
- Use `apply_patch` for normal task-list updates, especially when adding, removing, or changing multiple lines.
|
||||
- Use `edit_file` only for small exact replacements copied from the current `HEARTBEAT.md`.
|
||||
- Use `write_file` for first creation or intentional full-file rewrites.
|
||||
|
||||
When the user asks for a recurring/periodic task, update `HEARTBEAT.md` instead of creating a one-time cron reminder.
|
||||
When the user asks for a recurring/periodic task, update `HEARTBEAT.md` and register it via `cron` instead of creating a one-time reminder.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Heartbeat Tasks
|
||||
|
||||
This file is checked every 30 minutes by your nanobot agent.
|
||||
Add tasks below that you want the agent to work on periodically.
|
||||
This file is checked periodically by your nanobot agent.
|
||||
Register it as a cron job (e.g. `cron add --name heartbeat --schedule "every 30m" --message "Check HEARTBEAT.md"`) to get the same behavior as the legacy heartbeat service.
|
||||
|
||||
If this file has no tasks (only headers and comments), the agent will skip the heartbeat.
|
||||
If this file has no tasks (only headers and comments), the agent will skip it.
|
||||
|
||||
## Active Tasks
|
||||
|
||||
|
||||
@@ -63,5 +63,5 @@ documents the general tool contract and non-obvious usage patterns.
|
||||
## Scheduling and Background Work
|
||||
|
||||
- Use `cron` for scheduled reminders or recurring jobs; do not run `nanobot cron` through `exec`.
|
||||
- For heartbeat tasks, update `HEARTBEAT.md` according to the agent instructions.
|
||||
- For heartbeat tasks, register `HEARTBEAT.md` as a cron job according to the agent instructions.
|
||||
- Do not write reminders only to memory files when the user expects an actual notification.
|
||||
|
||||
@@ -626,3 +626,14 @@ def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]
|
||||
logger.exception("Failed to initialize git store for {}", workspace)
|
||||
|
||||
return added
|
||||
|
||||
|
||||
def load_bundled_template(template_name: str) -> str | None:
|
||||
"""Read a bundled template file from the nanobot package."""
|
||||
from importlib.resources import files as pkg_files
|
||||
|
||||
with suppress(Exception):
|
||||
tpl = pkg_files("nanobot") / "templates" / template_name
|
||||
if tpl.is_file():
|
||||
return tpl.read_text(encoding="utf-8")
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user