From c3ea620796798a517ff7d0a69f7853da4fd4ce49 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 19:18:10 -0700 Subject: [PATCH] feat: add multi-skill cron editing and docs --- agent/display.py | 3 +- cli.py | 334 ++++++++++------ cron/jobs.py | 59 ++- cron/scheduler.py | 38 +- gateway/delivery.py | 2 +- hermes_cli/cron.py | 211 ++++++++-- hermes_cli/main.py | 41 +- hermes_cli/tools_config.py | 2 +- tests/cron/test_scheduler.py | 46 +++ tests/hermes_cli/test_cron.py | 107 +++++ tests/tools/test_cronjob_tools.py | 32 ++ tools/cronjob_tools.py | 53 ++- .../docs/developer-guide/cron-internals.md | 42 +- website/docs/guides/daily-briefing-bot.md | 4 +- website/docs/reference/cli-commands.md | 8 +- website/docs/reference/tools-reference.md | 4 +- website/docs/reference/toolsets-reference.md | 20 +- website/docs/user-guide/features/cron.md | 367 +++++++++--------- website/docs/user-guide/features/tools.md | 2 +- 19 files changed, 968 insertions(+), 407 deletions(-) create mode 100644 tests/hermes_cli/test_cron.py diff --git a/agent/display.py b/agent/display.py index 07d35ea3..faec5a42 100644 --- a/agent/display.py +++ b/agent/display.py @@ -516,7 +516,8 @@ def get_cute_tool_message( if tool_name == "cronjob": action = args.get("action", "?") if action == "create": - label = args.get("name") or args.get("skill") or args.get("prompt", "task") + skills = args.get("skills") or ([] if not args.get("skill") else [args.get("skill")]) + label = args.get("name") or (skills[0] if skills else None) or args.get("prompt", "task") return _wrap(f"┊ ⏰ cron create {_trunc(label, 24)} {dur}") if action == "list": return _wrap(f"┊ ⏰ cron listing {dur}") diff --git a/cli.py b/cli.py index 8d07d3b8..6dcf5e16 100755 --- a/cli.py +++ b/cli.py @@ -429,7 +429,7 @@ from hermes_cli import callbacks as _callbacks from toolsets import get_all_toolsets, get_toolset_info, resolve_toolset, validate_toolset # Cron job system for scheduled tasks (execution is handled by the gateway) -from cron import create_job, list_jobs, remove_job, get_job, pause_job, resume_job, trigger_job +from cron import get_job # Resource cleanup imports for safe shutdown (terminal VMs, browser sessions) from tools.terminal_tool import cleanup_all_environments as _cleanup_all_terminals @@ -2588,162 +2588,248 @@ class HermesCLI: 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 + import shlex + from tools.cronjob_tools import cronjob as cronjob_tool + + def _cron_api(**kwargs): + return json.loads(cronjob_tool(**kwargs)) + + def _normalize_skills(values): + normalized = [] + for value in values: + text = str(value or "").strip() + if text and text not in normalized: + normalized.append(text) + return normalized + + def _parse_flags(tokens): + opts = { + "name": None, + "deliver": None, + "repeat": None, + "skills": [], + "add_skills": [], + "remove_skills": [], + "clear_skills": False, + "all": False, + "prompt": None, + "schedule": None, + "positionals": [], + } + i = 0 + while i < len(tokens): + token = tokens[i] + if token == "--name" and i + 1 < len(tokens): + opts["name"] = tokens[i + 1] + i += 2 + elif token == "--deliver" and i + 1 < len(tokens): + opts["deliver"] = tokens[i + 1] + i += 2 + elif token == "--repeat" and i + 1 < len(tokens): + try: + opts["repeat"] = int(tokens[i + 1]) + except ValueError: + print("(._.) --repeat must be an integer") + return None + i += 2 + elif token == "--skill" and i + 1 < len(tokens): + opts["skills"].append(tokens[i + 1]) + i += 2 + elif token == "--add-skill" and i + 1 < len(tokens): + opts["add_skills"].append(tokens[i + 1]) + i += 2 + elif token == "--remove-skill" and i + 1 < len(tokens): + opts["remove_skills"].append(tokens[i + 1]) + i += 2 + elif token == "--clear-skills": + opts["clear_skills"] = True + i += 1 + elif token == "--all": + opts["all"] = True + i += 1 + elif token == "--prompt" and i + 1 < len(tokens): + opts["prompt"] = tokens[i + 1] + i += 2 + elif token == "--schedule" and i + 1 < len(tokens): + opts["schedule"] = tokens[i + 1] + i += 2 + else: + opts["positionals"].append(token) + i += 1 + return opts + + tokens = shlex.split(cmd) + + if len(tokens) == 1: print() - print("+" + "-" * 60 + "+") - print("|" + " " * 18 + "(^_^) Scheduled Tasks" + " " * 19 + "|") - print("+" + "-" * 60 + "+") + print("+" + "-" * 68 + "+") + print("|" + " " * 22 + "(^_^) Scheduled Tasks" + " " * 23 + "|") + print("+" + "-" * 68 + "+") print() print(" Commands:") - print(" /cron - List scheduled jobs") - print(" /cron list - List scheduled jobs") - print(' /cron add - Add a new job') - print(" /cron pause - Pause a job") - print(" /cron resume - Resume a job") - print(" /cron run - Run a job on the next tick") - print(" /cron remove - Remove a job") + print(" /cron list") + print(' /cron add "every 2h" "Check server status" [--skill blogwatcher]') + print(' /cron edit --schedule "every 4h" --prompt "New task"') + print(" /cron edit --skill blogwatcher --skill find-nearby") + print(" /cron edit --remove-skill blogwatcher") + print(" /cron edit --clear-skills") + print(" /cron pause ") + print(" /cron resume ") + print(" /cron run ") + print(" /cron remove ") 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() + result = _cron_api(action="list") + jobs = result.get("jobs", []) if result.get("success") else [] if jobs: print(" Current Jobs:") - print(" " + "-" * 55) + print(" " + "-" * 63) 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}") + repeat_str = job.get("repeat", "?") + print(f" {job['job_id'][:12]:<12} | {job['schedule']:<15} | {repeat_str:<8}") + if job.get("skills"): + print(f" Skills: {', '.join(job['skills'])}") + print(f" {job.get('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(f" Next: {job['next_run_at']}") print() else: print(" No scheduled jobs. Use '/cron add' to create one.") print() return - - subcommand = parts[1].lower() - + + subcommand = tokens[1].lower() + opts = _parse_flags(tokens[2:]) + if opts is None: + return + if subcommand == "list": - # /cron list - just show jobs - jobs = list_jobs() + result = _cron_api(action="list", include_disabled=opts["all"]) + jobs = result.get("jobs", []) if result.get("success") else [] if not jobs: print("(._.) No scheduled jobs.") return - + print() print("Scheduled Jobs:") - print("-" * 70) + print("-" * 80) 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" ID: {job['job_id']}") print(f" Name: {job['name']}") - print(f" Schedule: {job['schedule_display']} ({repeat_str})") + print(f" State: {job.get('state', '?')}") + print(f" Schedule: {job['schedule']} ({job.get('repeat', '?')})") 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("skills"): + print(f" Skills: {', '.join(job['skills'])}") + print(f" Prompt: {job.get('prompt_preview', '')}") 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: + return + + if subcommand in {"add", "create"}: + positionals = opts["positionals"] + if not positionals: 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() + schedule = opts["schedule"] or positionals[0] + prompt = opts["prompt"] or " ".join(positionals[1:]) + skills = _normalize_skills(opts["skills"]) + if not prompt and not skills: + print("(._.) Please provide a prompt or at least one skill") + return + result = _cron_api( + action="create", + schedule=schedule, + prompt=prompt or None, + name=opts["name"], + deliver=opts["deliver"], + repeat=opts["repeat"], + skills=skills or None, + ) + if result.get("success"): + print(f"(^_^)b Created job: {result['job_id']}") + print(f" Schedule: {result['schedule']}") + if result.get("skills"): + print(f" Skills: {', '.join(result['skills'])}") + print(f" Next run: {result['next_run_at']}") 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 in {"pause", "resume", "run", "remove", "rm", "delete"}: - if len(parts) < 3: - print(f"(._.) Usage: /cron {subcommand} ") - return + print(f"(x_x) Failed to create job: {result.get('error')}") + return - job_id = parts[2].strip() - job = get_job(job_id) - - if not job: + if subcommand == "edit": + positionals = opts["positionals"] + if not positionals: + print("(._.) Usage: /cron edit [--schedule ...] [--prompt ...] [--skill ...]") + return + job_id = positionals[0] + existing = get_job(job_id) + if not existing: print(f"(._.) Job not found: {job_id}") return - if subcommand == "pause": - updated = pause_job(job_id, reason="paused from /cron") - if updated: - print(f"(^_^)b Paused job: {updated['name']} ({job_id})") - else: - print(f"(x_x) Failed to pause job: {job_id}") - elif subcommand == "resume": - updated = resume_job(job_id) - if updated: - print(f"(^_^)b Resumed job: {updated['name']} ({job_id})") - print(f" Next run: {updated.get('next_run_at')}") - else: - print(f"(x_x) Failed to resume job: {job_id}") - elif subcommand == "run": - updated = trigger_job(job_id) - if updated: - print(f"(^_^)b Triggered job: {updated['name']} ({job_id})") - print(" It will run on the next scheduler tick.") - else: - print(f"(x_x) Failed to trigger job: {job_id}") - else: - 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}") + final_skills = None + replacement_skills = _normalize_skills(opts["skills"]) + add_skills = _normalize_skills(opts["add_skills"]) + remove_skills = set(_normalize_skills(opts["remove_skills"])) + existing_skills = list(existing.get("skills") or ([] if not existing.get("skill") else [existing.get("skill")])) + if opts["clear_skills"]: + final_skills = [] + elif replacement_skills: + final_skills = replacement_skills + elif add_skills or remove_skills: + final_skills = [skill for skill in existing_skills if skill not in remove_skills] + for skill in add_skills: + if skill not in final_skills: + final_skills.append(skill) - else: - print(f"(._.) Unknown cron command: {subcommand}") - print(" Available: list, add, pause, resume, run, remove") + result = _cron_api( + action="update", + job_id=job_id, + schedule=opts["schedule"], + prompt=opts["prompt"], + name=opts["name"], + deliver=opts["deliver"], + repeat=opts["repeat"], + skills=final_skills, + ) + if result.get("success"): + job = result["job"] + print(f"(^_^)b Updated job: {job['job_id']}") + print(f" Schedule: {job['schedule']}") + if job.get("skills"): + print(f" Skills: {', '.join(job['skills'])}") + else: + print(" Skills: none") + else: + print(f"(x_x) Failed to update job: {result.get('error')}") + return + + if subcommand in {"pause", "resume", "run", "remove", "rm", "delete"}: + positionals = opts["positionals"] + if not positionals: + print(f"(._.) Usage: /cron {subcommand} ") + return + job_id = positionals[0] + action = "remove" if subcommand in {"remove", "rm", "delete"} else subcommand + result = _cron_api(action=action, job_id=job_id, reason="paused from /cron" if action == "pause" else None) + if not result.get("success"): + print(f"(x_x) Failed to {action} job: {result.get('error')}") + return + if action == "pause": + print(f"(^_^)b Paused job: {result['job']['name']} ({job_id})") + elif action == "resume": + print(f"(^_^)b Resumed job: {result['job']['name']} ({job_id})") + print(f" Next run: {result['job'].get('next_run_at')}") + elif action == "run": + print(f"(^_^)b Triggered job: {result['job']['name']} ({job_id})") + print(" It will run on the next scheduler tick.") + else: + removed = result.get("removed_job", {}) + print(f"(^_^)b Removed job: {removed.get('name', job_id)} ({job_id})") + return + + print(f"(._.) Unknown cron command: {subcommand}") + print(" Available: list, add, edit, pause, resume, run, remove") def _handle_skills_command(self, cmd: str): """Handle /skills slash command — delegates to hermes_cli.skills_hub.""" diff --git a/cron/jobs.py b/cron/jobs.py index 2fb5c95c..c55282a8 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -32,6 +32,32 @@ JOBS_FILE = CRON_DIR / "jobs.json" OUTPUT_DIR = CRON_DIR / "output" +def _normalize_skill_list(skill: Optional[str] = None, skills: Optional[Any] = None) -> List[str]: + """Normalize legacy/single-skill and multi-skill inputs into a unique ordered list.""" + if skills is None: + raw_items = [skill] if skill else [] + elif isinstance(skills, str): + raw_items = [skills] + else: + raw_items = list(skills) + + normalized: List[str] = [] + for item in raw_items: + text = str(item or "").strip() + if text and text not in normalized: + normalized.append(text) + return normalized + + +def _apply_skill_fields(job: Dict[str, Any]) -> Dict[str, Any]: + """Return a job dict with canonical `skills` and legacy `skill` fields aligned.""" + normalized = dict(job) + skills = _normalize_skill_list(normalized.get("skill"), normalized.get("skills")) + normalized["skills"] = skills + normalized["skill"] = skills[0] if skills else None + return normalized + + def _secure_dir(path: Path): """Set directory to owner-only access (0700). No-op on Windows.""" try: @@ -265,6 +291,7 @@ def create_job( deliver: Optional[str] = None, origin: Optional[Dict[str, Any]] = None, skill: Optional[str] = None, + skills: Optional[List[str]] = None, ) -> Dict[str, Any]: """ Create a new cron job. @@ -276,7 +303,8 @@ def create_job( repeat: How many times to run (None = forever, 1 = once) deliver: Where to deliver output ("origin", "local", "telegram", etc.) origin: Source info where job was created (for "origin" delivery) - skill: Optional skill name to load before running the prompt + skill: Optional legacy single skill name to load before running the prompt + skills: Optional ordered list of skills to load before running the prompt Returns: The created job dict @@ -294,12 +322,14 @@ def create_job( job_id = uuid.uuid4().hex[:12] now = _hermes_now().isoformat() - label_source = skill or prompt or "cron job" + normalized_skills = _normalize_skill_list(skill, skills) + label_source = (normalized_skills[0] if normalized_skills else prompt) or "cron job" job = { "id": job_id, "name": name or label_source[:50].strip(), "prompt": prompt, - "skill": skill, + "skills": normalized_skills, + "skill": normalized_skills[0] if normalized_skills else None, "schedule": parsed_schedule, "schedule_display": parsed_schedule.get("display", schedule), "repeat": { @@ -332,13 +362,13 @@ def get_job(job_id: str) -> Optional[Dict[str, Any]]: jobs = load_jobs() for job in jobs: if job["id"] == job_id: - return job + return _apply_skill_fields(job) return None def list_jobs(include_disabled: bool = False) -> List[Dict[str, Any]]: """List all jobs, optionally including disabled ones.""" - jobs = load_jobs() + jobs = [_apply_skill_fields(j) for j in load_jobs()] if not include_disabled: jobs = [j for j in jobs if j.get("enabled", True)] return jobs @@ -351,9 +381,14 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]] if job["id"] != job_id: continue - updated = {**job, **updates} + updated = _apply_skill_fields({**job, **updates}) schedule_changed = "schedule" in updates + if "skills" in updates or "skill" in updates: + normalized_skills = _normalize_skill_list(updated.get("skill"), updated.get("skills")) + updated["skills"] = normalized_skills + updated["skill"] = normalized_skills[0] if normalized_skills else None + if schedule_changed: updated_schedule = updated["schedule"] updated["schedule_display"] = updates.get( @@ -368,7 +403,7 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]] jobs[i] = updated save_jobs(jobs) - return jobs[i] + return _apply_skill_fields(jobs[i]) return None @@ -479,21 +514,21 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None): def get_due_jobs() -> List[Dict[str, Any]]: """Get all jobs that are due to run now.""" now = _hermes_now() - jobs = load_jobs() + jobs = [_apply_skill_fields(j) for j in 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 = _ensure_aware(datetime.fromisoformat(next_run)) if next_run_dt <= now: due.append(job) - + return due diff --git a/cron/scheduler.py b/cron/scheduler.py index e65986b2..62b54fbb 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -149,25 +149,37 @@ def _deliver_result(job: dict, content: str) -> None: def _build_job_prompt(job: dict) -> str: - """Build the effective prompt for a cron job, optionally loading a skill first.""" + """Build the effective prompt for a cron job, optionally loading one or more skills first.""" prompt = job.get("prompt", "") - skill_name = job.get("skill") - if not skill_name: + skills = job.get("skills") + if skills is None: + legacy = job.get("skill") + skills = [legacy] if legacy else [] + + skill_names = [str(name).strip() for name in skills if str(name).strip()] + if not skill_names: return prompt from tools.skills_tool import skill_view - loaded = json.loads(skill_view(skill_name)) - if not loaded.get("success"): - error = loaded.get("error") or f"Failed to load skill '{skill_name}'" - raise RuntimeError(error) + parts = [] + for skill_name in skill_names: + loaded = json.loads(skill_view(skill_name)) + if not loaded.get("success"): + error = loaded.get("error") or f"Failed to load skill '{skill_name}'" + raise RuntimeError(error) + + content = str(loaded.get("content") or "").strip() + if parts: + parts.append("") + parts.extend( + [ + f'[SYSTEM: The user has invoked the "{skill_name}" skill, indicating they want you to follow its instructions. The full skill content is loaded below.]', + "", + content, + ] + ) - content = str(loaded.get("content") or "").strip() - parts = [ - f'[SYSTEM: The user has invoked the "{skill_name}" skill, indicating they want you to follow its instructions. The full skill content is loaded below.]', - "", - content, - ] if prompt: parts.extend(["", f"The user has provided the following instruction alongside the skill invocation: {prompt}"]) return "\n".join(parts) diff --git a/gateway/delivery.py b/gateway/delivery.py index 630ab638..7ceb90ab 100644 --- a/gateway/delivery.py +++ b/gateway/delivery.py @@ -315,7 +315,7 @@ def build_delivery_context_for_tool( origin: Optional[SessionSource] = None ) -> Dict[str, Any]: """ - Build context for the schedule_cronjob tool to understand delivery options. + Build context for the unified cronjob tool to understand delivery options. This is passed to the tool so it can validate and explain delivery targets. """ diff --git a/hermes_cli/cron.py b/hermes_cli/cron.py index b76ef5ba..a068d637 100644 --- a/hermes_cli/cron.py +++ b/hermes_cli/cron.py @@ -1,15 +1,14 @@ """ Cron subcommand for hermes CLI. -Handles: hermes cron [list|status|tick] - -Cronjobs are executed automatically by the gateway daemon (hermes gateway). -Install the gateway as a service for background execution: - hermes gateway install +Handles standalone cron management commands like list, create, edit, +pause/resume/run/remove, status, and tick. """ +import json import sys from pathlib import Path +from typing import Iterable, List, Optional PROJECT_ROOT = Path(__file__).parent.parent.resolve() sys.path.insert(0, str(PROJECT_ROOT)) @@ -17,58 +16,82 @@ sys.path.insert(0, str(PROJECT_ROOT)) from hermes_cli.colors import Colors, color +def _normalize_skills(single_skill=None, skills: Optional[Iterable[str]] = None) -> Optional[List[str]]: + if skills is None: + if single_skill is None: + return None + raw_items = [single_skill] + else: + raw_items = list(skills) + + normalized: List[str] = [] + for item in raw_items: + text = str(item or "").strip() + if text and text not in normalized: + normalized.append(text) + return normalized + + +def _cron_api(**kwargs): + from tools.cronjob_tools import cronjob as cronjob_tool + + return json.loads(cronjob_tool(**kwargs)) + + def cron_list(show_all: bool = False): """List all scheduled jobs.""" from cron.jobs import list_jobs - + jobs = list_jobs(include_disabled=show_all) - + if not jobs: print(color("No scheduled jobs.", Colors.DIM)) - print(color("Create one with the /cron add command in chat, or via Telegram.", Colors.DIM)) + print(color("Create one with 'hermes cron create ...' or the /cron command in chat.", Colors.DIM)) return - + print() print(color("┌─────────────────────────────────────────────────────────────────────────┐", Colors.CYAN)) print(color("│ Scheduled Jobs │", Colors.CYAN)) print(color("└─────────────────────────────────────────────────────────────────────────┘", Colors.CYAN)) print() - + for job in jobs: job_id = job.get("id", "?")[:8] name = job.get("name", "(unnamed)") schedule = job.get("schedule_display", job.get("schedule", {}).get("value", "?")) - enabled = job.get("enabled", True) + state = job.get("state", "scheduled" if job.get("enabled", True) else "paused") next_run = job.get("next_run_at", "?") - + repeat_info = job.get("repeat", {}) repeat_times = repeat_info.get("times") repeat_completed = repeat_info.get("completed", 0) - - if repeat_times: - repeat_str = f"{repeat_completed}/{repeat_times}" - else: - repeat_str = "∞" - + repeat_str = f"{repeat_completed}/{repeat_times}" if repeat_times else "∞" + deliver = job.get("deliver", ["local"]) if isinstance(deliver, str): deliver = [deliver] deliver_str = ", ".join(deliver) - - if not enabled: - status = color("[disabled]", Colors.RED) - else: + + skills = job.get("skills") or ([job["skill"]] if job.get("skill") else []) + if state == "paused": + status = color("[paused]", Colors.YELLOW) + elif state == "completed": + status = color("[completed]", Colors.BLUE) + elif job.get("enabled", True): status = color("[active]", Colors.GREEN) - + else: + status = color("[disabled]", Colors.RED) + print(f" {color(job_id, Colors.YELLOW)} {status}") print(f" Name: {name}") print(f" Schedule: {schedule}") print(f" Repeat: {repeat_str}") print(f" Next run: {next_run}") print(f" Deliver: {deliver_str}") + if skills: + print(f" Skills: {', '.join(skills)}") print() - - # Warn if gateway isn't running + from hermes_cli.gateway import find_gateway_pids if not find_gateway_pids(): print(color(" ⚠ Gateway is not running — jobs won't fire automatically.", Colors.YELLOW)) @@ -86,9 +109,9 @@ def cron_status(): """Show cron execution status.""" from cron.jobs import list_jobs from hermes_cli.gateway import find_gateway_pids - + print() - + pids = find_gateway_pids() if pids: print(color("✓ Gateway is running — cron jobs will fire automatically", Colors.GREEN)) @@ -99,9 +122,9 @@ def cron_status(): print(" To enable automatic execution:") print(" hermes gateway install # Install as system service (recommended)") print(" hermes gateway # Or run in foreground") - + print() - + jobs = list_jobs(include_disabled=False) if jobs: next_runs = [j.get("next_run_at") for j in jobs if j.get("next_run_at")] @@ -110,25 +133,131 @@ def cron_status(): print(f" Next run: {min(next_runs)}") else: print(" No active jobs") - + print() +def cron_create(args): + result = _cron_api( + action="create", + schedule=args.schedule, + prompt=args.prompt, + name=getattr(args, "name", None), + deliver=getattr(args, "deliver", None), + repeat=getattr(args, "repeat", None), + skill=getattr(args, "skill", None), + skills=_normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None)), + ) + if not result.get("success"): + print(color(f"Failed to create job: {result.get('error', 'unknown error')}", Colors.RED)) + return 1 + print(color(f"Created job: {result['job_id']}", Colors.GREEN)) + print(f" Name: {result['name']}") + print(f" Schedule: {result['schedule']}") + if result.get("skills"): + print(f" Skills: {', '.join(result['skills'])}") + print(f" Next run: {result['next_run_at']}") + return 0 + + +def cron_edit(args): + from cron.jobs import get_job + + job = get_job(args.job_id) + if not job: + print(color(f"Job not found: {args.job_id}", Colors.RED)) + return 1 + + existing_skills = list(job.get("skills") or ([] if not job.get("skill") else [job.get("skill")])) + replacement_skills = _normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None)) + add_skills = _normalize_skills(None, getattr(args, "add_skills", None)) or [] + remove_skills = set(_normalize_skills(None, getattr(args, "remove_skills", None)) or []) + + final_skills = None + if getattr(args, "clear_skills", False): + final_skills = [] + elif replacement_skills is not None: + final_skills = replacement_skills + elif add_skills or remove_skills: + final_skills = [skill for skill in existing_skills if skill not in remove_skills] + for skill in add_skills: + if skill not in final_skills: + final_skills.append(skill) + + result = _cron_api( + action="update", + job_id=args.job_id, + schedule=getattr(args, "schedule", None), + prompt=getattr(args, "prompt", None), + name=getattr(args, "name", None), + deliver=getattr(args, "deliver", None), + repeat=getattr(args, "repeat", None), + skills=final_skills, + ) + if not result.get("success"): + print(color(f"Failed to update job: {result.get('error', 'unknown error')}", Colors.RED)) + return 1 + + updated = result["job"] + print(color(f"Updated job: {updated['job_id']}", Colors.GREEN)) + print(f" Name: {updated['name']}") + print(f" Schedule: {updated['schedule']}") + if updated.get("skills"): + print(f" Skills: {', '.join(updated['skills'])}") + else: + print(" Skills: none") + return 0 + + +def _job_action(action: str, job_id: str, success_verb: str) -> int: + result = _cron_api(action=action, job_id=job_id) + if not result.get("success"): + print(color(f"Failed to {action} job: {result.get('error', 'unknown error')}", Colors.RED)) + return 1 + job = result.get("job") or result.get("removed_job") or {} + print(color(f"{success_verb} job: {job.get('name', job_id)} ({job_id})", Colors.GREEN)) + if action in {"resume", "run"} and result.get("job", {}).get("next_run_at"): + print(f" Next run: {result['job']['next_run_at']}") + if action == "run": + print(" It will run on the next scheduler tick.") + return 0 + + def cron_command(args): """Handle cron subcommands.""" subcmd = getattr(args, 'cron_command', None) - + if subcmd is None or subcmd == "list": show_all = getattr(args, 'all', False) cron_list(show_all) - - elif subcmd == "tick": - cron_tick() - - elif subcmd == "status": + return 0 + + if subcmd == "status": cron_status() - - else: - print(f"Unknown cron command: {subcmd}") - print("Usage: hermes cron [list|status|tick]") - sys.exit(1) + return 0 + + if subcmd == "tick": + cron_tick() + return 0 + + if subcmd in {"create", "add"}: + return cron_create(args) + + if subcmd == "edit": + return cron_edit(args) + + if subcmd == "pause": + return _job_action("pause", args.job_id, "Paused") + + if subcmd == "resume": + return _job_action("resume", args.job_id, "Resumed") + + if subcmd == "run": + return _job_action("run", args.job_id, "Triggered") + + if subcmd in {"remove", "rm", "delete"}: + return _job_action("remove", args.job_id, "Removed") + + print(f"Unknown cron command: {subcmd}") + print("Usage: hermes cron [list|create|edit|pause|resume|run|remove|status|tick]") + sys.exit(1) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 6adf4ff7..6276d77d 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2589,13 +2589,48 @@ For more help on a command: # cron list cron_list = cron_subparsers.add_parser("list", help="List scheduled jobs") cron_list.add_argument("--all", action="store_true", help="Include disabled jobs") - + + # cron create/add + cron_create = cron_subparsers.add_parser("create", aliases=["add"], help="Create a scheduled job") + cron_create.add_argument("schedule", help="Schedule like '30m', 'every 2h', or '0 9 * * *'") + cron_create.add_argument("prompt", nargs="?", help="Optional self-contained prompt or task instruction") + cron_create.add_argument("--name", help="Optional human-friendly job name") + cron_create.add_argument("--deliver", help="Delivery target: origin, local, telegram, discord, signal, or platform:chat_id") + cron_create.add_argument("--repeat", type=int, help="Optional repeat count") + cron_create.add_argument("--skill", dest="skills", action="append", help="Attach a skill. Repeat to add multiple skills.") + + # cron edit + cron_edit = cron_subparsers.add_parser("edit", help="Edit an existing scheduled job") + cron_edit.add_argument("job_id", help="Job ID to edit") + cron_edit.add_argument("--schedule", help="New schedule") + cron_edit.add_argument("--prompt", help="New prompt/task instruction") + cron_edit.add_argument("--name", help="New job name") + cron_edit.add_argument("--deliver", help="New delivery target") + cron_edit.add_argument("--repeat", type=int, help="New repeat count") + cron_edit.add_argument("--skill", dest="skills", action="append", help="Replace the job's skills with this set. Repeat to attach multiple skills.") + cron_edit.add_argument("--add-skill", dest="add_skills", action="append", help="Append a skill without replacing the existing list. Repeatable.") + cron_edit.add_argument("--remove-skill", dest="remove_skills", action="append", help="Remove a specific attached skill. Repeatable.") + cron_edit.add_argument("--clear-skills", action="store_true", help="Remove all attached skills from the job") + + # lifecycle actions + cron_pause = cron_subparsers.add_parser("pause", help="Pause a scheduled job") + cron_pause.add_argument("job_id", help="Job ID to pause") + + cron_resume = cron_subparsers.add_parser("resume", help="Resume a paused job") + cron_resume.add_argument("job_id", help="Job ID to resume") + + cron_run = cron_subparsers.add_parser("run", help="Run a job on the next scheduler tick") + cron_run.add_argument("job_id", help="Job ID to trigger") + + cron_remove = cron_subparsers.add_parser("remove", aliases=["rm", "delete"], help="Remove a scheduled job") + cron_remove.add_argument("job_id", help="Job ID to remove") + # cron status cron_subparsers.add_parser("status", help="Check if cron scheduler is running") - + # cron tick (mostly for debugging) cron_subparsers.add_parser("tick", help="Run due jobs once and exit") - + cron_parser.set_defaults(func=cmd_cron) # ========================================================================= diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 3ae86efd..fda92501 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -91,7 +91,7 @@ CONFIGURABLE_TOOLSETS = [ ("session_search", "🔎 Session Search", "search past conversations"), ("clarify", "❓ Clarifying Questions", "clarify"), ("delegation", "👥 Task Delegation", "delegate_task"), - ("cronjob", "⏰ Cron Jobs", "create, list, update, pause, resume, remove, run"), + ("cronjob", "⏰ Cron Jobs", "create/list/update/pause/resume/run, with optional attached skills"), ("rl", "🧪 RL Training", "Tinker-Atropos training tools"), ("homeassistant", "🏠 Home Assistant", "smart home device control"), ] diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index 0b6a0838..3dbae4b4 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -248,3 +248,49 @@ class TestRunJobSkillBacked: assert "blogwatcher" in prompt_arg assert "Follow this skill" in prompt_arg assert "Check the feeds and summarize anything new." in prompt_arg + + def test_run_job_loads_multiple_skills_in_order(self, tmp_path): + job = { + "id": "multi-skill-job", + "name": "multi skill test", + "prompt": "Combine the results.", + "skills": ["blogwatcher", "find-nearby"], + } + + fake_db = MagicMock() + + def _skill_view(name): + return json.dumps({"success": True, "content": f"# {name}\nInstructions for {name}."}) + + with patch("cron.scheduler._hermes_home", tmp_path), \ + patch("cron.scheduler._resolve_origin", return_value=None), \ + patch("dotenv.load_dotenv"), \ + patch("hermes_state.SessionDB", return_value=fake_db), \ + patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + return_value={ + "api_key": "***", + "base_url": "https://example.invalid/v1", + "provider": "openrouter", + "api_mode": "chat_completions", + }, + ), \ + patch("tools.skills_tool.skill_view", side_effect=_skill_view) as skill_view_mock, \ + patch("run_agent.AIAgent") as mock_agent_cls: + mock_agent = MagicMock() + mock_agent.run_conversation.return_value = {"final_response": "ok"} + mock_agent_cls.return_value = mock_agent + + success, output, final_response, error = run_job(job) + + assert success is True + assert error is None + assert final_response == "ok" + assert skill_view_mock.call_count == 2 + assert [call.args[0] for call in skill_view_mock.call_args_list] == ["blogwatcher", "find-nearby"] + + prompt_arg = mock_agent.run_conversation.call_args.args[0] + assert prompt_arg.index("blogwatcher") < prompt_arg.index("find-nearby") + assert "Instructions for blogwatcher." in prompt_arg + assert "Instructions for find-nearby." in prompt_arg + assert "Combine the results." in prompt_arg diff --git a/tests/hermes_cli/test_cron.py b/tests/hermes_cli/test_cron.py new file mode 100644 index 00000000..9ae92048 --- /dev/null +++ b/tests/hermes_cli/test_cron.py @@ -0,0 +1,107 @@ +"""Tests for hermes_cli.cron command handling.""" + +from argparse import Namespace + +import pytest + +from cron.jobs import create_job, get_job, list_jobs +from hermes_cli.cron import cron_command + + +@pytest.fixture() +def tmp_cron_dir(tmp_path, monkeypatch): + monkeypatch.setattr("cron.jobs.CRON_DIR", tmp_path / "cron") + monkeypatch.setattr("cron.jobs.JOBS_FILE", tmp_path / "cron" / "jobs.json") + monkeypatch.setattr("cron.jobs.OUTPUT_DIR", tmp_path / "cron" / "output") + return tmp_path + + +class TestCronCommandLifecycle: + def test_pause_resume_run(self, tmp_cron_dir, capsys): + job = create_job(prompt="Check server status", schedule="every 1h") + + cron_command(Namespace(cron_command="pause", job_id=job["id"])) + paused = get_job(job["id"]) + assert paused["state"] == "paused" + + cron_command(Namespace(cron_command="resume", job_id=job["id"])) + resumed = get_job(job["id"]) + assert resumed["state"] == "scheduled" + + cron_command(Namespace(cron_command="run", job_id=job["id"])) + triggered = get_job(job["id"]) + assert triggered["state"] == "scheduled" + + out = capsys.readouterr().out + assert "Paused job" in out + assert "Resumed job" in out + assert "Triggered job" in out + + def test_edit_can_replace_and_clear_skills(self, tmp_cron_dir, capsys): + job = create_job( + prompt="Combine skill outputs", + schedule="every 1h", + skill="blogwatcher", + ) + + cron_command( + Namespace( + cron_command="edit", + job_id=job["id"], + schedule="every 2h", + prompt="Revised prompt", + name="Edited Job", + deliver=None, + repeat=None, + skill=None, + skills=["find-nearby", "blogwatcher"], + clear_skills=False, + ) + ) + updated = get_job(job["id"]) + assert updated["skills"] == ["find-nearby", "blogwatcher"] + assert updated["name"] == "Edited Job" + assert updated["prompt"] == "Revised prompt" + assert updated["schedule_display"] == "every 120m" + + cron_command( + Namespace( + cron_command="edit", + job_id=job["id"], + schedule=None, + prompt=None, + name=None, + deliver=None, + repeat=None, + skill=None, + skills=None, + clear_skills=True, + ) + ) + cleared = get_job(job["id"]) + assert cleared["skills"] == [] + assert cleared["skill"] is None + + out = capsys.readouterr().out + assert "Updated job" in out + + def test_create_with_multiple_skills(self, tmp_cron_dir, capsys): + cron_command( + Namespace( + cron_command="create", + schedule="every 1h", + prompt="Use both skills", + name="Skill combo", + deliver=None, + repeat=None, + skill=None, + skills=["blogwatcher", "find-nearby"], + ) + ) + out = capsys.readouterr().out + assert "Created job" in out + + jobs = list_jobs() + assert len(jobs) == 1 + assert jobs[0]["skills"] == ["blogwatcher", "find-nearby"] + assert jobs[0]["name"] == "Skill combo" diff --git a/tests/tools/test_cronjob_tools.py b/tests/tools/test_cronjob_tools.py index 93b2430e..5522fb7b 100644 --- a/tests/tools/test_cronjob_tools.py +++ b/tests/tools/test_cronjob_tools.py @@ -245,3 +245,35 @@ class TestUnifiedCronjobTool: listing = json.loads(cronjob(action="list")) assert listing["jobs"][0]["skill"] == "blogwatcher" + + def test_create_multi_skill_job(self): + result = json.loads( + cronjob( + action="create", + skills=["blogwatcher", "find-nearby"], + prompt="Use both skills and combine the result.", + schedule="every 1h", + name="Combo job", + ) + ) + assert result["success"] is True + assert result["skills"] == ["blogwatcher", "find-nearby"] + + listing = json.loads(cronjob(action="list")) + assert listing["jobs"][0]["skills"] == ["blogwatcher", "find-nearby"] + + def test_update_can_clear_skills(self): + created = json.loads( + cronjob( + action="create", + skills=["blogwatcher", "find-nearby"], + prompt="Use both skills and combine the result.", + schedule="every 1h", + ) + ) + updated = json.loads( + cronjob(action="update", job_id=created["job_id"], skills=[]) + ) + assert updated["success"] is True + assert updated["job"]["skills"] == [] + assert updated["job"]["skill"] is None diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 35ef1e63..219cf6f9 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -10,7 +10,7 @@ import os import re import sys from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional # Import from cron module (will be available when properly installed) sys.path.insert(0, str(Path(__file__).parent.parent)) @@ -85,12 +85,31 @@ def _repeat_display(job: Dict[str, Any]) -> str: return f"{completed}/{times}" if completed else f"{times} times" +def _canonical_skills(skill: Optional[str] = None, skills: Optional[Any] = None) -> List[str]: + if skills is None: + raw_items = [skill] if skill else [] + elif isinstance(skills, str): + raw_items = [skills] + else: + raw_items = list(skills) + + normalized: List[str] = [] + for item in raw_items: + text = str(item or "").strip() + if text and text not in normalized: + normalized.append(text) + return normalized + + + def _format_job(job: Dict[str, Any]) -> Dict[str, Any]: prompt = job.get("prompt", "") + skills = _canonical_skills(job.get("skill"), job.get("skills")) return { "job_id": job["id"], "name": job["name"], - "skill": job.get("skill"), + "skill": skills[0] if skills else None, + "skills": skills, "prompt_preview": prompt[:100] + "..." if len(prompt) > 100 else prompt, "schedule": job.get("schedule_display"), "repeat": _repeat_display(job), @@ -115,6 +134,7 @@ def cronjob( deliver: Optional[str] = None, include_disabled: bool = False, skill: Optional[str] = None, + skills: Optional[List[str]] = None, reason: Optional[str] = None, task_id: str = None, ) -> str: @@ -127,8 +147,9 @@ def cronjob( if normalized == "create": if not schedule: return json.dumps({"success": False, "error": "schedule is required for create"}, indent=2) - if not prompt and not skill: - return json.dumps({"success": False, "error": "create requires either prompt or skill"}, indent=2) + canonical_skills = _canonical_skills(skill, skills) + if not prompt and not canonical_skills: + return json.dumps({"success": False, "error": "create requires either prompt or at least one skill"}, indent=2) if prompt: scan_error = _scan_cron_prompt(prompt) if scan_error: @@ -141,7 +162,7 @@ def cronjob( repeat=repeat, deliver=deliver, origin=_origin_from_env(), - skill=skill, + skills=canonical_skills, ) return json.dumps( { @@ -149,6 +170,7 @@ def cronjob( "job_id": job["id"], "name": job["name"], "skill": job.get("skill"), + "skills": job.get("skills", []), "schedule": job["schedule_display"], "repeat": _repeat_display(job), "deliver": job.get("deliver", "local"), @@ -213,8 +235,10 @@ def cronjob( updates["name"] = name if deliver is not None: updates["deliver"] = deliver - if skill is not None: - updates["skill"] = skill + if skills is not None or skill is not None: + canonical_skills = _canonical_skills(skill, skills) + updates["skills"] = canonical_skills + updates["skill"] = canonical_skills[0] if canonical_skills else None if repeat is not None: repeat_state = dict(job.get("repeat") or {}) repeat_state["times"] = repeat @@ -272,12 +296,13 @@ CRONJOB_SCHEMA = { "name": "cronjob", "description": """Manage scheduled cron jobs with a single compressed tool. -Use action='create' to schedule a new job from a prompt or a skill. +Use action='create' to schedule a new job from a prompt or one or more skills. Use action='list' to inspect jobs. Use action='update', 'pause', 'resume', 'remove', or 'run' to manage an existing job. Jobs run in a fresh session with no current-chat context, so prompts must be self-contained. -If skill is provided on create, the future cron run loads that skill first, then follows the prompt as the task instruction. +If skill or skills are provided on create, the future cron run loads those skills in order, then follows the prompt as the task instruction. +On update, passing skills=[] clears attached skills. Important safety rule: cron-run sessions should not recursively schedule more cron jobs.""", "parameters": { @@ -293,7 +318,7 @@ Important safety rule: cron-run sessions should not recursively schedule more cr }, "prompt": { "type": "string", - "description": "For create: the full self-contained prompt. If skill is also provided, this becomes the task instruction paired with that skill." + "description": "For create: the full self-contained prompt. If skill or skills are also provided, this becomes the task instruction paired with those skills." }, "schedule": { "type": "string", @@ -317,7 +342,12 @@ Important safety rule: cron-run sessions should not recursively schedule more cr }, "skill": { "type": "string", - "description": "Optional skill name to load before executing the cron prompt" + "description": "Optional single skill name to load before executing the cron prompt" + }, + "skills": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional ordered list of skills to load before executing the cron prompt. On update, pass an empty array to clear attached skills." }, "reason": { "type": "string", @@ -365,6 +395,7 @@ registry.register( deliver=args.get("deliver"), include_disabled=args.get("include_disabled", False), skill=args.get("skill"), + skills=args.get("skills"), reason=args.get("reason"), task_id=kw.get("task_id"), ), diff --git a/website/docs/developer-guide/cron-internals.md b/website/docs/developer-guide/cron-internals.md index 574cc522..b47bc7bc 100644 --- a/website/docs/developer-guide/cron-internals.md +++ b/website/docs/developer-guide/cron-internals.md @@ -1,7 +1,7 @@ --- sidebar_position: 11 title: "Cron Internals" -description: "How Hermes stores, schedules, locks, and delivers cron jobs" +description: "How Hermes stores, schedules, edits, pauses, skill-loads, and delivers cron jobs" --- # Cron Internals @@ -10,7 +10,9 @@ Hermes cron support is implemented primarily in: - `cron/jobs.py` - `cron/scheduler.py` +- `tools/cronjob_tools.py` - `gateway/run.py` +- `hermes_cli/cron.py` ## Scheduling model @@ -21,9 +23,30 @@ Hermes supports: - cron expressions - explicit timestamps +The model-facing surface is a single `cronjob` tool with action-style operations: + +- `create` +- `list` +- `update` +- `pause` +- `resume` +- `run` +- `remove` + ## Job storage -Cron jobs are stored in Hermes-managed local state with atomic save/update semantics. +Cron jobs are stored in Hermes-managed local state (`~/.hermes/cron/jobs.json`) with atomic write semantics. + +Each job can carry: + +- prompt +- schedule metadata +- repeat counters +- delivery target +- lifecycle state (`scheduled`, `paused`, `completed`, etc.) +- zero, one, or multiple attached skills + +Backward compatibility is preserved for older jobs that only stored a legacy single `skill` field or none of the newer lifecycle fields. ## Runtime behavior @@ -32,11 +55,22 @@ The scheduler: - loads jobs - computes due work - executes jobs in fresh agent sessions +- optionally injects one or more skills before the prompt - handles repeat counters -- updates next-run metadata +- updates next-run metadata and state In gateway mode, cron ticking is integrated into the long-running gateway loop. +## Skill-backed jobs + +A cron job may attach multiple skills. At runtime, Hermes loads those skills in order and then appends the job prompt as the task instruction. + +This gives scheduled jobs reusable guidance without requiring the user to paste full skill bodies into the cron prompt. + +## Recursion guard + +Cron-run sessions disable the `cronjob` toolset. This prevents a scheduled job from recursively creating or mutating more cron jobs and accidentally exploding token usage or scheduler load. + ## Delivery model Cron jobs can deliver to: @@ -48,7 +82,7 @@ Cron jobs can deliver to: ## Locking -Hermes uses lock-based protections so concurrent cron ticks or overlapping scheduler processes do not corrupt job state. +Hermes uses lock-based protections so overlapping scheduler ticks do not execute the same due-job batch twice. ## Related docs diff --git a/website/docs/guides/daily-briefing-bot.md b/website/docs/guides/daily-briefing-bot.md index b6c97e4e..85f11c40 100644 --- a/website/docs/guides/daily-briefing-bot.md +++ b/website/docs/guides/daily-briefing-bot.md @@ -99,7 +99,7 @@ and open source LLMs. Summarize the top 3 stories in a concise briefing with links. Use a friendly, professional tone. Deliver to telegram. ``` -Hermes will create the cron job for you using the `schedule_cronjob` tool. +Hermes will create the cron job for you using the unified `cronjob` tool. ### Option B: CLI Slash Command @@ -232,7 +232,7 @@ Or ask conversationally: Remove my morning briefing cron job. ``` -Hermes will use `list_cronjobs` to find it and `remove_cronjob` to delete it. +Hermes will use `cronjob(action="list")` to find it and `cronjob(action="remove")` to delete it. ### Check Gateway Status diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 1d686974..d3f9a0ce 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -181,12 +181,18 @@ hermes status [--all] [--deep] ## `hermes cron` ```bash -hermes cron +hermes cron ``` | Subcommand | Description | |------------|-------------| | `list` | Show scheduled jobs. | +| `create` / `add` | Create a scheduled job from a prompt, optionally attaching one or more skills via repeated `--skill`. | +| `edit` | Update a job's schedule, prompt, name, delivery, repeat count, or attached skills. Supports `--clear-skills`, `--add-skill`, and `--remove-skill`. | +| `pause` | Pause a job without deleting it. | +| `resume` | Resume a paused job and compute its next future run. | +| `run` | Trigger a job on the next scheduler tick. | +| `remove` | Delete a scheduled job. | | `status` | Check whether the cron scheduler is running. | | `tick` | Run due jobs once and exit. | diff --git a/website/docs/reference/tools-reference.md b/website/docs/reference/tools-reference.md index a4fb2322..7a5e24a5 100644 --- a/website/docs/reference/tools-reference.md +++ b/website/docs/reference/tools-reference.md @@ -40,9 +40,7 @@ This page documents the built-in Hermes tool registry as it exists in code. Avai | Tool | Description | Requires environment | |------|-------------|----------------------| -| `list_cronjobs` | 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, re… | — | -| `remove_cronjob` | 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. | — | -| `schedule_cronjob` | 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: - Fu… | — | +| `cronjob` | Unified scheduled-task manager. Use `action="create"`, `"list"`, `"update"`, `"pause"`, `"resume"`, `"run"`, or `"remove"` to manage jobs. Supports skill-backed jobs with one or more attached skills, and `skills=[]` on update clears attached skills. Cron runs happen in fresh sessions with no current-chat context. | — | ## `delegation` toolset diff --git a/website/docs/reference/toolsets-reference.md b/website/docs/reference/toolsets-reference.md index 8f1adb10..1481414b 100644 --- a/website/docs/reference/toolsets-reference.md +++ b/website/docs/reference/toolsets-reference.md @@ -13,19 +13,19 @@ Toolsets are named bundles of tools that you can enable with `hermes chat --tool | `browser` | core | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `web_search` | | `clarify` | core | `clarify` | | `code_execution` | core | `execute_code` | -| `cronjob` | core | `list_cronjobs`, `remove_cronjob`, `schedule_cronjob` | +| `cronjob` | core | `cronjob` | | `debugging` | composite | `patch`, `process`, `read_file`, `search_files`, `terminal`, `web_extract`, `web_search`, `write_file` | | `delegation` | core | `delegate_task` | | `file` | core | `patch`, `read_file`, `search_files`, `write_file` | -| `hermes-cli` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` | -| `hermes-discord` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` | -| `hermes-email` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` | -| `hermes-gateway` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` | -| `hermes-homeassistant` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` | -| `hermes-signal` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` | -| `hermes-slack` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` | -| `hermes-telegram` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` | -| `hermes-whatsapp` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` | +| `hermes-cli` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `cronjob`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` | +| `hermes-discord` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `cronjob`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` | +| `hermes-email` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `cronjob`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` | +| `hermes-gateway` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `cronjob`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` | +| `hermes-homeassistant` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `cronjob`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` | +| `hermes-signal` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `cronjob`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` | +| `hermes-slack` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `cronjob`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` | +| `hermes-telegram` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `cronjob`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` | +| `hermes-whatsapp` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `cronjob`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` | | `homeassistant` | core | `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services` | | `honcho` | core | `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search` | | `image_gen` | core | `image_generate` | diff --git a/website/docs/user-guide/features/cron.md b/website/docs/user-guide/features/cron.md index b044eb0d..e9a4d4be 100644 --- a/website/docs/user-guide/features/cron.md +++ b/website/docs/user-guide/features/cron.md @@ -1,68 +1,183 @@ --- sidebar_position: 5 title: "Scheduled Tasks (Cron)" -description: "Schedule automated tasks with natural language — cron jobs, delivery options, and the gateway scheduler" +description: "Schedule automated tasks with natural language, manage them with one cron tool, and attach one or more skills" --- # Scheduled Tasks (Cron) -Schedule tasks to run automatically with natural language or cron expressions. The agent can self-schedule using the `schedule_cronjob` tool from any platform. +Schedule tasks to run automatically with natural language or cron expressions. Hermes exposes cron management through a single `cronjob` tool with action-style operations instead of separate schedule/list/remove tools. -## Creating Scheduled Tasks +## What cron can do now -### In the CLI +Cron jobs can: -Use the `/cron` slash command: +- schedule one-shot or recurring tasks +- pause, resume, edit, trigger, and remove jobs +- attach zero, one, or multiple skills to a job +- deliver results back to the origin chat, local files, or configured platform targets +- run in fresh agent sessions with the normal static tool list -``` +:::warning +Cron-run sessions cannot recursively create more cron jobs. Hermes disables cron management tools inside cron executions to prevent runaway scheduling loops. +::: + +## Creating scheduled tasks + +### In chat with `/cron` + +```bash /cron add 30m "Remind me to check the build" /cron add "every 2h" "Check server status" -/cron add "0 9 * * *" "Morning briefing" -/cron list -/cron remove +/cron add "every 1h" "Summarize new feed items" --skill blogwatcher +/cron add "every 1h" "Use both skills and combine the result" --skill blogwatcher --skill find-nearby ``` -### Through Natural Conversation - -Simply ask the agent on any platform: +### From the standalone CLI +```bash +hermes cron create "every 2h" "Check server status" +hermes cron create "every 1h" "Summarize new feed items" --skill blogwatcher +hermes cron create "every 1h" "Use both skills and combine the result" \ + --skill blogwatcher \ + --skill find-nearby \ + --name "Skill combo" ``` + +### Through natural conversation + +Ask Hermes normally: + +```text Every morning at 9am, check Hacker News for AI news and send me a summary on Telegram. ``` -The agent will use the `schedule_cronjob` tool to set it up. +Hermes will use the unified `cronjob` tool internally. -## How It Works +## Skill-backed cron jobs -**Cron execution is handled by the gateway daemon.** The gateway ticks the scheduler every 60 seconds, running any due jobs in isolated agent sessions: +A cron job can load one or more skills before it runs the prompt. + +### Single skill + +```python +cronjob( + action="create", + skill="blogwatcher", + prompt="Check the configured feeds and summarize anything new.", + schedule="0 9 * * *", + name="Morning feeds", +) +``` + +### Multiple skills + +Skills are loaded in order. The prompt becomes the task instruction layered on top of those skills. + +```python +cronjob( + action="create", + skills=["blogwatcher", "find-nearby"], + prompt="Look for new local events and interesting nearby places, then combine them into one short brief.", + schedule="every 6h", + name="Local brief", +) +``` + +This is useful when you want a scheduled agent to inherit reusable workflows without stuffing the full skill text into the cron prompt itself. + +## Editing jobs + +You do not need to delete and recreate jobs just to change them. + +### Chat + +```bash +/cron edit --schedule "every 4h" +/cron edit --prompt "Use the revised task" +/cron edit --skill blogwatcher --skill find-nearby +/cron edit --remove-skill blogwatcher +/cron edit --clear-skills +``` + +### Standalone CLI + +```bash +hermes cron edit --schedule "every 4h" +hermes cron edit --prompt "Use the revised task" +hermes cron edit --skill blogwatcher --skill find-nearby +hermes cron edit --add-skill find-nearby +hermes cron edit --remove-skill blogwatcher +hermes cron edit --clear-skills +``` + +Notes: + +- repeated `--skill` replaces the job's attached skill list +- `--add-skill` appends to the existing list without replacing it +- `--remove-skill` removes specific attached skills +- `--clear-skills` removes all attached skills + +## Lifecycle actions + +Cron jobs now have a fuller lifecycle than just create/remove. + +### Chat + +```bash +/cron list +/cron pause +/cron resume +/cron run +/cron remove +``` + +### Standalone CLI + +```bash +hermes cron list +hermes cron pause +hermes cron resume +hermes cron run +hermes cron remove +hermes cron status +hermes cron tick +``` + +What they do: + +- `pause` — keep the job but stop scheduling it +- `resume` — re-enable the job and compute the next future run +- `run` — trigger the job on the next scheduler tick +- `remove` — delete it entirely + +## How it works + +**Cron execution is handled by the gateway daemon.** The gateway ticks the scheduler every 60 seconds, running any due jobs in isolated agent sessions. ```bash hermes gateway install # Install as system service (recommended) hermes gateway # Or run in foreground -hermes cron list # View scheduled jobs -hermes cron status # Check if gateway is running +hermes cron list +hermes cron status ``` -### The Gateway Scheduler +### Gateway scheduler behavior -The scheduler runs as a background thread inside the gateway process. On each tick (every 60 seconds): +On each tick Hermes: -1. It loads all jobs from `~/.hermes/cron/jobs.json` -2. Checks each enabled job's `next_run_at` against the current time -3. For each due job, spawns a fresh `AIAgent` session with the job's prompt -4. The agent runs to completion with full tool access -5. The final response is delivered to the configured target -6. The job's run count is incremented and next run time computed -7. Jobs that hit their repeat limit are auto-removed +1. loads jobs from `~/.hermes/cron/jobs.json` +2. checks `next_run_at` against the current time +3. starts a fresh `AIAgent` session for each due job +4. optionally injects one or more attached skills into that fresh session +5. runs the prompt to completion +6. delivers the final response +7. updates run metadata and the next scheduled time -A **file-based lock** (`~/.hermes/cron/.tick.lock`) prevents duplicate execution if multiple processes overlap (e.g., gateway + manual tick). +A file lock at `~/.hermes/cron/.tick.lock` prevents overlapping scheduler ticks from double-running the same job batch. -:::info -Even if no messaging platforms are configured, the gateway stays running for cron. A file lock prevents duplicate execution if multiple processes overlap. -::: - -## Delivery Options +## Delivery options When scheduling jobs, you specify where the output goes: @@ -70,48 +185,34 @@ When scheduling jobs, you specify where the output goes: |--------|-------------|---------| | `"origin"` | Back to where the job was created | Default on messaging platforms | | `"local"` | Save to local files only (`~/.hermes/cron/output/`) | Default on CLI | -| `"telegram"` | Telegram home channel | Uses `TELEGRAM_HOME_CHANNEL` env var | -| `"discord"` | Discord home channel | Uses `DISCORD_HOME_CHANNEL` env var | -| `"telegram:123456"` | Specific Telegram chat by ID | For directing output to a specific chat | -| `"discord:987654"` | Specific Discord channel by ID | For directing output to a specific channel | +| `"telegram"` | Telegram home channel | Uses `TELEGRAM_HOME_CHANNEL` | +| `"discord"` | Discord home channel | Uses `DISCORD_HOME_CHANNEL` | +| `"telegram:123456"` | Specific Telegram chat by ID | Direct delivery | +| `"discord:987654"` | Specific Discord channel by ID | Direct delivery | -**How `"origin"` works:** When a job is created from a messaging platform, Hermes records the source platform and chat ID. When the job runs and deliver is `"origin"`, the output is sent back to that exact platform and chat. If origin info isn't available (e.g., job created from CLI), delivery falls back to local. +The agent's final response is automatically delivered. You do not need to call `send_message` in the cron prompt. -**How platform names work:** When you specify a bare platform name like `"telegram"`, Hermes first checks if the job's origin matches that platform and uses the origin chat ID. Otherwise, it falls back to the platform's home channel configured via environment variable (e.g., `TELEGRAM_HOME_CHANNEL`). +## Schedule formats -The agent's final response is automatically delivered — you do **not** need to include `send_message` in the cron prompt. +### Relative delays (one-shot) -The agent knows your connected platforms and home channels — it'll choose sensible defaults. - -## Schedule Formats - -### Relative Delays (One-Shot) - -Run once after a delay: - -``` +```text 30m → Run once in 30 minutes 2h → Run once in 2 hours 1d → Run once in 1 day ``` -Supported units: `m`/`min`/`minutes`, `h`/`hr`/`hours`, `d`/`day`/`days`. +### Intervals (recurring) -### Intervals (Recurring) - -Run repeatedly at fixed intervals: - -``` +```text every 30m → Every 30 minutes every 2h → Every 2 hours every 1d → Every day ``` -### Cron Expressions +### Cron expressions -Standard 5-field cron syntax for precise scheduling: - -``` +```text 0 9 * * * → Daily at 9:00 AM 0 9 * * 1-5 → Weekdays at 9:00 AM 0 */6 * * * → Every 6 hours @@ -119,155 +220,63 @@ Standard 5-field cron syntax for precise scheduling: 0 0 * * 0 → Every Sunday at midnight ``` -#### Cron Expression Cheat Sheet +### ISO timestamps -``` -┌───── minute (0-59) -│ ┌───── hour (0-23) -│ │ ┌───── day of month (1-31) -│ │ │ ┌───── month (1-12) -│ │ │ │ ┌───── day of week (0-7, 0 and 7 = Sunday) -│ │ │ │ │ -* * * * * - -Special characters: - * Any value - , List separator (1,3,5) - - Range (1-5) - / Step values (*/15 = every 15) -``` - -:::note -Cron expressions require the `croniter` Python package. Install with `pip install croniter` if not already available. -::: - -### ISO Timestamps - -Run once at a specific date/time: - -``` +```text 2026-03-15T09:00:00 → One-time at March 15, 2026 9:00 AM ``` -## Repeat Behavior +## Repeat behavior -The `repeat` parameter controls how many times a job runs: - -| Schedule Type | Default Repeat | Behavior | +| Schedule type | Default repeat | Behavior | |--------------|----------------|----------| -| One-shot (`30m`, timestamp) | 1 (run once) | Runs once, then auto-deleted | -| Interval (`every 2h`) | Forever (`null`) | Runs indefinitely until removed | -| Cron expression | Forever (`null`) | Runs indefinitely until removed | +| One-shot (`30m`, timestamp) | 1 | Runs once | +| Interval (`every 2h`) | forever | Runs until removed | +| Cron expression | forever | Runs until removed | -You can override the default: +You can override it: ```python -schedule_cronjob( +cronjob( + action="create", prompt="...", schedule="every 2h", - repeat=5 # Run exactly 5 times, then auto-delete + repeat=5, ) ``` -When a job hits its repeat limit, it is automatically removed from the job list. +## Managing jobs programmatically -## Real-World Examples - -### Daily Standup Report - -``` -Schedule a daily standup report: Every weekday at 9am, check the GitHub -repository at github.com/myorg/myproject for: -1. Pull requests opened/merged in the last 24 hours -2. Issues created or closed -3. Any CI/CD failures on the main branch -Format as a brief standup-style summary. Deliver to telegram. -``` - -The agent creates: -```python -schedule_cronjob( - prompt="Check github.com/myorg/myproject for PRs, issues, and CI status from the last 24 hours. Format as a standup report.", - schedule="0 9 * * 1-5", - name="Daily Standup Report", - deliver="telegram" -) -``` - -### Weekly Backup Verification - -``` -Every Sunday at 2am, verify that backups exist in /data/backups/ for -each day of the past week. Check file sizes are > 1MB. Report any -gaps or suspiciously small files. -``` - -### Monitoring Alerts - -``` -Every 15 minutes, curl https://api.myservice.com/health and verify -it returns HTTP 200 with {"status": "ok"}. If it fails, include the -error details and response code. Deliver to telegram:123456789. -``` +The agent-facing API is one tool: ```python -schedule_cronjob( - prompt="Run 'curl -s -o /dev/null -w \"%{http_code}\" https://api.myservice.com/health' and verify it returns 200. Also fetch the full response with 'curl -s https://api.myservice.com/health' and check for {\"status\": \"ok\"}. Report the result.", - schedule="every 15m", - name="API Health Check", - deliver="telegram:123456789" -) +cronjob(action="create", ...) +cronjob(action="list") +cronjob(action="update", job_id="...") +cronjob(action="pause", job_id="...") +cronjob(action="resume", job_id="...") +cronjob(action="run", job_id="...") +cronjob(action="remove", job_id="...") ``` -### Periodic Disk Usage Check +For `update`, pass `skills=[]` to remove all attached skills. -```python -schedule_cronjob( - prompt="Check disk usage with 'df -h' and report any partitions above 80% usage. Also check Docker disk usage with 'docker system df' if Docker is installed.", - schedule="0 8 * * *", - name="Disk Usage Report", - deliver="origin" -) -``` +## Job storage -## Managing Jobs +Jobs are stored in `~/.hermes/cron/jobs.json`. Output from job runs is saved to `~/.hermes/cron/output/{job_id}/{timestamp}.md`. -```bash -# CLI commands -hermes cron list # View all scheduled jobs -hermes cron status # Check if the scheduler is running +The storage uses atomic file writes so interrupted writes do not leave a partially written job file behind. -# Slash commands (inside chat) -/cron list -/cron remove -``` - -The agent can also manage jobs conversationally: -- `list_cronjobs` — Shows all jobs with IDs, schedules, repeat status, and next run times -- `remove_cronjob` — Removes a job by ID (use `list_cronjobs` to find the ID) - -## Job Storage - -Jobs are stored as JSON in `~/.hermes/cron/jobs.json`. Output from job runs is saved to `~/.hermes/cron/output/{job_id}/{timestamp}.md`. - -The storage uses atomic file writes (temp file + rename) to prevent corruption from concurrent access. - -## Self-Contained Prompts +## Self-contained prompts still matter :::warning Important -Cron job prompts run in a **completely fresh agent session** with zero memory of any prior conversation. The prompt must contain **everything** the agent needs: - -- Full context and background -- Specific file paths, URLs, server addresses -- Clear instructions and success criteria -- Any credentials or configuration details +Cron jobs run in a completely fresh agent session. The prompt must contain everything the agent needs that is not already provided by attached skills. +::: **BAD:** `"Check on that server issue"` + **GOOD:** `"SSH into server 192.168.1.100 as user 'deploy', check if nginx is running with 'systemctl status nginx', and verify https://example.com returns HTTP 200."` -::: ## Security -:::warning -Scheduled task prompts are scanned for instruction-override patterns (prompt injection). Jobs matching threat patterns like credential exfiltration, SSH backdoor attempts, or prompt injection are blocked at creation time. Content with invisible Unicode characters (zero-width spaces, directional overrides) is also rejected. -::: +Scheduled task prompts are scanned for prompt-injection and credential-exfiltration patterns at creation and update time. Prompts containing invisible Unicode tricks, SSH backdoor attempts, or obvious secret-exfiltration payloads are blocked. diff --git a/website/docs/user-guide/features/tools.md b/website/docs/user-guide/features/tools.md index c752a562..faf1023e 100644 --- a/website/docs/user-guide/features/tools.md +++ b/website/docs/user-guide/features/tools.md @@ -22,7 +22,7 @@ High-level categories: | **Media** | `vision_analyze`, `image_generate`, `text_to_speech` | Multimodal analysis and generation. | | **Agent orchestration** | `todo`, `clarify`, `execute_code`, `delegate_task` | Planning, clarification, code execution, and subagent delegation. | | **Memory & recall** | `memory`, `session_search`, `honcho_*` | Persistent memory, session search, and Honcho cross-session context. | -| **Automation & delivery** | `schedule_cronjob`, `send_message` | Scheduled tasks and outbound messaging delivery. | +| **Automation & delivery** | `cronjob`, `send_message` | Scheduled tasks with create/list/update/pause/resume/run/remove actions, plus outbound messaging delivery. | | **Integrations** | `ha_*`, MCP server tools, `rl_*` | Home Assistant, MCP, RL training, and other integrations. | For the authoritative code-derived registry, see [Built-in Tools Reference](/docs/reference/tools-reference) and [Toolsets Reference](/docs/reference/toolsets-reference).