ragnarbot-ai 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. ragnarbot/__init__.py +6 -0
  2. ragnarbot/__main__.py +8 -0
  3. ragnarbot/agent/__init__.py +8 -0
  4. ragnarbot/agent/context.py +223 -0
  5. ragnarbot/agent/loop.py +365 -0
  6. ragnarbot/agent/memory.py +109 -0
  7. ragnarbot/agent/skills.py +228 -0
  8. ragnarbot/agent/subagent.py +241 -0
  9. ragnarbot/agent/tools/__init__.py +6 -0
  10. ragnarbot/agent/tools/base.py +102 -0
  11. ragnarbot/agent/tools/cron.py +114 -0
  12. ragnarbot/agent/tools/filesystem.py +191 -0
  13. ragnarbot/agent/tools/message.py +86 -0
  14. ragnarbot/agent/tools/registry.py +73 -0
  15. ragnarbot/agent/tools/shell.py +141 -0
  16. ragnarbot/agent/tools/spawn.py +65 -0
  17. ragnarbot/agent/tools/web.py +163 -0
  18. ragnarbot/bus/__init__.py +6 -0
  19. ragnarbot/bus/events.py +37 -0
  20. ragnarbot/bus/queue.py +81 -0
  21. ragnarbot/channels/__init__.py +6 -0
  22. ragnarbot/channels/base.py +121 -0
  23. ragnarbot/channels/manager.py +129 -0
  24. ragnarbot/channels/telegram.py +302 -0
  25. ragnarbot/cli/__init__.py +1 -0
  26. ragnarbot/cli/commands.py +568 -0
  27. ragnarbot/config/__init__.py +6 -0
  28. ragnarbot/config/loader.py +95 -0
  29. ragnarbot/config/schema.py +114 -0
  30. ragnarbot/cron/__init__.py +6 -0
  31. ragnarbot/cron/service.py +346 -0
  32. ragnarbot/cron/types.py +59 -0
  33. ragnarbot/heartbeat/__init__.py +5 -0
  34. ragnarbot/heartbeat/service.py +130 -0
  35. ragnarbot/providers/__init__.py +6 -0
  36. ragnarbot/providers/base.py +69 -0
  37. ragnarbot/providers/litellm_provider.py +135 -0
  38. ragnarbot/providers/transcription.py +67 -0
  39. ragnarbot/session/__init__.py +5 -0
  40. ragnarbot/session/manager.py +202 -0
  41. ragnarbot/skills/README.md +24 -0
  42. ragnarbot/skills/cron/SKILL.md +40 -0
  43. ragnarbot/skills/github/SKILL.md +48 -0
  44. ragnarbot/skills/skill-creator/SKILL.md +371 -0
  45. ragnarbot/skills/summarize/SKILL.md +67 -0
  46. ragnarbot/skills/tmux/SKILL.md +121 -0
  47. ragnarbot/skills/tmux/scripts/find-sessions.sh +112 -0
  48. ragnarbot/skills/tmux/scripts/wait-for-text.sh +83 -0
  49. ragnarbot/skills/weather/SKILL.md +49 -0
  50. ragnarbot/utils/__init__.py +5 -0
  51. ragnarbot/utils/helpers.py +91 -0
  52. ragnarbot_ai-0.1.0.dist-info/METADATA +28 -0
  53. ragnarbot_ai-0.1.0.dist-info/RECORD +56 -0
  54. ragnarbot_ai-0.1.0.dist-info/WHEEL +4 -0
  55. ragnarbot_ai-0.1.0.dist-info/entry_points.txt +2 -0
  56. ragnarbot_ai-0.1.0.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,114 @@
