Merge branch 'main' into pr-1098
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -1,61 +1,69 @@
|
|||||||
"""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
|
||||||
@@ -67,7 +75,6 @@ class HeartbeatService:
|
|||||||
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")
|
||||||
@@ -75,6 +82,29 @@ class HeartbeatService:
|
|||||||
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:
|
||||||
@@ -110,28 +140,34 @@ class HeartbeatService:
|
|||||||
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user