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.
- ragnarbot/__init__.py +6 -0
- ragnarbot/__main__.py +8 -0
- ragnarbot/agent/__init__.py +8 -0
- ragnarbot/agent/context.py +223 -0
- ragnarbot/agent/loop.py +365 -0
- ragnarbot/agent/memory.py +109 -0
- ragnarbot/agent/skills.py +228 -0
- ragnarbot/agent/subagent.py +241 -0
- ragnarbot/agent/tools/__init__.py +6 -0
- ragnarbot/agent/tools/base.py +102 -0
- ragnarbot/agent/tools/cron.py +114 -0
- ragnarbot/agent/tools/filesystem.py +191 -0
- ragnarbot/agent/tools/message.py +86 -0
- ragnarbot/agent/tools/registry.py +73 -0
- ragnarbot/agent/tools/shell.py +141 -0
- ragnarbot/agent/tools/spawn.py +65 -0
- ragnarbot/agent/tools/web.py +163 -0
- ragnarbot/bus/__init__.py +6 -0
- ragnarbot/bus/events.py +37 -0
- ragnarbot/bus/queue.py +81 -0
- ragnarbot/channels/__init__.py +6 -0
- ragnarbot/channels/base.py +121 -0
- ragnarbot/channels/manager.py +129 -0
- ragnarbot/channels/telegram.py +302 -0
- ragnarbot/cli/__init__.py +1 -0
- ragnarbot/cli/commands.py +568 -0
- ragnarbot/config/__init__.py +6 -0
- ragnarbot/config/loader.py +95 -0
- ragnarbot/config/schema.py +114 -0
- ragnarbot/cron/__init__.py +6 -0
- ragnarbot/cron/service.py +346 -0
- ragnarbot/cron/types.py +59 -0
- ragnarbot/heartbeat/__init__.py +5 -0
- ragnarbot/heartbeat/service.py +130 -0
- ragnarbot/providers/__init__.py +6 -0
- ragnarbot/providers/base.py +69 -0
- ragnarbot/providers/litellm_provider.py +135 -0
- ragnarbot/providers/transcription.py +67 -0
- ragnarbot/session/__init__.py +5 -0
- ragnarbot/session/manager.py +202 -0
- ragnarbot/skills/README.md +24 -0
- ragnarbot/skills/cron/SKILL.md +40 -0
- ragnarbot/skills/github/SKILL.md +48 -0
- ragnarbot/skills/skill-creator/SKILL.md +371 -0
- ragnarbot/skills/summarize/SKILL.md +67 -0
- ragnarbot/skills/tmux/SKILL.md +121 -0
- ragnarbot/skills/tmux/scripts/find-sessions.sh +112 -0
- ragnarbot/skills/tmux/scripts/wait-for-text.sh +83 -0
- ragnarbot/skills/weather/SKILL.md +49 -0
- ragnarbot/utils/__init__.py +5 -0
- ragnarbot/utils/helpers.py +91 -0
- ragnarbot_ai-0.1.0.dist-info/METADATA +28 -0
- ragnarbot_ai-0.1.0.dist-info/RECORD +56 -0
- ragnarbot_ai-0.1.0.dist-info/WHEEL +4 -0
- ragnarbot_ai-0.1.0.dist-info/entry_points.txt +2 -0
- 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,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
|
+
}
|
ragnarbot/cron/types.py
ADDED
|
@@ -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,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
|