返回 JoyAI-Echo
service.py
1 """Heartbeat service - periodic agent wake-up to check for tasks."""
2
3 from __future__ import annotations
4
5 import asyncio
6 from pathlib import Path
7 from typing import TYPE_CHECKING, Any, Callable, Coroutine
8
9 from loguru import logger
10
11 if TYPE_CHECKING:
12 from nanobot.providers.base import LLMProvider
13
14 _HEARTBEAT_TOOL = [
15 {
16 "type": "function",
17 "function": {
18 "name": "heartbeat",
19 "description": "Report heartbeat decision after reviewing tasks.",
20 "parameters": {
21 "type": "object",
22 "properties": {
23 "action": {
24 "type": "string",
25 "enum": ["skip", "run"],
26 "description": "skip = nothing to do, run = has active tasks",
27 },
28 "tasks": {
29 "type": "string",
30 "description": "Natural-language summary of active tasks (required for run)",
31 },
32 },
33 "required": ["action"],
34 },
35 },
36 }
37 ]
38
39
40 class HeartbeatService:
41 """
42 Periodic heartbeat service that wakes the agent to check for tasks.
43
44 Phase 1 (decision): reads HEARTBEAT.md and asks the LLM — via a virtual
45 tool call — whether there are active tasks. This avoids free-text parsing
46 and the unreliable HEARTBEAT_OK token.
47
48 Phase 2 (execution): only triggered when Phase 1 returns ``run``. The
49 ``on_execute`` callback runs the task through the full agent loop and
50 returns the result to deliver.
51 """
52
53 def __init__(
54 self,
55 workspace: Path,
56 provider: LLMProvider,
57 model: str,
58 on_execute: Callable[[str], Coroutine[Any, Any, str]] | None = None,
59 on_notify: Callable[[str], Coroutine[Any, Any, None]] | None = None,
60 interval_s: int = 30 * 60,
61 enabled: bool = True,
62 timezone: str | None = None,
63 ):
64 self.workspace = workspace
65 self.provider = provider
66 self.model = model
67 self.on_execute = on_execute
68 self.on_notify = on_notify
69 self.interval_s = interval_s
70 self.enabled = enabled
71 self.timezone = timezone
72 self._running = False
73 self._task: asyncio.Task | None = None
74
75 @property
76 def heartbeat_file(self) -> Path:
77 return self.workspace / "HEARTBEAT.md"
78
79 def _read_heartbeat_file(self) -> str | None:
80 if self.heartbeat_file.exists():
81 try:
82 return self.heartbeat_file.read_text(encoding="utf-8")
83 except Exception:
84 return None
85 return None
86
87 async def _decide(self, content: str) -> tuple[str, str]:
88 """Phase 1: ask LLM to decide skip/run via virtual tool call.
89
90 Returns (action, tasks) where action is 'skip' or 'run'.
91 """
92 from nanobot.utils.helpers import current_time_str
93
94 response = await self.provider.chat_with_retry(
95 messages=[
96 {"role": "system", "content": "You are a heartbeat agent. Call the heartbeat tool to report your decision."},
97 {"role": "user", "content": (
98 f"Current Time: {current_time_str(self.timezone)}\n\n"
99 "Review the following HEARTBEAT.md and decide whether there are active tasks.\n\n"
100 f"{content}"
101 )},
102 ],
103 tools=_HEARTBEAT_TOOL,
104 model=self.model,
105 )
106
107 if not response.should_execute_tools:
108 if response.has_tool_calls:
109 logger.warning(
110 "Ignoring heartbeat tool calls under finish_reason='{}'",
111 response.finish_reason,
112 )
113 return "skip", ""
114
115 args = response.tool_calls[0].arguments
116 return args.get("action", "skip"), args.get("tasks", "")
117
118 async def start(self) -> None:
119 """Start the heartbeat service."""
120 if not self.enabled:
121 logger.info("Heartbeat disabled")
122 return
123 if self._running:
124 logger.warning("Heartbeat already running")
125 return
126
127 self._running = True
128 self._task = asyncio.create_task(self._run_loop())
129 logger.info("Heartbeat started (every {}s)", self.interval_s)
130
131 def stop(self) -> None:
132 """Stop the heartbeat service."""
133 self._running = False
134 if self._task:
135 self._task.cancel()
136 self._task = None
137
138 async def _run_loop(self) -> None:
139 """Main heartbeat loop."""
140 while self._running:
141 try:
142 await asyncio.sleep(self.interval_s)
143 if self._running:
144 await self._tick()
145 except asyncio.CancelledError:
146 break
147 except Exception as e:
148 logger.error("Heartbeat error: {}", e)
149
150 async def _tick(self) -> None:
151 """Execute a single heartbeat tick."""
152 from nanobot.utils.evaluator import evaluate_response
153
154 content = self._read_heartbeat_file()
155 if not content:
156 logger.debug("Heartbeat: HEARTBEAT.md missing or empty")
157 return
158
159 logger.info("Heartbeat: checking for tasks...")
160
161 try:
162 action, tasks = await self._decide(content)
163
164 if action != "run":
165 logger.info("Heartbeat: OK (nothing to report)")
166 return
167
168 logger.info("Heartbeat: tasks found, executing...")
169 if self.on_execute:
170 response = await self.on_execute(tasks)
171
172 if response:
173 should_notify = await evaluate_response(
174 response, tasks, self.provider, self.model,
175 )
176 if should_notify and self.on_notify:
177 logger.info("Heartbeat: completed, delivering response")
178 await self.on_notify(response)
179 else:
180 logger.info("Heartbeat: silenced by post-run evaluation")
181 except Exception:
182 logger.exception("Heartbeat execution failed")
183
184 async def trigger_now(self) -> str | None:
185 """Manually trigger a heartbeat."""
186 content = self._read_heartbeat_file()
187 if not content:
188 return None
189 action, tasks = await self._decide(content)
190 if action != "run" or not self.on_execute:
191 return None
192 return await self.on_execute(tasks)
193
193 lines PYTHON