From a3ba41fce21e546c011bd830f816c0aaff16c7cd Mon Sep 17 00:00:00 2001 From: teknium1 Date: Mon, 2 Feb 2026 08:26:42 -0800 Subject: [PATCH] Implement cron job management system for scheduled tasks (similar to OpenAI's Pulse but the AI can also schedule jobs) - Introduced a new cron job system allowing users to schedule automated tasks via the CLI, supporting one-time reminders and recurring jobs. - Added commands for managing cron jobs: `/cron` to list jobs, `/cron add` to create new jobs, and `/cron remove` to delete jobs. - Implemented job storage in `~/.hermes/cron/jobs.json` with output saved to `~/.hermes/cron/output/{job_id}/{timestamp}.md`. - Enhanced the CLI and README documentation to include detailed usage instructions and examples for cron job management. - Integrated cron job tools into the hermes-cli toolset, ensuring they are only available in interactive CLI mode. - Added support for cron expression parsing with the `croniter` package, enabling flexible scheduling options. --- README.md | 72 ++++++++ TODO.md | 73 +++++--- cli.py | 167 ++++++++++++++++++ cron/__init__.py | 36 ++++ cron/jobs.py | 372 +++++++++++++++++++++++++++++++++++++++++ cron/scheduler.py | 188 +++++++++++++++++++++ model_tools.py | 109 +++++++++++- requirements.txt | 4 +- tools/__init__.py | 21 +++ tools/cronjob_tools.py | 341 +++++++++++++++++++++++++++++++++++++ toolsets.py | 36 ++++ 11 files changed, 1384 insertions(+), 35 deletions(-) create mode 100644 cron/__init__.py create mode 100644 cron/jobs.py create mode 100644 cron/scheduler.py create mode 100644 tools/cronjob_tools.py diff --git a/README.md b/README.md index 309c7c689..af2848371 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,77 @@ CONTEXT_COMPRESSION_MODEL=google/gemini-2.0-flash-001 ✅ Compressed: 20 → 9 messages (~45,000 tokens saved) ``` +## Scheduled Tasks (Cron Jobs) + +Hermes Agent can schedule automated tasks to run in the future - either one-time reminders or recurring jobs. + +### CLI Commands + +```bash +# List scheduled jobs +/cron + +# Add a one-shot reminder (runs once in 30 minutes) +/cron add 30m Remind me to check the build status + +# Add a recurring job (every 2 hours) +/cron add "every 2h" Check server status at 192.168.1.100 and report any issues + +# Add a cron expression (daily at 9am) +/cron add "0 9 * * *" Generate a morning briefing summarizing GitHub notifications + +# Remove a job +/cron remove abc123def456 +``` + +### Agent Self-Scheduling + +The agent can also schedule its own follow-up tasks using tools: + +```python +# Available when using hermes-cli toolset (default for CLI) +schedule_cronjob(prompt="...", schedule="30m", repeat=1) # One-shot +schedule_cronjob(prompt="...", schedule="every 2h") # Recurring +list_cronjobs() # View all jobs +remove_cronjob(job_id="...") # Cancel a job +``` + +**⚠️ Important:** Cronjobs run in **isolated sessions with NO prior context**. The prompt must be completely self-contained with all necessary information (file paths, URLs, server addresses, etc.). The future agent will not remember anything from the current conversation. + +### Schedule Formats + +| Format | Example | Description | +|--------|---------|-------------| +| Duration | `30m`, `2h`, `1d` | One-shot delay from now | +| Interval | `every 30m`, `every 2h` | Recurring at fixed intervals | +| Cron | `0 9 * * *` | Cron expression (requires `croniter`) | +| Timestamp | `2026-02-03T14:00` | One-shot at specific time | + +### Repeat Options + +| repeat | Behavior | +|--------|----------| +| (omitted) | One-shot schedules run once; intervals/cron run forever | +| `1` | Run once then auto-delete | +| `N` | Run N times then auto-delete | + +### Running the Cron Daemon + +Jobs are stored in `~/.hermes/cron/jobs.json` and executed by a scheduler: + +```bash +# Option 1: Built-in daemon (checks every 60 seconds) +python cli.py --cron-daemon + +# Option 2: System cron integration (run once per minute) +# Add to crontab: crontab -e +*/1 * * * * cd ~/hermes-agent && python cli.py --cron-tick-once >> ~/.hermes/cron/cron.log 2>&1 +``` + +### Job Output + +Job outputs are saved to `~/.hermes/cron/output/{job_id}/{timestamp}.md` for review. + ## Interactive CLI The CLI provides a rich interactive experience for working with the agent. @@ -357,6 +428,7 @@ The CLI provides a rich interactive experience for working with the agent. | `/history` | Show conversation history | | `/save` | Save current conversation to file | | `/config` | Show current configuration | +| `/cron` | Manage scheduled tasks (list, add, remove) | | `/quit` | Exit the CLI | ### Configuration diff --git a/TODO.md b/TODO.md index a94ae89f7..7a68afade 100644 --- a/TODO.md +++ b/TODO.md @@ -485,48 +485,67 @@ These items need to be addressed ASAP: --- -## 11. Scheduled Tasks / Cron Jobs ⏰ +## 11. Scheduled Tasks / Cron Jobs ⏰ ✅ COMPLETE **Problem:** Agent only runs on-demand. Some tasks benefit from scheduled execution (daily summaries, monitoring, reminders). -**Ideas:** -- [ ] **Cron-style scheduler** - Run agent turns on a schedule - - Store jobs in `~/.hermes/cron/jobs.json` - - Each job: `{ id, schedule, prompt, session_mode, delivery }` - - Uses APScheduler or similar Python library +**Solution Implemented:** + +- [x] **Cron-style scheduler** - Run agent turns on a schedule + - Jobs stored in `~/.hermes/cron/jobs.json` + - Each job: `{ id, name, prompt, schedule, repeat, enabled, next_run_at, ... }` + - Built-in scheduler daemon or system cron integration -- [ ] **Session modes:** - - `isolated` - Fresh session each run (no history, clean context) - - `main` - Append to main session (agent remembers previous scheduled runs) +- [x] **Schedule formats:** + - Duration: `30m`, `2h`, `1d` (one-shot delay) + - Interval: `every 30m`, `every 2h` (recurring) + - Cron expression: `0 9 * * *` (requires `croniter` package) + - ISO timestamp: `2026-02-03T14:00:00` (one-shot at specific time) + +- [x] **Repeat options:** + - `repeat=None` (or omit): One-shot schedules run once; intervals/cron run forever + - `repeat=1`: Run once then auto-delete + - `repeat=N`: Run exactly N times then auto-delete -- [ ] **Delivery options:** - - Write output to file (`~/.hermes/cron/output/{job_id}/{timestamp}.md`) - - Send to messaging channel (if integrations enabled) - - Both - -- [ ] **CLI interface:** +- [x] **CLI interface:** ```bash # List scheduled jobs - python cli.py --cron list + /cron + /cron list - # Add a job (runs daily at 9am) - python cli.py --cron add "Summarize my email inbox" --schedule "0 9 * * *" + # Add a one-shot job (runs once in 30 minutes) + /cron add 30m "Remind me to check the build status" - # Quick syntax for simple intervals - python cli.py --cron add "Check server status" --every 30m + # Add a recurring job (every 2 hours) + /cron add "every 2h" "Check server status at 192.168.1.100" + + # Add a cron expression (daily at 9am) + /cron add "0 9 * * *" "Generate morning briefing" # Remove a job - python cli.py --cron remove + /cron remove ``` -- [ ] **Agent self-scheduling** - Let the agent create its own cron jobs - - New tool: `schedule_task(prompt, schedule, session_mode)` - - "Remind me to check the deployment tomorrow at 9am" - - Agent can set follow-up tasks for itself +- [x] **Agent self-scheduling tools** (hermes-cli toolset): + - `schedule_cronjob(prompt, schedule, name?, repeat?)` - Create a scheduled task + - `list_cronjobs()` - View all scheduled jobs + - `remove_cronjob(job_id)` - Cancel a job + - Tool descriptions emphasize: **cronjobs run in isolated sessions with NO context** -- [ ] **In-chat command:** `/cronjob {prompt} {frequency}` when using messaging integrations +- [x] **Daemon modes:** + ```bash + # Built-in daemon (checks every 60 seconds) + python cli.py --cron-daemon + + # Single tick for system cron integration + python cli.py --cron-tick-once + ``` -**Files to create:** `cron/scheduler.py`, `cron/jobs.py`, `tools/schedule_tool.py` +- [x] **Output storage:** `~/.hermes/cron/output/{job_id}/{timestamp}.md` + +**Files created:** `cron/__init__.py`, `cron/jobs.py`, `cron/scheduler.py`, `tools/cronjob_tools.py` + +**Toolset:** `hermes-cli` (default for CLI) includes cronjob tools; not in batch runner toolsets --- diff --git a/cli.py b/cli.py index d73e10112..210e069df 100755 --- a/cli.py +++ b/cli.py @@ -192,6 +192,9 @@ from run_agent import AIAgent from model_tools import get_tool_definitions, get_all_tool_names, get_toolset_for_tool, get_available_toolsets from toolsets import get_all_toolsets, get_toolset_info, resolve_toolset, validate_toolset +# Cron job system for scheduled tasks +from cron import create_job, list_jobs, remove_job, get_job, run_daemon as run_cron_daemon, tick as cron_tick + # ============================================================================ # ASCII Art & Branding # ============================================================================ @@ -402,6 +405,7 @@ COMMANDS = { "/reset": "Reset conversation only (keep screen)", "/save": "Save the current conversation", "/config": "Show current configuration", + "/cron": "Manage scheduled tasks (list, add, remove)", "/quit": "Exit the CLI (also: /exit, /q)", } @@ -878,6 +882,142 @@ class HermesCLI: print(" Usage: /personality ") print() + def _handle_cron_command(self, cmd: str): + """Handle the /cron command to manage scheduled tasks.""" + parts = cmd.split(maxsplit=2) + + if len(parts) == 1: + # /cron - show help and list + print() + print("+" + "-" * 60 + "+") + print("|" + " " * 18 + "(^_^) Scheduled Tasks" + " " * 19 + "|") + print("+" + "-" * 60 + "+") + print() + print(" Commands:") + print(" /cron - List scheduled jobs") + print(" /cron list - List scheduled jobs") + print(' /cron add - Add a new job') + print(" /cron remove - Remove a job") + print() + print(" Schedule formats:") + print(" 30m, 2h, 1d - One-shot delay") + print(' "every 30m", "every 2h" - Recurring interval') + print(' "0 9 * * *" - Cron expression') + print() + + # Show current jobs + jobs = list_jobs() + if jobs: + print(" Current Jobs:") + print(" " + "-" * 55) + for job in jobs: + # Format repeat status + times = job["repeat"].get("times") + completed = job["repeat"].get("completed", 0) + if times is None: + repeat_str = "forever" + else: + repeat_str = f"{completed}/{times}" + + print(f" {job['id'][:12]:<12} | {job['schedule_display']:<15} | {repeat_str:<8}") + prompt_preview = job['prompt'][:45] + "..." if len(job['prompt']) > 45 else job['prompt'] + print(f" {prompt_preview}") + if job.get("next_run_at"): + from datetime import datetime + next_run = datetime.fromisoformat(job["next_run_at"]) + print(f" Next: {next_run.strftime('%Y-%m-%d %H:%M')}") + print() + else: + print(" No scheduled jobs. Use '/cron add' to create one.") + print() + return + + subcommand = parts[1].lower() + + if subcommand == "list": + # /cron list - just show jobs + jobs = list_jobs() + if not jobs: + print("(._.) No scheduled jobs.") + return + + print() + print("Scheduled Jobs:") + print("-" * 70) + for job in jobs: + times = job["repeat"].get("times") + completed = job["repeat"].get("completed", 0) + repeat_str = "forever" if times is None else f"{completed}/{times}" + + print(f" ID: {job['id']}") + print(f" Name: {job['name']}") + print(f" Schedule: {job['schedule_display']} ({repeat_str})") + print(f" Next run: {job.get('next_run_at', 'N/A')}") + print(f" Prompt: {job['prompt'][:80]}{'...' if len(job['prompt']) > 80 else ''}") + if job.get("last_run_at"): + print(f" Last run: {job['last_run_at']} ({job.get('last_status', '?')})") + print() + + elif subcommand == "add": + # /cron add + if len(parts) < 3: + print("(._.) Usage: /cron add ") + print(" Example: /cron add 30m Remind me to take a break") + print(' Example: /cron add "every 2h" Check server status at 192.168.1.1') + return + + # Parse schedule and prompt + rest = parts[2].strip() + + # Handle quoted schedule (e.g., "every 30m" or "0 9 * * *") + if rest.startswith('"'): + # Find closing quote + close_quote = rest.find('"', 1) + if close_quote == -1: + print("(._.) Unmatched quote in schedule") + return + schedule = rest[1:close_quote] + prompt = rest[close_quote + 1:].strip() + else: + # First word is schedule + schedule_parts = rest.split(maxsplit=1) + schedule = schedule_parts[0] + prompt = schedule_parts[1] if len(schedule_parts) > 1 else "" + + if not prompt: + print("(._.) Please provide a prompt for the job") + return + + try: + job = create_job(prompt=prompt, schedule=schedule) + print(f"(^_^)b Created job: {job['id']}") + print(f" Schedule: {job['schedule_display']}") + print(f" Next run: {job['next_run_at']}") + except Exception as e: + print(f"(x_x) Failed to create job: {e}") + + elif subcommand == "remove" or subcommand == "rm" or subcommand == "delete": + # /cron remove + if len(parts) < 3: + print("(._.) Usage: /cron remove ") + return + + job_id = parts[2].strip() + job = get_job(job_id) + + if not job: + print(f"(._.) Job not found: {job_id}") + return + + if remove_job(job_id): + print(f"(^_^)b Removed job: {job['name']} ({job_id})") + else: + print(f"(x_x) Failed to remove job: {job_id}") + + else: + print(f"(._.) Unknown cron command: {subcommand}") + print(" Available: list, add, remove") + def process_command(self, command: str) -> bool: """ Process a slash command. @@ -933,6 +1073,8 @@ class HermesCLI: self._handle_personality_command(cmd) elif cmd == "/save": self.save_conversation() + elif cmd.startswith("/cron"): + self._handle_cron_command(command) # Use original command for proper parsing else: self.console.print(f"[bold red]Unknown command: {cmd}[/]") self.console.print("[dim #B8860B]Type /help for available commands[/]") @@ -1072,6 +1214,8 @@ def main( compact: bool = False, list_tools: bool = False, list_toolsets: bool = False, + cron_daemon: bool = False, + cron_tick_once: bool = False, ): """ Hermes Agent CLI - Interactive AI Assistant @@ -1088,21 +1232,41 @@ def main( compact: Use compact display mode list_tools: List available tools and exit list_toolsets: List available toolsets and exit + cron_daemon: Run as cron daemon (check and execute due jobs continuously) + cron_tick_once: Run due cron jobs once and exit (for system cron integration) Examples: python cli.py # Start interactive mode python cli.py --toolsets web,terminal # Use specific toolsets python cli.py -q "What is Python?" # Single query mode python cli.py --list-tools # List tools and exit + python cli.py --cron-daemon # Run cron scheduler daemon + python cli.py --cron-tick-once # Check and run due jobs once """ # Signal to terminal_tool that we're in interactive mode # This enables interactive sudo password prompts with timeout os.environ["HERMES_INTERACTIVE"] = "1" + # Handle cron daemon mode (runs before CLI initialization) + if cron_daemon: + print("Starting Hermes Cron Daemon...") + print("Jobs will be checked every 60 seconds.") + print("Press Ctrl+C to stop.\n") + run_cron_daemon(check_interval=60, verbose=True) + return + + # Handle cron tick (single run for system cron integration) + if cron_tick_once: + jobs_run = cron_tick(verbose=True) + if jobs_run: + print(f"Executed {jobs_run} job(s)") + return + # Handle query shorthand query = query or q # Parse toolsets - handle both string and tuple/list inputs + # Default to hermes-cli toolset which includes cronjob management tools toolsets_list = None if toolsets: if isinstance(toolsets, str): @@ -1115,6 +1279,9 @@ def main( toolsets_list.extend([x.strip() for x in t.split(",")]) else: toolsets_list.append(str(t)) + else: + # Default: use hermes-cli toolset for full CLI functionality including cronjob tools + toolsets_list = ["hermes-cli"] # Create CLI instance cli = HermesCLI( diff --git a/cron/__init__.py b/cron/__init__.py new file mode 100644 index 000000000..446187c7b --- /dev/null +++ b/cron/__init__.py @@ -0,0 +1,36 @@ +""" +Cron job scheduling system for Hermes Agent. + +This module provides scheduled task execution, allowing the agent to: +- Run automated tasks on schedules (cron expressions, intervals, one-shot) +- Self-schedule reminders and follow-up tasks +- Execute tasks in isolated sessions (no prior context) + +Usage: + # Run due jobs (for system cron integration) + python -c "from cron import tick; tick()" + + # Or via CLI + python cli.py --cron-daemon +""" + +from cron.jobs import ( + create_job, + get_job, + list_jobs, + remove_job, + update_job, + JOBS_FILE, +) +from cron.scheduler import tick, run_daemon + +__all__ = [ + "create_job", + "get_job", + "list_jobs", + "remove_job", + "update_job", + "tick", + "run_daemon", + "JOBS_FILE", +] diff --git a/cron/jobs.py b/cron/jobs.py new file mode 100644 index 000000000..9f7ff47c0 --- /dev/null +++ b/cron/jobs.py @@ -0,0 +1,372 @@ +""" +Cron job storage and management. + +Jobs are stored in ~/.hermes/cron/jobs.json +Output is saved to ~/.hermes/cron/output/{job_id}/{timestamp}.md +""" + +import json +import os +import re +import uuid +from datetime import datetime, timedelta +from pathlib import Path +from typing import Optional, Dict, List, Any + +try: + from croniter import croniter + HAS_CRONITER = True +except ImportError: + HAS_CRONITER = False + +# ============================================================================= +# Configuration +# ============================================================================= + +HERMES_DIR = Path.home() / ".hermes" +CRON_DIR = HERMES_DIR / "cron" +JOBS_FILE = CRON_DIR / "jobs.json" +OUTPUT_DIR = CRON_DIR / "output" + + +def ensure_dirs(): + """Ensure cron directories exist.""" + CRON_DIR.mkdir(parents=True, exist_ok=True) + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + +# ============================================================================= +# Schedule Parsing +# ============================================================================= + +def parse_duration(s: str) -> int: + """ + Parse duration string into minutes. + + Examples: + "30m" → 30 + "2h" → 120 + "1d" → 1440 + """ + s = s.strip().lower() + match = re.match(r'^(\d+)\s*(m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)$', s) + if not match: + raise ValueError(f"Invalid duration: '{s}'. Use format like '30m', '2h', or '1d'") + + value = int(match.group(1)) + unit = match.group(2)[0] # First char: m, h, or d + + multipliers = {'m': 1, 'h': 60, 'd': 1440} + return value * multipliers[unit] + + +def parse_schedule(schedule: str) -> Dict[str, Any]: + """ + Parse schedule string into structured format. + + Returns dict with: + - kind: "once" | "interval" | "cron" + - For "once": "run_at" (ISO timestamp) + - For "interval": "minutes" (int) + - For "cron": "expr" (cron expression) + + Examples: + "30m" → once in 30 minutes + "2h" → once in 2 hours + "every 30m" → recurring every 30 minutes + "every 2h" → recurring every 2 hours + "0 9 * * *" → cron expression + "2026-02-03T14:00" → once at timestamp + """ + schedule = schedule.strip() + original = schedule + schedule_lower = schedule.lower() + + # "every X" pattern → recurring interval + if schedule_lower.startswith("every "): + duration_str = schedule[6:].strip() + minutes = parse_duration(duration_str) + return { + "kind": "interval", + "minutes": minutes, + "display": f"every {minutes}m" + } + + # Check for cron expression (5 or 6 space-separated fields) + # Cron fields: minute hour day month weekday [year] + parts = schedule.split() + if len(parts) >= 5 and all( + re.match(r'^[\d\*\-,/]+$', p) for p in parts[:5] + ): + if not HAS_CRONITER: + raise ValueError("Cron expressions require 'croniter' package. Install with: pip install croniter") + # Validate cron expression + try: + croniter(schedule) + except Exception as e: + raise ValueError(f"Invalid cron expression '{schedule}': {e}") + return { + "kind": "cron", + "expr": schedule, + "display": schedule + } + + # ISO timestamp (contains T or looks like date) + if 'T' in schedule or re.match(r'^\d{4}-\d{2}-\d{2}', schedule): + try: + # Parse and validate + dt = datetime.fromisoformat(schedule.replace('Z', '+00:00')) + return { + "kind": "once", + "run_at": dt.isoformat(), + "display": f"once at {dt.strftime('%Y-%m-%d %H:%M')}" + } + except ValueError as e: + raise ValueError(f"Invalid timestamp '{schedule}': {e}") + + # Duration like "30m", "2h", "1d" → one-shot from now + try: + minutes = parse_duration(schedule) + run_at = datetime.now() + timedelta(minutes=minutes) + return { + "kind": "once", + "run_at": run_at.isoformat(), + "display": f"once in {original}" + } + except ValueError: + pass + + raise ValueError( + f"Invalid schedule '{original}'. Use:\n" + f" - Duration: '30m', '2h', '1d' (one-shot)\n" + f" - Interval: 'every 30m', 'every 2h' (recurring)\n" + f" - Cron: '0 9 * * *' (cron expression)\n" + f" - Timestamp: '2026-02-03T14:00:00' (one-shot at time)" + ) + + +def compute_next_run(schedule: Dict[str, Any], last_run_at: Optional[str] = None) -> Optional[str]: + """ + Compute the next run time for a schedule. + + Returns ISO timestamp string, or None if no more runs. + """ + now = datetime.now() + + if schedule["kind"] == "once": + run_at = datetime.fromisoformat(schedule["run_at"]) + # If in the future, return it; if in the past, no more runs + return schedule["run_at"] if run_at > now else None + + elif schedule["kind"] == "interval": + minutes = schedule["minutes"] + if last_run_at: + # Next run is last_run + interval + last = datetime.fromisoformat(last_run_at) + next_run = last + timedelta(minutes=minutes) + else: + # First run is now + interval + next_run = now + timedelta(minutes=minutes) + return next_run.isoformat() + + elif schedule["kind"] == "cron": + if not HAS_CRONITER: + return None + cron = croniter(schedule["expr"], now) + next_run = cron.get_next(datetime) + return next_run.isoformat() + + return None + + +# ============================================================================= +# Job CRUD Operations +# ============================================================================= + +def load_jobs() -> List[Dict[str, Any]]: + """Load all jobs from storage.""" + ensure_dirs() + if not JOBS_FILE.exists(): + return [] + + try: + with open(JOBS_FILE, 'r', encoding='utf-8') as f: + data = json.load(f) + return data.get("jobs", []) + except (json.JSONDecodeError, IOError): + return [] + + +def save_jobs(jobs: List[Dict[str, Any]]): + """Save all jobs to storage.""" + ensure_dirs() + with open(JOBS_FILE, 'w', encoding='utf-8') as f: + json.dump({"jobs": jobs, "updated_at": datetime.now().isoformat()}, f, indent=2) + + +def create_job( + prompt: str, + schedule: str, + name: Optional[str] = None, + repeat: Optional[int] = None +) -> Dict[str, Any]: + """ + Create a new cron job. + + Args: + prompt: The prompt to run (must be self-contained) + schedule: Schedule string (see parse_schedule) + name: Optional friendly name + repeat: How many times to run (None = forever, 1 = once) + + Returns: + The created job dict + """ + parsed_schedule = parse_schedule(schedule) + + # Auto-set repeat=1 for one-shot schedules if not specified + if parsed_schedule["kind"] == "once" and repeat is None: + repeat = 1 + + job_id = uuid.uuid4().hex[:12] + now = datetime.now().isoformat() + + job = { + "id": job_id, + "name": name or prompt[:50].strip(), + "prompt": prompt, + "schedule": parsed_schedule, + "schedule_display": parsed_schedule.get("display", schedule), + "repeat": { + "times": repeat, # None = forever + "completed": 0 + }, + "enabled": True, + "created_at": now, + "next_run_at": compute_next_run(parsed_schedule), + "last_run_at": None, + "last_status": None, + "last_error": None + } + + jobs = load_jobs() + jobs.append(job) + save_jobs(jobs) + + return job + + +def get_job(job_id: str) -> Optional[Dict[str, Any]]: + """Get a job by ID.""" + jobs = load_jobs() + for job in jobs: + if job["id"] == job_id: + return job + return None + + +def list_jobs(include_disabled: bool = False) -> List[Dict[str, Any]]: + """List all jobs, optionally including disabled ones.""" + jobs = load_jobs() + if not include_disabled: + jobs = [j for j in jobs if j.get("enabled", True)] + return jobs + + +def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Update a job by ID.""" + jobs = load_jobs() + for i, job in enumerate(jobs): + if job["id"] == job_id: + jobs[i] = {**job, **updates} + save_jobs(jobs) + return jobs[i] + return None + + +def remove_job(job_id: str) -> bool: + """Remove a job by ID.""" + jobs = load_jobs() + original_len = len(jobs) + jobs = [j for j in jobs if j["id"] != job_id] + if len(jobs) < original_len: + save_jobs(jobs) + return True + return False + + +def mark_job_run(job_id: str, success: bool, error: Optional[str] = None): + """ + Mark a job as having been run. + + Updates last_run_at, last_status, increments completed count, + computes next_run_at, and auto-deletes if repeat limit reached. + """ + jobs = load_jobs() + for i, job in enumerate(jobs): + if job["id"] == job_id: + now = datetime.now().isoformat() + job["last_run_at"] = now + job["last_status"] = "ok" if success else "error" + job["last_error"] = error if not success else None + + # Increment completed count + if job.get("repeat"): + job["repeat"]["completed"] = job["repeat"].get("completed", 0) + 1 + + # Check if we've hit the repeat limit + times = job["repeat"].get("times") + completed = job["repeat"]["completed"] + if times is not None and completed >= times: + # Remove the job (limit reached) + jobs.pop(i) + save_jobs(jobs) + return + + # Compute next run + job["next_run_at"] = compute_next_run(job["schedule"], now) + + # If no next run (one-shot completed), disable + if job["next_run_at"] is None: + job["enabled"] = False + + save_jobs(jobs) + return + + save_jobs(jobs) + + +def get_due_jobs() -> List[Dict[str, Any]]: + """Get all jobs that are due to run now.""" + now = datetime.now() + jobs = load_jobs() + due = [] + + for job in jobs: + if not job.get("enabled", True): + continue + + next_run = job.get("next_run_at") + if not next_run: + continue + + next_run_dt = datetime.fromisoformat(next_run) + if next_run_dt <= now: + due.append(job) + + return due + + +def save_job_output(job_id: str, output: str): + """Save job output to file.""" + ensure_dirs() + job_output_dir = OUTPUT_DIR / job_id + job_output_dir.mkdir(parents=True, exist_ok=True) + + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + output_file = job_output_dir / f"{timestamp}.md" + + with open(output_file, 'w', encoding='utf-8') as f: + f.write(output) + + return output_file diff --git a/cron/scheduler.py b/cron/scheduler.py new file mode 100644 index 000000000..ea8f1c40e --- /dev/null +++ b/cron/scheduler.py @@ -0,0 +1,188 @@ +""" +Cron job scheduler - executes due jobs. + +This module provides: +- tick(): Run all due jobs once (for system cron integration) +- run_daemon(): Run continuously, checking every 60 seconds +""" + +import os +import sys +import time +import traceback +from datetime import datetime +from pathlib import Path +from typing import Optional + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from cron.jobs import get_due_jobs, mark_job_run, save_job_output + + +def run_job(job: dict) -> tuple[bool, str, Optional[str]]: + """ + Execute a single cron job. + + Returns: + Tuple of (success, output, error_message) + """ + from run_agent import AIAgent + + job_id = job["id"] + job_name = job["name"] + prompt = job["prompt"] + + print(f"[cron] Running job '{job_name}' (ID: {job_id})") + print(f"[cron] Prompt: {prompt[:100]}{'...' if len(prompt) > 100 else ''}") + + try: + # Create agent with default settings + # Jobs run in isolated sessions (no prior context) + agent = AIAgent( + model=os.getenv("HERMES_MODEL", "anthropic/claude-sonnet-4"), + quiet_mode=True, + session_id=f"cron_{job_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + ) + + # Run the conversation + result = agent.run_conversation(prompt) + + # Extract final response + final_response = result.get("final_response", "") + if not final_response: + final_response = "(No response generated)" + + # Build output document + output = f"""# Cron Job: {job_name} + +**Job ID:** {job_id} +**Run Time:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} +**Schedule:** {job.get('schedule_display', 'N/A')} + +## Prompt + +{prompt} + +## Response + +{final_response} +""" + + print(f"[cron] Job '{job_name}' completed successfully") + return True, output, None + + except Exception as e: + error_msg = f"{type(e).__name__}: {str(e)}" + print(f"[cron] Job '{job_name}' failed: {error_msg}") + + # Build error output + output = f"""# Cron Job: {job_name} (FAILED) + +**Job ID:** {job_id} +**Run Time:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} +**Schedule:** {job.get('schedule_display', 'N/A')} + +## Prompt + +{prompt} + +## Error + +``` +{error_msg} + +{traceback.format_exc()} +``` +""" + return False, output, error_msg + + +def tick(verbose: bool = True) -> int: + """ + Check and run all due jobs. + + This is designed to be called by system cron every minute: + */1 * * * * cd ~/hermes-agent && python -c "from cron import tick; tick()" + + Args: + verbose: Whether to print status messages + + Returns: + Number of jobs executed + """ + due_jobs = get_due_jobs() + + if verbose and not due_jobs: + print(f"[cron] {datetime.now().strftime('%H:%M:%S')} - No jobs due") + return 0 + + if verbose: + print(f"[cron] {datetime.now().strftime('%H:%M:%S')} - {len(due_jobs)} job(s) due") + + executed = 0 + for job in due_jobs: + try: + success, output, error = run_job(job) + + # Save output to file + output_file = save_job_output(job["id"], output) + if verbose: + print(f"[cron] Output saved to: {output_file}") + + # Mark job as run (handles repeat counting, next_run computation) + mark_job_run(job["id"], success, error) + executed += 1 + + except Exception as e: + print(f"[cron] Error processing job {job['id']}: {e}") + mark_job_run(job["id"], False, str(e)) + + return executed + + +def run_daemon(check_interval: int = 60, verbose: bool = True): + """ + Run the cron daemon continuously. + + Checks for due jobs every `check_interval` seconds. + + Args: + check_interval: Seconds between checks (default: 60) + verbose: Whether to print status messages + """ + print(f"[cron] Starting daemon (checking every {check_interval}s)") + print(f"[cron] Press Ctrl+C to stop") + print() + + try: + while True: + try: + tick(verbose=verbose) + except Exception as e: + print(f"[cron] Tick error: {e}") + + time.sleep(check_interval) + + except KeyboardInterrupt: + print("\n[cron] Daemon stopped") + + +if __name__ == "__main__": + # Allow running directly: python cron/scheduler.py [daemon|tick] + import argparse + + parser = argparse.ArgumentParser(description="Hermes Cron Scheduler") + parser.add_argument("mode", choices=["daemon", "tick"], default="tick", nargs="?", + help="Mode: 'tick' to run once, 'daemon' to run continuously") + parser.add_argument("--interval", type=int, default=60, + help="Check interval in seconds for daemon mode") + parser.add_argument("--quiet", "-q", action="store_true", + help="Suppress status messages") + + args = parser.parse_args() + + if args.mode == "daemon": + run_daemon(check_interval=args.interval, verbose=not args.quiet) + else: + tick(verbose=not args.quiet) diff --git a/model_tools.py b/model_tools.py index 7f752318a..9878951d3 100644 --- a/model_tools.py +++ b/model_tools.py @@ -38,6 +38,17 @@ from tools.vision_tools import vision_analyze_tool, check_vision_requirements from tools.mixture_of_agents_tool import mixture_of_agents_tool, check_moa_requirements from tools.image_generation_tool import image_generate_tool, check_image_generation_requirements from tools.skills_tool import skills_categories, skills_list, skill_view, check_skills_requirements, SKILLS_TOOL_DESCRIPTION +# Cronjob management tools (CLI-only) +from tools.cronjob_tools import ( + schedule_cronjob, + list_cronjobs, + remove_cronjob, + check_cronjob_requirements, + get_cronjob_tool_definitions, + SCHEDULE_CRONJOB_SCHEMA, + LIST_CRONJOBS_SCHEMA, + REMOVE_CRONJOB_SCHEMA +) # Browser automation tools (agent-browser + Browserbase) from tools.browser_tool import ( browser_navigate, @@ -313,6 +324,22 @@ def get_browser_tool_definitions() -> List[Dict[str, Any]]: return [{"type": "function", "function": schema} for schema in BROWSER_TOOL_SCHEMAS] +def get_cronjob_tool_definitions_formatted() -> List[Dict[str, Any]]: + """ + Get tool definitions for cronjob management tools in OpenAI's expected format. + + These tools are only available in the hermes-cli toolset (interactive CLI mode). + + Returns: + List[Dict]: List of cronjob tool definitions compatible with OpenAI API + """ + return [{"type": "function", "function": schema} for schema in [ + SCHEDULE_CRONJOB_SCHEMA, + LIST_CRONJOBS_SCHEMA, + REMOVE_CRONJOB_SCHEMA + ]] + + def get_all_tool_names() -> List[str]: """ Get the names of all available tools across all toolsets. @@ -355,6 +382,12 @@ def get_all_tool_names() -> List[str]: "browser_vision" ]) + # Cronjob management tools (CLI-only, checked at runtime) + if check_cronjob_requirements(): + tool_names.extend([ + "schedule_cronjob", "list_cronjobs", "remove_cronjob" + ]) + return tool_names @@ -389,7 +422,11 @@ def get_toolset_for_tool(tool_name: str) -> str: "browser_press": "browser_tools", "browser_close": "browser_tools", "browser_get_images": "browser_tools", - "browser_vision": "browser_tools" + "browser_vision": "browser_tools", + # Cronjob management tools + "schedule_cronjob": "cronjob_tools", + "list_cronjobs": "cronjob_tools", + "remove_cronjob": "cronjob_tools" } return toolset_mapping.get(tool_name, "unknown") @@ -462,6 +499,11 @@ def get_tool_definitions( for tool in get_browser_tool_definitions(): all_available_tools_map[tool["function"]["name"]] = tool + # Cronjob management tools (CLI-only) + if check_cronjob_requirements(): + for tool in get_cronjob_tool_definitions_formatted(): + all_available_tools_map[tool["function"]["name"]] = tool + # Determine which tools to include based on toolsets tools_to_include = set() @@ -474,7 +516,7 @@ def get_tool_definitions( print(f"✅ Enabled toolset '{toolset_name}': {', '.join(resolved_tools) if resolved_tools else 'no tools'}") else: # Try legacy compatibility - if toolset_name in ["web_tools", "terminal_tools", "vision_tools", "moa_tools", "image_tools", "skills_tools", "browser_tools"]: + if toolset_name in ["web_tools", "terminal_tools", "vision_tools", "moa_tools", "image_tools", "skills_tools", "browser_tools", "cronjob_tools"]: # Map legacy names to new system legacy_map = { "web_tools": ["web_search", "web_extract"], @@ -488,7 +530,8 @@ def get_tool_definitions( "browser_type", "browser_scroll", "browser_back", "browser_press", "browser_close", "browser_get_images", "browser_vision" - ] + ], + "cronjob_tools": ["schedule_cronjob", "list_cronjobs", "remove_cronjob"] } legacy_tools = legacy_map.get(toolset_name, []) tools_to_include.update(legacy_tools) @@ -516,7 +559,7 @@ def get_tool_definitions( print(f"🚫 Disabled toolset '{toolset_name}': {', '.join(resolved_tools) if resolved_tools else 'no tools'}") else: # Try legacy compatibility - if toolset_name in ["web_tools", "terminal_tools", "vision_tools", "moa_tools", "image_tools", "skills_tools", "browser_tools"]: + if toolset_name in ["web_tools", "terminal_tools", "vision_tools", "moa_tools", "image_tools", "skills_tools", "browser_tools", "cronjob_tools"]: legacy_map = { "web_tools": ["web_search", "web_extract"], "terminal_tools": ["terminal"], @@ -529,7 +572,8 @@ def get_tool_definitions( "browser_type", "browser_scroll", "browser_back", "browser_press", "browser_close", "browser_get_images", "browser_vision" - ] + ], + "cronjob_tools": ["schedule_cronjob", "list_cronjobs", "remove_cronjob"] } legacy_tools = legacy_map.get(toolset_name, []) tools_to_include.difference_update(legacy_tools) @@ -792,6 +836,48 @@ def handle_browser_function_call( return json.dumps({"error": f"Unknown browser function: {function_name}"}, ensure_ascii=False) +def handle_cronjob_function_call( + function_name: str, + function_args: Dict[str, Any], + task_id: Optional[str] = None +) -> str: + """ + Handle function calls for cronjob management tools. + + These tools are only available in interactive CLI mode (hermes-cli toolset). + + Args: + function_name (str): Name of the cronjob function to call + function_args (Dict): Arguments for the function + task_id (str): Task identifier (unused, for API consistency) + + Returns: + str: Function result as JSON string + """ + if function_name == "schedule_cronjob": + return schedule_cronjob( + prompt=function_args.get("prompt", ""), + schedule=function_args.get("schedule", ""), + name=function_args.get("name"), + repeat=function_args.get("repeat"), + task_id=task_id + ) + + elif function_name == "list_cronjobs": + return list_cronjobs( + include_disabled=function_args.get("include_disabled", False), + task_id=task_id + ) + + elif function_name == "remove_cronjob": + return remove_cronjob( + job_id=function_args.get("job_id", ""), + task_id=task_id + ) + + return json.dumps({"error": f"Unknown cronjob function: {function_name}"}, ensure_ascii=False) + + def handle_function_call( function_name: str, function_args: Dict[str, Any], @@ -851,6 +937,10 @@ def handle_function_call( ]: return handle_browser_function_call(function_name, function_args, task_id, user_task) + # Route cronjob management tools + elif function_name in ["schedule_cronjob", "list_cronjobs", "remove_cronjob"]: + return handle_cronjob_function_call(function_name, function_args, task_id) + else: error_msg = f"Unknown function: {function_name}" print(f"❌ {error_msg}") @@ -916,6 +1006,12 @@ def get_available_toolsets() -> Dict[str, Dict[str, Any]]: ], "description": "Browser automation for web interaction using agent-browser CLI with Browserbase cloud execution", "requirements": ["BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID", "agent-browser npm package"] + }, + "cronjob_tools": { + "available": check_cronjob_requirements(), + "tools": ["schedule_cronjob", "list_cronjobs", "remove_cronjob"], + "description": "Schedule and manage automated tasks (cronjobs) - only available in interactive CLI mode", + "requirements": ["HERMES_INTERACTIVE=1 (set automatically by cli.py)"] } } @@ -935,7 +1031,8 @@ def check_toolset_requirements() -> Dict[str, bool]: "moa_tools": check_moa_requirements(), "image_tools": check_image_generation_requirements(), "skills_tools": check_skills_requirements(), - "browser_tools": check_browser_requirements() + "browser_tools": check_browser_requirements(), + "cronjob_tools": check_cronjob_requirements() } if __name__ == "__main__": diff --git a/requirements.txt b/requirements.txt index 828aeaba2..4bc28b6db 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,5 +30,5 @@ platformdirs # modal # boto3 -# Optional: Legacy Hecate terminal backend -# git+ssh://git@github.com/NousResearch/hecate.git +# Optional: For cron expression parsing (cronjob scheduling) +croniter \ No newline at end of file diff --git a/tools/__init__.py b/tools/__init__.py index 8d2ee3b40..3365dab44 100644 --- a/tools/__init__.py +++ b/tools/__init__.py @@ -83,6 +83,18 @@ from .browser_tool import ( BROWSER_TOOL_SCHEMAS ) +# Cronjob management tools (CLI-only, hermes-cli toolset) +from .cronjob_tools import ( + schedule_cronjob, + list_cronjobs, + remove_cronjob, + check_cronjob_requirements, + get_cronjob_tool_definitions, + SCHEDULE_CRONJOB_SCHEMA, + LIST_CRONJOBS_SCHEMA, + REMOVE_CRONJOB_SCHEMA +) + __all__ = [ # Web tools 'web_search_tool', @@ -131,5 +143,14 @@ __all__ = [ 'get_active_browser_sessions', 'check_browser_requirements', 'BROWSER_TOOL_SCHEMAS', + # Cronjob management tools (CLI-only) + 'schedule_cronjob', + 'list_cronjobs', + 'remove_cronjob', + 'check_cronjob_requirements', + 'get_cronjob_tool_definitions', + 'SCHEDULE_CRONJOB_SCHEMA', + 'LIST_CRONJOBS_SCHEMA', + 'REMOVE_CRONJOB_SCHEMA', ] diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py new file mode 100644 index 000000000..f5573082d --- /dev/null +++ b/tools/cronjob_tools.py @@ -0,0 +1,341 @@ +""" +Cron job management tools for Hermes Agent. + +These tools allow the agent to schedule, list, and remove automated tasks. +Only available when running via CLI (hermes-cli toolset). + +IMPORTANT: Cronjobs run in isolated sessions with NO prior context. +The prompt must contain ALL necessary information. +""" + +import json +import os +from typing import Optional + +# Import from cron module (will be available when properly installed) +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from cron.jobs import create_job, get_job, list_jobs, remove_job + + +# ============================================================================= +# Tool: schedule_cronjob +# ============================================================================= + +def schedule_cronjob( + prompt: str, + schedule: str, + name: Optional[str] = None, + repeat: Optional[int] = None, + task_id: str = None +) -> str: + """ + Schedule an automated task to run the agent on a schedule. + + IMPORTANT: When the cronjob runs, it starts a COMPLETELY FRESH session. + The agent will have NO memory of this conversation or any prior context. + Therefore, the prompt MUST contain ALL necessary information: + - Full context of what needs to be done + - Specific file paths, URLs, or identifiers + - Clear success criteria + - Any relevant background information + + BAD prompt: "Check on that server issue" + GOOD prompt: "SSH into server 192.168.1.100 as user 'deploy', check if nginx + is running with 'systemctl status nginx', and verify the site + https://example.com returns HTTP 200. Report any issues found." + + Args: + prompt: Complete, self-contained instructions for the future agent. + Must include ALL context needed - the agent won't remember anything. + schedule: When to run. Either: + - Duration for one-shot: "30m", "2h", "1d" (runs once) + - Interval: "every 30m", "every 2h" (recurring) + - Cron expression: "0 9 * * *" (daily at 9am) + - ISO timestamp: "2026-02-03T14:00:00" (one-shot at specific time) + name: Optional human-friendly name for the job (for listing/management) + repeat: How many times to run. Omit for default behavior: + - One-shot schedules default to repeat=1 (run once) + - Intervals/cron default to forever + - Set repeat=5 to run 5 times then auto-delete + + Returns: + JSON with job_id, next_run time, and confirmation + """ + try: + job = create_job( + prompt=prompt, + schedule=schedule, + name=name, + repeat=repeat + ) + + # Format repeat info for display + times = job["repeat"].get("times") + if times is None: + repeat_display = "forever" + elif times == 1: + repeat_display = "once" + else: + repeat_display = f"{times} times" + + return json.dumps({ + "success": True, + "job_id": job["id"], + "name": job["name"], + "schedule": job["schedule_display"], + "repeat": repeat_display, + "next_run_at": job["next_run_at"], + "message": f"Cronjob '{job['name']}' created. It will run {repeat_display}, next at {job['next_run_at']}." + }, indent=2) + + except Exception as e: + return json.dumps({ + "success": False, + "error": str(e) + }, indent=2) + + +SCHEDULE_CRONJOB_SCHEMA = { + "name": "schedule_cronjob", + "description": """Schedule an automated task to run the agent on a schedule. + +⚠️ CRITICAL: The cronjob runs in a FRESH SESSION with NO CONTEXT from this conversation. +The prompt must be COMPLETELY SELF-CONTAINED with ALL necessary information including: +- Full context and background +- Specific file paths, URLs, server addresses +- Clear instructions and success criteria +- Any credentials or configuration details + +The future agent will NOT remember anything from the current conversation. + +SCHEDULE FORMATS: +- One-shot: "30m", "2h", "1d" (runs once after delay) +- Interval: "every 30m", "every 2h" (recurring) +- Cron: "0 9 * * *" (cron expression for precise scheduling) +- Timestamp: "2026-02-03T14:00:00" (specific date/time) + +REPEAT BEHAVIOR: +- One-shot schedules: run once by default +- Intervals/cron: run forever by default +- Set repeat=N to run exactly N times then auto-delete + +Use for: reminders, periodic checks, scheduled reports, automated maintenance.""", + "parameters": { + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "Complete, self-contained instructions. Must include ALL context - the future agent will have NO memory of this conversation." + }, + "schedule": { + "type": "string", + "description": "When to run: '30m' (once in 30min), 'every 30m' (recurring), '0 9 * * *' (cron), or ISO timestamp" + }, + "name": { + "type": "string", + "description": "Optional human-friendly name for the job" + }, + "repeat": { + "type": "integer", + "description": "How many times to run. Omit for default (once for one-shot, forever for recurring). Set to N for exactly N runs." + } + }, + "required": ["prompt", "schedule"] + } +} + + +# ============================================================================= +# Tool: list_cronjobs +# ============================================================================= + +def list_cronjobs(include_disabled: bool = False, task_id: str = None) -> str: + """ + List all scheduled cronjobs. + + Returns information about each job including: + - Job ID (needed for removal) + - Name + - Schedule (human-readable) + - Repeat status (completed/total or 'forever') + - Next scheduled run time + - Last run time and status (if any) + + Args: + include_disabled: Whether to include disabled/completed jobs + + Returns: + JSON array of all scheduled jobs + """ + try: + jobs = list_jobs(include_disabled=include_disabled) + + formatted_jobs = [] + for job in jobs: + # Format repeat status + times = job["repeat"].get("times") + completed = job["repeat"].get("completed", 0) + if times is None: + repeat_status = "forever" + else: + repeat_status = f"{completed}/{times}" + + formatted_jobs.append({ + "job_id": job["id"], + "name": job["name"], + "prompt_preview": job["prompt"][:100] + "..." if len(job["prompt"]) > 100 else job["prompt"], + "schedule": job["schedule_display"], + "repeat": repeat_status, + "next_run_at": job.get("next_run_at"), + "last_run_at": job.get("last_run_at"), + "last_status": job.get("last_status"), + "enabled": job.get("enabled", True) + }) + + return json.dumps({ + "success": True, + "count": len(formatted_jobs), + "jobs": formatted_jobs + }, indent=2) + + except Exception as e: + return json.dumps({ + "success": False, + "error": str(e) + }, indent=2) + + +LIST_CRONJOBS_SCHEMA = { + "name": "list_cronjobs", + "description": """List all scheduled cronjobs with their IDs, schedules, and status. + +Use this to: +- See what jobs are currently scheduled +- Find job IDs for removal with remove_cronjob +- Check job status and next run times + +Returns job_id, name, schedule, repeat status, next/last run times.""", + "parameters": { + "type": "object", + "properties": { + "include_disabled": { + "type": "boolean", + "description": "Include disabled/completed jobs in the list (default: false)" + } + }, + "required": [] + } +} + + +# ============================================================================= +# Tool: remove_cronjob +# ============================================================================= + +def remove_cronjob(job_id: str, task_id: str = None) -> str: + """ + Remove a scheduled cronjob by its ID. + + Use list_cronjobs first to find the job_id of the job you want to remove. + + Args: + job_id: The ID of the job to remove (from list_cronjobs output) + + Returns: + JSON confirmation of removal + """ + try: + job = get_job(job_id) + if not job: + return json.dumps({ + "success": False, + "error": f"Job with ID '{job_id}' not found. Use list_cronjobs to see available jobs." + }, indent=2) + + removed = remove_job(job_id) + if removed: + return json.dumps({ + "success": True, + "message": f"Cronjob '{job['name']}' (ID: {job_id}) has been removed.", + "removed_job": { + "id": job_id, + "name": job["name"], + "schedule": job["schedule_display"] + } + }, indent=2) + else: + return json.dumps({ + "success": False, + "error": f"Failed to remove job '{job_id}'" + }, indent=2) + + except Exception as e: + return json.dumps({ + "success": False, + "error": str(e) + }, indent=2) + + +REMOVE_CRONJOB_SCHEMA = { + "name": "remove_cronjob", + "description": """Remove a scheduled cronjob by its ID. + +Use list_cronjobs first to find the job_id of the job you want to remove. +Jobs that have completed their repeat count are auto-removed, but you can +use this to cancel a job before it completes.""", + "parameters": { + "type": "object", + "properties": { + "job_id": { + "type": "string", + "description": "The ID of the cronjob to remove (from list_cronjobs output)" + } + }, + "required": ["job_id"] + } +} + + +# ============================================================================= +# Requirements check +# ============================================================================= + +def check_cronjob_requirements() -> bool: + """ + Check if cronjob tools can be used. + + Only available in interactive CLI mode (HERMES_INTERACTIVE=1). + """ + return os.getenv("HERMES_INTERACTIVE") == "1" + + +# ============================================================================= +# Exports +# ============================================================================= + +def get_cronjob_tool_definitions(): + """Return tool definitions for cronjob management.""" + return [ + SCHEDULE_CRONJOB_SCHEMA, + LIST_CRONJOBS_SCHEMA, + REMOVE_CRONJOB_SCHEMA + ] + + +# For direct testing +if __name__ == "__main__": + # Test the tools + print("Testing schedule_cronjob:") + result = schedule_cronjob( + prompt="Test prompt for cron job", + schedule="5m", + name="Test Job" + ) + print(result) + + print("\nTesting list_cronjobs:") + result = list_cronjobs() + print(result) diff --git a/toolsets.py b/toolsets.py index 0390c02e4..b74b2fd38 100644 --- a/toolsets.py +++ b/toolsets.py @@ -84,6 +84,12 @@ TOOLSETS = { "includes": [] }, + "cronjob": { + "description": "Cronjob management tools - schedule, list, and remove automated tasks (CLI-only)", + "tools": ["schedule_cronjob", "list_cronjobs", "remove_cronjob"], + "includes": [] + }, + # Scenario-specific toolsets "debugging": { @@ -96,6 +102,36 @@ TOOLSETS = { "description": "Safe toolkit without terminal access", "tools": ["mixture_of_agents"], "includes": ["web", "vision", "creative"] + }, + + # ========================================================================== + # CLI-specific toolsets (only available when running via cli.py) + # ========================================================================== + + "hermes-cli": { + "description": "Full interactive CLI toolset - all default tools plus cronjob management", + "tools": [ + # Web tools + "web_search", "web_extract", + # Terminal + "terminal", + # Vision + "vision_analyze", + # Image generation + "image_generate", + # MoA + "mixture_of_agents", + # Skills + "skills_categories", "skills_list", "skill_view", + # Browser + "browser_navigate", "browser_snapshot", "browser_click", + "browser_type", "browser_scroll", "browser_back", + "browser_press", "browser_close", "browser_get_images", + "browser_vision", + # Cronjob management (CLI-only) + "schedule_cronjob", "list_cronjobs", "remove_cronjob" + ], + "includes": [] } }