Initial commit: Ajarbot with optimizations
Features: - Multi-platform bot (Slack, Telegram) - Memory system with SQLite FTS - Tool use capabilities (file ops, commands) - Scheduled tasks system - Dynamic model switching (/sonnet, /haiku) - Prompt caching for cost optimization Optimizations: - Default to Haiku 4.5 (12x cheaper) - Reduced context: 3 messages, 2 memory results - Optimized SOUL.md (48% smaller) - Automatic caching when using Sonnet (90% savings) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
408
scheduled_tasks.py
Normal file
408
scheduled_tasks.py
Normal file
@@ -0,0 +1,408 @@
|
||||
"""
|
||||
Advanced Scheduled Tasks System with Agent/LLM integration.
|
||||
|
||||
Supports cron-like scheduling for tasks that require the Agent to execute,
|
||||
with output delivery to messaging platforms (Slack, Telegram, etc.).
|
||||
|
||||
Example use cases:
|
||||
- Daily weather reports at 8am and 6pm
|
||||
- Weekly summary on Friday at 5pm
|
||||
- Hourly health checks
|
||||
- Custom periodic agent tasks
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import traceback
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from agent import Agent
|
||||
|
||||
# Mapping of day abbreviations to weekday numbers (Monday=0)
|
||||
_DAY_NAMES = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
||||
|
||||
# Scheduler polling interval in seconds
|
||||
_SCHEDULER_POLL_INTERVAL = 60
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScheduledTask:
|
||||
"""Defines a scheduled task that uses the Agent."""
|
||||
name: str
|
||||
prompt: str
|
||||
schedule: str # "daily 08:00", "hourly", "weekly mon 09:00"
|
||||
enabled: bool = True
|
||||
username: str = "scheduler"
|
||||
|
||||
# Optional: Send output to messaging platform
|
||||
send_to_platform: Optional[str] = None
|
||||
send_to_channel: Optional[str] = None
|
||||
|
||||
# Tracking
|
||||
last_run: Optional[datetime] = None
|
||||
next_run: Optional[datetime] = None
|
||||
|
||||
|
||||
class TaskScheduler:
|
||||
"""
|
||||
Manages scheduled tasks that require Agent/LLM execution.
|
||||
|
||||
Unlike the simple heartbeat, this:
|
||||
- Supports cron-like scheduling (specific times)
|
||||
- Can send outputs to messaging platforms
|
||||
- Tracks task execution history
|
||||
- Allows dynamic task management
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent: Agent,
|
||||
config_file: Optional[str] = None,
|
||||
) -> None:
|
||||
self.agent = agent
|
||||
self.config_file = Path(
|
||||
config_file or "config/scheduled_tasks.yaml"
|
||||
)
|
||||
self.tasks: List[ScheduledTask] = []
|
||||
self.running = False
|
||||
self.thread: Optional[threading.Thread] = None
|
||||
|
||||
# Adapter integration (set by runtime)
|
||||
self.adapters: Dict[str, Any] = {}
|
||||
|
||||
self.on_task_complete: Optional[
|
||||
Callable[[ScheduledTask, str], None]
|
||||
] = None
|
||||
|
||||
self._load_tasks()
|
||||
|
||||
def _load_tasks(self) -> None:
|
||||
"""Load scheduled tasks from YAML config."""
|
||||
if not self.config_file.exists():
|
||||
self._create_default_config()
|
||||
return
|
||||
|
||||
with open(self.config_file) as f:
|
||||
config = yaml.safe_load(f) or {}
|
||||
|
||||
for task_config in config.get("tasks", []):
|
||||
task = ScheduledTask(
|
||||
name=task_config["name"],
|
||||
prompt=task_config["prompt"],
|
||||
schedule=task_config["schedule"],
|
||||
enabled=task_config.get("enabled", True),
|
||||
username=task_config.get("username", "scheduler"),
|
||||
send_to_platform=task_config.get("send_to_platform"),
|
||||
send_to_channel=task_config.get("send_to_channel"),
|
||||
)
|
||||
task.next_run = self._calculate_next_run(task.schedule)
|
||||
self.tasks.append(task)
|
||||
|
||||
print(f"[Scheduler] Loaded {len(self.tasks)} task(s)")
|
||||
|
||||
def _create_default_config(self) -> None:
|
||||
"""Create default scheduled tasks config."""
|
||||
default_config = {
|
||||
"tasks": [
|
||||
{
|
||||
"name": "morning-briefing",
|
||||
"prompt": (
|
||||
"Good morning! Please provide a brief summary "
|
||||
"of:\n1. Any pending tasks\n"
|
||||
"2. Today's priorities\n"
|
||||
"3. A motivational message to start the day"
|
||||
),
|
||||
"schedule": "daily 08:00",
|
||||
"enabled": False,
|
||||
"send_to_platform": None,
|
||||
"send_to_channel": None,
|
||||
},
|
||||
{
|
||||
"name": "evening-summary",
|
||||
"prompt": (
|
||||
"Good evening! Please provide:\n"
|
||||
"1. Summary of what was accomplished today\n"
|
||||
"2. Any tasks still pending\n"
|
||||
"3. Preview of tomorrow's priorities"
|
||||
),
|
||||
"schedule": "daily 18:00",
|
||||
"enabled": False,
|
||||
},
|
||||
{
|
||||
"name": "weekly-review",
|
||||
"prompt": (
|
||||
"It's the end of the week! Please provide:\n"
|
||||
"1. Week highlights and accomplishments\n"
|
||||
"2. Lessons learned\n"
|
||||
"3. Goals for next week"
|
||||
),
|
||||
"schedule": "weekly fri 17:00",
|
||||
"enabled": False,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
self.config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.config_file, "w") as f:
|
||||
yaml.dump(
|
||||
default_config, f,
|
||||
default_flow_style=False,
|
||||
sort_keys=False,
|
||||
)
|
||||
|
||||
print(f"[Scheduler] Created default config at {self.config_file}")
|
||||
|
||||
def _calculate_next_run(self, schedule: str) -> datetime:
|
||||
"""Calculate next run time from schedule string."""
|
||||
now = datetime.now()
|
||||
parts = schedule.lower().split()
|
||||
|
||||
if parts[0] == "hourly":
|
||||
return now.replace(
|
||||
minute=0, second=0, microsecond=0
|
||||
) + timedelta(hours=1)
|
||||
|
||||
if parts[0] == "daily":
|
||||
if len(parts) < 2:
|
||||
raise ValueError(f"Invalid schedule: {schedule}")
|
||||
|
||||
hour, minute = map(int, parts[1].split(":"))
|
||||
next_run = now.replace(
|
||||
hour=hour, minute=minute, second=0, microsecond=0
|
||||
)
|
||||
if next_run <= now:
|
||||
next_run += timedelta(days=1)
|
||||
return next_run
|
||||
|
||||
if parts[0] == "weekly":
|
||||
if len(parts) < 3:
|
||||
raise ValueError(f"Invalid schedule: {schedule}")
|
||||
|
||||
target_day = _DAY_NAMES.index(parts[1])
|
||||
hour, minute = map(int, parts[2].split(":"))
|
||||
|
||||
days_ahead = target_day - now.weekday()
|
||||
if days_ahead <= 0:
|
||||
days_ahead += 7
|
||||
|
||||
next_run = now + timedelta(days=days_ahead)
|
||||
next_run = next_run.replace(
|
||||
hour=hour, minute=minute, second=0, microsecond=0
|
||||
)
|
||||
return next_run
|
||||
|
||||
raise ValueError(f"Unknown schedule format: {schedule}")
|
||||
|
||||
def add_adapter(self, platform: str, adapter: Any) -> None:
|
||||
"""Register an adapter for sending task outputs."""
|
||||
self.adapters[platform] = adapter
|
||||
print(f"[Scheduler] Registered adapter: {platform}")
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the scheduler in a background thread."""
|
||||
if self.running:
|
||||
return
|
||||
|
||||
self.running = True
|
||||
self.thread = threading.Thread(
|
||||
target=self._run_scheduler_loop, daemon=True
|
||||
)
|
||||
self.thread.start()
|
||||
|
||||
print(f"[Scheduler] Started with {len(self.tasks)} task(s)")
|
||||
for task in self.tasks:
|
||||
if task.enabled and task.next_run:
|
||||
formatted = task.next_run.strftime("%Y-%m-%d %H:%M")
|
||||
print(
|
||||
f" - {task.name}: next run at {formatted}"
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the scheduler."""
|
||||
self.running = False
|
||||
if self.thread:
|
||||
self.thread.join()
|
||||
print("[Scheduler] Stopped")
|
||||
|
||||
def _run_scheduler_loop(self) -> None:
|
||||
"""Main scheduler loop (runs in background thread)."""
|
||||
while self.running:
|
||||
try:
|
||||
now = datetime.now()
|
||||
|
||||
for task in self.tasks:
|
||||
if not task.enabled:
|
||||
continue
|
||||
|
||||
if task.next_run and now >= task.next_run:
|
||||
print(
|
||||
f"[Scheduler] Executing task: {task.name}"
|
||||
)
|
||||
|
||||
threading.Thread(
|
||||
target=self._execute_task,
|
||||
args=(task,),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
task.last_run = now
|
||||
task.next_run = self._calculate_next_run(
|
||||
task.schedule
|
||||
)
|
||||
formatted = task.next_run.strftime(
|
||||
"%Y-%m-%d %H:%M"
|
||||
)
|
||||
print(
|
||||
f"[Scheduler] Next run for {task.name}: "
|
||||
f"{formatted}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Scheduler] Error in scheduler loop: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
threading.Event().wait(_SCHEDULER_POLL_INTERVAL)
|
||||
|
||||
def _execute_task(self, task: ScheduledTask) -> None:
|
||||
"""Execute a single task using the Agent."""
|
||||
try:
|
||||
print(f"[Scheduler] Running: {task.name}")
|
||||
|
||||
response = self.agent.chat(
|
||||
user_message=task.prompt,
|
||||
username=task.username,
|
||||
)
|
||||
|
||||
print(f"[Scheduler] Task completed: {task.name}")
|
||||
print(f" Response: {response[:100]}...")
|
||||
|
||||
if task.send_to_platform and task.send_to_channel:
|
||||
asyncio.run(self._send_to_platform(task, response))
|
||||
|
||||
if self.on_task_complete:
|
||||
self.on_task_complete(task, response)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Scheduler] Task failed: {task.name}")
|
||||
print(f" Error: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
async def _send_to_platform(
|
||||
self, task: ScheduledTask, response: str
|
||||
) -> None:
|
||||
"""Send task output to messaging platform."""
|
||||
adapter = self.adapters.get(task.send_to_platform)
|
||||
|
||||
if not adapter:
|
||||
print(
|
||||
f"[Scheduler] Adapter not found: "
|
||||
f"{task.send_to_platform}"
|
||||
)
|
||||
return
|
||||
|
||||
from adapters.base import OutboundMessage
|
||||
|
||||
message = OutboundMessage(
|
||||
platform=task.send_to_platform,
|
||||
channel_id=task.send_to_channel,
|
||||
text=f"**{task.name}**\n\n{response}",
|
||||
)
|
||||
|
||||
result = await adapter.send_message(message)
|
||||
|
||||
if result.get("success"):
|
||||
print(
|
||||
f"[Scheduler] Sent to "
|
||||
f"{task.send_to_platform}:{task.send_to_channel}"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"[Scheduler] Failed to send: {result.get('error')}"
|
||||
)
|
||||
|
||||
def list_tasks(self) -> List[Dict[str, Any]]:
|
||||
"""Get status of all tasks."""
|
||||
result = []
|
||||
for task in self.tasks:
|
||||
send_to = None
|
||||
if task.send_to_platform:
|
||||
send_to = (
|
||||
f"{task.send_to_platform}:{task.send_to_channel}"
|
||||
)
|
||||
result.append({
|
||||
"name": task.name,
|
||||
"schedule": task.schedule,
|
||||
"enabled": task.enabled,
|
||||
"next_run": (
|
||||
task.next_run.isoformat()
|
||||
if task.next_run
|
||||
else None
|
||||
),
|
||||
"last_run": (
|
||||
task.last_run.isoformat()
|
||||
if task.last_run
|
||||
else None
|
||||
),
|
||||
"send_to": send_to,
|
||||
})
|
||||
return result
|
||||
|
||||
def run_task_now(self, task_name: str) -> str:
|
||||
"""Manually trigger a task immediately."""
|
||||
task = next(
|
||||
(t for t in self.tasks if t.name == task_name), None
|
||||
)
|
||||
|
||||
if not task:
|
||||
return f"Task not found: {task_name}"
|
||||
|
||||
print(f"[Scheduler] Manual execution: {task_name}")
|
||||
self._execute_task(task)
|
||||
return f"Task '{task_name}' executed"
|
||||
|
||||
|
||||
def integrate_scheduler_with_runtime(
|
||||
runtime: Any,
|
||||
agent: Agent,
|
||||
config_file: Optional[str] = None,
|
||||
) -> TaskScheduler:
|
||||
"""
|
||||
Integrate scheduled tasks with the bot runtime.
|
||||
|
||||
Usage in bot_runner.py:
|
||||
scheduler = integrate_scheduler_with_runtime(runtime, agent)
|
||||
scheduler.start()
|
||||
"""
|
||||
scheduler = TaskScheduler(agent, config_file)
|
||||
|
||||
for adapter in runtime.registry.get_all():
|
||||
scheduler.add_adapter(adapter.platform_name, adapter)
|
||||
|
||||
return scheduler
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
agent = Agent(
|
||||
provider="claude", workspace_dir="./memory_workspace"
|
||||
)
|
||||
|
||||
scheduler = TaskScheduler(
|
||||
agent, config_file="config/scheduled_tasks.yaml"
|
||||
)
|
||||
|
||||
print("\nScheduled tasks:")
|
||||
for task_info in scheduler.list_tasks():
|
||||
print(
|
||||
f" {task_info['name']}: {task_info['schedule']} "
|
||||
f"(next: {task_info['next_run']})"
|
||||
)
|
||||
|
||||
if scheduler.tasks:
|
||||
test_task = scheduler.tasks[0]
|
||||
print(f"\nTesting task: {test_task.name}")
|
||||
scheduler.run_task_now(test_task.name)
|
||||
Reference in New Issue
Block a user