diff --git a/agent/display.py b/agent/display.py index 72b56318d..faec5a421 100644 --- a/agent/display.py +++ b/agent/display.py @@ -80,7 +80,7 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int = 40) -> str | N "image_generate": "prompt", "text_to_speech": "text", "vision_analyze": "question", "mixture_of_agents": "user_prompt", "skill_view": "name", "skills_list": "category", - "schedule_cronjob": "name", + "cronjob": "action", "execute_code": "code", "delegate_task": "goal", "clarify": "question", "skill_manage": "name", } @@ -513,12 +513,15 @@ def get_cute_tool_message( return _wrap(f"β”Š 🧠 reason {_trunc(args.get('user_prompt', ''), 30)} {dur}") if tool_name == "send_message": return _wrap(f"β”Š πŸ“¨ send {args.get('target', '?')}: \"{_trunc(args.get('message', ''), 25)}\" {dur}") - if tool_name == "schedule_cronjob": - return _wrap(f"β”Š ⏰ schedule {_trunc(args.get('name', args.get('prompt', 'task')), 30)} {dur}") - if tool_name == "list_cronjobs": - return _wrap(f"β”Š ⏰ jobs listing {dur}") - if tool_name == "remove_cronjob": - return _wrap(f"β”Š ⏰ remove job {args.get('job_id', '?')} {dur}") + if tool_name == "cronjob": + action = args.get("action", "?") + if action == "create": + 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}") + return _wrap(f"β”Š ⏰ cron {action} {args.get('job_id', '')} {dur}") if tool_name.startswith("rl_"): rl = { "rl_list_environments": "list envs", "rl_select_environment": f"select {args.get('name', '')}", diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 104398c28..aa2cc7072 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -456,7 +456,7 @@ platform_toolsets: # moa - mixture_of_agents (requires OPENROUTER_API_KEY) # todo - todo (in-memory task planning, no deps) # tts - text_to_speech (Edge TTS free, or ELEVENLABS/OPENAI key) -# cronjob - schedule_cronjob, list_cronjobs, remove_cronjob +# cronjob - cronjob (create/list/update/pause/resume/run/remove scheduled tasks) # rl - rl_list_environments, rl_start_training, etc. (requires TINKER_API_KEY) # # PRESETS (curated bundles): diff --git a/cli.py b/cli.py index d696488f2..44c7889cd 100755 --- a/cli.py +++ b/cli.py @@ -429,8 +429,8 @@ from hermes_cli.commands import COMMANDS, SlashCommandCompleter 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 (CRUD only β€” execution is handled by the gateway) -from cron import create_job, list_jobs, remove_job, get_job +# Cron job system for scheduled tasks (execution is handled by the gateway) +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 @@ -2627,139 +2627,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 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") + print(f"(x_x) Failed to create job: {result.get('error')}") + return + + if subcommand == "edit": + positionals = opts["positionals"] + if not positionals: + print("(._.) Usage: /cron edit [--schedule ...] [--prompt ...] [--skill ...]") 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: + job_id = positionals[0] + existing = get_job(job_id) + if not existing: print(f"(._.) Job not found: {job_id}") return - - if remove_job(job_id): - print(f"(^_^)b Removed job: {job['name']} ({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) + + 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 remove job: {job_id}") - - else: - print(f"(._.) Unknown cron command: {subcommand}") - print(" Available: list, add, remove") + 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/__init__.py b/cron/__init__.py index 6a8f3ecba..31d7bf8e7 100644 --- a/cron/__init__.py +++ b/cron/__init__.py @@ -20,6 +20,9 @@ from cron.jobs import ( list_jobs, remove_job, update_job, + pause_job, + resume_job, + trigger_job, JOBS_FILE, ) from cron.scheduler import tick @@ -30,6 +33,9 @@ __all__ = [ "list_jobs", "remove_job", "update_job", + "pause_job", + "resume_job", + "trigger_job", "tick", "JOBS_FILE", ] diff --git a/cron/jobs.py b/cron/jobs.py index 186424c65..ca00e08db 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: @@ -263,39 +289,47 @@ def create_job( name: Optional[str] = None, repeat: Optional[int] = None, deliver: Optional[str] = None, - origin: Optional[Dict[str, Any]] = None + origin: Optional[Dict[str, Any]] = None, + skill: Optional[str] = None, + skills: Optional[List[str]] = None, ) -> Dict[str, Any]: """ Create a new cron job. - + Args: - prompt: The prompt to run (must be self-contained) + prompt: The prompt to run (must be self-contained, or a task instruction when skill is set) schedule: Schedule string (see parse_schedule) name: Optional friendly name 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 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 """ 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 - + # Default delivery to origin if available, otherwise local if deliver is None: deliver = "origin" if origin else "local" - + job_id = uuid.uuid4().hex[:12] now = _hermes_now().isoformat() - + + normalized_skills = _normalize_skill_list(skill, skills) + label_source = (prompt or (normalized_skills[0] if normalized_skills else None)) or "cron job" job = { "id": job_id, - "name": name or prompt[:50].strip(), + "name": name or label_source[:50].strip(), "prompt": prompt, + "skills": normalized_skills, + "skill": normalized_skills[0] if normalized_skills else None, "schedule": parsed_schedule, "schedule_display": parsed_schedule.get("display", schedule), "repeat": { @@ -303,6 +337,9 @@ def create_job( "completed": 0 }, "enabled": True, + "state": "scheduled", + "paused_at": None, + "paused_reason": None, "created_at": now, "next_run_at": compute_next_run(parsed_schedule), "last_run_at": None, @@ -312,11 +349,11 @@ def create_job( "deliver": deliver, "origin": origin, # Tracks where job was created for "origin" delivery } - + jobs = load_jobs() jobs.append(job) save_jobs(jobs) - + return job @@ -325,29 +362,100 @@ 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 def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: - """Update a job by ID.""" + """Update a job by ID, refreshing derived schedule fields when needed.""" jobs = load_jobs() for i, job in enumerate(jobs): - if job["id"] == job_id: - jobs[i] = {**job, **updates} - save_jobs(jobs) - return jobs[i] + if job["id"] != job_id: + continue + + 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( + "schedule_display", + updated_schedule.get("display", updated.get("schedule_display")), + ) + if updated.get("state") != "paused": + updated["next_run_at"] = compute_next_run(updated_schedule) + + if updated.get("enabled", True) and updated.get("state") != "paused" and not updated.get("next_run_at"): + updated["next_run_at"] = compute_next_run(updated["schedule"]) + + jobs[i] = updated + save_jobs(jobs) + return _apply_skill_fields(jobs[i]) return None +def pause_job(job_id: str, reason: Optional[str] = None) -> Optional[Dict[str, Any]]: + """Pause a job without deleting it.""" + return update_job( + job_id, + { + "enabled": False, + "state": "paused", + "paused_at": _hermes_now().isoformat(), + "paused_reason": reason, + }, + ) + + +def resume_job(job_id: str) -> Optional[Dict[str, Any]]: + """Resume a paused job and compute the next future run from now.""" + job = get_job(job_id) + if not job: + return None + + next_run_at = compute_next_run(job["schedule"]) + return update_job( + job_id, + { + "enabled": True, + "state": "scheduled", + "paused_at": None, + "paused_reason": None, + "next_run_at": next_run_at, + }, + ) + + +def trigger_job(job_id: str) -> Optional[Dict[str, Any]]: + """Schedule a job to run on the next scheduler tick.""" + job = get_job(job_id) + if not job: + return None + return update_job( + job_id, + { + "enabled": True, + "state": "scheduled", + "paused_at": None, + "paused_reason": None, + "next_run_at": _hermes_now().isoformat(), + }, + ) + + def remove_job(job_id: str) -> bool: """Remove a job by ID.""" jobs = load_jobs() @@ -389,11 +497,14 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None): # 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 - + job["state"] = "completed" + elif job.get("state") != "paused": + job["state"] = "scheduled" + save_jobs(jobs) return @@ -403,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 4f85677d8..2938a43e5 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -9,6 +9,7 @@ runs at a time if multiple processes overlap. """ import asyncio +import json import logging import os import sys @@ -174,6 +175,43 @@ def _deliver_result(job: dict, content: str) -> None: logger.warning("Job '%s': mirror_to_session failed: %s", job["id"], e) +def _build_job_prompt(job: dict) -> str: + """Build the effective prompt for a cron job, optionally loading one or more skills first.""" + prompt = job.get("prompt", "") + 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 + + 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, + ] + ) + + if prompt: + parts.extend(["", f"The user has provided the following instruction alongside the skill invocation: {prompt}"]) + return "\n".join(parts) + + def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: """ Execute a single cron job. @@ -194,10 +232,10 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: job_id = job["id"] job_name = job["name"] - prompt = job["prompt"] + prompt = _build_job_prompt(job) origin = _resolve_origin(job) delivery_target = _resolve_delivery_target(job) - + logger.info("Running job '%s' (ID: %s)", job_name, job_id) logger.info("Prompt: %s", prompt[:100]) @@ -301,6 +339,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: providers_ignored=pr.get("ignore"), providers_order=pr.get("order"), provider_sort=pr.get("sort"), + disabled_toolsets=["cronjob"], quiet_mode=True, platform="cron", session_id=f"cron_{job_id}_{_hermes_now().strftime('%Y%m%d_%H%M%S')}", diff --git a/gateway/delivery.py b/gateway/delivery.py index 69ec6376c..28b7cf754 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/gateway/platforms/ADDING_A_PLATFORM.md b/gateway/platforms/ADDING_A_PLATFORM.md index dadd9890d..f773f8c8f 100644 --- a/gateway/platforms/ADDING_A_PLATFORM.md +++ b/gateway/platforms/ADDING_A_PLATFORM.md @@ -173,7 +173,7 @@ platform_map = { } ``` -Without this, `schedule_cronjob(deliver="your_platform")` silently fails. +Without this, `cronjob(action="create", deliver="your_platform", ...)` silently fails. --- diff --git a/gateway/run.py b/gateway/run.py index e97db0255..e973852b4 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3794,9 +3794,7 @@ class GatewayRunner: "memory": "🧠", "session_search": "πŸ”", "send_message": "πŸ“¨", - "schedule_cronjob": "⏰", - "list_cronjobs": "⏰", - "remove_cronjob": "⏰", + "cronjob": "⏰", "execute_code": "🐍", "delegate_task": "πŸ”€", "clarify": "❓", diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index a9a1a67ba..7e964bd4e 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -43,7 +43,7 @@ COMMANDS_BY_CATEGORY = { "/tools": "List available tools", "/toolsets": "List available toolsets", "/skills": "Search, install, inspect, or manage skills from online registries", - "/cron": "Manage scheduled tasks (list, add, remove)", + "/cron": "Manage scheduled tasks (list, add/create, edit, pause, resume, run, remove)", "/reload-mcp": "Reload MCP servers from config.yaml", }, "Info": { diff --git a/hermes_cli/cron.py b/hermes_cli/cron.py index b76ef5bac..a068d6377 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 c1b60e582..8137c550b 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2615,13 +2615,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 cb9b99657..fda92501c 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", "schedule, list, remove"), + ("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/model_tools.py b/model_tools.py index 2139eb080..7ef2df10f 100644 --- a/model_tools.py +++ b/model_tools.py @@ -144,7 +144,7 @@ _LEGACY_TOOLSET_MAP = { "browser_press", "browser_close", "browser_get_images", "browser_vision" ], - "cronjob_tools": ["schedule_cronjob", "list_cronjobs", "remove_cronjob"], + "cronjob_tools": ["cronjob"], "rl_tools": [ "rl_list_environments", "rl_select_environment", "rl_get_current_config", "rl_edit_config", diff --git a/run_agent.py b/run_agent.py index bdf049655..ec51c37c6 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3804,7 +3804,7 @@ class AIAgent: 'image_generate': '🎨', 'text_to_speech': 'πŸ”Š', 'vision_analyze': 'πŸ‘οΈ', 'mixture_of_agents': '🧠', 'skills_list': 'πŸ“š', 'skill_view': 'πŸ“š', - 'schedule_cronjob': '⏰', 'list_cronjobs': '⏰', 'remove_cronjob': '⏰', + 'cronjob': '⏰', 'send_message': 'πŸ“¨', 'todo': 'πŸ“‹', 'memory': '🧠', 'session_search': 'πŸ”', 'clarify': '❓', 'execute_code': '🐍', 'delegate_task': 'πŸ”€', } diff --git a/skills/autonomous-ai-agents/hermes-agent/SKILL.md b/skills/autonomous-ai-agents/hermes-agent/SKILL.md index 467109568..a0678b0a2 100644 --- a/skills/autonomous-ai-agents/hermes-agent/SKILL.md +++ b/skills/autonomous-ai-agents/hermes-agent/SKILL.md @@ -155,7 +155,7 @@ terminal(command="hermes chat -q 'Summarize this codebase' --model google/gemini ## Gateway Cron Integration -For scheduled autonomous tasks, use the `schedule_cronjob` tool instead of spawning processes β€” cron jobs handle delivery, retry, and persistence automatically. +For scheduled autonomous tasks, use the unified `cronjob` tool instead of spawning processes β€” cron jobs handle delivery, retry, and persistence automatically. ## Key Differences Between Modes diff --git a/tests/cron/test_jobs.py b/tests/cron/test_jobs.py index b39342ce1..802a744f3 100644 --- a/tests/cron/test_jobs.py +++ b/tests/cron/test_jobs.py @@ -16,6 +16,8 @@ from cron.jobs import ( get_job, list_jobs, update_job, + pause_job, + resume_job, remove_job, mark_job_run, get_due_jobs, @@ -233,14 +235,18 @@ class TestUpdateJob: job = create_job(prompt="Daily report", schedule="every 1h") assert job["schedule"]["kind"] == "interval" assert job["schedule"]["minutes"] == 60 + old_next_run = job["next_run_at"] new_schedule = parse_schedule("every 2h") - updated = update_job(job["id"], {"schedule": new_schedule}) + updated = update_job(job["id"], {"schedule": new_schedule, "schedule_display": new_schedule["display"]}) assert updated is not None assert updated["schedule"]["kind"] == "interval" assert updated["schedule"]["minutes"] == 120 + assert updated["schedule_display"] == "every 120m" + assert updated["next_run_at"] != old_next_run # Verify persisted to disk fetched = get_job(job["id"]) assert fetched["schedule"]["minutes"] == 120 + assert fetched["schedule_display"] == "every 120m" def test_update_enable_disable(self, tmp_cron_dir): job = create_job(prompt="Toggle me", schedule="every 1h") @@ -255,6 +261,26 @@ class TestUpdateJob: assert result is None +class TestPauseResumeJob: + def test_pause_sets_state(self, tmp_cron_dir): + job = create_job(prompt="Pause me", schedule="every 1h") + paused = pause_job(job["id"], reason="user paused") + assert paused is not None + assert paused["enabled"] is False + assert paused["state"] == "paused" + assert paused["paused_reason"] == "user paused" + + def test_resume_reenables_job(self, tmp_cron_dir): + job = create_job(prompt="Resume me", schedule="every 1h") + pause_job(job["id"], reason="user paused") + resumed = resume_job(job["id"]) + assert resumed is not None + assert resumed["enabled"] is True + assert resumed["state"] == "scheduled" + assert resumed["paused_at"] is None + assert resumed["paused_reason"] is None + + class TestMarkJobRun: def test_increments_completed(self, tmp_cron_dir): job = create_job(prompt="Test", schedule="every 1h") diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index 6af83f1e1..a053028cf 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -253,3 +253,94 @@ class TestRunJobConfigLogging: assert any("failed to parse prefill messages" in r.message for r in caplog.records), \ f"Expected 'failed to parse prefill messages' warning in logs, got: {[r.message for r in caplog.records]}" + + +class TestRunJobSkillBacked: + def test_run_job_loads_skill_and_disables_recursive_cron_tools(self, tmp_path): + job = { + "id": "skill-job", + "name": "skill test", + "prompt": "Check the feeds and summarize anything new.", + "skill": "blogwatcher", + } + + fake_db = MagicMock() + + 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", return_value=json.dumps({"success": True, "content": "# Blogwatcher\nFollow this skill."})), \ + 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" + + kwargs = mock_agent_cls.call_args.kwargs + assert "cronjob" in (kwargs["disabled_toolsets"] or []) + + prompt_arg = mock_agent.run_conversation.call_args.args[0] + 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 000000000..9ae920482 --- /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 500087d5c..97a4cd522 100644 --- a/tests/tools/test_cronjob_tools.py +++ b/tests/tools/test_cronjob_tools.py @@ -6,6 +6,7 @@ from pathlib import Path from tools.cronjob_tools import ( _scan_cron_prompt, + cronjob, schedule_cronjob, list_cronjobs, remove_cronjob, @@ -180,3 +181,111 @@ class TestRemoveCronjob: result = json.loads(remove_cronjob("nonexistent_id")) assert result["success"] is False assert "not found" in result["error"].lower() + + +class TestUnifiedCronjobTool: + @pytest.fixture(autouse=True) + def _setup_cron_dir(self, 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") + + def test_create_and_list(self): + created = json.loads( + cronjob( + action="create", + prompt="Check server status", + schedule="every 1h", + name="Server Check", + ) + ) + assert created["success"] is True + + listing = json.loads(cronjob(action="list")) + assert listing["success"] is True + assert listing["count"] == 1 + assert listing["jobs"][0]["name"] == "Server Check" + assert listing["jobs"][0]["state"] == "scheduled" + + def test_pause_and_resume(self): + created = json.loads(cronjob(action="create", prompt="Check", schedule="every 1h")) + job_id = created["job_id"] + + paused = json.loads(cronjob(action="pause", job_id=job_id)) + assert paused["success"] is True + assert paused["job"]["state"] == "paused" + + resumed = json.loads(cronjob(action="resume", job_id=job_id)) + assert resumed["success"] is True + assert resumed["job"]["state"] == "scheduled" + + def test_update_schedule_recomputes_display(self): + created = json.loads(cronjob(action="create", prompt="Check", schedule="every 1h")) + job_id = created["job_id"] + + updated = json.loads( + cronjob(action="update", job_id=job_id, schedule="every 2h", name="New Name") + ) + assert updated["success"] is True + assert updated["job"]["name"] == "New Name" + assert updated["job"]["schedule"] == "every 120m" + + def test_create_skill_backed_job(self): + result = json.loads( + cronjob( + action="create", + skill="blogwatcher", + prompt="Check the configured feeds and summarize anything new.", + schedule="every 1h", + name="Morning feeds", + ) + ) + assert result["success"] is True + assert result["skill"] == "blogwatcher" + + 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_multi_skill_default_name_prefers_prompt_when_present(self): + result = json.loads( + cronjob( + action="create", + skills=["blogwatcher", "find-nearby"], + prompt="Use both skills and combine the result.", + schedule="every 1h", + ) + ) + assert result["success"] is True + assert result["name"] == "Use both skills and combine the result." + + 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/__init__.py b/tools/__init__.py index 04eabd023..6c02865d0 100644 --- a/tools/__init__.py +++ b/tools/__init__.py @@ -84,14 +84,13 @@ from .browser_tool import ( # Cronjob management tools (CLI-only, hermes-cli toolset) from .cronjob_tools import ( + cronjob, schedule_cronjob, list_cronjobs, remove_cronjob, check_cronjob_requirements, get_cronjob_tool_definitions, - SCHEDULE_CRONJOB_SCHEMA, - LIST_CRONJOBS_SCHEMA, - REMOVE_CRONJOB_SCHEMA + CRONJOB_SCHEMA, ) # RL Training tools (Tinker-Atropos) @@ -211,14 +210,13 @@ __all__ = [ 'check_browser_requirements', 'BROWSER_TOOL_SCHEMAS', # Cronjob management tools (CLI-only) + 'cronjob', 'schedule_cronjob', 'list_cronjobs', 'remove_cronjob', 'check_cronjob_requirements', 'get_cronjob_tool_definitions', - 'SCHEDULE_CRONJOB_SCHEMA', - 'LIST_CRONJOBS_SCHEMA', - 'REMOVE_CRONJOB_SCHEMA', + 'CRONJOB_SCHEMA', # RL Training tools 'rl_list_environments', 'rl_select_environment', diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index bad2e22af..124223c77 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -1,24 +1,31 @@ """ 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. +Expose a single compressed action-oriented tool to avoid schema/context bloat. +Compatibility wrappers remain for direct Python callers and legacy tests. """ import json import os import re -from typing import Optional - -# Import from cron module (will be available when properly installed) import sys from pathlib import Path +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)) -from cron.jobs import create_job, get_job, list_jobs, remove_job +from cron.jobs import ( + create_job, + get_job, + list_jobs, + parse_schedule, + pause_job, + remove_job, + resume_job, + trigger_job, + update_job, +) # --------------------------------------------------------------------------- @@ -56,9 +63,207 @@ def _scan_cron_prompt(prompt: str) -> str: return "" -# ============================================================================= -# Tool: schedule_cronjob -# ============================================================================= +def _origin_from_env() -> Optional[Dict[str, str]]: + origin_platform = os.getenv("HERMES_SESSION_PLATFORM") + origin_chat_id = os.getenv("HERMES_SESSION_CHAT_ID") + if origin_platform and origin_chat_id: + return { + "platform": origin_platform, + "chat_id": origin_chat_id, + "chat_name": os.getenv("HERMES_SESSION_CHAT_NAME"), + } + return None + + +def _repeat_display(job: Dict[str, Any]) -> str: + times = (job.get("repeat") or {}).get("times") + completed = (job.get("repeat") or {}).get("completed", 0) + if times is None: + return "forever" + if times == 1: + return "once" if completed == 0 else "1/1" + 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": 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), + "deliver": job.get("deliver", "local"), + "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), + "state": job.get("state", "scheduled" if job.get("enabled", True) else "paused"), + "paused_at": job.get("paused_at"), + "paused_reason": job.get("paused_reason"), + } + + +def cronjob( + action: str, + job_id: Optional[str] = None, + prompt: Optional[str] = None, + schedule: Optional[str] = None, + name: Optional[str] = None, + repeat: Optional[int] = None, + 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: + """Unified cron job management tool.""" + del task_id # unused but kept for handler signature compatibility + + try: + normalized = (action or "").strip().lower() + + if normalized == "create": + if not schedule: + return json.dumps({"success": False, "error": "schedule is required for create"}, 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: + return json.dumps({"success": False, "error": scan_error}, indent=2) + + job = create_job( + prompt=prompt or "", + schedule=schedule, + name=name, + repeat=repeat, + deliver=deliver, + origin=_origin_from_env(), + skills=canonical_skills, + ) + return json.dumps( + { + "success": True, + "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"), + "next_run_at": job["next_run_at"], + "job": _format_job(job), + "message": f"Cron job '{job['name']}' created.", + }, + indent=2, + ) + + if normalized == "list": + jobs = [_format_job(job) for job in list_jobs(include_disabled=include_disabled)] + return json.dumps({"success": True, "count": len(jobs), "jobs": jobs}, indent=2) + + if not job_id: + return json.dumps({"success": False, "error": f"job_id is required for action '{normalized}'"}, indent=2) + + job = get_job(job_id) + if not job: + return json.dumps( + {"success": False, "error": f"Job with ID '{job_id}' not found. Use cronjob(action='list') to inspect jobs."}, + indent=2, + ) + + if normalized == "remove": + removed = remove_job(job_id) + if not removed: + return json.dumps({"success": False, "error": f"Failed to remove job '{job_id}'"}, indent=2) + return json.dumps( + { + "success": True, + "message": f"Cron job '{job['name']}' removed.", + "removed_job": { + "id": job_id, + "name": job["name"], + "schedule": job.get("schedule_display"), + }, + }, + indent=2, + ) + + if normalized == "pause": + updated = pause_job(job_id, reason=reason) + return json.dumps({"success": True, "job": _format_job(updated)}, indent=2) + + if normalized == "resume": + updated = resume_job(job_id) + return json.dumps({"success": True, "job": _format_job(updated)}, indent=2) + + if normalized in {"run", "run_now", "trigger"}: + updated = trigger_job(job_id) + return json.dumps({"success": True, "job": _format_job(updated)}, indent=2) + + if normalized == "update": + updates: Dict[str, Any] = {} + if prompt is not None: + scan_error = _scan_cron_prompt(prompt) + if scan_error: + return json.dumps({"success": False, "error": scan_error}, indent=2) + updates["prompt"] = prompt + if name is not None: + updates["name"] = name + if deliver is not None: + updates["deliver"] = deliver + 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 + updates["repeat"] = repeat_state + if schedule is not None: + parsed_schedule = parse_schedule(schedule) + updates["schedule"] = parsed_schedule + updates["schedule_display"] = parsed_schedule.get("display", schedule) + if job.get("state") != "paused": + updates["state"] = "scheduled" + updates["enabled"] = True + if not updates: + return json.dumps({"success": False, "error": "No updates provided."}, indent=2) + updated = update_job(job_id, updates) + return json.dumps({"success": True, "job": _format_job(updated)}, indent=2) + + return json.dumps({"success": False, "error": f"Unknown cron action '{action}'"}, indent=2) + + except Exception as e: + return json.dumps({"success": False, "error": str(e)}, indent=2) + + +# --------------------------------------------------------------------------- +# Compatibility wrappers +# --------------------------------------------------------------------------- def schedule_cronjob( prompt: str, @@ -66,329 +271,104 @@ def schedule_cronjob( name: Optional[str] = None, repeat: Optional[int] = None, deliver: Optional[str] = None, - task_id: str = 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 - deliver: Where to send the output. Options: - - "origin": Back to where this job was created (default) - - "local": Save to local files only (~/.hermes/cron/output/) - - "telegram": Send to Telegram home channel - - "discord": Send to Discord home channel - - "signal": Send to Signal home channel - - "telegram:123456": Send to specific chat ID - - "signal:+15551234567": Send to specific Signal number - - Returns: - JSON with job_id, next_run time, and confirmation - """ - # Scan prompt for critical threats before scheduling - scan_error = _scan_cron_prompt(prompt) - if scan_error: - return json.dumps({"success": False, "error": scan_error}, indent=2) - - # Get origin info from environment if available - origin = None - origin_platform = os.getenv("HERMES_SESSION_PLATFORM") - origin_chat_id = os.getenv("HERMES_SESSION_CHAT_ID") - if origin_platform and origin_chat_id: - origin = { - "platform": origin_platform, - "chat_id": origin_chat_id, - "chat_name": os.getenv("HERMES_SESSION_CHAT_NAME"), - } - - try: - job = create_job( - prompt=prompt, - schedule=schedule, - name=name, - repeat=repeat, - deliver=deliver, - origin=origin - ) - - # 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, - "deliver": job.get("deliver", "local"), - "next_run_at": job["next_run_at"], - "message": f"Cronjob '{job['name']}' created. It will run {repeat_display}, deliver to {job.get('deliver', 'local')}, next at {job['next_run_at']}." - }, indent=2) - - except Exception as e: - return json.dumps({ - "success": False, - "error": str(e) - }, indent=2) + return cronjob( + action="create", + prompt=prompt, + schedule=schedule, + name=name, + repeat=repeat, + deliver=deliver, + task_id=task_id, + ) -SCHEDULE_CRONJOB_SCHEMA = { - "name": "schedule_cronjob", - "description": """Schedule an automated task to run the agent on a schedule. +def list_cronjobs(include_disabled: bool = False, task_id: str = None) -> str: + return cronjob(action="list", include_disabled=include_disabled, task_id=task_id) -⚠️ 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. +def remove_cronjob(job_id: str, task_id: str = None) -> str: + return cronjob(action="remove", job_id=job_id, task_id=task_id) -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 +CRONJOB_SCHEMA = { + "name": "cronjob", + "description": """Manage scheduled cron jobs with a single compressed tool. -DELIVERY OPTIONS (where output goes): -- "origin": Back to current chat (default if in messaging platform) -- "local": Save to local files only (default if in CLI) -- "telegram": Send to Telegram home channel -- "discord": Send to Discord home channel -- "telegram:123456": Send to specific chat (if user provides ID) +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 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. NOTE: The agent's final response is auto-delivered to the target β€” do NOT use send_message in the prompt for that same destination. Same-target send_message -calls are skipped so the cron doesn't double-message the user. Put the main +calls are skipped to avoid duplicate cron deliveries. Put the primary user-facing content in the final response, and use send_message only for additional or different targets. -Use for: reminders, periodic checks, scheduled reports, automated maintenance.""", +Important safety rule: cron-run sessions should not recursively schedule more cron jobs.""", "parameters": { "type": "object", "properties": { + "action": { + "type": "string", + "description": "One of: create, list, update, pause, resume, remove, run" + }, + "job_id": { + "type": "string", + "description": "Required for update/pause/resume/remove/run" + }, "prompt": { "type": "string", - "description": "Complete, self-contained instructions. Must include ALL context - the future agent will have NO memory of this conversation." + "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", - "description": "When to run: '30m' (once in 30min), 'every 30m' (recurring), '0 9 * * *' (cron), or ISO timestamp" + "description": "For create/update: '30m', 'every 2h', '0 9 * * *', or ISO timestamp" }, "name": { "type": "string", - "description": "Optional human-friendly name for the job" + "description": "Optional human-friendly name" }, "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." + "description": "Optional repeat count. Omit for defaults (once for one-shot, forever for recurring)." }, "deliver": { "type": "string", - "description": "Where to send output: 'origin' (back to this chat), 'local' (files only), 'telegram', 'discord', 'signal', or 'platform:chat_id'" - } - }, - "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, - "deliver": job.get("deliver", "local"), - "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": { + "description": "Delivery target: origin, local, telegram, discord, signal, or platform:chat_id" + }, "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": { + "description": "For list: include paused/completed jobs" + }, + "skill": { "type": "string", - "description": "The ID of the cronjob to remove (from list_cronjobs output)" + "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", + "description": "Optional pause reason" } }, - "required": ["job_id"] + "required": ["action"] } } -# ============================================================================= -# Requirements check -# ============================================================================= - def check_cronjob_requirements() -> bool: """ Check if cronjob tools can be used. - + Available in interactive CLI mode and gateway/messaging platforms. Cronjobs are server-side scheduled tasks so they work from any interface. """ @@ -399,66 +379,31 @@ def check_cronjob_requirements() -> bool: ) -# ============================================================================= -# 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) + return [CRONJOB_SCHEMA] # --- Registry --- from tools.registry import registry registry.register( - name="schedule_cronjob", + name="cronjob", toolset="cronjob", - schema=SCHEDULE_CRONJOB_SCHEMA, - handler=lambda args, **kw: schedule_cronjob( - prompt=args.get("prompt", ""), - schedule=args.get("schedule", ""), + schema=CRONJOB_SCHEMA, + handler=lambda args, **kw: cronjob( + action=args.get("action", ""), + job_id=args.get("job_id"), + prompt=args.get("prompt"), + schedule=args.get("schedule"), name=args.get("name"), repeat=args.get("repeat"), deliver=args.get("deliver"), - task_id=kw.get("task_id")), - check_fn=check_cronjob_requirements, -) -registry.register( - name="list_cronjobs", - toolset="cronjob", - schema=LIST_CRONJOBS_SCHEMA, - handler=lambda args, **kw: list_cronjobs( include_disabled=args.get("include_disabled", False), - task_id=kw.get("task_id")), - check_fn=check_cronjob_requirements, -) -registry.register( - name="remove_cronjob", - toolset="cronjob", - schema=REMOVE_CRONJOB_SCHEMA, - handler=lambda args, **kw: remove_cronjob( - job_id=args.get("job_id", ""), - task_id=kw.get("task_id")), + skill=args.get("skill"), + skills=args.get("skills"), + reason=args.get("reason"), + task_id=kw.get("task_id"), + ), check_fn=check_cronjob_requirements, ) diff --git a/toolsets.py b/toolsets.py index 221ff2ca8..cd811d376 100644 --- a/toolsets.py +++ b/toolsets.py @@ -57,7 +57,7 @@ _HERMES_CORE_TOOLS = [ # Code execution + delegation "execute_code", "delegate_task", # Cronjob management - "schedule_cronjob", "list_cronjobs", "remove_cronjob", + "cronjob", # Cross-platform messaging (gated on gateway running via check_fn) "send_message", # Honcho memory tools (gated on honcho being active via check_fn) @@ -125,8 +125,8 @@ TOOLSETS = { }, "cronjob": { - "description": "Cronjob management tools - schedule, list, and remove automated tasks", - "tools": ["schedule_cronjob", "list_cronjobs", "remove_cronjob"], + "description": "Cronjob management tool - create, list, update, pause, resume, remove, and trigger scheduled tasks", + "tools": ["cronjob"], "includes": [] }, diff --git a/website/docs/developer-guide/cron-internals.md b/website/docs/developer-guide/cron-internals.md index 574cc522a..b47bc7bc1 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 b6c97e4e8..85f11c408 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 1d6869743..d3f9a0ce3 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/slash-commands.md b/website/docs/reference/slash-commands.md index 302e9e518..9ef454600 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -54,7 +54,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/tools` | List available tools | | `/toolsets` | List available toolsets | | `/skills` | Search, install, inspect, or manage skills from online registries | -| `/cron` | Manage scheduled tasks (list, add, remove) | +| `/cron` | Manage scheduled tasks (list, add/create, edit, pause, resume, run, remove) | | `/reload-mcp` | Reload MCP servers from config.yaml | ### Info diff --git a/website/docs/reference/tools-reference.md b/website/docs/reference/tools-reference.md index a4fb2322d..7a5e24a52 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 8f1adb108..1481414bb 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 03bf4bfbc..dd7d5606f 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,36 @@ 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 for that same destination. If a cron run calls `send_message` to the exact target the scheduler will already deliver to, Hermes skips that duplicate send and tells the model to put the user-facing content in the final response instead. Use `send_message` only for additional or different targets. -The agent knows your connected platforms and home channels β€” it'll choose sensible defaults. +### Relative delays (one-shot) -## 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 +222,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 c752a5628..faf1023e7 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).