refactor(cron): remove CLI cron commands and unify scheduling via cron tool
This commit is contained in:
17
README.md
17
README.md
@@ -901,23 +901,6 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
|
|||||||
|
|
||||||
Interactive mode exits: `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
|
Interactive mode exits: `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Scheduled Tasks (Cron)</b></summary>
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Add a job
|
|
||||||
nanobot cron add --name "daily" --message "Good morning!" --cron "0 9 * * *"
|
|
||||||
nanobot cron add --name "hourly" --message "Check status" --every 3600
|
|
||||||
|
|
||||||
# List jobs
|
|
||||||
nanobot cron list
|
|
||||||
|
|
||||||
# Remove a job
|
|
||||||
nanobot cron remove <job_id>
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Heartbeat (Periodic Tasks)</b></summary>
|
<summary><b>Heartbeat (Periodic Tasks)</b></summary>
|
||||||
|
|
||||||
|
|||||||
@@ -782,221 +782,6 @@ def channels_login():
|
|||||||
console.print("[red]npm not found. Please install Node.js.[/red]")
|
console.print("[red]npm not found. Please install Node.js.[/red]")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Cron Commands
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
cron_app = typer.Typer(help="Manage scheduled tasks")
|
|
||||||
app.add_typer(cron_app, name="cron")
|
|
||||||
|
|
||||||
|
|
||||||
@cron_app.command("list")
|
|
||||||
def cron_list(
|
|
||||||
all: bool = typer.Option(False, "--all", "-a", help="Include disabled jobs"),
|
|
||||||
):
|
|
||||||
"""List scheduled jobs."""
|
|
||||||
from nanobot.config.loader import get_data_dir
|
|
||||||
from nanobot.cron.service import CronService
|
|
||||||
|
|
||||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
|
||||||
service = CronService(store_path)
|
|
||||||
|
|
||||||
jobs = service.list_jobs(include_disabled=all)
|
|
||||||
|
|
||||||
if not jobs:
|
|
||||||
console.print("No scheduled jobs.")
|
|
||||||
return
|
|
||||||
|
|
||||||
table = Table(title="Scheduled Jobs")
|
|
||||||
table.add_column("ID", style="cyan")
|
|
||||||
table.add_column("Name")
|
|
||||||
table.add_column("Schedule")
|
|
||||||
table.add_column("Status")
|
|
||||||
table.add_column("Next Run")
|
|
||||||
|
|
||||||
import time
|
|
||||||
from datetime import datetime as _dt
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
for job in jobs:
|
|
||||||
# Format schedule
|
|
||||||
if job.schedule.kind == "every":
|
|
||||||
sched = f"every {(job.schedule.every_ms or 0) // 1000}s"
|
|
||||||
elif job.schedule.kind == "cron":
|
|
||||||
sched = f"{job.schedule.expr or ''} ({job.schedule.tz})" if job.schedule.tz else (job.schedule.expr or "")
|
|
||||||
else:
|
|
||||||
sched = "one-time"
|
|
||||||
|
|
||||||
# Format next run
|
|
||||||
next_run = ""
|
|
||||||
if job.state.next_run_at_ms:
|
|
||||||
ts = job.state.next_run_at_ms / 1000
|
|
||||||
try:
|
|
||||||
tz = ZoneInfo(job.schedule.tz) if job.schedule.tz else None
|
|
||||||
next_run = _dt.fromtimestamp(ts, tz).strftime("%Y-%m-%d %H:%M")
|
|
||||||
except Exception:
|
|
||||||
next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts))
|
|
||||||
|
|
||||||
status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]"
|
|
||||||
|
|
||||||
table.add_row(job.id, job.name, sched, status, next_run)
|
|
||||||
|
|
||||||
console.print(table)
|
|
||||||
|
|
||||||
|
|
||||||
@cron_app.command("add")
|
|
||||||
def cron_add(
|
|
||||||
name: str = typer.Option(..., "--name", "-n", help="Job name"),
|
|
||||||
message: str = typer.Option(..., "--message", "-m", help="Message for agent"),
|
|
||||||
every: int = typer.Option(None, "--every", "-e", help="Run every N seconds"),
|
|
||||||
cron_expr: str = typer.Option(None, "--cron", "-c", help="Cron expression (e.g. '0 9 * * *')"),
|
|
||||||
tz: str | None = typer.Option(None, "--tz", help="IANA timezone for cron (e.g. 'America/Vancouver')"),
|
|
||||||
at: str = typer.Option(None, "--at", help="Run once at time (ISO format)"),
|
|
||||||
deliver: bool = typer.Option(False, "--deliver", "-d", help="Deliver response to channel"),
|
|
||||||
to: str = typer.Option(None, "--to", help="Recipient for delivery"),
|
|
||||||
channel: str = typer.Option(None, "--channel", help="Channel for delivery (e.g. 'telegram', 'whatsapp')"),
|
|
||||||
):
|
|
||||||
"""Add a scheduled job."""
|
|
||||||
from nanobot.config.loader import get_data_dir
|
|
||||||
from nanobot.cron.service import CronService
|
|
||||||
from nanobot.cron.types import CronSchedule
|
|
||||||
|
|
||||||
if tz and not cron_expr:
|
|
||||||
console.print("[red]Error: --tz can only be used with --cron[/red]")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
# Determine schedule type
|
|
||||||
if every:
|
|
||||||
schedule = CronSchedule(kind="every", every_ms=every * 1000)
|
|
||||||
elif cron_expr:
|
|
||||||
schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz)
|
|
||||||
elif at:
|
|
||||||
import datetime
|
|
||||||
dt = datetime.datetime.fromisoformat(at)
|
|
||||||
schedule = CronSchedule(kind="at", at_ms=int(dt.timestamp() * 1000))
|
|
||||||
else:
|
|
||||||
console.print("[red]Error: Must specify --every, --cron, or --at[/red]")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
|
||||||
service = CronService(store_path)
|
|
||||||
|
|
||||||
try:
|
|
||||||
job = service.add_job(
|
|
||||||
name=name,
|
|
||||||
schedule=schedule,
|
|
||||||
message=message,
|
|
||||||
deliver=deliver,
|
|
||||||
to=to,
|
|
||||||
channel=channel,
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
|
||||||
console.print(f"[red]Error: {e}[/red]")
|
|
||||||
raise typer.Exit(1) from e
|
|
||||||
|
|
||||||
console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})")
|
|
||||||
|
|
||||||
|
|
||||||
@cron_app.command("remove")
|
|
||||||
def cron_remove(
|
|
||||||
job_id: str = typer.Argument(..., help="Job ID to remove"),
|
|
||||||
):
|
|
||||||
"""Remove a scheduled job."""
|
|
||||||
from nanobot.config.loader import get_data_dir
|
|
||||||
from nanobot.cron.service import CronService
|
|
||||||
|
|
||||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
|
||||||
service = CronService(store_path)
|
|
||||||
|
|
||||||
if service.remove_job(job_id):
|
|
||||||
console.print(f"[green]✓[/green] Removed job {job_id}")
|
|
||||||
else:
|
|
||||||
console.print(f"[red]Job {job_id} not found[/red]")
|
|
||||||
|
|
||||||
|
|
||||||
@cron_app.command("enable")
|
|
||||||
def cron_enable(
|
|
||||||
job_id: str = typer.Argument(..., help="Job ID"),
|
|
||||||
disable: bool = typer.Option(False, "--disable", help="Disable instead of enable"),
|
|
||||||
):
|
|
||||||
"""Enable or disable a job."""
|
|
||||||
from nanobot.config.loader import get_data_dir
|
|
||||||
from nanobot.cron.service import CronService
|
|
||||||
|
|
||||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
|
||||||
service = CronService(store_path)
|
|
||||||
|
|
||||||
job = service.enable_job(job_id, enabled=not disable)
|
|
||||||
if job:
|
|
||||||
status = "disabled" if disable else "enabled"
|
|
||||||
console.print(f"[green]✓[/green] Job '{job.name}' {status}")
|
|
||||||
else:
|
|
||||||
console.print(f"[red]Job {job_id} not found[/red]")
|
|
||||||
|
|
||||||
|
|
||||||
@cron_app.command("run")
|
|
||||||
def cron_run(
|
|
||||||
job_id: str = typer.Argument(..., help="Job ID to run"),
|
|
||||||
force: bool = typer.Option(False, "--force", "-f", help="Run even if disabled"),
|
|
||||||
):
|
|
||||||
"""Manually run a job."""
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
|
||||||
from nanobot.bus.queue import MessageBus
|
|
||||||
from nanobot.config.loader import get_data_dir, load_config
|
|
||||||
from nanobot.cron.service import CronService
|
|
||||||
from nanobot.cron.types import CronJob
|
|
||||||
logger.disable("nanobot")
|
|
||||||
|
|
||||||
config = load_config()
|
|
||||||
provider = _make_provider(config)
|
|
||||||
bus = MessageBus()
|
|
||||||
agent_loop = AgentLoop(
|
|
||||||
bus=bus,
|
|
||||||
provider=provider,
|
|
||||||
workspace=config.workspace_path,
|
|
||||||
model=config.agents.defaults.model,
|
|
||||||
temperature=config.agents.defaults.temperature,
|
|
||||||
max_tokens=config.agents.defaults.max_tokens,
|
|
||||||
max_iterations=config.agents.defaults.max_tool_iterations,
|
|
||||||
memory_window=config.agents.defaults.memory_window,
|
|
||||||
reasoning_effort=config.agents.defaults.reasoning_effort,
|
|
||||||
brave_api_key=config.tools.web.search.api_key or None,
|
|
||||||
web_proxy=config.tools.web.proxy or None,
|
|
||||||
exec_config=config.tools.exec,
|
|
||||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
|
||||||
mcp_servers=config.tools.mcp_servers,
|
|
||||||
channels_config=config.channels,
|
|
||||||
)
|
|
||||||
|
|
||||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
|
||||||
service = CronService(store_path)
|
|
||||||
|
|
||||||
result_holder = []
|
|
||||||
|
|
||||||
async def on_job(job: CronJob) -> str | None:
|
|
||||||
response = await agent_loop.process_direct(
|
|
||||||
job.payload.message,
|
|
||||||
session_key=f"cron:{job.id}",
|
|
||||||
channel=job.payload.channel or "cli",
|
|
||||||
chat_id=job.payload.to or "direct",
|
|
||||||
)
|
|
||||||
result_holder.append(response)
|
|
||||||
return response
|
|
||||||
|
|
||||||
service.on_job = on_job
|
|
||||||
|
|
||||||
async def run():
|
|
||||||
return await service.run_job(job_id, force=force)
|
|
||||||
|
|
||||||
if asyncio.run(run()):
|
|
||||||
console.print("[green]✓[/green] Job executed")
|
|
||||||
if result_holder:
|
|
||||||
_print_agent_response(result_holder[0], render_markdown=True)
|
|
||||||
else:
|
|
||||||
console.print(f"[red]Failed to run job {job_id}[/red]")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Status Commands
|
# Status Commands
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -4,17 +4,15 @@ You are a helpful AI assistant. Be concise, accurate, and friendly.
|
|||||||
|
|
||||||
## Scheduled Reminders
|
## Scheduled Reminders
|
||||||
|
|
||||||
When user asks for a reminder at a specific time, use `exec` to run:
|
Before scheduling reminders, check available skills and follow skill guidance first.
|
||||||
```
|
Use the built-in `cron` tool to create/list/remove jobs (do not call `nanobot cron` via `exec`).
|
||||||
nanobot cron add --name "reminder" --message "Your message" --at "YYYY-MM-DDTHH:MM:SS" --deliver --to "USER_ID" --channel "CHANNEL"
|
|
||||||
```
|
|
||||||
Get USER_ID and CHANNEL from the current session (e.g., `8281248569` and `telegram` from `telegram:8281248569`).
|
Get USER_ID and CHANNEL from the current session (e.g., `8281248569` and `telegram` from `telegram:8281248569`).
|
||||||
|
|
||||||
**Do NOT just write reminders to MEMORY.md** — that won't trigger actual notifications.
|
**Do NOT just write reminders to MEMORY.md** — that won't trigger actual notifications.
|
||||||
|
|
||||||
## Heartbeat Tasks
|
## Heartbeat Tasks
|
||||||
|
|
||||||
`HEARTBEAT.md` is checked every 30 minutes. Use file tools to manage periodic tasks:
|
`HEARTBEAT.md` is checked on the configured heartbeat interval. Use file tools to manage periodic tasks:
|
||||||
|
|
||||||
- **Add**: `edit_file` to append new tasks
|
- **Add**: `edit_file` to append new tasks
|
||||||
- **Remove**: `edit_file` to delete completed tasks
|
- **Remove**: `edit_file` to delete completed tasks
|
||||||
|
|||||||
Reference in New Issue
Block a user