feat: add multi-skill cron editing and docs
This commit is contained in:
parent
df5c61b37c
commit
c3ea620796
19 changed files with 968 additions and 407 deletions
|
|
@ -516,7 +516,8 @@ def get_cute_tool_message(
|
||||||
if tool_name == "cronjob":
|
if tool_name == "cronjob":
|
||||||
action = args.get("action", "?")
|
action = args.get("action", "?")
|
||||||
if action == "create":
|
if action == "create":
|
||||||
label = args.get("name") or args.get("skill") or args.get("prompt", "task")
|
skills = args.get("skills") or ([] if not args.get("skill") else [args.get("skill")])
|
||||||
|
label = args.get("name") or (skills[0] if skills else None) or args.get("prompt", "task")
|
||||||
return _wrap(f"┊ ⏰ cron create {_trunc(label, 24)} {dur}")
|
return _wrap(f"┊ ⏰ cron create {_trunc(label, 24)} {dur}")
|
||||||
if action == "list":
|
if action == "list":
|
||||||
return _wrap(f"┊ ⏰ cron listing {dur}")
|
return _wrap(f"┊ ⏰ cron listing {dur}")
|
||||||
|
|
|
||||||
334
cli.py
334
cli.py
|
|
@ -429,7 +429,7 @@ from hermes_cli import callbacks as _callbacks
|
||||||
from toolsets import get_all_toolsets, get_toolset_info, resolve_toolset, validate_toolset
|
from toolsets import get_all_toolsets, get_toolset_info, resolve_toolset, validate_toolset
|
||||||
|
|
||||||
# Cron job system for scheduled tasks (execution is handled by the gateway)
|
# Cron job system for scheduled tasks (execution is handled by the gateway)
|
||||||
from cron import create_job, list_jobs, remove_job, get_job, pause_job, resume_job, trigger_job
|
from cron import get_job
|
||||||
|
|
||||||
# Resource cleanup imports for safe shutdown (terminal VMs, browser sessions)
|
# Resource cleanup imports for safe shutdown (terminal VMs, browser sessions)
|
||||||
from tools.terminal_tool import cleanup_all_environments as _cleanup_all_terminals
|
from tools.terminal_tool import cleanup_all_environments as _cleanup_all_terminals
|
||||||
|
|
@ -2588,162 +2588,248 @@ class HermesCLI:
|
||||||
|
|
||||||
def _handle_cron_command(self, cmd: str):
|
def _handle_cron_command(self, cmd: str):
|
||||||
"""Handle the /cron command to manage scheduled tasks."""
|
"""Handle the /cron command to manage scheduled tasks."""
|
||||||
parts = cmd.split(maxsplit=2)
|
import shlex
|
||||||
|
from tools.cronjob_tools import cronjob as cronjob_tool
|
||||||
if len(parts) == 1:
|
|
||||||
# /cron - show help and list
|
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()
|
||||||
print("+" + "-" * 60 + "+")
|
print("+" + "-" * 68 + "+")
|
||||||
print("|" + " " * 18 + "(^_^) Scheduled Tasks" + " " * 19 + "|")
|
print("|" + " " * 22 + "(^_^) Scheduled Tasks" + " " * 23 + "|")
|
||||||
print("+" + "-" * 60 + "+")
|
print("+" + "-" * 68 + "+")
|
||||||
print()
|
print()
|
||||||
print(" Commands:")
|
print(" Commands:")
|
||||||
print(" /cron - List scheduled jobs")
|
print(" /cron list")
|
||||||
print(" /cron list - List scheduled jobs")
|
print(' /cron add "every 2h" "Check server status" [--skill blogwatcher]')
|
||||||
print(' /cron add <schedule> <prompt> - Add a new job')
|
print(' /cron edit <job_id> --schedule "every 4h" --prompt "New task"')
|
||||||
print(" /cron pause <job_id> - Pause a job")
|
print(" /cron edit <job_id> --skill blogwatcher --skill find-nearby")
|
||||||
print(" /cron resume <job_id> - Resume a job")
|
print(" /cron edit <job_id> --remove-skill blogwatcher")
|
||||||
print(" /cron run <job_id> - Run a job on the next tick")
|
print(" /cron edit <job_id> --clear-skills")
|
||||||
print(" /cron remove <job_id> - Remove a job")
|
print(" /cron pause <job_id>")
|
||||||
|
print(" /cron resume <job_id>")
|
||||||
|
print(" /cron run <job_id>")
|
||||||
|
print(" /cron remove <job_id>")
|
||||||
print()
|
print()
|
||||||
print(" Schedule formats:")
|
result = _cron_api(action="list")
|
||||||
print(" 30m, 2h, 1d - One-shot delay")
|
jobs = result.get("jobs", []) if result.get("success") else []
|
||||||
print(' "every 30m", "every 2h" - Recurring interval')
|
|
||||||
print(' "0 9 * * *" - Cron expression')
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Show current jobs
|
|
||||||
jobs = list_jobs()
|
|
||||||
if jobs:
|
if jobs:
|
||||||
print(" Current Jobs:")
|
print(" Current Jobs:")
|
||||||
print(" " + "-" * 55)
|
print(" " + "-" * 63)
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
# Format repeat status
|
repeat_str = job.get("repeat", "?")
|
||||||
times = job["repeat"].get("times")
|
print(f" {job['job_id'][:12]:<12} | {job['schedule']:<15} | {repeat_str:<8}")
|
||||||
completed = job["repeat"].get("completed", 0)
|
if job.get("skills"):
|
||||||
if times is None:
|
print(f" Skills: {', '.join(job['skills'])}")
|
||||||
repeat_str = "forever"
|
print(f" {job.get('prompt_preview', '')}")
|
||||||
else:
|
|
||||||
repeat_str = f"{completed}/{times}"
|
|
||||||
|
|
||||||
print(f" {job['id'][:12]:<12} | {job['schedule_display']:<15} | {repeat_str:<8}")
|
|
||||||
prompt_preview = job['prompt'][:45] + "..." if len(job['prompt']) > 45 else job['prompt']
|
|
||||||
print(f" {prompt_preview}")
|
|
||||||
if job.get("next_run_at"):
|
if job.get("next_run_at"):
|
||||||
from datetime import datetime
|
print(f" Next: {job['next_run_at']}")
|
||||||
next_run = datetime.fromisoformat(job["next_run_at"])
|
|
||||||
print(f" Next: {next_run.strftime('%Y-%m-%d %H:%M')}")
|
|
||||||
print()
|
print()
|
||||||
else:
|
else:
|
||||||
print(" No scheduled jobs. Use '/cron add' to create one.")
|
print(" No scheduled jobs. Use '/cron add' to create one.")
|
||||||
print()
|
print()
|
||||||
return
|
return
|
||||||
|
|
||||||
subcommand = parts[1].lower()
|
subcommand = tokens[1].lower()
|
||||||
|
opts = _parse_flags(tokens[2:])
|
||||||
|
if opts is None:
|
||||||
|
return
|
||||||
|
|
||||||
if subcommand == "list":
|
if subcommand == "list":
|
||||||
# /cron list - just show jobs
|
result = _cron_api(action="list", include_disabled=opts["all"])
|
||||||
jobs = list_jobs()
|
jobs = result.get("jobs", []) if result.get("success") else []
|
||||||
if not jobs:
|
if not jobs:
|
||||||
print("(._.) No scheduled jobs.")
|
print("(._.) No scheduled jobs.")
|
||||||
return
|
return
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("Scheduled Jobs:")
|
print("Scheduled Jobs:")
|
||||||
print("-" * 70)
|
print("-" * 80)
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
times = job["repeat"].get("times")
|
print(f" ID: {job['job_id']}")
|
||||||
completed = job["repeat"].get("completed", 0)
|
|
||||||
repeat_str = "forever" if times is None else f"{completed}/{times}"
|
|
||||||
|
|
||||||
print(f" ID: {job['id']}")
|
|
||||||
print(f" Name: {job['name']}")
|
print(f" 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" 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"):
|
if job.get("last_run_at"):
|
||||||
print(f" Last run: {job['last_run_at']} ({job.get('last_status', '?')})")
|
print(f" Last run: {job['last_run_at']} ({job.get('last_status', '?')})")
|
||||||
print()
|
print()
|
||||||
|
return
|
||||||
elif subcommand == "add":
|
|
||||||
# /cron add <schedule> <prompt>
|
if subcommand in {"add", "create"}:
|
||||||
if len(parts) < 3:
|
positionals = opts["positionals"]
|
||||||
|
if not positionals:
|
||||||
print("(._.) Usage: /cron add <schedule> <prompt>")
|
print("(._.) Usage: /cron add <schedule> <prompt>")
|
||||||
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
|
return
|
||||||
|
schedule = opts["schedule"] or positionals[0]
|
||||||
# Parse schedule and prompt
|
prompt = opts["prompt"] or " ".join(positionals[1:])
|
||||||
rest = parts[2].strip()
|
skills = _normalize_skills(opts["skills"])
|
||||||
|
if not prompt and not skills:
|
||||||
# Handle quoted schedule (e.g., "every 30m" or "0 9 * * *")
|
print("(._.) Please provide a prompt or at least one skill")
|
||||||
if rest.startswith('"'):
|
return
|
||||||
# Find closing quote
|
result = _cron_api(
|
||||||
close_quote = rest.find('"', 1)
|
action="create",
|
||||||
if close_quote == -1:
|
schedule=schedule,
|
||||||
print("(._.) Unmatched quote in schedule")
|
prompt=prompt or None,
|
||||||
return
|
name=opts["name"],
|
||||||
schedule = rest[1:close_quote]
|
deliver=opts["deliver"],
|
||||||
prompt = rest[close_quote + 1:].strip()
|
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:
|
else:
|
||||||
# First word is schedule
|
print(f"(x_x) Failed to create job: {result.get('error')}")
|
||||||
schedule_parts = rest.split(maxsplit=1)
|
return
|
||||||
schedule = schedule_parts[0]
|
|
||||||
prompt = schedule_parts[1] if len(schedule_parts) > 1 else ""
|
|
||||||
|
|
||||||
if not prompt:
|
|
||||||
print("(._.) Please provide a prompt for the job")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
job = create_job(prompt=prompt, schedule=schedule)
|
|
||||||
print(f"(^_^)b Created job: {job['id']}")
|
|
||||||
print(f" Schedule: {job['schedule_display']}")
|
|
||||||
print(f" Next run: {job['next_run_at']}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"(x_x) Failed to create job: {e}")
|
|
||||||
|
|
||||||
elif subcommand in {"pause", "resume", "run", "remove", "rm", "delete"}:
|
|
||||||
if len(parts) < 3:
|
|
||||||
print(f"(._.) Usage: /cron {subcommand} <job_id>")
|
|
||||||
return
|
|
||||||
|
|
||||||
job_id = parts[2].strip()
|
if subcommand == "edit":
|
||||||
job = get_job(job_id)
|
positionals = opts["positionals"]
|
||||||
|
if not positionals:
|
||||||
if not job:
|
print("(._.) Usage: /cron edit <job_id> [--schedule ...] [--prompt ...] [--skill ...]")
|
||||||
|
return
|
||||||
|
job_id = positionals[0]
|
||||||
|
existing = get_job(job_id)
|
||||||
|
if not existing:
|
||||||
print(f"(._.) Job not found: {job_id}")
|
print(f"(._.) Job not found: {job_id}")
|
||||||
return
|
return
|
||||||
|
|
||||||
if subcommand == "pause":
|
final_skills = None
|
||||||
updated = pause_job(job_id, reason="paused from /cron")
|
replacement_skills = _normalize_skills(opts["skills"])
|
||||||
if updated:
|
add_skills = _normalize_skills(opts["add_skills"])
|
||||||
print(f"(^_^)b Paused job: {updated['name']} ({job_id})")
|
remove_skills = set(_normalize_skills(opts["remove_skills"]))
|
||||||
else:
|
existing_skills = list(existing.get("skills") or ([] if not existing.get("skill") else [existing.get("skill")]))
|
||||||
print(f"(x_x) Failed to pause job: {job_id}")
|
if opts["clear_skills"]:
|
||||||
elif subcommand == "resume":
|
final_skills = []
|
||||||
updated = resume_job(job_id)
|
elif replacement_skills:
|
||||||
if updated:
|
final_skills = replacement_skills
|
||||||
print(f"(^_^)b Resumed job: {updated['name']} ({job_id})")
|
elif add_skills or remove_skills:
|
||||||
print(f" Next run: {updated.get('next_run_at')}")
|
final_skills = [skill for skill in existing_skills if skill not in remove_skills]
|
||||||
else:
|
for skill in add_skills:
|
||||||
print(f"(x_x) Failed to resume job: {job_id}")
|
if skill not in final_skills:
|
||||||
elif subcommand == "run":
|
final_skills.append(skill)
|
||||||
updated = trigger_job(job_id)
|
|
||||||
if updated:
|
|
||||||
print(f"(^_^)b Triggered job: {updated['name']} ({job_id})")
|
|
||||||
print(" It will run on the next scheduler tick.")
|
|
||||||
else:
|
|
||||||
print(f"(x_x) Failed to trigger job: {job_id}")
|
|
||||||
else:
|
|
||||||
if remove_job(job_id):
|
|
||||||
print(f"(^_^)b Removed job: {job['name']} ({job_id})")
|
|
||||||
else:
|
|
||||||
print(f"(x_x) Failed to remove job: {job_id}")
|
|
||||||
|
|
||||||
else:
|
result = _cron_api(
|
||||||
print(f"(._.) Unknown cron command: {subcommand}")
|
action="update",
|
||||||
print(" Available: list, add, pause, resume, run, remove")
|
job_id=job_id,
|
||||||
|
schedule=opts["schedule"],
|
||||||
|
prompt=opts["prompt"],
|
||||||
|
name=opts["name"],
|
||||||
|
deliver=opts["deliver"],
|
||||||
|
repeat=opts["repeat"],
|
||||||
|
skills=final_skills,
|
||||||
|
)
|
||||||
|
if result.get("success"):
|
||||||
|
job = result["job"]
|
||||||
|
print(f"(^_^)b Updated job: {job['job_id']}")
|
||||||
|
print(f" Schedule: {job['schedule']}")
|
||||||
|
if job.get("skills"):
|
||||||
|
print(f" Skills: {', '.join(job['skills'])}")
|
||||||
|
else:
|
||||||
|
print(" Skills: none")
|
||||||
|
else:
|
||||||
|
print(f"(x_x) Failed to update job: {result.get('error')}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if subcommand in {"pause", "resume", "run", "remove", "rm", "delete"}:
|
||||||
|
positionals = opts["positionals"]
|
||||||
|
if not positionals:
|
||||||
|
print(f"(._.) Usage: /cron {subcommand} <job_id>")
|
||||||
|
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):
|
def _handle_skills_command(self, cmd: str):
|
||||||
"""Handle /skills slash command — delegates to hermes_cli.skills_hub."""
|
"""Handle /skills slash command — delegates to hermes_cli.skills_hub."""
|
||||||
|
|
|
||||||
59
cron/jobs.py
59
cron/jobs.py
|
|
@ -32,6 +32,32 @@ JOBS_FILE = CRON_DIR / "jobs.json"
|
||||||
OUTPUT_DIR = CRON_DIR / "output"
|
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):
|
def _secure_dir(path: Path):
|
||||||
"""Set directory to owner-only access (0700). No-op on Windows."""
|
"""Set directory to owner-only access (0700). No-op on Windows."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -265,6 +291,7 @@ def create_job(
|
||||||
deliver: Optional[str] = None,
|
deliver: Optional[str] = None,
|
||||||
origin: Optional[Dict[str, Any]] = None,
|
origin: Optional[Dict[str, Any]] = None,
|
||||||
skill: Optional[str] = None,
|
skill: Optional[str] = None,
|
||||||
|
skills: Optional[List[str]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Create a new cron job.
|
Create a new cron job.
|
||||||
|
|
@ -276,7 +303,8 @@ def create_job(
|
||||||
repeat: How many times to run (None = forever, 1 = once)
|
repeat: How many times to run (None = forever, 1 = once)
|
||||||
deliver: Where to deliver output ("origin", "local", "telegram", etc.)
|
deliver: Where to deliver output ("origin", "local", "telegram", etc.)
|
||||||
origin: Source info where job was created (for "origin" delivery)
|
origin: Source info where job was created (for "origin" delivery)
|
||||||
skill: Optional skill name to load before running the prompt
|
skill: Optional legacy single skill name to load before running the prompt
|
||||||
|
skills: Optional ordered list of skills to load before running the prompt
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The created job dict
|
The created job dict
|
||||||
|
|
@ -294,12 +322,14 @@ def create_job(
|
||||||
job_id = uuid.uuid4().hex[:12]
|
job_id = uuid.uuid4().hex[:12]
|
||||||
now = _hermes_now().isoformat()
|
now = _hermes_now().isoformat()
|
||||||
|
|
||||||
label_source = skill or prompt or "cron job"
|
normalized_skills = _normalize_skill_list(skill, skills)
|
||||||
|
label_source = (normalized_skills[0] if normalized_skills else prompt) or "cron job"
|
||||||
job = {
|
job = {
|
||||||
"id": job_id,
|
"id": job_id,
|
||||||
"name": name or label_source[:50].strip(),
|
"name": name or label_source[:50].strip(),
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"skill": skill,
|
"skills": normalized_skills,
|
||||||
|
"skill": normalized_skills[0] if normalized_skills else None,
|
||||||
"schedule": parsed_schedule,
|
"schedule": parsed_schedule,
|
||||||
"schedule_display": parsed_schedule.get("display", schedule),
|
"schedule_display": parsed_schedule.get("display", schedule),
|
||||||
"repeat": {
|
"repeat": {
|
||||||
|
|
@ -332,13 +362,13 @@ def get_job(job_id: str) -> Optional[Dict[str, Any]]:
|
||||||
jobs = load_jobs()
|
jobs = load_jobs()
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
if job["id"] == job_id:
|
if job["id"] == job_id:
|
||||||
return job
|
return _apply_skill_fields(job)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def list_jobs(include_disabled: bool = False) -> List[Dict[str, Any]]:
|
def list_jobs(include_disabled: bool = False) -> List[Dict[str, Any]]:
|
||||||
"""List all jobs, optionally including disabled ones."""
|
"""List all jobs, optionally including disabled ones."""
|
||||||
jobs = load_jobs()
|
jobs = [_apply_skill_fields(j) for j in load_jobs()]
|
||||||
if not include_disabled:
|
if not include_disabled:
|
||||||
jobs = [j for j in jobs if j.get("enabled", True)]
|
jobs = [j for j in jobs if j.get("enabled", True)]
|
||||||
return jobs
|
return jobs
|
||||||
|
|
@ -351,9 +381,14 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]
|
||||||
if job["id"] != job_id:
|
if job["id"] != job_id:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
updated = {**job, **updates}
|
updated = _apply_skill_fields({**job, **updates})
|
||||||
schedule_changed = "schedule" in 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:
|
if schedule_changed:
|
||||||
updated_schedule = updated["schedule"]
|
updated_schedule = updated["schedule"]
|
||||||
updated["schedule_display"] = updates.get(
|
updated["schedule_display"] = updates.get(
|
||||||
|
|
@ -368,7 +403,7 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]
|
||||||
|
|
||||||
jobs[i] = updated
|
jobs[i] = updated
|
||||||
save_jobs(jobs)
|
save_jobs(jobs)
|
||||||
return jobs[i]
|
return _apply_skill_fields(jobs[i])
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -479,21 +514,21 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None):
|
||||||
def get_due_jobs() -> List[Dict[str, Any]]:
|
def get_due_jobs() -> List[Dict[str, Any]]:
|
||||||
"""Get all jobs that are due to run now."""
|
"""Get all jobs that are due to run now."""
|
||||||
now = _hermes_now()
|
now = _hermes_now()
|
||||||
jobs = load_jobs()
|
jobs = [_apply_skill_fields(j) for j in load_jobs()]
|
||||||
due = []
|
due = []
|
||||||
|
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
if not job.get("enabled", True):
|
if not job.get("enabled", True):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
next_run = job.get("next_run_at")
|
next_run = job.get("next_run_at")
|
||||||
if not next_run:
|
if not next_run:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
next_run_dt = _ensure_aware(datetime.fromisoformat(next_run))
|
next_run_dt = _ensure_aware(datetime.fromisoformat(next_run))
|
||||||
if next_run_dt <= now:
|
if next_run_dt <= now:
|
||||||
due.append(job)
|
due.append(job)
|
||||||
|
|
||||||
return due
|
return due
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -149,25 +149,37 @@ def _deliver_result(job: dict, content: str) -> None:
|
||||||
|
|
||||||
|
|
||||||
def _build_job_prompt(job: dict) -> str:
|
def _build_job_prompt(job: dict) -> str:
|
||||||
"""Build the effective prompt for a cron job, optionally loading a skill first."""
|
"""Build the effective prompt for a cron job, optionally loading one or more skills first."""
|
||||||
prompt = job.get("prompt", "")
|
prompt = job.get("prompt", "")
|
||||||
skill_name = job.get("skill")
|
skills = job.get("skills")
|
||||||
if not skill_name:
|
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
|
return prompt
|
||||||
|
|
||||||
from tools.skills_tool import skill_view
|
from tools.skills_tool import skill_view
|
||||||
|
|
||||||
loaded = json.loads(skill_view(skill_name))
|
parts = []
|
||||||
if not loaded.get("success"):
|
for skill_name in skill_names:
|
||||||
error = loaded.get("error") or f"Failed to load skill '{skill_name}'"
|
loaded = json.loads(skill_view(skill_name))
|
||||||
raise RuntimeError(error)
|
if not loaded.get("success"):
|
||||||
|
error = loaded.get("error") or f"Failed to load skill '{skill_name}'"
|
||||||
|
raise RuntimeError(error)
|
||||||
|
|
||||||
|
content = str(loaded.get("content") or "").strip()
|
||||||
|
if parts:
|
||||||
|
parts.append("")
|
||||||
|
parts.extend(
|
||||||
|
[
|
||||||
|
f'[SYSTEM: The user has invoked the "{skill_name}" skill, indicating they want you to follow its instructions. The full skill content is loaded below.]',
|
||||||
|
"",
|
||||||
|
content,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
content = str(loaded.get("content") or "").strip()
|
|
||||||
parts = [
|
|
||||||
f'[SYSTEM: The user has invoked the "{skill_name}" skill, indicating they want you to follow its instructions. The full skill content is loaded below.]',
|
|
||||||
"",
|
|
||||||
content,
|
|
||||||
]
|
|
||||||
if prompt:
|
if prompt:
|
||||||
parts.extend(["", f"The user has provided the following instruction alongside the skill invocation: {prompt}"])
|
parts.extend(["", f"The user has provided the following instruction alongside the skill invocation: {prompt}"])
|
||||||
return "\n".join(parts)
|
return "\n".join(parts)
|
||||||
|
|
|
||||||
|
|
@ -315,7 +315,7 @@ def build_delivery_context_for_tool(
|
||||||
origin: Optional[SessionSource] = None
|
origin: Optional[SessionSource] = None
|
||||||
) -> Dict[str, Any]:
|
) -> 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.
|
This is passed to the tool so it can validate and explain delivery targets.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
"""
|
"""
|
||||||
Cron subcommand for hermes CLI.
|
Cron subcommand for hermes CLI.
|
||||||
|
|
||||||
Handles: hermes cron [list|status|tick]
|
Handles standalone cron management commands like list, create, edit,
|
||||||
|
pause/resume/run/remove, status, and tick.
|
||||||
Cronjobs are executed automatically by the gateway daemon (hermes gateway).
|
|
||||||
Install the gateway as a service for background execution:
|
|
||||||
hermes gateway install
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Iterable, List, Optional
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
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
|
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):
|
def cron_list(show_all: bool = False):
|
||||||
"""List all scheduled jobs."""
|
"""List all scheduled jobs."""
|
||||||
from cron.jobs import list_jobs
|
from cron.jobs import list_jobs
|
||||||
|
|
||||||
jobs = list_jobs(include_disabled=show_all)
|
jobs = list_jobs(include_disabled=show_all)
|
||||||
|
|
||||||
if not jobs:
|
if not jobs:
|
||||||
print(color("No scheduled jobs.", Colors.DIM))
|
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
|
return
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print(color("┌─────────────────────────────────────────────────────────────────────────┐", Colors.CYAN))
|
print(color("┌─────────────────────────────────────────────────────────────────────────┐", Colors.CYAN))
|
||||||
print(color("│ Scheduled Jobs │", Colors.CYAN))
|
print(color("│ Scheduled Jobs │", Colors.CYAN))
|
||||||
print(color("└─────────────────────────────────────────────────────────────────────────┘", Colors.CYAN))
|
print(color("└─────────────────────────────────────────────────────────────────────────┘", Colors.CYAN))
|
||||||
print()
|
print()
|
||||||
|
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
job_id = job.get("id", "?")[:8]
|
job_id = job.get("id", "?")[:8]
|
||||||
name = job.get("name", "(unnamed)")
|
name = job.get("name", "(unnamed)")
|
||||||
schedule = job.get("schedule_display", job.get("schedule", {}).get("value", "?"))
|
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", "?")
|
next_run = job.get("next_run_at", "?")
|
||||||
|
|
||||||
repeat_info = job.get("repeat", {})
|
repeat_info = job.get("repeat", {})
|
||||||
repeat_times = repeat_info.get("times")
|
repeat_times = repeat_info.get("times")
|
||||||
repeat_completed = repeat_info.get("completed", 0)
|
repeat_completed = repeat_info.get("completed", 0)
|
||||||
|
repeat_str = f"{repeat_completed}/{repeat_times}" if repeat_times else "∞"
|
||||||
if repeat_times:
|
|
||||||
repeat_str = f"{repeat_completed}/{repeat_times}"
|
|
||||||
else:
|
|
||||||
repeat_str = "∞"
|
|
||||||
|
|
||||||
deliver = job.get("deliver", ["local"])
|
deliver = job.get("deliver", ["local"])
|
||||||
if isinstance(deliver, str):
|
if isinstance(deliver, str):
|
||||||
deliver = [deliver]
|
deliver = [deliver]
|
||||||
deliver_str = ", ".join(deliver)
|
deliver_str = ", ".join(deliver)
|
||||||
|
|
||||||
if not enabled:
|
skills = job.get("skills") or ([job["skill"]] if job.get("skill") else [])
|
||||||
status = color("[disabled]", Colors.RED)
|
if state == "paused":
|
||||||
else:
|
status = color("[paused]", Colors.YELLOW)
|
||||||
|
elif state == "completed":
|
||||||
|
status = color("[completed]", Colors.BLUE)
|
||||||
|
elif job.get("enabled", True):
|
||||||
status = color("[active]", Colors.GREEN)
|
status = color("[active]", Colors.GREEN)
|
||||||
|
else:
|
||||||
|
status = color("[disabled]", Colors.RED)
|
||||||
|
|
||||||
print(f" {color(job_id, Colors.YELLOW)} {status}")
|
print(f" {color(job_id, Colors.YELLOW)} {status}")
|
||||||
print(f" Name: {name}")
|
print(f" Name: {name}")
|
||||||
print(f" Schedule: {schedule}")
|
print(f" Schedule: {schedule}")
|
||||||
print(f" Repeat: {repeat_str}")
|
print(f" Repeat: {repeat_str}")
|
||||||
print(f" Next run: {next_run}")
|
print(f" Next run: {next_run}")
|
||||||
print(f" Deliver: {deliver_str}")
|
print(f" Deliver: {deliver_str}")
|
||||||
|
if skills:
|
||||||
|
print(f" Skills: {', '.join(skills)}")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# Warn if gateway isn't running
|
|
||||||
from hermes_cli.gateway import find_gateway_pids
|
from hermes_cli.gateway import find_gateway_pids
|
||||||
if not find_gateway_pids():
|
if not find_gateway_pids():
|
||||||
print(color(" ⚠ Gateway is not running — jobs won't fire automatically.", Colors.YELLOW))
|
print(color(" ⚠ Gateway is not running — jobs won't fire automatically.", Colors.YELLOW))
|
||||||
|
|
@ -86,9 +109,9 @@ def cron_status():
|
||||||
"""Show cron execution status."""
|
"""Show cron execution status."""
|
||||||
from cron.jobs import list_jobs
|
from cron.jobs import list_jobs
|
||||||
from hermes_cli.gateway import find_gateway_pids
|
from hermes_cli.gateway import find_gateway_pids
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|
||||||
pids = find_gateway_pids()
|
pids = find_gateway_pids()
|
||||||
if pids:
|
if pids:
|
||||||
print(color("✓ Gateway is running — cron jobs will fire automatically", Colors.GREEN))
|
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(" To enable automatic execution:")
|
||||||
print(" hermes gateway install # Install as system service (recommended)")
|
print(" hermes gateway install # Install as system service (recommended)")
|
||||||
print(" hermes gateway # Or run in foreground")
|
print(" hermes gateway # Or run in foreground")
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|
||||||
jobs = list_jobs(include_disabled=False)
|
jobs = list_jobs(include_disabled=False)
|
||||||
if jobs:
|
if jobs:
|
||||||
next_runs = [j.get("next_run_at") for j in jobs if j.get("next_run_at")]
|
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)}")
|
print(f" Next run: {min(next_runs)}")
|
||||||
else:
|
else:
|
||||||
print(" No active jobs")
|
print(" No active jobs")
|
||||||
|
|
||||||
print()
|
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):
|
def cron_command(args):
|
||||||
"""Handle cron subcommands."""
|
"""Handle cron subcommands."""
|
||||||
subcmd = getattr(args, 'cron_command', None)
|
subcmd = getattr(args, 'cron_command', None)
|
||||||
|
|
||||||
if subcmd is None or subcmd == "list":
|
if subcmd is None or subcmd == "list":
|
||||||
show_all = getattr(args, 'all', False)
|
show_all = getattr(args, 'all', False)
|
||||||
cron_list(show_all)
|
cron_list(show_all)
|
||||||
|
return 0
|
||||||
elif subcmd == "tick":
|
|
||||||
cron_tick()
|
if subcmd == "status":
|
||||||
|
|
||||||
elif subcmd == "status":
|
|
||||||
cron_status()
|
cron_status()
|
||||||
|
return 0
|
||||||
else:
|
|
||||||
print(f"Unknown cron command: {subcmd}")
|
if subcmd == "tick":
|
||||||
print("Usage: hermes cron [list|status|tick]")
|
cron_tick()
|
||||||
sys.exit(1)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -2589,13 +2589,48 @@ For more help on a command:
|
||||||
# cron list
|
# cron list
|
||||||
cron_list = cron_subparsers.add_parser("list", help="List scheduled jobs")
|
cron_list = cron_subparsers.add_parser("list", help="List scheduled jobs")
|
||||||
cron_list.add_argument("--all", action="store_true", help="Include disabled 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 status
|
||||||
cron_subparsers.add_parser("status", help="Check if cron scheduler is running")
|
cron_subparsers.add_parser("status", help="Check if cron scheduler is running")
|
||||||
|
|
||||||
# cron tick (mostly for debugging)
|
# cron tick (mostly for debugging)
|
||||||
cron_subparsers.add_parser("tick", help="Run due jobs once and exit")
|
cron_subparsers.add_parser("tick", help="Run due jobs once and exit")
|
||||||
|
|
||||||
cron_parser.set_defaults(func=cmd_cron)
|
cron_parser.set_defaults(func=cmd_cron)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ CONFIGURABLE_TOOLSETS = [
|
||||||
("session_search", "🔎 Session Search", "search past conversations"),
|
("session_search", "🔎 Session Search", "search past conversations"),
|
||||||
("clarify", "❓ Clarifying Questions", "clarify"),
|
("clarify", "❓ Clarifying Questions", "clarify"),
|
||||||
("delegation", "👥 Task Delegation", "delegate_task"),
|
("delegation", "👥 Task Delegation", "delegate_task"),
|
||||||
("cronjob", "⏰ Cron Jobs", "create, list, update, pause, resume, remove, run"),
|
("cronjob", "⏰ Cron Jobs", "create/list/update/pause/resume/run, with optional attached skills"),
|
||||||
("rl", "🧪 RL Training", "Tinker-Atropos training tools"),
|
("rl", "🧪 RL Training", "Tinker-Atropos training tools"),
|
||||||
("homeassistant", "🏠 Home Assistant", "smart home device control"),
|
("homeassistant", "🏠 Home Assistant", "smart home device control"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -248,3 +248,49 @@ class TestRunJobSkillBacked:
|
||||||
assert "blogwatcher" in prompt_arg
|
assert "blogwatcher" in prompt_arg
|
||||||
assert "Follow this skill" in prompt_arg
|
assert "Follow this skill" in prompt_arg
|
||||||
assert "Check the feeds and summarize anything new." 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
|
||||||
|
|
|
||||||
107
tests/hermes_cli/test_cron.py
Normal file
107
tests/hermes_cli/test_cron.py
Normal file
|
|
@ -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"
|
||||||
|
|
@ -245,3 +245,35 @@ class TestUnifiedCronjobTool:
|
||||||
|
|
||||||
listing = json.loads(cronjob(action="list"))
|
listing = json.loads(cronjob(action="list"))
|
||||||
assert listing["jobs"][0]["skill"] == "blogwatcher"
|
assert listing["jobs"][0]["skill"] == "blogwatcher"
|
||||||
|
|
||||||
|
def test_create_multi_skill_job(self):
|
||||||
|
result = json.loads(
|
||||||
|
cronjob(
|
||||||
|
action="create",
|
||||||
|
skills=["blogwatcher", "find-nearby"],
|
||||||
|
prompt="Use both skills and combine the result.",
|
||||||
|
schedule="every 1h",
|
||||||
|
name="Combo job",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["skills"] == ["blogwatcher", "find-nearby"]
|
||||||
|
|
||||||
|
listing = json.loads(cronjob(action="list"))
|
||||||
|
assert listing["jobs"][0]["skills"] == ["blogwatcher", "find-nearby"]
|
||||||
|
|
||||||
|
def test_update_can_clear_skills(self):
|
||||||
|
created = json.loads(
|
||||||
|
cronjob(
|
||||||
|
action="create",
|
||||||
|
skills=["blogwatcher", "find-nearby"],
|
||||||
|
prompt="Use both skills and combine the result.",
|
||||||
|
schedule="every 1h",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
updated = json.loads(
|
||||||
|
cronjob(action="update", job_id=created["job_id"], skills=[])
|
||||||
|
)
|
||||||
|
assert updated["success"] is True
|
||||||
|
assert updated["job"]["skills"] == []
|
||||||
|
assert updated["job"]["skill"] is None
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
# Import from cron module (will be available when properly installed)
|
# Import from cron module (will be available when properly installed)
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
@ -85,12 +85,31 @@ def _repeat_display(job: Dict[str, Any]) -> str:
|
||||||
return f"{completed}/{times}" if completed else f"{times} times"
|
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]:
|
def _format_job(job: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
prompt = job.get("prompt", "")
|
prompt = job.get("prompt", "")
|
||||||
|
skills = _canonical_skills(job.get("skill"), job.get("skills"))
|
||||||
return {
|
return {
|
||||||
"job_id": job["id"],
|
"job_id": job["id"],
|
||||||
"name": job["name"],
|
"name": job["name"],
|
||||||
"skill": job.get("skill"),
|
"skill": skills[0] if skills else None,
|
||||||
|
"skills": skills,
|
||||||
"prompt_preview": prompt[:100] + "..." if len(prompt) > 100 else prompt,
|
"prompt_preview": prompt[:100] + "..." if len(prompt) > 100 else prompt,
|
||||||
"schedule": job.get("schedule_display"),
|
"schedule": job.get("schedule_display"),
|
||||||
"repeat": _repeat_display(job),
|
"repeat": _repeat_display(job),
|
||||||
|
|
@ -115,6 +134,7 @@ def cronjob(
|
||||||
deliver: Optional[str] = None,
|
deliver: Optional[str] = None,
|
||||||
include_disabled: bool = False,
|
include_disabled: bool = False,
|
||||||
skill: Optional[str] = None,
|
skill: Optional[str] = None,
|
||||||
|
skills: Optional[List[str]] = None,
|
||||||
reason: Optional[str] = None,
|
reason: Optional[str] = None,
|
||||||
task_id: str = None,
|
task_id: str = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
|
@ -127,8 +147,9 @@ def cronjob(
|
||||||
if normalized == "create":
|
if normalized == "create":
|
||||||
if not schedule:
|
if not schedule:
|
||||||
return json.dumps({"success": False, "error": "schedule is required for create"}, indent=2)
|
return json.dumps({"success": False, "error": "schedule is required for create"}, indent=2)
|
||||||
if not prompt and not skill:
|
canonical_skills = _canonical_skills(skill, skills)
|
||||||
return json.dumps({"success": False, "error": "create requires either prompt or skill"}, indent=2)
|
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:
|
if prompt:
|
||||||
scan_error = _scan_cron_prompt(prompt)
|
scan_error = _scan_cron_prompt(prompt)
|
||||||
if scan_error:
|
if scan_error:
|
||||||
|
|
@ -141,7 +162,7 @@ def cronjob(
|
||||||
repeat=repeat,
|
repeat=repeat,
|
||||||
deliver=deliver,
|
deliver=deliver,
|
||||||
origin=_origin_from_env(),
|
origin=_origin_from_env(),
|
||||||
skill=skill,
|
skills=canonical_skills,
|
||||||
)
|
)
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
{
|
{
|
||||||
|
|
@ -149,6 +170,7 @@ def cronjob(
|
||||||
"job_id": job["id"],
|
"job_id": job["id"],
|
||||||
"name": job["name"],
|
"name": job["name"],
|
||||||
"skill": job.get("skill"),
|
"skill": job.get("skill"),
|
||||||
|
"skills": job.get("skills", []),
|
||||||
"schedule": job["schedule_display"],
|
"schedule": job["schedule_display"],
|
||||||
"repeat": _repeat_display(job),
|
"repeat": _repeat_display(job),
|
||||||
"deliver": job.get("deliver", "local"),
|
"deliver": job.get("deliver", "local"),
|
||||||
|
|
@ -213,8 +235,10 @@ def cronjob(
|
||||||
updates["name"] = name
|
updates["name"] = name
|
||||||
if deliver is not None:
|
if deliver is not None:
|
||||||
updates["deliver"] = deliver
|
updates["deliver"] = deliver
|
||||||
if skill is not None:
|
if skills is not None or skill is not None:
|
||||||
updates["skill"] = skill
|
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:
|
if repeat is not None:
|
||||||
repeat_state = dict(job.get("repeat") or {})
|
repeat_state = dict(job.get("repeat") or {})
|
||||||
repeat_state["times"] = repeat
|
repeat_state["times"] = repeat
|
||||||
|
|
@ -272,12 +296,13 @@ CRONJOB_SCHEMA = {
|
||||||
"name": "cronjob",
|
"name": "cronjob",
|
||||||
"description": """Manage scheduled cron jobs with a single compressed tool.
|
"description": """Manage scheduled cron jobs with a single compressed tool.
|
||||||
|
|
||||||
Use action='create' to schedule a new job from a prompt or a skill.
|
Use action='create' to schedule a new job from a prompt or one or more skills.
|
||||||
Use action='list' to inspect jobs.
|
Use action='list' to inspect jobs.
|
||||||
Use action='update', 'pause', 'resume', 'remove', or 'run' to manage an existing job.
|
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.
|
Jobs run in a fresh session with no current-chat context, so prompts must be self-contained.
|
||||||
If skill is provided on create, the future cron run loads that skill first, then follows the prompt as the task instruction.
|
If skill or skills are provided on create, the future cron run loads those skills in order, then follows the prompt as the task instruction.
|
||||||
|
On update, passing skills=[] clears attached skills.
|
||||||
|
|
||||||
Important safety rule: cron-run sessions should not recursively schedule more cron jobs.""",
|
Important safety rule: cron-run sessions should not recursively schedule more cron jobs.""",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|
@ -293,7 +318,7 @@ Important safety rule: cron-run sessions should not recursively schedule more cr
|
||||||
},
|
},
|
||||||
"prompt": {
|
"prompt": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "For create: the full self-contained prompt. If skill is also provided, this becomes the task instruction paired with that skill."
|
"description": "For create: the full self-contained prompt. If skill or skills are also provided, this becomes the task instruction paired with those skills."
|
||||||
},
|
},
|
||||||
"schedule": {
|
"schedule": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
@ -317,7 +342,12 @@ Important safety rule: cron-run sessions should not recursively schedule more cr
|
||||||
},
|
},
|
||||||
"skill": {
|
"skill": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional skill name to load before executing the cron prompt"
|
"description": "Optional single skill name to load before executing the cron prompt"
|
||||||
|
},
|
||||||
|
"skills": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Optional ordered list of skills to load before executing the cron prompt. On update, pass an empty array to clear attached skills."
|
||||||
},
|
},
|
||||||
"reason": {
|
"reason": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
@ -365,6 +395,7 @@ registry.register(
|
||||||
deliver=args.get("deliver"),
|
deliver=args.get("deliver"),
|
||||||
include_disabled=args.get("include_disabled", False),
|
include_disabled=args.get("include_disabled", False),
|
||||||
skill=args.get("skill"),
|
skill=args.get("skill"),
|
||||||
|
skills=args.get("skills"),
|
||||||
reason=args.get("reason"),
|
reason=args.get("reason"),
|
||||||
task_id=kw.get("task_id"),
|
task_id=kw.get("task_id"),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
sidebar_position: 11
|
sidebar_position: 11
|
||||||
title: "Cron Internals"
|
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
|
# Cron Internals
|
||||||
|
|
@ -10,7 +10,9 @@ Hermes cron support is implemented primarily in:
|
||||||
|
|
||||||
- `cron/jobs.py`
|
- `cron/jobs.py`
|
||||||
- `cron/scheduler.py`
|
- `cron/scheduler.py`
|
||||||
|
- `tools/cronjob_tools.py`
|
||||||
- `gateway/run.py`
|
- `gateway/run.py`
|
||||||
|
- `hermes_cli/cron.py`
|
||||||
|
|
||||||
## Scheduling model
|
## Scheduling model
|
||||||
|
|
||||||
|
|
@ -21,9 +23,30 @@ Hermes supports:
|
||||||
- cron expressions
|
- cron expressions
|
||||||
- explicit timestamps
|
- explicit timestamps
|
||||||
|
|
||||||
|
The model-facing surface is a single `cronjob` tool with action-style operations:
|
||||||
|
|
||||||
|
- `create`
|
||||||
|
- `list`
|
||||||
|
- `update`
|
||||||
|
- `pause`
|
||||||
|
- `resume`
|
||||||
|
- `run`
|
||||||
|
- `remove`
|
||||||
|
|
||||||
## Job storage
|
## 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
|
## Runtime behavior
|
||||||
|
|
||||||
|
|
@ -32,11 +55,22 @@ The scheduler:
|
||||||
- loads jobs
|
- loads jobs
|
||||||
- computes due work
|
- computes due work
|
||||||
- executes jobs in fresh agent sessions
|
- executes jobs in fresh agent sessions
|
||||||
|
- optionally injects one or more skills before the prompt
|
||||||
- handles repeat counters
|
- 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.
|
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
|
## Delivery model
|
||||||
|
|
||||||
Cron jobs can deliver to:
|
Cron jobs can deliver to:
|
||||||
|
|
@ -48,7 +82,7 @@ Cron jobs can deliver to:
|
||||||
|
|
||||||
## Locking
|
## 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
|
## Related docs
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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
|
### Option B: CLI Slash Command
|
||||||
|
|
||||||
|
|
@ -232,7 +232,7 @@ Or ask conversationally:
|
||||||
Remove my morning briefing cron job.
|
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
|
### Check Gateway Status
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -181,12 +181,18 @@ hermes status [--all] [--deep]
|
||||||
## `hermes cron`
|
## `hermes cron`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hermes cron <list|status|tick>
|
hermes cron <list|create|edit|pause|resume|run|remove|status|tick>
|
||||||
```
|
```
|
||||||
|
|
||||||
| Subcommand | Description |
|
| Subcommand | Description |
|
||||||
|------------|-------------|
|
|------------|-------------|
|
||||||
| `list` | Show scheduled jobs. |
|
| `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. |
|
| `status` | Check whether the cron scheduler is running. |
|
||||||
| `tick` | Run due jobs once and exit. |
|
| `tick` | Run due jobs once and exit. |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,9 +40,7 @@ This page documents the built-in Hermes tool registry as it exists in code. Avai
|
||||||
|
|
||||||
| Tool | Description | Requires environment |
|
| 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… | — |
|
| `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. | — |
|
||||||
| `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… | — |
|
|
||||||
|
|
||||||
## `delegation` toolset
|
## `delegation` toolset
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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` |
|
| `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` |
|
| `clarify` | core | `clarify` |
|
||||||
| `code_execution` | core | `execute_code` |
|
| `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` |
|
| `debugging` | composite | `patch`, `process`, `read_file`, `search_files`, `terminal`, `web_extract`, `web_search`, `write_file` |
|
||||||
| `delegation` | core | `delegate_task` |
|
| `delegation` | core | `delegate_task` |
|
||||||
| `file` | core | `patch`, `read_file`, `search_files`, `write_file` |
|
| `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-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`, `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`, `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`, `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`, `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`, `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`, `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`, `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`, `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`, `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`, `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`, `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`, `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`, `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`, `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`, `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`, `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` |
|
| `homeassistant` | core | `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services` |
|
||||||
| `honcho` | core | `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search` |
|
| `honcho` | core | `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search` |
|
||||||
| `image_gen` | core | `image_generate` |
|
| `image_gen` | core | `image_generate` |
|
||||||
|
|
|
||||||
|
|
@ -1,68 +1,183 @@
|
||||||
---
|
---
|
||||||
sidebar_position: 5
|
sidebar_position: 5
|
||||||
title: "Scheduled Tasks (Cron)"
|
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)
|
# 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 30m "Remind me to check the build"
|
||||||
/cron add "every 2h" "Check server status"
|
/cron add "every 2h" "Check server status"
|
||||||
/cron add "0 9 * * *" "Morning briefing"
|
/cron add "every 1h" "Summarize new feed items" --skill blogwatcher
|
||||||
/cron list
|
/cron add "every 1h" "Use both skills and combine the result" --skill blogwatcher --skill find-nearby
|
||||||
/cron remove <job_id>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Through Natural Conversation
|
### From the standalone CLI
|
||||||
|
|
||||||
Simply ask the agent on any platform:
|
|
||||||
|
|
||||||
|
```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.
|
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 <job_id> --schedule "every 4h"
|
||||||
|
/cron edit <job_id> --prompt "Use the revised task"
|
||||||
|
/cron edit <job_id> --skill blogwatcher --skill find-nearby
|
||||||
|
/cron edit <job_id> --remove-skill blogwatcher
|
||||||
|
/cron edit <job_id> --clear-skills
|
||||||
|
```
|
||||||
|
|
||||||
|
### Standalone CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes cron edit <job_id> --schedule "every 4h"
|
||||||
|
hermes cron edit <job_id> --prompt "Use the revised task"
|
||||||
|
hermes cron edit <job_id> --skill blogwatcher --skill find-nearby
|
||||||
|
hermes cron edit <job_id> --add-skill find-nearby
|
||||||
|
hermes cron edit <job_id> --remove-skill blogwatcher
|
||||||
|
hermes cron edit <job_id> --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 <job_id>
|
||||||
|
/cron resume <job_id>
|
||||||
|
/cron run <job_id>
|
||||||
|
/cron remove <job_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Standalone CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes cron list
|
||||||
|
hermes cron pause <job_id>
|
||||||
|
hermes cron resume <job_id>
|
||||||
|
hermes cron run <job_id>
|
||||||
|
hermes cron remove <job_id>
|
||||||
|
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
|
```bash
|
||||||
hermes gateway install # Install as system service (recommended)
|
hermes gateway install # Install as system service (recommended)
|
||||||
hermes gateway # Or run in foreground
|
hermes gateway # Or run in foreground
|
||||||
|
|
||||||
hermes cron list # View scheduled jobs
|
hermes cron list
|
||||||
hermes cron status # Check if gateway is running
|
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`
|
1. loads jobs from `~/.hermes/cron/jobs.json`
|
||||||
2. Checks each enabled job's `next_run_at` against the current time
|
2. checks `next_run_at` against the current time
|
||||||
3. For each due job, spawns a fresh `AIAgent` session with the job's prompt
|
3. starts a fresh `AIAgent` session for each due job
|
||||||
4. The agent runs to completion with full tool access
|
4. optionally injects one or more attached skills into that fresh session
|
||||||
5. The final response is delivered to the configured target
|
5. runs the prompt to completion
|
||||||
6. The job's run count is incremented and next run time computed
|
6. delivers the final response
|
||||||
7. Jobs that hit their repeat limit are auto-removed
|
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
|
## Delivery options
|
||||||
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
|
|
||||||
|
|
||||||
When scheduling jobs, you specify where the output goes:
|
When scheduling jobs, you specify where the output goes:
|
||||||
|
|
||||||
|
|
@ -70,48 +185,34 @@ When scheduling jobs, you specify where the output goes:
|
||||||
|--------|-------------|---------|
|
|--------|-------------|---------|
|
||||||
| `"origin"` | Back to where the job was created | Default on messaging platforms |
|
| `"origin"` | Back to where the job was created | Default on messaging platforms |
|
||||||
| `"local"` | Save to local files only (`~/.hermes/cron/output/`) | Default on CLI |
|
| `"local"` | Save to local files only (`~/.hermes/cron/output/`) | Default on CLI |
|
||||||
| `"telegram"` | Telegram home channel | Uses `TELEGRAM_HOME_CHANNEL` env var |
|
| `"telegram"` | Telegram home channel | Uses `TELEGRAM_HOME_CHANNEL` |
|
||||||
| `"discord"` | Discord home channel | Uses `DISCORD_HOME_CHANNEL` env var |
|
| `"discord"` | Discord home channel | Uses `DISCORD_HOME_CHANNEL` |
|
||||||
| `"telegram:123456"` | Specific Telegram chat by ID | For directing output to a specific chat |
|
| `"telegram:123456"` | Specific Telegram chat by ID | Direct delivery |
|
||||||
| `"discord:987654"` | Specific Discord channel by ID | For directing output to a specific channel |
|
| `"discord:987654"` | Specific Discord channel by ID | Direct delivery |
|
||||||
|
|
||||||
**How `"origin"` works:** When a job is created from a messaging platform, Hermes records the source platform and chat ID. When the job runs and deliver is `"origin"`, the output is sent back to that exact platform and chat. If origin info isn't available (e.g., job created from CLI), delivery falls back to local.
|
The agent's final response is automatically delivered. You do not need to call `send_message` in the cron prompt.
|
||||||
|
|
||||||
**How platform names work:** When you specify a bare platform name like `"telegram"`, Hermes first checks if the job's origin matches that platform and uses the origin chat ID. Otherwise, it falls back to the platform's home channel configured via environment variable (e.g., `TELEGRAM_HOME_CHANNEL`).
|
## Schedule formats
|
||||||
|
|
||||||
The agent's final response is automatically delivered — you do **not** need to include `send_message` in the cron prompt.
|
### Relative delays (one-shot)
|
||||||
|
|
||||||
The agent knows your connected platforms and home channels — it'll choose sensible defaults.
|
```text
|
||||||
|
|
||||||
## Schedule Formats
|
|
||||||
|
|
||||||
### Relative Delays (One-Shot)
|
|
||||||
|
|
||||||
Run once after a delay:
|
|
||||||
|
|
||||||
```
|
|
||||||
30m → Run once in 30 minutes
|
30m → Run once in 30 minutes
|
||||||
2h → Run once in 2 hours
|
2h → Run once in 2 hours
|
||||||
1d → Run once in 1 day
|
1d → Run once in 1 day
|
||||||
```
|
```
|
||||||
|
|
||||||
Supported units: `m`/`min`/`minutes`, `h`/`hr`/`hours`, `d`/`day`/`days`.
|
### Intervals (recurring)
|
||||||
|
|
||||||
### Intervals (Recurring)
|
```text
|
||||||
|
|
||||||
Run repeatedly at fixed intervals:
|
|
||||||
|
|
||||||
```
|
|
||||||
every 30m → Every 30 minutes
|
every 30m → Every 30 minutes
|
||||||
every 2h → Every 2 hours
|
every 2h → Every 2 hours
|
||||||
every 1d → Every day
|
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 * * * → Daily at 9:00 AM
|
||||||
0 9 * * 1-5 → Weekdays at 9:00 AM
|
0 9 * * 1-5 → Weekdays at 9:00 AM
|
||||||
0 */6 * * * → Every 6 hours
|
0 */6 * * * → Every 6 hours
|
||||||
|
|
@ -119,155 +220,63 @@ Standard 5-field cron syntax for precise scheduling:
|
||||||
0 0 * * 0 → Every Sunday at midnight
|
0 0 * * 0 → Every Sunday at midnight
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Cron Expression Cheat Sheet
|
### ISO timestamps
|
||||||
|
|
||||||
```
|
```text
|
||||||
┌───── 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:
|
|
||||||
|
|
||||||
```
|
|
||||||
2026-03-15T09:00:00 → One-time at March 15, 2026 9:00 AM
|
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 |
|
| One-shot (`30m`, timestamp) | 1 | Runs once |
|
||||||
| Interval (`every 2h`) | Forever (`null`) | Runs indefinitely until removed |
|
| Interval (`every 2h`) | forever | Runs until removed |
|
||||||
| Cron expression | Forever (`null`) | Runs indefinitely until removed |
|
| Cron expression | forever | Runs until removed |
|
||||||
|
|
||||||
You can override the default:
|
You can override it:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
schedule_cronjob(
|
cronjob(
|
||||||
|
action="create",
|
||||||
prompt="...",
|
prompt="...",
|
||||||
schedule="every 2h",
|
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
|
The agent-facing API is one tool:
|
||||||
|
|
||||||
### 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.
|
|
||||||
```
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
schedule_cronjob(
|
cronjob(action="create", ...)
|
||||||
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.",
|
cronjob(action="list")
|
||||||
schedule="every 15m",
|
cronjob(action="update", job_id="...")
|
||||||
name="API Health Check",
|
cronjob(action="pause", job_id="...")
|
||||||
deliver="telegram:123456789"
|
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
|
## Job storage
|
||||||
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"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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
|
The storage uses atomic file writes so interrupted writes do not leave a partially written job file behind.
|
||||||
# CLI commands
|
|
||||||
hermes cron list # View all scheduled jobs
|
|
||||||
hermes cron status # Check if the scheduler is running
|
|
||||||
|
|
||||||
# Slash commands (inside chat)
|
## Self-contained prompts still matter
|
||||||
/cron list
|
|
||||||
/cron remove <job_id>
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
:::warning Important
|
:::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:
|
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.
|
||||||
|
:::
|
||||||
- Full context and background
|
|
||||||
- Specific file paths, URLs, server addresses
|
|
||||||
- Clear instructions and success criteria
|
|
||||||
- Any credentials or configuration details
|
|
||||||
|
|
||||||
**BAD:** `"Check on that server issue"`
|
**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."`
|
**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
|
## Security
|
||||||
|
|
||||||
:::warning
|
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.
|
||||||
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.
|
|
||||||
:::
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ High-level categories:
|
||||||
| **Media** | `vision_analyze`, `image_generate`, `text_to_speech` | Multimodal analysis and generation. |
|
| **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. |
|
| **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. |
|
| **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. |
|
| **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).
|
For the authoritative code-derived registry, see [Built-in Tools Reference](/docs/reference/tools-reference) and [Toolsets Reference](/docs/reference/toolsets-reference).
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue