feat: compress cron management into one tool
This commit is contained in:
parent
c6cc92295c
commit
df5c61b37c
15 changed files with 574 additions and 397 deletions
|
|
@ -80,7 +80,7 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int = 40) -> str | N
|
||||||
"image_generate": "prompt", "text_to_speech": "text",
|
"image_generate": "prompt", "text_to_speech": "text",
|
||||||
"vision_analyze": "question", "mixture_of_agents": "user_prompt",
|
"vision_analyze": "question", "mixture_of_agents": "user_prompt",
|
||||||
"skill_view": "name", "skills_list": "category",
|
"skill_view": "name", "skills_list": "category",
|
||||||
"schedule_cronjob": "name",
|
"cronjob": "action",
|
||||||
"execute_code": "code", "delegate_task": "goal",
|
"execute_code": "code", "delegate_task": "goal",
|
||||||
"clarify": "question", "skill_manage": "name",
|
"clarify": "question", "skill_manage": "name",
|
||||||
}
|
}
|
||||||
|
|
@ -513,12 +513,14 @@ def get_cute_tool_message(
|
||||||
return _wrap(f"┊ 🧠 reason {_trunc(args.get('user_prompt', ''), 30)} {dur}")
|
return _wrap(f"┊ 🧠 reason {_trunc(args.get('user_prompt', ''), 30)} {dur}")
|
||||||
if tool_name == "send_message":
|
if tool_name == "send_message":
|
||||||
return _wrap(f"┊ 📨 send {args.get('target', '?')}: \"{_trunc(args.get('message', ''), 25)}\" {dur}")
|
return _wrap(f"┊ 📨 send {args.get('target', '?')}: \"{_trunc(args.get('message', ''), 25)}\" {dur}")
|
||||||
if tool_name == "schedule_cronjob":
|
if tool_name == "cronjob":
|
||||||
return _wrap(f"┊ ⏰ schedule {_trunc(args.get('name', args.get('prompt', 'task')), 30)} {dur}")
|
action = args.get("action", "?")
|
||||||
if tool_name == "list_cronjobs":
|
if action == "create":
|
||||||
return _wrap(f"┊ ⏰ jobs listing {dur}")
|
label = args.get("name") or args.get("skill") or args.get("prompt", "task")
|
||||||
if tool_name == "remove_cronjob":
|
return _wrap(f"┊ ⏰ cron create {_trunc(label, 24)} {dur}")
|
||||||
return _wrap(f"┊ ⏰ remove job {args.get('job_id', '?')} {dur}")
|
if action == "list":
|
||||||
|
return _wrap(f"┊ ⏰ cron listing {dur}")
|
||||||
|
return _wrap(f"┊ ⏰ cron {action} {args.get('job_id', '')} {dur}")
|
||||||
if tool_name.startswith("rl_"):
|
if tool_name.startswith("rl_"):
|
||||||
rl = {
|
rl = {
|
||||||
"rl_list_environments": "list envs", "rl_select_environment": f"select {args.get('name', '')}",
|
"rl_list_environments": "list envs", "rl_select_environment": f"select {args.get('name', '')}",
|
||||||
|
|
|
||||||
49
cli.py
49
cli.py
|
|
@ -428,8 +428,8 @@ from hermes_cli.commands import COMMANDS, SlashCommandCompleter
|
||||||
from hermes_cli import callbacks as _callbacks
|
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 (CRUD only — 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
|
from cron import create_job, list_jobs, remove_job, get_job, pause_job, resume_job, trigger_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
|
||||||
|
|
@ -2601,6 +2601,9 @@ class HermesCLI:
|
||||||
print(" /cron - List scheduled jobs")
|
print(" /cron - List scheduled jobs")
|
||||||
print(" /cron list - List scheduled jobs")
|
print(" /cron list - List scheduled jobs")
|
||||||
print(' /cron add <schedule> <prompt> - Add a new job')
|
print(' /cron add <schedule> <prompt> - Add a new job')
|
||||||
|
print(" /cron pause <job_id> - Pause a job")
|
||||||
|
print(" /cron resume <job_id> - Resume a job")
|
||||||
|
print(" /cron run <job_id> - Run a job on the next tick")
|
||||||
print(" /cron remove <job_id> - Remove a job")
|
print(" /cron remove <job_id> - Remove a job")
|
||||||
print()
|
print()
|
||||||
print(" Schedule formats:")
|
print(" Schedule formats:")
|
||||||
|
|
@ -2700,27 +2703,47 @@ class HermesCLI:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"(x_x) Failed to create job: {e}")
|
print(f"(x_x) Failed to create job: {e}")
|
||||||
|
|
||||||
elif subcommand == "remove" or subcommand == "rm" or subcommand == "delete":
|
elif subcommand in {"pause", "resume", "run", "remove", "rm", "delete"}:
|
||||||
# /cron remove <job_id>
|
|
||||||
if len(parts) < 3:
|
if len(parts) < 3:
|
||||||
print("(._.) Usage: /cron remove <job_id>")
|
print(f"(._.) Usage: /cron {subcommand} <job_id>")
|
||||||
return
|
return
|
||||||
|
|
||||||
job_id = parts[2].strip()
|
job_id = parts[2].strip()
|
||||||
job = get_job(job_id)
|
job = get_job(job_id)
|
||||||
|
|
||||||
if not job:
|
if not job:
|
||||||
print(f"(._.) Job not found: {job_id}")
|
print(f"(._.) Job not found: {job_id}")
|
||||||
return
|
return
|
||||||
|
|
||||||
if remove_job(job_id):
|
if subcommand == "pause":
|
||||||
print(f"(^_^)b Removed job: {job['name']} ({job_id})")
|
updated = pause_job(job_id, reason="paused from /cron")
|
||||||
|
if updated:
|
||||||
|
print(f"(^_^)b Paused job: {updated['name']} ({job_id})")
|
||||||
|
else:
|
||||||
|
print(f"(x_x) Failed to pause job: {job_id}")
|
||||||
|
elif subcommand == "resume":
|
||||||
|
updated = resume_job(job_id)
|
||||||
|
if updated:
|
||||||
|
print(f"(^_^)b Resumed job: {updated['name']} ({job_id})")
|
||||||
|
print(f" Next run: {updated.get('next_run_at')}")
|
||||||
|
else:
|
||||||
|
print(f"(x_x) Failed to resume job: {job_id}")
|
||||||
|
elif subcommand == "run":
|
||||||
|
updated = trigger_job(job_id)
|
||||||
|
if updated:
|
||||||
|
print(f"(^_^)b Triggered job: {updated['name']} ({job_id})")
|
||||||
|
print(" It will run on the next scheduler tick.")
|
||||||
|
else:
|
||||||
|
print(f"(x_x) Failed to trigger job: {job_id}")
|
||||||
else:
|
else:
|
||||||
print(f"(x_x) Failed to remove job: {job_id}")
|
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:
|
else:
|
||||||
print(f"(._.) Unknown cron command: {subcommand}")
|
print(f"(._.) Unknown cron command: {subcommand}")
|
||||||
print(" Available: list, add, remove")
|
print(" Available: list, add, 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."""
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ from cron.jobs import (
|
||||||
list_jobs,
|
list_jobs,
|
||||||
remove_job,
|
remove_job,
|
||||||
update_job,
|
update_job,
|
||||||
|
pause_job,
|
||||||
|
resume_job,
|
||||||
|
trigger_job,
|
||||||
JOBS_FILE,
|
JOBS_FILE,
|
||||||
)
|
)
|
||||||
from cron.scheduler import tick
|
from cron.scheduler import tick
|
||||||
|
|
@ -30,6 +33,9 @@ __all__ = [
|
||||||
"list_jobs",
|
"list_jobs",
|
||||||
"remove_job",
|
"remove_job",
|
||||||
"update_job",
|
"update_job",
|
||||||
|
"pause_job",
|
||||||
|
"resume_job",
|
||||||
|
"trigger_job",
|
||||||
"tick",
|
"tick",
|
||||||
"JOBS_FILE",
|
"JOBS_FILE",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
112
cron/jobs.py
112
cron/jobs.py
|
|
@ -263,39 +263,43 @@ def create_job(
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
repeat: Optional[int] = None,
|
repeat: Optional[int] = None,
|
||||||
deliver: Optional[str] = None,
|
deliver: Optional[str] = None,
|
||||||
origin: Optional[Dict[str, Any]] = None
|
origin: Optional[Dict[str, Any]] = None,
|
||||||
|
skill: Optional[str] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Create a new cron job.
|
Create a new cron job.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
prompt: The prompt to run (must be self-contained)
|
prompt: The prompt to run (must be self-contained, or a task instruction when skill is set)
|
||||||
schedule: Schedule string (see parse_schedule)
|
schedule: Schedule string (see parse_schedule)
|
||||||
name: Optional friendly name
|
name: Optional friendly name
|
||||||
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
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The created job dict
|
The created job dict
|
||||||
"""
|
"""
|
||||||
parsed_schedule = parse_schedule(schedule)
|
parsed_schedule = parse_schedule(schedule)
|
||||||
|
|
||||||
# Auto-set repeat=1 for one-shot schedules if not specified
|
# Auto-set repeat=1 for one-shot schedules if not specified
|
||||||
if parsed_schedule["kind"] == "once" and repeat is None:
|
if parsed_schedule["kind"] == "once" and repeat is None:
|
||||||
repeat = 1
|
repeat = 1
|
||||||
|
|
||||||
# Default delivery to origin if available, otherwise local
|
# Default delivery to origin if available, otherwise local
|
||||||
if deliver is None:
|
if deliver is None:
|
||||||
deliver = "origin" if origin else "local"
|
deliver = "origin" if origin else "local"
|
||||||
|
|
||||||
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"
|
||||||
job = {
|
job = {
|
||||||
"id": job_id,
|
"id": job_id,
|
||||||
"name": name or prompt[:50].strip(),
|
"name": name or label_source[:50].strip(),
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
|
"skill": skill,
|
||||||
"schedule": parsed_schedule,
|
"schedule": parsed_schedule,
|
||||||
"schedule_display": parsed_schedule.get("display", schedule),
|
"schedule_display": parsed_schedule.get("display", schedule),
|
||||||
"repeat": {
|
"repeat": {
|
||||||
|
|
@ -303,6 +307,9 @@ def create_job(
|
||||||
"completed": 0
|
"completed": 0
|
||||||
},
|
},
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
|
"state": "scheduled",
|
||||||
|
"paused_at": None,
|
||||||
|
"paused_reason": None,
|
||||||
"created_at": now,
|
"created_at": now,
|
||||||
"next_run_at": compute_next_run(parsed_schedule),
|
"next_run_at": compute_next_run(parsed_schedule),
|
||||||
"last_run_at": None,
|
"last_run_at": None,
|
||||||
|
|
@ -312,11 +319,11 @@ def create_job(
|
||||||
"deliver": deliver,
|
"deliver": deliver,
|
||||||
"origin": origin, # Tracks where job was created for "origin" delivery
|
"origin": origin, # Tracks where job was created for "origin" delivery
|
||||||
}
|
}
|
||||||
|
|
||||||
jobs = load_jobs()
|
jobs = load_jobs()
|
||||||
jobs.append(job)
|
jobs.append(job)
|
||||||
save_jobs(jobs)
|
save_jobs(jobs)
|
||||||
|
|
||||||
return job
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -338,16 +345,82 @@ def list_jobs(include_disabled: bool = False) -> List[Dict[str, Any]]:
|
||||||
|
|
||||||
|
|
||||||
def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
"""Update a job by ID."""
|
"""Update a job by ID, refreshing derived schedule fields when needed."""
|
||||||
jobs = load_jobs()
|
jobs = load_jobs()
|
||||||
for i, job in enumerate(jobs):
|
for i, job in enumerate(jobs):
|
||||||
if job["id"] == job_id:
|
if job["id"] != job_id:
|
||||||
jobs[i] = {**job, **updates}
|
continue
|
||||||
save_jobs(jobs)
|
|
||||||
return jobs[i]
|
updated = {**job, **updates}
|
||||||
|
schedule_changed = "schedule" in updates
|
||||||
|
|
||||||
|
if schedule_changed:
|
||||||
|
updated_schedule = updated["schedule"]
|
||||||
|
updated["schedule_display"] = updates.get(
|
||||||
|
"schedule_display",
|
||||||
|
updated_schedule.get("display", updated.get("schedule_display")),
|
||||||
|
)
|
||||||
|
if updated.get("state") != "paused":
|
||||||
|
updated["next_run_at"] = compute_next_run(updated_schedule)
|
||||||
|
|
||||||
|
if updated.get("enabled", True) and updated.get("state") != "paused" and not updated.get("next_run_at"):
|
||||||
|
updated["next_run_at"] = compute_next_run(updated["schedule"])
|
||||||
|
|
||||||
|
jobs[i] = updated
|
||||||
|
save_jobs(jobs)
|
||||||
|
return jobs[i]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def pause_job(job_id: str, reason: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Pause a job without deleting it."""
|
||||||
|
return update_job(
|
||||||
|
job_id,
|
||||||
|
{
|
||||||
|
"enabled": False,
|
||||||
|
"state": "paused",
|
||||||
|
"paused_at": _hermes_now().isoformat(),
|
||||||
|
"paused_reason": reason,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resume_job(job_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Resume a paused job and compute the next future run from now."""
|
||||||
|
job = get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
return None
|
||||||
|
|
||||||
|
next_run_at = compute_next_run(job["schedule"])
|
||||||
|
return update_job(
|
||||||
|
job_id,
|
||||||
|
{
|
||||||
|
"enabled": True,
|
||||||
|
"state": "scheduled",
|
||||||
|
"paused_at": None,
|
||||||
|
"paused_reason": None,
|
||||||
|
"next_run_at": next_run_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def trigger_job(job_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Schedule a job to run on the next scheduler tick."""
|
||||||
|
job = get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
return None
|
||||||
|
return update_job(
|
||||||
|
job_id,
|
||||||
|
{
|
||||||
|
"enabled": True,
|
||||||
|
"state": "scheduled",
|
||||||
|
"paused_at": None,
|
||||||
|
"paused_reason": None,
|
||||||
|
"next_run_at": _hermes_now().isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def remove_job(job_id: str) -> bool:
|
def remove_job(job_id: str) -> bool:
|
||||||
"""Remove a job by ID."""
|
"""Remove a job by ID."""
|
||||||
jobs = load_jobs()
|
jobs = load_jobs()
|
||||||
|
|
@ -389,11 +462,14 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None):
|
||||||
|
|
||||||
# Compute next run
|
# Compute next run
|
||||||
job["next_run_at"] = compute_next_run(job["schedule"], now)
|
job["next_run_at"] = compute_next_run(job["schedule"], now)
|
||||||
|
|
||||||
# If no next run (one-shot completed), disable
|
# If no next run (one-shot completed), disable
|
||||||
if job["next_run_at"] is None:
|
if job["next_run_at"] is None:
|
||||||
job["enabled"] = False
|
job["enabled"] = False
|
||||||
|
job["state"] = "completed"
|
||||||
|
elif job.get("state") != "paused":
|
||||||
|
job["state"] = "scheduled"
|
||||||
|
|
||||||
save_jobs(jobs)
|
save_jobs(jobs)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ runs at a time if multiple processes overlap.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -147,6 +148,31 @@ def _deliver_result(job: dict, content: str) -> None:
|
||||||
logger.warning("Job '%s': mirror_to_session failed: %s", job["id"], e)
|
logger.warning("Job '%s': mirror_to_session failed: %s", job["id"], e)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_job_prompt(job: dict) -> str:
|
||||||
|
"""Build the effective prompt for a cron job, optionally loading a skill first."""
|
||||||
|
prompt = job.get("prompt", "")
|
||||||
|
skill_name = job.get("skill")
|
||||||
|
if not skill_name:
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
from tools.skills_tool import skill_view
|
||||||
|
|
||||||
|
loaded = json.loads(skill_view(skill_name))
|
||||||
|
if not loaded.get("success"):
|
||||||
|
error = loaded.get("error") or f"Failed to load skill '{skill_name}'"
|
||||||
|
raise RuntimeError(error)
|
||||||
|
|
||||||
|
content = str(loaded.get("content") or "").strip()
|
||||||
|
parts = [
|
||||||
|
f'[SYSTEM: The user has invoked the "{skill_name}" skill, indicating they want you to follow its instructions. The full skill content is loaded below.]',
|
||||||
|
"",
|
||||||
|
content,
|
||||||
|
]
|
||||||
|
if prompt:
|
||||||
|
parts.extend(["", f"The user has provided the following instruction alongside the skill invocation: {prompt}"])
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||||
"""
|
"""
|
||||||
Execute a single cron job.
|
Execute a single cron job.
|
||||||
|
|
@ -167,9 +193,9 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||||
|
|
||||||
job_id = job["id"]
|
job_id = job["id"]
|
||||||
job_name = job["name"]
|
job_name = job["name"]
|
||||||
prompt = job["prompt"]
|
prompt = _build_job_prompt(job)
|
||||||
origin = _resolve_origin(job)
|
origin = _resolve_origin(job)
|
||||||
|
|
||||||
logger.info("Running job '%s' (ID: %s)", job_name, job_id)
|
logger.info("Running job '%s' (ID: %s)", job_name, job_id)
|
||||||
logger.info("Prompt: %s", prompt[:100])
|
logger.info("Prompt: %s", prompt[:100])
|
||||||
|
|
||||||
|
|
@ -268,6 +294,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||||
providers_ignored=pr.get("ignore"),
|
providers_ignored=pr.get("ignore"),
|
||||||
providers_order=pr.get("order"),
|
providers_order=pr.get("order"),
|
||||||
provider_sort=pr.get("sort"),
|
provider_sort=pr.get("sort"),
|
||||||
|
disabled_toolsets=["cronjob"],
|
||||||
quiet_mode=True,
|
quiet_mode=True,
|
||||||
platform="cron",
|
platform="cron",
|
||||||
session_id=f"cron_{job_id}_{_hermes_now().strftime('%Y%m%d_%H%M%S')}",
|
session_id=f"cron_{job_id}_{_hermes_now().strftime('%Y%m%d_%H%M%S')}",
|
||||||
|
|
|
||||||
|
|
@ -3694,9 +3694,7 @@ class GatewayRunner:
|
||||||
"memory": "🧠",
|
"memory": "🧠",
|
||||||
"session_search": "🔍",
|
"session_search": "🔍",
|
||||||
"send_message": "📨",
|
"send_message": "📨",
|
||||||
"schedule_cronjob": "⏰",
|
"cronjob": "⏰",
|
||||||
"list_cronjobs": "⏰",
|
|
||||||
"remove_cronjob": "⏰",
|
|
||||||
"execute_code": "🐍",
|
"execute_code": "🐍",
|
||||||
"delegate_task": "🔀",
|
"delegate_task": "🔀",
|
||||||
"clarify": "❓",
|
"clarify": "❓",
|
||||||
|
|
|
||||||
|
|
@ -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", "schedule, list, remove"),
|
("cronjob", "⏰ Cron Jobs", "create, list, update, pause, resume, remove, run"),
|
||||||
("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"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,7 @@ _LEGACY_TOOLSET_MAP = {
|
||||||
"browser_press", "browser_close", "browser_get_images",
|
"browser_press", "browser_close", "browser_get_images",
|
||||||
"browser_vision"
|
"browser_vision"
|
||||||
],
|
],
|
||||||
"cronjob_tools": ["schedule_cronjob", "list_cronjobs", "remove_cronjob"],
|
"cronjob_tools": ["cronjob"],
|
||||||
"rl_tools": [
|
"rl_tools": [
|
||||||
"rl_list_environments", "rl_select_environment",
|
"rl_list_environments", "rl_select_environment",
|
||||||
"rl_get_current_config", "rl_edit_config",
|
"rl_get_current_config", "rl_edit_config",
|
||||||
|
|
|
||||||
|
|
@ -3804,7 +3804,7 @@ class AIAgent:
|
||||||
'image_generate': '🎨', 'text_to_speech': '🔊',
|
'image_generate': '🎨', 'text_to_speech': '🔊',
|
||||||
'vision_analyze': '👁️', 'mixture_of_agents': '🧠',
|
'vision_analyze': '👁️', 'mixture_of_agents': '🧠',
|
||||||
'skills_list': '📚', 'skill_view': '📚',
|
'skills_list': '📚', 'skill_view': '📚',
|
||||||
'schedule_cronjob': '⏰', 'list_cronjobs': '⏰', 'remove_cronjob': '⏰',
|
'cronjob': '⏰',
|
||||||
'send_message': '📨', 'todo': '📋', 'memory': '🧠', 'session_search': '🔍',
|
'send_message': '📨', 'todo': '📋', 'memory': '🧠', 'session_search': '🔍',
|
||||||
'clarify': '❓', 'execute_code': '🐍', 'delegate_task': '🔀',
|
'clarify': '❓', 'execute_code': '🐍', 'delegate_task': '🔀',
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ from cron.jobs import (
|
||||||
get_job,
|
get_job,
|
||||||
list_jobs,
|
list_jobs,
|
||||||
update_job,
|
update_job,
|
||||||
|
pause_job,
|
||||||
|
resume_job,
|
||||||
remove_job,
|
remove_job,
|
||||||
mark_job_run,
|
mark_job_run,
|
||||||
get_due_jobs,
|
get_due_jobs,
|
||||||
|
|
@ -233,14 +235,18 @@ class TestUpdateJob:
|
||||||
job = create_job(prompt="Daily report", schedule="every 1h")
|
job = create_job(prompt="Daily report", schedule="every 1h")
|
||||||
assert job["schedule"]["kind"] == "interval"
|
assert job["schedule"]["kind"] == "interval"
|
||||||
assert job["schedule"]["minutes"] == 60
|
assert job["schedule"]["minutes"] == 60
|
||||||
|
old_next_run = job["next_run_at"]
|
||||||
new_schedule = parse_schedule("every 2h")
|
new_schedule = parse_schedule("every 2h")
|
||||||
updated = update_job(job["id"], {"schedule": new_schedule})
|
updated = update_job(job["id"], {"schedule": new_schedule, "schedule_display": new_schedule["display"]})
|
||||||
assert updated is not None
|
assert updated is not None
|
||||||
assert updated["schedule"]["kind"] == "interval"
|
assert updated["schedule"]["kind"] == "interval"
|
||||||
assert updated["schedule"]["minutes"] == 120
|
assert updated["schedule"]["minutes"] == 120
|
||||||
|
assert updated["schedule_display"] == "every 120m"
|
||||||
|
assert updated["next_run_at"] != old_next_run
|
||||||
# Verify persisted to disk
|
# Verify persisted to disk
|
||||||
fetched = get_job(job["id"])
|
fetched = get_job(job["id"])
|
||||||
assert fetched["schedule"]["minutes"] == 120
|
assert fetched["schedule"]["minutes"] == 120
|
||||||
|
assert fetched["schedule_display"] == "every 120m"
|
||||||
|
|
||||||
def test_update_enable_disable(self, tmp_cron_dir):
|
def test_update_enable_disable(self, tmp_cron_dir):
|
||||||
job = create_job(prompt="Toggle me", schedule="every 1h")
|
job = create_job(prompt="Toggle me", schedule="every 1h")
|
||||||
|
|
@ -255,6 +261,26 @@ class TestUpdateJob:
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestPauseResumeJob:
|
||||||
|
def test_pause_sets_state(self, tmp_cron_dir):
|
||||||
|
job = create_job(prompt="Pause me", schedule="every 1h")
|
||||||
|
paused = pause_job(job["id"], reason="user paused")
|
||||||
|
assert paused is not None
|
||||||
|
assert paused["enabled"] is False
|
||||||
|
assert paused["state"] == "paused"
|
||||||
|
assert paused["paused_reason"] == "user paused"
|
||||||
|
|
||||||
|
def test_resume_reenables_job(self, tmp_cron_dir):
|
||||||
|
job = create_job(prompt="Resume me", schedule="every 1h")
|
||||||
|
pause_job(job["id"], reason="user paused")
|
||||||
|
resumed = resume_job(job["id"])
|
||||||
|
assert resumed is not None
|
||||||
|
assert resumed["enabled"] is True
|
||||||
|
assert resumed["state"] == "scheduled"
|
||||||
|
assert resumed["paused_at"] is None
|
||||||
|
assert resumed["paused_reason"] is None
|
||||||
|
|
||||||
|
|
||||||
class TestMarkJobRun:
|
class TestMarkJobRun:
|
||||||
def test_increments_completed(self, tmp_cron_dir):
|
def test_increments_completed(self, tmp_cron_dir):
|
||||||
job = create_job(prompt="Test", schedule="every 1h")
|
job = create_job(prompt="Test", schedule="every 1h")
|
||||||
|
|
|
||||||
|
|
@ -203,3 +203,48 @@ class TestRunJobConfigLogging:
|
||||||
|
|
||||||
assert any("failed to parse prefill messages" in r.message for r in caplog.records), \
|
assert any("failed to parse prefill messages" in r.message for r in caplog.records), \
|
||||||
f"Expected 'failed to parse prefill messages' warning in logs, got: {[r.message for r in caplog.records]}"
|
f"Expected 'failed to parse prefill messages' warning in logs, got: {[r.message for r in caplog.records]}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunJobSkillBacked:
|
||||||
|
def test_run_job_loads_skill_and_disables_recursive_cron_tools(self, tmp_path):
|
||||||
|
job = {
|
||||||
|
"id": "skill-job",
|
||||||
|
"name": "skill test",
|
||||||
|
"prompt": "Check the feeds and summarize anything new.",
|
||||||
|
"skill": "blogwatcher",
|
||||||
|
}
|
||||||
|
|
||||||
|
fake_db = MagicMock()
|
||||||
|
|
||||||
|
with patch("cron.scheduler._hermes_home", tmp_path), \
|
||||||
|
patch("cron.scheduler._resolve_origin", return_value=None), \
|
||||||
|
patch("dotenv.load_dotenv"), \
|
||||||
|
patch("hermes_state.SessionDB", return_value=fake_db), \
|
||||||
|
patch(
|
||||||
|
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||||
|
return_value={
|
||||||
|
"api_key": "***",
|
||||||
|
"base_url": "https://example.invalid/v1",
|
||||||
|
"provider": "openrouter",
|
||||||
|
"api_mode": "chat_completions",
|
||||||
|
},
|
||||||
|
), \
|
||||||
|
patch("tools.skills_tool.skill_view", return_value=json.dumps({"success": True, "content": "# Blogwatcher\nFollow this skill."})), \
|
||||||
|
patch("run_agent.AIAgent") as mock_agent_cls:
|
||||||
|
mock_agent = MagicMock()
|
||||||
|
mock_agent.run_conversation.return_value = {"final_response": "ok"}
|
||||||
|
mock_agent_cls.return_value = mock_agent
|
||||||
|
|
||||||
|
success, output, final_response, error = run_job(job)
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
assert error is None
|
||||||
|
assert final_response == "ok"
|
||||||
|
|
||||||
|
kwargs = mock_agent_cls.call_args.kwargs
|
||||||
|
assert "cronjob" in (kwargs["disabled_toolsets"] or [])
|
||||||
|
|
||||||
|
prompt_arg = mock_agent.run_conversation.call_args.args[0]
|
||||||
|
assert "blogwatcher" in prompt_arg
|
||||||
|
assert "Follow this skill" in prompt_arg
|
||||||
|
assert "Check the feeds and summarize anything new." in prompt_arg
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from pathlib import Path
|
||||||
|
|
||||||
from tools.cronjob_tools import (
|
from tools.cronjob_tools import (
|
||||||
_scan_cron_prompt,
|
_scan_cron_prompt,
|
||||||
|
cronjob,
|
||||||
schedule_cronjob,
|
schedule_cronjob,
|
||||||
list_cronjobs,
|
list_cronjobs,
|
||||||
remove_cronjob,
|
remove_cronjob,
|
||||||
|
|
@ -180,3 +181,67 @@ class TestRemoveCronjob:
|
||||||
result = json.loads(remove_cronjob("nonexistent_id"))
|
result = json.loads(remove_cronjob("nonexistent_id"))
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
assert "not found" in result["error"].lower()
|
assert "not found" in result["error"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnifiedCronjobTool:
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _setup_cron_dir(self, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr("cron.jobs.CRON_DIR", tmp_path / "cron")
|
||||||
|
monkeypatch.setattr("cron.jobs.JOBS_FILE", tmp_path / "cron" / "jobs.json")
|
||||||
|
monkeypatch.setattr("cron.jobs.OUTPUT_DIR", tmp_path / "cron" / "output")
|
||||||
|
|
||||||
|
def test_create_and_list(self):
|
||||||
|
created = json.loads(
|
||||||
|
cronjob(
|
||||||
|
action="create",
|
||||||
|
prompt="Check server status",
|
||||||
|
schedule="every 1h",
|
||||||
|
name="Server Check",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert created["success"] is True
|
||||||
|
|
||||||
|
listing = json.loads(cronjob(action="list"))
|
||||||
|
assert listing["success"] is True
|
||||||
|
assert listing["count"] == 1
|
||||||
|
assert listing["jobs"][0]["name"] == "Server Check"
|
||||||
|
assert listing["jobs"][0]["state"] == "scheduled"
|
||||||
|
|
||||||
|
def test_pause_and_resume(self):
|
||||||
|
created = json.loads(cronjob(action="create", prompt="Check", schedule="every 1h"))
|
||||||
|
job_id = created["job_id"]
|
||||||
|
|
||||||
|
paused = json.loads(cronjob(action="pause", job_id=job_id))
|
||||||
|
assert paused["success"] is True
|
||||||
|
assert paused["job"]["state"] == "paused"
|
||||||
|
|
||||||
|
resumed = json.loads(cronjob(action="resume", job_id=job_id))
|
||||||
|
assert resumed["success"] is True
|
||||||
|
assert resumed["job"]["state"] == "scheduled"
|
||||||
|
|
||||||
|
def test_update_schedule_recomputes_display(self):
|
||||||
|
created = json.loads(cronjob(action="create", prompt="Check", schedule="every 1h"))
|
||||||
|
job_id = created["job_id"]
|
||||||
|
|
||||||
|
updated = json.loads(
|
||||||
|
cronjob(action="update", job_id=job_id, schedule="every 2h", name="New Name")
|
||||||
|
)
|
||||||
|
assert updated["success"] is True
|
||||||
|
assert updated["job"]["name"] == "New Name"
|
||||||
|
assert updated["job"]["schedule"] == "every 120m"
|
||||||
|
|
||||||
|
def test_create_skill_backed_job(self):
|
||||||
|
result = json.loads(
|
||||||
|
cronjob(
|
||||||
|
action="create",
|
||||||
|
skill="blogwatcher",
|
||||||
|
prompt="Check the configured feeds and summarize anything new.",
|
||||||
|
schedule="every 1h",
|
||||||
|
name="Morning feeds",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["skill"] == "blogwatcher"
|
||||||
|
|
||||||
|
listing = json.loads(cronjob(action="list"))
|
||||||
|
assert listing["jobs"][0]["skill"] == "blogwatcher"
|
||||||
|
|
|
||||||
|
|
@ -84,14 +84,13 @@ from .browser_tool import (
|
||||||
|
|
||||||
# Cronjob management tools (CLI-only, hermes-cli toolset)
|
# Cronjob management tools (CLI-only, hermes-cli toolset)
|
||||||
from .cronjob_tools import (
|
from .cronjob_tools import (
|
||||||
|
cronjob,
|
||||||
schedule_cronjob,
|
schedule_cronjob,
|
||||||
list_cronjobs,
|
list_cronjobs,
|
||||||
remove_cronjob,
|
remove_cronjob,
|
||||||
check_cronjob_requirements,
|
check_cronjob_requirements,
|
||||||
get_cronjob_tool_definitions,
|
get_cronjob_tool_definitions,
|
||||||
SCHEDULE_CRONJOB_SCHEMA,
|
CRONJOB_SCHEMA,
|
||||||
LIST_CRONJOBS_SCHEMA,
|
|
||||||
REMOVE_CRONJOB_SCHEMA
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# RL Training tools (Tinker-Atropos)
|
# RL Training tools (Tinker-Atropos)
|
||||||
|
|
@ -211,14 +210,13 @@ __all__ = [
|
||||||
'check_browser_requirements',
|
'check_browser_requirements',
|
||||||
'BROWSER_TOOL_SCHEMAS',
|
'BROWSER_TOOL_SCHEMAS',
|
||||||
# Cronjob management tools (CLI-only)
|
# Cronjob management tools (CLI-only)
|
||||||
|
'cronjob',
|
||||||
'schedule_cronjob',
|
'schedule_cronjob',
|
||||||
'list_cronjobs',
|
'list_cronjobs',
|
||||||
'remove_cronjob',
|
'remove_cronjob',
|
||||||
'check_cronjob_requirements',
|
'check_cronjob_requirements',
|
||||||
'get_cronjob_tool_definitions',
|
'get_cronjob_tool_definitions',
|
||||||
'SCHEDULE_CRONJOB_SCHEMA',
|
'CRONJOB_SCHEMA',
|
||||||
'LIST_CRONJOBS_SCHEMA',
|
|
||||||
'REMOVE_CRONJOB_SCHEMA',
|
|
||||||
# RL Training tools
|
# RL Training tools
|
||||||
'rl_list_environments',
|
'rl_list_environments',
|
||||||
'rl_select_environment',
|
'rl_select_environment',
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,31 @@
|
||||||
"""
|
"""
|
||||||
Cron job management tools for Hermes Agent.
|
Cron job management tools for Hermes Agent.
|
||||||
|
|
||||||
These tools allow the agent to schedule, list, and remove automated tasks.
|
Expose a single compressed action-oriented tool to avoid schema/context bloat.
|
||||||
Only available when running via CLI (hermes-cli toolset).
|
Compatibility wrappers remain for direct Python callers and legacy tests.
|
||||||
|
|
||||||
IMPORTANT: Cronjobs run in isolated sessions with NO prior context.
|
|
||||||
The prompt must contain ALL necessary information.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
# Import from cron module (will be available when properly installed)
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
# 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))
|
||||||
|
|
||||||
from cron.jobs import create_job, get_job, list_jobs, remove_job
|
from cron.jobs import (
|
||||||
|
create_job,
|
||||||
|
get_job,
|
||||||
|
list_jobs,
|
||||||
|
parse_schedule,
|
||||||
|
pause_job,
|
||||||
|
remove_job,
|
||||||
|
resume_job,
|
||||||
|
trigger_job,
|
||||||
|
update_job,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -56,9 +63,183 @@ def _scan_cron_prompt(prompt: str) -> str:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
def _origin_from_env() -> Optional[Dict[str, str]]:
|
||||||
# Tool: schedule_cronjob
|
origin_platform = os.getenv("HERMES_SESSION_PLATFORM")
|
||||||
# =============================================================================
|
origin_chat_id = os.getenv("HERMES_SESSION_CHAT_ID")
|
||||||
|
if origin_platform and origin_chat_id:
|
||||||
|
return {
|
||||||
|
"platform": origin_platform,
|
||||||
|
"chat_id": origin_chat_id,
|
||||||
|
"chat_name": os.getenv("HERMES_SESSION_CHAT_NAME"),
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _repeat_display(job: Dict[str, Any]) -> str:
|
||||||
|
times = (job.get("repeat") or {}).get("times")
|
||||||
|
completed = (job.get("repeat") or {}).get("completed", 0)
|
||||||
|
if times is None:
|
||||||
|
return "forever"
|
||||||
|
if times == 1:
|
||||||
|
return "once" if completed == 0 else "1/1"
|
||||||
|
return f"{completed}/{times}" if completed else f"{times} times"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_job(job: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
prompt = job.get("prompt", "")
|
||||||
|
return {
|
||||||
|
"job_id": job["id"],
|
||||||
|
"name": job["name"],
|
||||||
|
"skill": job.get("skill"),
|
||||||
|
"prompt_preview": prompt[:100] + "..." if len(prompt) > 100 else prompt,
|
||||||
|
"schedule": job.get("schedule_display"),
|
||||||
|
"repeat": _repeat_display(job),
|
||||||
|
"deliver": job.get("deliver", "local"),
|
||||||
|
"next_run_at": job.get("next_run_at"),
|
||||||
|
"last_run_at": job.get("last_run_at"),
|
||||||
|
"last_status": job.get("last_status"),
|
||||||
|
"enabled": job.get("enabled", True),
|
||||||
|
"state": job.get("state", "scheduled" if job.get("enabled", True) else "paused"),
|
||||||
|
"paused_at": job.get("paused_at"),
|
||||||
|
"paused_reason": job.get("paused_reason"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cronjob(
|
||||||
|
action: str,
|
||||||
|
job_id: Optional[str] = None,
|
||||||
|
prompt: Optional[str] = None,
|
||||||
|
schedule: Optional[str] = None,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
repeat: Optional[int] = None,
|
||||||
|
deliver: Optional[str] = None,
|
||||||
|
include_disabled: bool = False,
|
||||||
|
skill: Optional[str] = None,
|
||||||
|
reason: Optional[str] = None,
|
||||||
|
task_id: str = None,
|
||||||
|
) -> str:
|
||||||
|
"""Unified cron job management tool."""
|
||||||
|
del task_id # unused but kept for handler signature compatibility
|
||||||
|
|
||||||
|
try:
|
||||||
|
normalized = (action or "").strip().lower()
|
||||||
|
|
||||||
|
if normalized == "create":
|
||||||
|
if not schedule:
|
||||||
|
return json.dumps({"success": False, "error": "schedule is required for create"}, indent=2)
|
||||||
|
if not prompt and not skill:
|
||||||
|
return json.dumps({"success": False, "error": "create requires either prompt or skill"}, indent=2)
|
||||||
|
if prompt:
|
||||||
|
scan_error = _scan_cron_prompt(prompt)
|
||||||
|
if scan_error:
|
||||||
|
return json.dumps({"success": False, "error": scan_error}, indent=2)
|
||||||
|
|
||||||
|
job = create_job(
|
||||||
|
prompt=prompt or "",
|
||||||
|
schedule=schedule,
|
||||||
|
name=name,
|
||||||
|
repeat=repeat,
|
||||||
|
deliver=deliver,
|
||||||
|
origin=_origin_from_env(),
|
||||||
|
skill=skill,
|
||||||
|
)
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"job_id": job["id"],
|
||||||
|
"name": job["name"],
|
||||||
|
"skill": job.get("skill"),
|
||||||
|
"schedule": job["schedule_display"],
|
||||||
|
"repeat": _repeat_display(job),
|
||||||
|
"deliver": job.get("deliver", "local"),
|
||||||
|
"next_run_at": job["next_run_at"],
|
||||||
|
"job": _format_job(job),
|
||||||
|
"message": f"Cron job '{job['name']}' created.",
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized == "list":
|
||||||
|
jobs = [_format_job(job) for job in list_jobs(include_disabled=include_disabled)]
|
||||||
|
return json.dumps({"success": True, "count": len(jobs), "jobs": jobs}, indent=2)
|
||||||
|
|
||||||
|
if not job_id:
|
||||||
|
return json.dumps({"success": False, "error": f"job_id is required for action '{normalized}'"}, indent=2)
|
||||||
|
|
||||||
|
job = get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
return json.dumps(
|
||||||
|
{"success": False, "error": f"Job with ID '{job_id}' not found. Use cronjob(action='list') to inspect jobs."},
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized == "remove":
|
||||||
|
removed = remove_job(job_id)
|
||||||
|
if not removed:
|
||||||
|
return json.dumps({"success": False, "error": f"Failed to remove job '{job_id}'"}, indent=2)
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": f"Cron job '{job['name']}' removed.",
|
||||||
|
"removed_job": {
|
||||||
|
"id": job_id,
|
||||||
|
"name": job["name"],
|
||||||
|
"schedule": job.get("schedule_display"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized == "pause":
|
||||||
|
updated = pause_job(job_id, reason=reason)
|
||||||
|
return json.dumps({"success": True, "job": _format_job(updated)}, indent=2)
|
||||||
|
|
||||||
|
if normalized == "resume":
|
||||||
|
updated = resume_job(job_id)
|
||||||
|
return json.dumps({"success": True, "job": _format_job(updated)}, indent=2)
|
||||||
|
|
||||||
|
if normalized in {"run", "run_now", "trigger"}:
|
||||||
|
updated = trigger_job(job_id)
|
||||||
|
return json.dumps({"success": True, "job": _format_job(updated)}, indent=2)
|
||||||
|
|
||||||
|
if normalized == "update":
|
||||||
|
updates: Dict[str, Any] = {}
|
||||||
|
if prompt is not None:
|
||||||
|
scan_error = _scan_cron_prompt(prompt)
|
||||||
|
if scan_error:
|
||||||
|
return json.dumps({"success": False, "error": scan_error}, indent=2)
|
||||||
|
updates["prompt"] = prompt
|
||||||
|
if name is not None:
|
||||||
|
updates["name"] = name
|
||||||
|
if deliver is not None:
|
||||||
|
updates["deliver"] = deliver
|
||||||
|
if skill is not None:
|
||||||
|
updates["skill"] = skill
|
||||||
|
if repeat is not None:
|
||||||
|
repeat_state = dict(job.get("repeat") or {})
|
||||||
|
repeat_state["times"] = repeat
|
||||||
|
updates["repeat"] = repeat_state
|
||||||
|
if schedule is not None:
|
||||||
|
parsed_schedule = parse_schedule(schedule)
|
||||||
|
updates["schedule"] = parsed_schedule
|
||||||
|
updates["schedule_display"] = parsed_schedule.get("display", schedule)
|
||||||
|
if job.get("state") != "paused":
|
||||||
|
updates["state"] = "scheduled"
|
||||||
|
updates["enabled"] = True
|
||||||
|
if not updates:
|
||||||
|
return json.dumps({"success": False, "error": "No updates provided."}, indent=2)
|
||||||
|
updated = update_job(job_id, updates)
|
||||||
|
return json.dumps({"success": True, "job": _format_job(updated)}, indent=2)
|
||||||
|
|
||||||
|
return json.dumps({"success": False, "error": f"Unknown cron action '{action}'"}, indent=2)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Compatibility wrappers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def schedule_cronjob(
|
def schedule_cronjob(
|
||||||
prompt: str,
|
prompt: str,
|
||||||
|
|
@ -66,326 +247,92 @@ def schedule_cronjob(
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
repeat: Optional[int] = None,
|
repeat: Optional[int] = None,
|
||||||
deliver: Optional[str] = None,
|
deliver: Optional[str] = None,
|
||||||
task_id: str = None
|
task_id: str = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
return cronjob(
|
||||||
Schedule an automated task to run the agent on a schedule.
|
action="create",
|
||||||
|
prompt=prompt,
|
||||||
IMPORTANT: When the cronjob runs, it starts a COMPLETELY FRESH session.
|
schedule=schedule,
|
||||||
The agent will have NO memory of this conversation or any prior context.
|
name=name,
|
||||||
Therefore, the prompt MUST contain ALL necessary information:
|
repeat=repeat,
|
||||||
- Full context of what needs to be done
|
deliver=deliver,
|
||||||
- Specific file paths, URLs, or identifiers
|
task_id=task_id,
|
||||||
- Clear success criteria
|
)
|
||||||
- Any relevant background information
|
|
||||||
|
|
||||||
BAD prompt: "Check on that server issue"
|
|
||||||
GOOD prompt: "SSH into server 192.168.1.100 as user 'deploy', check if nginx
|
|
||||||
is running with 'systemctl status nginx', and verify the site
|
|
||||||
https://example.com returns HTTP 200. Report any issues found."
|
|
||||||
|
|
||||||
Args:
|
|
||||||
prompt: Complete, self-contained instructions for the future agent.
|
|
||||||
Must include ALL context needed - the agent won't remember anything.
|
|
||||||
schedule: When to run. Either:
|
|
||||||
- Duration for one-shot: "30m", "2h", "1d" (runs once)
|
|
||||||
- Interval: "every 30m", "every 2h" (recurring)
|
|
||||||
- Cron expression: "0 9 * * *" (daily at 9am)
|
|
||||||
- ISO timestamp: "2026-02-03T14:00:00" (one-shot at specific time)
|
|
||||||
name: Optional human-friendly name for the job (for listing/management)
|
|
||||||
repeat: How many times to run. Omit for default behavior:
|
|
||||||
- One-shot schedules default to repeat=1 (run once)
|
|
||||||
- Intervals/cron default to forever
|
|
||||||
- Set repeat=5 to run 5 times then auto-delete
|
|
||||||
deliver: Where to send the output. Options:
|
|
||||||
- "origin": Back to where this job was created (default)
|
|
||||||
- "local": Save to local files only (~/.hermes/cron/output/)
|
|
||||||
- "telegram": Send to Telegram home channel
|
|
||||||
- "discord": Send to Discord home channel
|
|
||||||
- "signal": Send to Signal home channel
|
|
||||||
- "telegram:123456": Send to specific chat ID
|
|
||||||
- "signal:+15551234567": Send to specific Signal number
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON with job_id, next_run time, and confirmation
|
|
||||||
"""
|
|
||||||
# Scan prompt for critical threats before scheduling
|
|
||||||
scan_error = _scan_cron_prompt(prompt)
|
|
||||||
if scan_error:
|
|
||||||
return json.dumps({"success": False, "error": scan_error}, indent=2)
|
|
||||||
|
|
||||||
# Get origin info from environment if available
|
|
||||||
origin = None
|
|
||||||
origin_platform = os.getenv("HERMES_SESSION_PLATFORM")
|
|
||||||
origin_chat_id = os.getenv("HERMES_SESSION_CHAT_ID")
|
|
||||||
if origin_platform and origin_chat_id:
|
|
||||||
origin = {
|
|
||||||
"platform": origin_platform,
|
|
||||||
"chat_id": origin_chat_id,
|
|
||||||
"chat_name": os.getenv("HERMES_SESSION_CHAT_NAME"),
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
job = create_job(
|
|
||||||
prompt=prompt,
|
|
||||||
schedule=schedule,
|
|
||||||
name=name,
|
|
||||||
repeat=repeat,
|
|
||||||
deliver=deliver,
|
|
||||||
origin=origin
|
|
||||||
)
|
|
||||||
|
|
||||||
# Format repeat info for display
|
|
||||||
times = job["repeat"].get("times")
|
|
||||||
if times is None:
|
|
||||||
repeat_display = "forever"
|
|
||||||
elif times == 1:
|
|
||||||
repeat_display = "once"
|
|
||||||
else:
|
|
||||||
repeat_display = f"{times} times"
|
|
||||||
|
|
||||||
return json.dumps({
|
|
||||||
"success": True,
|
|
||||||
"job_id": job["id"],
|
|
||||||
"name": job["name"],
|
|
||||||
"schedule": job["schedule_display"],
|
|
||||||
"repeat": repeat_display,
|
|
||||||
"deliver": job.get("deliver", "local"),
|
|
||||||
"next_run_at": job["next_run_at"],
|
|
||||||
"message": f"Cronjob '{job['name']}' created. It will run {repeat_display}, deliver to {job.get('deliver', 'local')}, next at {job['next_run_at']}."
|
|
||||||
}, indent=2)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return json.dumps({
|
|
||||||
"success": False,
|
|
||||||
"error": str(e)
|
|
||||||
}, indent=2)
|
|
||||||
|
|
||||||
|
|
||||||
SCHEDULE_CRONJOB_SCHEMA = {
|
def list_cronjobs(include_disabled: bool = False, task_id: str = None) -> str:
|
||||||
"name": "schedule_cronjob",
|
return cronjob(action="list", include_disabled=include_disabled, task_id=task_id)
|
||||||
"description": """Schedule an automated task to run the agent on a schedule.
|
|
||||||
|
|
||||||
⚠️ CRITICAL: The cronjob runs in a FRESH SESSION with NO CONTEXT from this conversation.
|
|
||||||
The prompt must be COMPLETELY SELF-CONTAINED with ALL necessary information including:
|
|
||||||
- Full context and background
|
|
||||||
- Specific file paths, URLs, server addresses
|
|
||||||
- Clear instructions and success criteria
|
|
||||||
- Any credentials or configuration details
|
|
||||||
|
|
||||||
The future agent will NOT remember anything from the current conversation.
|
def remove_cronjob(job_id: str, task_id: str = None) -> str:
|
||||||
|
return cronjob(action="remove", job_id=job_id, task_id=task_id)
|
||||||
|
|
||||||
SCHEDULE FORMATS:
|
|
||||||
- One-shot: "30m", "2h", "1d" (runs once after delay)
|
|
||||||
- Interval: "every 30m", "every 2h" (recurring)
|
|
||||||
- Cron: "0 9 * * *" (cron expression for precise scheduling)
|
|
||||||
- Timestamp: "2026-02-03T14:00:00" (specific date/time)
|
|
||||||
|
|
||||||
REPEAT BEHAVIOR:
|
CRONJOB_SCHEMA = {
|
||||||
- One-shot schedules: run once by default
|
"name": "cronjob",
|
||||||
- Intervals/cron: run forever by default
|
"description": """Manage scheduled cron jobs with a single compressed tool.
|
||||||
- Set repeat=N to run exactly N times then auto-delete
|
|
||||||
|
|
||||||
DELIVERY OPTIONS (where output goes):
|
Use action='create' to schedule a new job from a prompt or a skill.
|
||||||
- "origin": Back to current chat (default if in messaging platform)
|
Use action='list' to inspect jobs.
|
||||||
- "local": Save to local files only (default if in CLI)
|
Use action='update', 'pause', 'resume', 'remove', or 'run' to manage an existing job.
|
||||||
- "telegram": Send to Telegram home channel
|
|
||||||
- "discord": Send to Discord home channel
|
|
||||||
- "telegram:123456": Send to specific chat (if user provides ID)
|
|
||||||
|
|
||||||
NOTE: The agent's final response is auto-delivered to the target — do NOT use
|
Jobs run in a fresh session with no current-chat context, so prompts must be self-contained.
|
||||||
send_message in the prompt. Just have the agent compose its response normally.
|
If skill is provided on create, the future cron run loads that skill first, then follows the prompt as the task instruction.
|
||||||
|
|
||||||
Use for: reminders, periodic checks, scheduled reports, automated maintenance.""",
|
Important safety rule: cron-run sessions should not recursively schedule more cron jobs.""",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "One of: create, list, update, pause, resume, remove, run"
|
||||||
|
},
|
||||||
|
"job_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Required for update/pause/resume/remove/run"
|
||||||
|
},
|
||||||
"prompt": {
|
"prompt": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Complete, self-contained instructions. Must include ALL context - the future agent will have NO memory of this conversation."
|
"description": "For create: the full self-contained prompt. If skill is also provided, this becomes the task instruction paired with that skill."
|
||||||
},
|
},
|
||||||
"schedule": {
|
"schedule": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "When to run: '30m' (once in 30min), 'every 30m' (recurring), '0 9 * * *' (cron), or ISO timestamp"
|
"description": "For create/update: '30m', 'every 2h', '0 9 * * *', or ISO timestamp"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional human-friendly name for the job"
|
"description": "Optional human-friendly name"
|
||||||
},
|
},
|
||||||
"repeat": {
|
"repeat": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "How many times to run. Omit for default (once for one-shot, forever for recurring). Set to N for exactly N runs."
|
"description": "Optional repeat count. Omit for defaults (once for one-shot, forever for recurring)."
|
||||||
},
|
},
|
||||||
"deliver": {
|
"deliver": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Where to send output: 'origin' (back to this chat), 'local' (files only), 'telegram', 'discord', 'signal', or 'platform:chat_id'"
|
"description": "Delivery target: origin, local, telegram, discord, signal, or platform:chat_id"
|
||||||
}
|
},
|
||||||
},
|
|
||||||
"required": ["prompt", "schedule"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Tool: list_cronjobs
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def list_cronjobs(include_disabled: bool = False, task_id: str = None) -> str:
|
|
||||||
"""
|
|
||||||
List all scheduled cronjobs.
|
|
||||||
|
|
||||||
Returns information about each job including:
|
|
||||||
- Job ID (needed for removal)
|
|
||||||
- Name
|
|
||||||
- Schedule (human-readable)
|
|
||||||
- Repeat status (completed/total or 'forever')
|
|
||||||
- Next scheduled run time
|
|
||||||
- Last run time and status (if any)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
include_disabled: Whether to include disabled/completed jobs
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON array of all scheduled jobs
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
jobs = list_jobs(include_disabled=include_disabled)
|
|
||||||
|
|
||||||
formatted_jobs = []
|
|
||||||
for job in jobs:
|
|
||||||
# Format repeat status
|
|
||||||
times = job["repeat"].get("times")
|
|
||||||
completed = job["repeat"].get("completed", 0)
|
|
||||||
if times is None:
|
|
||||||
repeat_status = "forever"
|
|
||||||
else:
|
|
||||||
repeat_status = f"{completed}/{times}"
|
|
||||||
|
|
||||||
formatted_jobs.append({
|
|
||||||
"job_id": job["id"],
|
|
||||||
"name": job["name"],
|
|
||||||
"prompt_preview": job["prompt"][:100] + "..." if len(job["prompt"]) > 100 else job["prompt"],
|
|
||||||
"schedule": job["schedule_display"],
|
|
||||||
"repeat": repeat_status,
|
|
||||||
"deliver": job.get("deliver", "local"),
|
|
||||||
"next_run_at": job.get("next_run_at"),
|
|
||||||
"last_run_at": job.get("last_run_at"),
|
|
||||||
"last_status": job.get("last_status"),
|
|
||||||
"enabled": job.get("enabled", True)
|
|
||||||
})
|
|
||||||
|
|
||||||
return json.dumps({
|
|
||||||
"success": True,
|
|
||||||
"count": len(formatted_jobs),
|
|
||||||
"jobs": formatted_jobs
|
|
||||||
}, indent=2)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return json.dumps({
|
|
||||||
"success": False,
|
|
||||||
"error": str(e)
|
|
||||||
}, indent=2)
|
|
||||||
|
|
||||||
|
|
||||||
LIST_CRONJOBS_SCHEMA = {
|
|
||||||
"name": "list_cronjobs",
|
|
||||||
"description": """List all scheduled cronjobs with their IDs, schedules, and status.
|
|
||||||
|
|
||||||
Use this to:
|
|
||||||
- See what jobs are currently scheduled
|
|
||||||
- Find job IDs for removal with remove_cronjob
|
|
||||||
- Check job status and next run times
|
|
||||||
|
|
||||||
Returns job_id, name, schedule, repeat status, next/last run times.""",
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"include_disabled": {
|
"include_disabled": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Include disabled/completed jobs in the list (default: false)"
|
"description": "For list: include paused/completed jobs"
|
||||||
}
|
},
|
||||||
},
|
"skill": {
|
||||||
"required": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Tool: remove_cronjob
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def remove_cronjob(job_id: str, task_id: str = None) -> str:
|
|
||||||
"""
|
|
||||||
Remove a scheduled cronjob by its ID.
|
|
||||||
|
|
||||||
Use list_cronjobs first to find the job_id of the job you want to remove.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
job_id: The ID of the job to remove (from list_cronjobs output)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON confirmation of removal
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
job = get_job(job_id)
|
|
||||||
if not job:
|
|
||||||
return json.dumps({
|
|
||||||
"success": False,
|
|
||||||
"error": f"Job with ID '{job_id}' not found. Use list_cronjobs to see available jobs."
|
|
||||||
}, indent=2)
|
|
||||||
|
|
||||||
removed = remove_job(job_id)
|
|
||||||
if removed:
|
|
||||||
return json.dumps({
|
|
||||||
"success": True,
|
|
||||||
"message": f"Cronjob '{job['name']}' (ID: {job_id}) has been removed.",
|
|
||||||
"removed_job": {
|
|
||||||
"id": job_id,
|
|
||||||
"name": job["name"],
|
|
||||||
"schedule": job["schedule_display"]
|
|
||||||
}
|
|
||||||
}, indent=2)
|
|
||||||
else:
|
|
||||||
return json.dumps({
|
|
||||||
"success": False,
|
|
||||||
"error": f"Failed to remove job '{job_id}'"
|
|
||||||
}, indent=2)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return json.dumps({
|
|
||||||
"success": False,
|
|
||||||
"error": str(e)
|
|
||||||
}, indent=2)
|
|
||||||
|
|
||||||
|
|
||||||
REMOVE_CRONJOB_SCHEMA = {
|
|
||||||
"name": "remove_cronjob",
|
|
||||||
"description": """Remove a scheduled cronjob by its ID.
|
|
||||||
|
|
||||||
Use list_cronjobs first to find the job_id of the job you want to remove.
|
|
||||||
Jobs that have completed their repeat count are auto-removed, but you can
|
|
||||||
use this to cancel a job before it completes.""",
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"job_id": {
|
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The ID of the cronjob to remove (from list_cronjobs output)"
|
"description": "Optional skill name to load before executing the cron prompt"
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional pause reason"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["job_id"]
|
"required": ["action"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Requirements check
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def check_cronjob_requirements() -> bool:
|
def check_cronjob_requirements() -> bool:
|
||||||
"""
|
"""
|
||||||
Check if cronjob tools can be used.
|
Check if cronjob tools can be used.
|
||||||
|
|
||||||
Available in interactive CLI mode and gateway/messaging platforms.
|
Available in interactive CLI mode and gateway/messaging platforms.
|
||||||
Cronjobs are server-side scheduled tasks so they work from any interface.
|
Cronjobs are server-side scheduled tasks so they work from any interface.
|
||||||
"""
|
"""
|
||||||
|
|
@ -396,66 +343,30 @@ def check_cronjob_requirements() -> bool:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Exports
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def get_cronjob_tool_definitions():
|
def get_cronjob_tool_definitions():
|
||||||
"""Return tool definitions for cronjob management."""
|
"""Return tool definitions for cronjob management."""
|
||||||
return [
|
return [CRONJOB_SCHEMA]
|
||||||
SCHEDULE_CRONJOB_SCHEMA,
|
|
||||||
LIST_CRONJOBS_SCHEMA,
|
|
||||||
REMOVE_CRONJOB_SCHEMA
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# For direct testing
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# Test the tools
|
|
||||||
print("Testing schedule_cronjob:")
|
|
||||||
result = schedule_cronjob(
|
|
||||||
prompt="Test prompt for cron job",
|
|
||||||
schedule="5m",
|
|
||||||
name="Test Job"
|
|
||||||
)
|
|
||||||
print(result)
|
|
||||||
|
|
||||||
print("\nTesting list_cronjobs:")
|
|
||||||
result = list_cronjobs()
|
|
||||||
print(result)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Registry ---
|
# --- Registry ---
|
||||||
from tools.registry import registry
|
from tools.registry import registry
|
||||||
|
|
||||||
registry.register(
|
registry.register(
|
||||||
name="schedule_cronjob",
|
name="cronjob",
|
||||||
toolset="cronjob",
|
toolset="cronjob",
|
||||||
schema=SCHEDULE_CRONJOB_SCHEMA,
|
schema=CRONJOB_SCHEMA,
|
||||||
handler=lambda args, **kw: schedule_cronjob(
|
handler=lambda args, **kw: cronjob(
|
||||||
prompt=args.get("prompt", ""),
|
action=args.get("action", ""),
|
||||||
schedule=args.get("schedule", ""),
|
job_id=args.get("job_id"),
|
||||||
|
prompt=args.get("prompt"),
|
||||||
|
schedule=args.get("schedule"),
|
||||||
name=args.get("name"),
|
name=args.get("name"),
|
||||||
repeat=args.get("repeat"),
|
repeat=args.get("repeat"),
|
||||||
deliver=args.get("deliver"),
|
deliver=args.get("deliver"),
|
||||||
task_id=kw.get("task_id")),
|
|
||||||
check_fn=check_cronjob_requirements,
|
|
||||||
)
|
|
||||||
registry.register(
|
|
||||||
name="list_cronjobs",
|
|
||||||
toolset="cronjob",
|
|
||||||
schema=LIST_CRONJOBS_SCHEMA,
|
|
||||||
handler=lambda args, **kw: list_cronjobs(
|
|
||||||
include_disabled=args.get("include_disabled", False),
|
include_disabled=args.get("include_disabled", False),
|
||||||
task_id=kw.get("task_id")),
|
skill=args.get("skill"),
|
||||||
check_fn=check_cronjob_requirements,
|
reason=args.get("reason"),
|
||||||
)
|
task_id=kw.get("task_id"),
|
||||||
registry.register(
|
),
|
||||||
name="remove_cronjob",
|
|
||||||
toolset="cronjob",
|
|
||||||
schema=REMOVE_CRONJOB_SCHEMA,
|
|
||||||
handler=lambda args, **kw: remove_cronjob(
|
|
||||||
job_id=args.get("job_id", ""),
|
|
||||||
task_id=kw.get("task_id")),
|
|
||||||
check_fn=check_cronjob_requirements,
|
check_fn=check_cronjob_requirements,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ _HERMES_CORE_TOOLS = [
|
||||||
# Code execution + delegation
|
# Code execution + delegation
|
||||||
"execute_code", "delegate_task",
|
"execute_code", "delegate_task",
|
||||||
# Cronjob management
|
# Cronjob management
|
||||||
"schedule_cronjob", "list_cronjobs", "remove_cronjob",
|
"cronjob",
|
||||||
# Cross-platform messaging (gated on gateway running via check_fn)
|
# Cross-platform messaging (gated on gateway running via check_fn)
|
||||||
"send_message",
|
"send_message",
|
||||||
# Honcho memory tools (gated on honcho being active via check_fn)
|
# Honcho memory tools (gated on honcho being active via check_fn)
|
||||||
|
|
@ -125,8 +125,8 @@ TOOLSETS = {
|
||||||
},
|
},
|
||||||
|
|
||||||
"cronjob": {
|
"cronjob": {
|
||||||
"description": "Cronjob management tools - schedule, list, and remove automated tasks",
|
"description": "Cronjob management tool - create, list, update, pause, resume, remove, and trigger scheduled tasks",
|
||||||
"tools": ["schedule_cronjob", "list_cronjobs", "remove_cronjob"],
|
"tools": ["cronjob"],
|
||||||
"includes": []
|
"includes": []
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue