tanyar09 d9919828c5 Fix cron scheduled tasks not executing
- Add auto-start functionality to cron service when jobs are added
  if service is not running and event loop is available
- Add 'reminder' field to CronPayload to distinguish between
  simple reminders (send message directly) and tasks (execute via agent)
- Update cron tool to accept 'reminder' parameter
- Fix callback logic to check reminder field: reminders send directly,
  tasks are processed through agent
- Ensures both 'remind me to X' and 'schedule a task to do X' work correctly
2026-03-03 13:09:44 -05:00

221 lines
10 KiB
Python

"""Cron tool for scheduling reminders and tasks."""
from typing import Any
from nanobot.agent.tools.base import Tool
from nanobot.cron.service import CronService
from nanobot.cron.types import CronSchedule
class CronTool(Tool):
"""Tool to schedule reminders and recurring tasks."""
def __init__(self, cron_service: CronService):
self._cron = cron_service
self._channel = ""
self._chat_id = ""
def set_context(self, channel: str, chat_id: str) -> None:
"""Set the current session context for delivery."""
self._channel = channel
self._chat_id = chat_id
@property
def name(self) -> str:
return "cron"
@property
def description(self) -> str:
return "Schedule reminders and recurring tasks. REQUIRED: Always include 'action' parameter ('add', 'list', or 'remove'). For reminders, use action='add' with message and timing (in_seconds, at, every_seconds, or cron_expr)."
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["add", "list", "remove"],
"description": "REQUIRED: Action to perform. Use 'add' to create a reminder, 'list' to see all jobs, or 'remove' to delete a job."
},
"message": {
"type": "string",
"description": "Reminder message (for add)"
},
"every_seconds": {
"type": "integer",
"description": "Interval in seconds (for recurring tasks)"
},
"cron_expr": {
"type": "string",
"description": "Cron expression like '0 9 * * *' (for scheduled tasks)"
},
"tz": {
"type": "string",
"description": "IANA timezone for cron expressions (e.g. 'America/Vancouver')"
},
"at": {
"type": "string",
"description": "ISO datetime string for one-time execution. Format: YYYY-MM-DDTHH:MM:SS (e.g. '2026-03-03T12:19:30'). You MUST calculate this from the current time shown in your system prompt plus the requested seconds/minutes, then format as ISO string."
},
"in_seconds": {
"type": "integer",
"description": "Alternative to 'at': Schedule reminder in N seconds from now. Use this instead of calculating 'at' manually. Example: in_seconds=25 for 'remind me in 25 seconds'."
},
"reminder": {
"type": "boolean",
"description": "If true, this is a simple reminder (message sent directly to user). If false or omitted, this is a task (agent executes the message). Use reminder=true for 'remind me to X', reminder=false for 'schedule a task to do X'."
},
"job_id": {
"type": "string",
"description": "Job ID (for remove)"
}
},
"required": ["action"]
}
async def execute(
self,
action: str,
message: str = "",
every_seconds: int | None = None,
cron_expr: str | None = None,
tz: str | None = None,
at: str | None = None,
in_seconds: int | None = None,
reminder: bool = False,
job_id: str | None = None,
**kwargs: Any
) -> str:
from loguru import logger
logger.debug(f"CronTool.execute: action={action}, message={message[:50] if message else None}, every_seconds={every_seconds}, at={at}, in_seconds={in_seconds}, reminder={reminder}, channel={self._channel}, chat_id={self._chat_id}")
if action == "add":
result = self._add_job(message, every_seconds, cron_expr, tz, at, in_seconds, reminder)
logger.debug(f"CronTool._add_job result: {result}")
return result
elif action == "list":
return self._list_jobs()
elif action == "remove":
return self._remove_job(job_id)
return f"Unknown action: {action}"
def _add_job(
self,
message: str,
every_seconds: int | None,
cron_expr: str | None,
tz: str | None,
at: str | None,
in_seconds: int | None = None,
reminder: bool = False,
) -> str:
if not message:
return "Error: message is required for add"
# Use defaults for CLI mode if context not set
channel = self._channel or "cli"
chat_id = self._chat_id or "direct"
# Validate timezone only if used with cron_expr
if tz and cron_expr:
from zoneinfo import ZoneInfo
try:
ZoneInfo(tz)
except (KeyError, Exception):
return f"Error: unknown timezone '{tz}'"
elif tz and not cron_expr:
# Ignore tz if not used with cron_expr (common mistake)
tz = None
# Build schedule - prioritize 'in_seconds' for relative time, then 'at' for absolute time
delete_after = False
# Handle relative time (in_seconds) - compute datetime automatically
if in_seconds is not None:
from datetime import datetime, timedelta
from time import time as _time
future_time = datetime.now() + timedelta(seconds=in_seconds)
at = future_time.isoformat()
# Fall through to 'at' handling below
if at:
# One-time reminder at specific time
from datetime import datetime
try:
# Check if agent passed description text, Python code, or other invalid values
if "iso datetime" in at.lower() or "e.g." in at.lower() or "example" in at.lower() or at.startswith("("):
return f"Error: You passed description text '{at}' instead of an actual datetime string. You must: 1) Read current time from system prompt (e.g. '2026-03-03 12:19:04'), 2) Add requested seconds/minutes to it, 3) Format as ISO string like '2026-03-03T12:19:29'. Do NOT use description text or examples."
if "datetime.now()" in at or "timedelta" in at:
return f"Error: You passed Python code '{at}' instead of an actual datetime string. You must compute the datetime value first, then pass the ISO format string (e.g. '2026-03-03T12:19:29')."
dt = datetime.fromisoformat(at)
# If datetime is naive (no timezone), assume local timezone
if dt.tzinfo is None:
import time
# Get local timezone offset
local_offset = time.timezone if (time.daylight == 0) else time.altzone
# Convert naive datetime to UTC-aware for consistent timestamp calculation
dt = dt.replace(tzinfo=None)
# Calculate timestamp assuming local time
at_ms = int(dt.timestamp() * 1000)
else:
at_ms = int(dt.timestamp() * 1000)
# Validate that the time is in the future (allow 5 second buffer for processing)
from time import time as _time
from datetime import datetime as _dt
now_ms = int(_time() * 1000)
buffer_ms = 5000 # 5 second buffer for processing time
if at_ms <= (now_ms + buffer_ms):
now_str = _dt.now().strftime("%Y-%m-%d %H:%M:%S")
scheduled_str = _dt.fromtimestamp(at_ms / 1000).strftime("%Y-%m-%d %H:%M:%S")
diff_sec = (now_ms - at_ms) / 1000
if diff_sec > 0:
return f"Error: scheduled time ({scheduled_str}) is in the past by {diff_sec:.0f} seconds. Current time is {now_str}. You must ADD the requested seconds to the current time. Example: if current time is 12:21:46 and user wants reminder in 25 seconds, calculate 12:21:46 + 25 seconds = 12:22:11, then pass '2026-03-03T12:22:11'."
else:
return f"Error: scheduled time ({scheduled_str}) is too close to current time ({now_str}). You must ADD the requested seconds to the current time. Example: if current time is 12:21:46 and user wants reminder in 25 seconds, calculate 12:21:46 + 25 seconds = 12:22:11, then pass '2026-03-03T12:22:11'."
schedule = CronSchedule(kind="at", at_ms=at_ms)
delete_after = True
except (ValueError, Exception) as e:
return f"Error: invalid datetime format for 'at': {str(e)}. Expected ISO format like '2026-03-03T12:05:30', not Python code."
elif every_seconds:
# Recurring reminder
schedule = CronSchedule(kind="every", every_ms=every_seconds * 1000)
elif cron_expr:
# Cron expression
schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz)
else:
return "Error: either every_seconds, cron_expr, or at is required"
try:
job = self._cron.add_job(
name=message[:30],
schedule=schedule,
message=message,
deliver=True,
channel=channel,
to=chat_id,
delete_after_run=delete_after,
reminder=reminder,
)
return f"Created job '{job.name}' (id: {job.id})"
except Exception as e:
return f"Error creating cron job: {str(e)}"
def _list_jobs(self) -> str:
jobs = self._cron.list_jobs()
if not jobs:
return "No scheduled jobs."
lines = [f"- {j.name} (id: {j.id}, {j.schedule.kind})" for j in jobs]
return "Scheduled jobs:\n" + "\n".join(lines)
def _remove_job(self, job_id: str | None) -> str:
if not job_id:
return "Error: job_id is required for remove"
if self._cron.remove_job(job_id):
return f"Removed job {job_id}"
return f"Job {job_id} not found"