1
+ """Configuration schema using Pydantic."""
2
+
3
+ from pathlib import Path
4
+ from pydantic import BaseModel, Field
5
+ from pydantic_settings import BaseSettings
6
+
7
+
8
+ class TelegramConfig(BaseModel):
9
+ """Telegram channel configuration."""
10
+ enabled: bool = False
11
+ token: str = "" # Bot token from @BotFather
12
+ allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames
13
+ proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080"
14
+
15
+
16
+ class ChannelsConfig(BaseModel):
17
+ """Configuration for chat channels."""
18
+ telegram: TelegramConfig = Field(default_factory=TelegramConfig)
19
+
20
+
21
+ class AgentDefaults(BaseModel):
22
+ """Default agent configuration."""
23
+ workspace: str = "~/.ragnarbot/workspace"
24
+ model: str = "anthropic/claude-opus-4-5"
25
+ max_tokens: int = 8192
26
+ temperature: float = 0.7
27
+ max_tool_iterations: int = 20
28
+
29
+
30
+ class AgentsConfig(BaseModel):
31
+ """Agent configuration."""
32
+ defaults: AgentDefaults = Field(default_factory=AgentDefaults)
33
+
34
+
35
+ class ProviderConfig(BaseModel):
36
+ """LLM provider configuration."""
37
+ api_key: str = ""
38
+ api_base: str | None = None
39
+
40
+
41
+ class ProvidersConfig(BaseModel):
42
+ """Configuration for LLM providers."""
43
+ anthropic: ProviderConfig = Field(default_factory=ProviderConfig)
44
+ openai: ProviderConfig = Field(default_factory=ProviderConfig)
45
+ gemini: ProviderConfig = Field(default_factory=ProviderConfig)
46
+
47
+
48
+ class TranscriptionConfig(BaseModel):
49
+ """Voice transcription configuration (Groq Whisper)."""
50
+ api_key: str = "" # Groq API key for Whisper
51
+
52
+
53
+ class GatewayConfig(BaseModel):
54
+ """Gateway/server configuration."""
55
+ host: str = "0.0.0.0"
56
+ port: int = 18790
57
+
58
+
59
+ class WebSearchConfig(BaseModel):
60
+ """Web search tool configuration."""
61
+ api_key: str = "" # Brave Search API key
62
+ max_results: int = 5
63
+
64
+
65
+ class WebToolsConfig(BaseModel):
66
+ """Web tools configuration."""
67
+ search: WebSearchConfig = Field(default_factory=WebSearchConfig)
68
+
69
+
70
+ class ExecToolConfig(BaseModel):
71
+ """Shell exec tool configuration."""
72
+ timeout: int = 60
73
+ restrict_to_workspace: bool = False # If true, block commands accessing paths outside workspace
74
+
75
+
76
+ class ToolsConfig(BaseModel):
77
+ """Tools configuration."""
78
+ web: WebToolsConfig = Field(default_factory=WebToolsConfig)
79
+ exec: ExecToolConfig = Field(default_factory=ExecToolConfig)
80
+
81
+
82
+ class Config(BaseSettings):
83
+ """Root configuration for ragnarbot."""
84
+ agents: AgentsConfig = Field(default_factory=AgentsConfig)
85
+ channels: ChannelsConfig = Field(default_factory=ChannelsConfig)
86
+ providers: ProvidersConfig = Field(default_factory=ProvidersConfig)
87
+ transcription: TranscriptionConfig = Field(default_factory=TranscriptionConfig)
88
+ gateway: GatewayConfig = Field(default_factory=GatewayConfig)
89
+ tools: ToolsConfig = Field(default_factory=ToolsConfig)
90
+
91
+ @property
92
+ def workspace_path(self) -> Path:
93
+ """Get expanded workspace path."""
94
+ return Path(self.agents.defaults.workspace).expanduser()
95
+
96
+ def get_api_key(self) -> str | None:
97
+ """Get API key in priority order: Anthropic > OpenAI > Gemini."""
98
+ return (
99
+ self.providers.anthropic.api_key or
100
+ self.providers.openai.api_key or
101
+ self.providers.gemini.api_key or
102
+ None
103
+ )
104
+
105
+ def get_api_base(self) -> str | None:
106
+ """Get API base URL if a provider has a custom base configured."""
107
+ for provider in [self.providers.anthropic, self.providers.openai, self.providers.gemini]:
108
+ if provider.api_base:
109
+ return provider.api_base
110
+ return None
111
+
112
+ class Config:
113
+ env_prefix = "RAGNARBOT_"
114
+ env_nested_delimiter = "__"
@@ -0,0 +1,6 @@
1
+ """Cron service for scheduled agent tasks."""
2
+
3
+ from ragnarbot.cron.service import CronService
4
+ from ragnarbot.cron.types import CronJob, CronSchedule
5
+
6
+ __all__ = ["CronService", "CronJob", "CronSchedule"]
@@ -0,0 +1,346 @@
1
+ """Cron service for scheduling agent tasks."""
2
+
3
+ import asyncio
4
+ import json
5
+ import time
6
+ import uuid
7
+ from pathlib import Path
8
+ from typing import Any, Callable, Coroutine
9
+
10
+ from loguru import logger
11
+
12
+ from ragnarbot.cron.types import CronJob, CronJobState, CronPayload, CronSchedule, CronStore
13
+
14
+
15
+ def _now_ms() -> int:
16
+ return int(time.time() * 1000)
17
+
18
+
19
+ def _compute_next_run(schedule: CronSchedule, now_ms: int) -> int | None:
20
+ """Compute next run time in ms."""
21
+ if schedule.kind == "at":
22
+ return schedule.at_ms if schedule.at_ms and schedule.at_ms > now_ms else None
23
+
24
+ if schedule.kind == "every":
25
+ if not schedule.every_ms or schedule.every_ms <= 0:
26
+ return None
27
+ # Next interval from now
28
+ return now_ms + schedule.every_ms
29
+
30
+ if schedule.kind == "cron" and schedule.expr:
31
+ try:
32
+ from croniter import croniter
33
+ cron = croniter(schedule.expr, time.time())
34
+ next_time = cron.get_next()
35
+ return int(next_time * 1000)
36
+ except Exception:
37
+ return None
38
+
39
+ return None
40
+
41
+
42
+ class CronService:
43
+ """Service for managing and executing scheduled jobs."""
44
+
45
+ def __init__(
46
+ self,
47
+ store_path: Path,
48
+ on_job: Callable[[CronJob], Coroutine[Any, Any, str | None]] | None = None
49
+ ):
50
+ self.store_path = store_path
51
+ self.on_job = on_job # Callback to execute job, returns response text
52
+ self._store: CronStore | None = None
53
+ self._timer_task: asyncio.Task | None = None
54
+ self._running = False
55
+
56
+ def _load_store(self) -> CronStore:
57
+ """Load jobs from disk."""
58
+ if self._store:
59
+ return self._store
60
+
61
+ if self.store_path.exists():
62
+ try:
63
+ data = json.loads(self.store_path.read_text())
64
+ jobs = []
65
+ for j in data.get("jobs", []):
66
+ jobs.append(CronJob(
67
+ id=j["id"],
68
+ name=j["name"],
69
+ enabled=j.get("enabled", True),
70
+ schedule=CronSchedule(
71
+ kind=j["schedule"]["kind"],
72
+ at_ms=j["schedule"].get("atMs"),
73
+ every_ms=j["schedule"].get("everyMs"),
74
+ expr=j["schedule"].get("expr"),
75
+ tz=j["schedule"].get("tz"),
76
+ ),
77
+ payload=CronPayload(
78
+ kind=j["payload"].get("kind", "agent_turn"),
79
+ message=j["payload"].get("message", ""),
80
+ deliver=j["payload"].get("deliver", False),
81
+ channel=j["payload"].get("channel"),
82
+ to=j["payload"].get("to"),
83
+ ),
84
+ state=CronJobState(
85
+ next_run_at_ms=j.get("state", {}).get("nextRunAtMs"),
86
+ last_run_at_ms=j.get("state", {}).get("lastRunAtMs"),
87
+ last_status=j.get("state", {}).get("lastStatus"),
88
+ last_error=j.get("state", {}).get("lastError"),
89
+ ),
90
+ created_at_ms=j.get("createdAtMs", 0),
91
+ updated_at_ms=j.get("updatedAtMs", 0),
92
+ delete_after_run=j.get("deleteAfterRun", False),
93
+ ))
94
+ self._store = CronStore(jobs=jobs)
95
+ except Exception as e:
96
+ logger.warning(f"Failed to load cron store: {e}")
97
+ self._store = CronStore()
98
+ else:
99
+ self._store = CronStore()
100
+
101
+ return self._store
102
+
103
+ def _save_store(self) -> None:
104
+ """Save jobs to disk."""
105
+ if not self._store:
106
+ return
107
+
108
+ self.store_path.parent.mkdir(parents=True, exist_ok=True)
109
+
110
+ data = {
111
+ "version": self._store.version,
112
+ "jobs": [
113
+ {
114
+ "id": j.id,
115
+ "name": j.name,
116
+ "enabled": j.enabled,
117
+ "schedule": {
118
+ "kind": j.schedule.kind,
119
+ "atMs": j.schedule.at_ms,
120
+ "everyMs": j.schedule.every_ms,
121
+ "expr": j.schedule.expr,
122
+ "tz": j.schedule.tz,
123
+ },
124
+ "payload": {
125
+ "kind": j.payload.kind,
126
+ "message": j.payload.message,
127
+ "deliver": j.payload.deliver,
128
+ "channel": j.payload.channel,
129
+ "to": j.payload.to,
130
+ },
131
+ "state": {
132
+ "nextRunAtMs": j.state.next_run_at_ms,
133
+ "lastRunAtMs": j.state.last_run_at_ms,
134
+ "lastStatus": j.state.last_status,
135
+ "lastError": j.state.last_error,
136
+ },
137
+ "createdAtMs": j.created_at_ms,
138
+ "updatedAtMs": j.updated_at_ms,
139
+ "deleteAfterRun": j.delete_after_run,
140
+ }
141
+ for j in self._store.jobs
142
+ ]
143
+ }
144
+
145
+ self.store_path.write_text(json.dumps(data, indent=2))
146
+
147
+ async def start(self) -> None:
148
+ """Start the cron service."""
149
+ self._running = True
150
+ self._load_store()
151
+ self._recompute_next_runs()
152
+ self._save_store()
153
+ self._arm_timer()
154
+ logger.info(f"Cron service started with {len(self._store.jobs if self._store else [])} jobs")
155
+
156
+ def stop(self) -> None:
157
+ """Stop the cron service."""
158
+ self._running = False
159
+ if self._timer_task:
160
+ self._timer_task.cancel()
161
+ self._timer_task = None
162
+
163
+ def _recompute_next_runs(self) -> None:
164
+ """Recompute next run times for all enabled jobs."""
165
+ if not self._store:
166
+ return
167
+ now = _now_ms()
168
+ for job in self._store.jobs:
169
+ if job.enabled:
170
+ job.state.next_run_at_ms = _compute_next_run(job.schedule, now)
171
+
172
+ def _get_next_wake_ms(self) -> int | None:
173
+ """Get the earliest next run time across all jobs."""
174
+ if not self._store:
175
+ return None
176
+ times = [j.state.next_run_at_ms for j in self._store.jobs
177
+ if j.enabled and j.state.next_run_at_ms]
178
+ return min(times) if times else None
179
+
180
+ def _arm_timer(self) -> None:
181
+ """Schedule the next timer tick."""
182
+ if self._timer_task:
183
+ self._timer_task.cancel()
184
+
185
+ next_wake = self._get_next_wake_ms()
186
+ if not next_wake or not self._running:
187
+ return
188
+
189
+ delay_ms = max(0, next_wake - _now_ms())
190
+ delay_s = delay_ms / 1000
191
+
192
+ async def tick():
193
+ await asyncio.sleep(delay_s)
194
+ if self._running:
195
+ await self._on_timer()
196
+
197
+ self._timer_task = asyncio.create_task(tick())
198
+
199
+ async def _on_timer(self) -> None:
200
+ """Handle timer tick - run due jobs."""
201
+ if not self._store:
202
+ return
203
+
204
+ now = _now_ms()
205
+ due_jobs = [
206
+ j for j in self._store.jobs
207
+ if j.enabled and j.state.next_run_at_ms and now >= j.state.next_run_at_ms
208
+ ]
209
+
210
+ for job in due_jobs:
211
+ await self._execute_job(job)
212
+
213
+ self._save_store()
214
+ self._arm_timer()
215
+
216
+ async def _execute_job(self, job: CronJob) -> None:
217
+ """Execute a single job."""
218
+ start_ms = _now_ms()
219
+ logger.info(f"Cron: executing job '{job.name}' ({job.id})")
220
+
221
+ try:
222
+ response = None
223
+ if self.on_job:
224
+ response = await self.on_job(job)
225
+
226
+ job.state.last_status = "ok"
227
+ job.state.last_error = None
228
+ logger.info(f"Cron: job '{job.name}' completed")
229
+
230
+ except Exception as e:
231
+ job.state.last_status = "error"
232
+ job.state.last_error = str(e)
233
+ logger.error(f"Cron: job '{job.name}' failed: {e}")
234
+
235
+ job.state.last_run_at_ms = start_ms
236
+ job.updated_at_ms = _now_ms()
237
+
238
+ # Handle one-shot jobs
239
+ if job.schedule.kind == "at":
240
+ if job.delete_after_run:
241
+ self._store.jobs = [j for j in self._store.jobs if j.id != job.id]
242
+ else:
243
+ job.enabled = False
244
+ job.state.next_run_at_ms = None
245
+ else:
246
+ # Compute next run
247
+ job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms())
248
+
249
+ # ========== Public API ==========
250
+
251
+ def list_jobs(self, include_disabled: bool = False) -> list[CronJob]:
252
+ """List all jobs."""
253
+ store = self._load_store()
254
+ jobs = store.jobs if include_disabled else [j for j in store.jobs if j.enabled]
255
+ return sorted(jobs, key=lambda j: j.state.next_run_at_ms or float('inf'))
256
+
257
+ def add_job(
258
+ self,
259
+ name: str,
260
+ schedule: CronSchedule,
261
+ message: str,
262
+ deliver: bool = False,
263
+ channel: str | None = None,
264
+ to: str | None = None,
265
+ delete_after_run: bool = False,
266
+ ) -> CronJob:
267
+ """Add a new job."""
268
+ store = self._load_store()
269
+ now = _now_ms()
270
+
271
+ job = CronJob(
272
+ id=str(uuid.uuid4())[:8],
273
+ name=name,
274
+ enabled=True,
275
+ schedule=schedule,
276
+ payload=CronPayload(
277
+ kind="agent_turn",
278
+ message=message,
279
+ deliver=deliver,
280
+ channel=channel,
281
+ to=to,
282
+ ),
283
+ state=CronJobState(next_run_at_ms=_compute_next_run(schedule, now)),
284
+ created_at_ms=now,
285
+ updated_at_ms=now,
286
+ delete_after_run=delete_after_run,
287
+ )
288
+
289
+ store.jobs.append(job)
290
+ self._save_store()
291
+ self._arm_timer()
292
+
293
+ logger.info(f"Cron: added job '{name}' ({job.id})")
294
+ return job
295
+
296
+ def remove_job(self, job_id: str) -> bool:
297
+ """Remove a job by ID."""
298
+ store = self._load_store()
299
+ before = len(store.jobs)
300
+ store.jobs = [j for j in store.jobs if j.id != job_id]
301
+ removed = len(store.jobs) < before
302
+
303
+ if removed:
304
+ self._save_store()
305
+ self._arm_timer()
306
+ logger.info(f"Cron: removed job {job_id}")
307
+
308
+ return removed
309
+
310
+ def enable_job(self, job_id: str, enabled: bool = True) -> CronJob | None:
311
+ """Enable or disable a job."""
312
+ store = self._load_store()
313
+ for job in store.jobs:
314
+ if job.id == job_id:
315
+ job.enabled = enabled
316
+ job.updated_at_ms = _now_ms()
317
+ if enabled:
318
+ job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms())
319
+ else:
320
+ job.state.next_run_at_ms = None
321
+ self._save_store()
322
+ self._arm_timer()
323
+ return job
324
+ return None
325
+
326
+ async def run_job(self, job_id: str, force: bool = False) -> bool:
327
+ """Manually run a job."""
328
+ store = self._load_store()
329
+ for job in store.jobs:
330
+ if job.id == job_id:
331
+ if not force and not job.enabled:
332
+ return False
333
+ await self._execute_job(job)
334
+ self._save_store()
335
+ self._arm_timer()
336
+ return True
337
+ return False
338
+
339
+ def status(self) -> dict:
340
+ """Get service status."""
341
+ store = self._load_store()
342
+ return {
343
+ "enabled": self._running,
344
+ "jobs": len(store.jobs),
345
+ "next_wake_at_ms": self._get_next_wake_ms(),
346
+ }
@@ -0,0 +1,59 @@
1
+ """Cron types."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Literal
5
+
6
+
7
+ @dataclass
8
+ class CronSchedule:
9
+ """Schedule definition for a cron job."""
10
+ kind: Literal["at", "every", "cron"]
11
+ # For "at": timestamp in ms
12
+ at_ms: int | None = None
13
+ # For "every": interval in ms
14
+ every_ms: int | None = None
15
+ # For "cron": cron expression (e.g. "0 9 * * *")
16
+ expr: str | None = None
17
+ # Timezone for cron expressions
18
+ tz: str | None = None
19
+
20
+
21
+ @dataclass
22
+ class CronPayload:
23
+ """What to do when the job runs."""
24
+ kind: Literal["system_event", "agent_turn"] = "agent_turn"
25
+ message: str = ""
26
+ # Deliver response to channel
27
+ deliver: bool = False
28
+ channel: str | None = None # e.g. "telegram"
29
+ to: str | None = None # e.g. phone number
30
+
31
+
32
+ @dataclass
33
+ class CronJobState:
34
+ """Runtime state of a job."""
35
+ next_run_at_ms: int | None = None
36
+ last_run_at_ms: int | None = None
37
+ last_status: Literal["ok", "error", "skipped"] | None = None
38
+ last_error: str | None = None
39
+
40
+
41
+ @dataclass
42
+ class CronJob:
43
+ """A scheduled job."""
44
+ id: str
45
+ name: str
46
+ enabled: bool = True
47
+ schedule: CronSchedule = field(default_factory=lambda: CronSchedule(kind="every"))
48
+ payload: CronPayload = field(default_factory=CronPayload)
49
+ state: CronJobState = field(default_factory=CronJobState)
50
+ created_at_ms: int = 0
51
+ updated_at_ms: int = 0
52
+ delete_after_run: bool = False
53
+
54
+
55
+ @dataclass
56
+ class CronStore:
57
+ """Persistent store for cron jobs."""
58
+ version: int = 1
59
+ jobs: list[CronJob] = field(default_factory=list)
@@ -0,0 +1,5 @@
1
+ """Heartbeat service for periodic agent wake-ups."""
2
+
3
+ from ragnarbot.heartbeat.service import HeartbeatService
4
+
5
+ __all__ = ["HeartbeatService"]
@@ -0,0 +1,130 @@
1
+ """Heartbeat service - periodic agent wake-up to check for tasks."""
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+ from typing import Any, Callable, Coroutine
6
+
7
+ from loguru import logger
8
+
9
+ # Default interval: 30 minutes
10
+ DEFAULT_HEARTBEAT_INTERVAL_S = 30 * 60
11
+
12
+ # The prompt sent to agent during heartbeat
13
+ HEARTBEAT_PROMPT = """Read HEARTBEAT.md in your workspace (if it exists).
14
+ Follow any instructions or tasks listed there.
15
+ If nothing needs attention, reply with just: HEARTBEAT_OK"""
16
+
17
+ # Token that indicates "nothing to do"
18
+ HEARTBEAT_OK_TOKEN = "HEARTBEAT_OK"
19
+
20
+
21
+ def _is_heartbeat_empty(content: str | None) -> bool:
22
+ """Check if HEARTBEAT.md has no actionable content."""
23
+ if not content:
24
+ return True
25
+
26
+ # Lines to skip: empty, headers, HTML comments, empty checkboxes
27
+ skip_patterns = {"- [ ]", "* [ ]", "- [x]", "* [x]"}
28
+
29
+ for line in content.split("\n"):
30
+ line = line.strip()
31
+ if not line or line.startswith("#") or line.startswith("<!--") or line in skip_patterns:
32
+ continue
33
+ return False # Found actionable content
34
+
35
+ return True
36
+
37
+
38
+ class HeartbeatService:
39
+ """
40
+ Periodic heartbeat service that wakes the agent to check for tasks.
41
+
42
+ The agent reads HEARTBEAT.md from the workspace and executes any
43
+ tasks listed there. If nothing needs attention, it replies HEARTBEAT_OK.
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ workspace: Path,
49
+ on_heartbeat: Callable[[str], Coroutine[Any, Any, str]] | None = None,
50
+ interval_s: int = DEFAULT_HEARTBEAT_INTERVAL_S,
51
+ enabled: bool = True,
52
+ ):
53
+ self.workspace = workspace
54
+ self.on_heartbeat = on_heartbeat
55
+ self.interval_s = interval_s
56
+ self.enabled = enabled
57
+ self._running = False
58
+ self._task: asyncio.Task | None = None
59
+
60
+ @property
61
+ def heartbeat_file(self) -> Path:
62
+ return self.workspace / "HEARTBEAT.md"
63
+
64
+ def _read_heartbeat_file(self) -> str | None:
65
+ """Read HEARTBEAT.md content."""
66
+ if self.heartbeat_file.exists():
67
+ try:
68
+ return self.heartbeat_file.read_text()
69
+ except Exception:
70
+ return None
71
+ return None
72
+
73
+ async def start(self) -> None:
74
+ """Start the heartbeat service."""
75
+ if not self.enabled:
76
+ logger.info("Heartbeat disabled")
77
+ return
78
+
79
+ self._running = True
80
+ self._task = asyncio.create_task(self._run_loop())
81
+ logger.info(f"Heartbeat started (every {self.interval_s}s)")
82
+
83
+ def stop(self) -> None:
84
+ """Stop the heartbeat service."""
85
+ self._running = False
86
+ if self._task:
87
+ self._task.cancel()
88
+ self._task = None
89
+
90
+ async def _run_loop(self) -> None:
91
+ """Main heartbeat loop."""
92
+ while self._running:
93
+ try:
94
+ await asyncio.sleep(self.interval_s)
95
+ if self._running:
96
+ await self._tick()
97
+ except asyncio.CancelledError:
98
+ break
99
+ except Exception as e:
100
+ logger.error(f"Heartbeat error: {e}")
101
+
102
+ async def _tick(self) -> None:
103
+ """Execute a single heartbeat tick."""
104
+ content = self._read_heartbeat_file()
105
+
106
+ # Skip if HEARTBEAT.md is empty or doesn't exist
107
+ if _is_heartbeat_empty(content):
108
+ logger.debug("Heartbeat: no tasks (HEARTBEAT.md empty)")
109
+ return
110
+
111
+ logger.info("Heartbeat: checking for tasks...")
112
+
113
+ if self.on_heartbeat:
114
+ try:
115
+ response = await self.on_heartbeat(HEARTBEAT_PROMPT)
116
+
117
+ # Check if agent said "nothing to do"
118
+ if HEARTBEAT_OK_TOKEN.replace("_", "") in response.upper().replace("_", ""):
119
+ logger.info("Heartbeat: OK (no action needed)")
120
+ else:
121
+ logger.info(f"Heartbeat: completed task")
122
+
123
+ except Exception as e:
124
+ logger.error(f"Heartbeat execution failed: {e}")
125
+
126
+ async def trigger_now(self) -> str | None:
127
+ """Manually trigger a heartbeat."""
128
+ if self.on_heartbeat:
129
+ return await self.on_heartbeat(HEARTBEAT_PROMPT)
130
+ return None
@@ -0,0 +1,6 @@
1
+ """LLM provider abstraction module."""
2
+
3
+ from ragnarbot.providers.base import LLMProvider, LLMResponse
4
+ from ragnarbot.providers.litellm_provider import LiteLLMProvider
5
+
6
+ __all__ = ["LLMProvider", "LLMResponse", "LiteLLMProvider"]