""" 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)