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