"""Built-in tool: schedule a delayed recall for the current session."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from ._internal.base import Tool, ToolContext, ToolResult, current_session_id, current_user_role
from ._internal.time_parser import parse_when
def _parse_offset(offset: str) -> tuple[int | None, int]:
"""Parse timezone offset string like '+03:00' or '-05:30' into (hours, minutes)."""
import re
m = re.match(r"^([+-])(\d{1,2}):(\d{2})$", offset)
if not m:
return None, 0
sign = 1 if m.group(1) == "+" else -1
hours = int(m.group(2)) * sign
minutes = int(m.group(3)) * sign
return hours, minutes
class ScheduleRecallTool(Tool):
name = "schedule_recall"
description = (
"Schedule a headless callback for the current session. "
"At the chosen time Navi wakes up, reads your self-instruction, and continues working using other tools. "
"Only one pending recall per session is allowed — cancel the old one first if you need a new timer.\n\n"
"CORE PRINCIPLE: this is a TOOL for CONTINUING WORK, not a chat reminder.\n"
" BAD message: 'Tell the user that 2 hours have passed.'\n"
" GOOD message: 'Read /tmp/build.log with filesystem. If errors, read last 50 lines and report. Otherwise confirm success.'\n\n"
"Call types:\n"
" once — single delayed action (check logs in 30m, continue after reboot).\n"
" recurring — periodic action with interval_seconds (poll API every 5 min, check inbox every 15 min).\n"
" immediate — fire ASAP; use to offload heavy multi-tool work without blocking the chat.\n\n"
"Popular scenarios:\n"
" 1. Hit iteration limit? Schedule immediate with context 'Continue Nginx config from step 3...'\n"
" 2. Waiting for a build/export? Schedule once for estimated finish time and tell yourself which files to check.\n"
" 3. Periodic monitoring? Schedule recurring 900s (15 min) with context 'Read /var/log/app/errors.log...'\n"
" 4. Heavy task with 20+ tool calls? Use immediate so user can keep chatting while you work headlessly.\n\n"
"Rules:\n"
" • additional_context_message must read like a todo item for yourself.\n"
" • Mention specific tools, files, or URLs — future-you has the same tools but not your short-term memory.\n"
" • Multi-phase tasks: chain recalls — phase 1 runs, then schedules recall for phase 2.\n"
" • Only one pending recall per session. Use manage_recall cancel before scheduling a new one."
)
parameters = {
"type": "object",
"properties": {
"call_type": {
"type": "string",
"enum": ["once", "recurring", "immediate"],
"description": "Type of recall: once (single), recurring (repeats), immediate (ASAP).",
},
"when": {
"type": "string",
"description": (
"When to trigger. Prefer RELATIVE formats — they work correctly regardless of timezone differences:\n"
" • '30m', '2h 15m', '1d 6h' — compact relative\n"
" • 'in 3 hours', 'in 2 days' — natural language relative\n"
" • 'tomorrow at 09:00' — tomorrow at specific local time\n"
"Use ABSOLUTE ISO datetime ONLY if you know the user's exact timezone offset (e.g., '2026-05-15T14:00:00+03:00'). "
"Never use UTC-only ISO like '2026-05-15T14:00:00+00:00' unless the user explicitly confirmed UTC.\n"
"Ignored for immediate calls."
),
},
"timezone_offset": {
"type": "string",
"description": (
"User's timezone offset in ±HH:MM format (e.g., '+03:00', '-05:00'). "
"Required when using absolute times or 'tomorrow at HH:MM' if the user's timezone is known. "
"If not provided, the server assumes UTC."
),
},
"interval_seconds": {
"type": "integer",
"description": "Repeat interval in seconds. Required only for recurring.",
},
"internal_comment": {
"type": "string",
"description": "Optional human-readable note why this recall was set.",
},
"additional_context_message": {
"type": "string",
"description": (
"Self-instruction describing exactly what to do when the recall fires. "
"Write it as a todo for yourself: mention specific tools, files, or steps. "
"This is injected as a system message and Navi acts on it with other tools."
),
},
},
"required": ["call_type", "additional_context_message"],
}
def __init__(self, scheduler: "RecallScheduler" | None = None) -> None:
self._scheduler = scheduler
async def execute(self, params: dict, ctx: ToolContext | None = None) -> ToolResult:
from navi.core.scheduler import RecallExistsError, RecallScheduler
scheduler = self._scheduler
if scheduler is None:
return ToolResult(
success=False,
output="Scheduler not available.",
error="no scheduler",
)
session_id = ctx.session_id if ctx else current_session_id.get(None)
if not session_id:
return ToolResult(
success=False,
output="No current session.",
error="missing session_id",
)
call_type = (params.get("call_type") or "").strip().lower()
when = (params.get("when") or "").strip()
timezone_offset: str | None = params.get("timezone_offset")
interval_seconds: int | None = params.get("interval_seconds")
internal_comment: str | None = params.get("internal_comment")
additional_context_message = (params.get("additional_context_message") or "").strip()
if call_type not in ("once", "recurring", "immediate"):
return ToolResult(
success=False,
output=f"Invalid call_type: {call_type}. Use once, recurring, or immediate.",
error="bad_call_type",
)
if call_type == "recurring":
if not interval_seconds or interval_seconds <= 0:
return ToolResult(
success=False,
output="interval_seconds is required and must be > 0 for recurring recalls.",
error="missing interval",
)
else:
interval_seconds = None
# Resolve trigger time
if call_type == "immediate":
trigger_at = datetime.now(timezone.utc)
elif when:
trigger_at, error = parse_when(when)
if error:
return ToolResult(success=False, output=error, error="parse_failed")
# If user provided a timezone offset, adjust absolute times to UTC.
# Relative times ("2m", "in 2 hours") are already correct — they add to
# server UTC "now" — so we only adjust when a timezone offset was given.
if timezone_offset:
offset_hours, offset_minutes = _parse_offset(timezone_offset)
if offset_hours is not None:
# The parser produced a UTC timestamp, but the user actually meant
# local time. Subtract the offset to get the real UTC trigger.
# If parse_when returned an aware datetime (e.g. ISO with offset),
# normalize to UTC first so we don't double-apply the offset.
if trigger_at.tzinfo is not None:
trigger_at = trigger_at.astimezone(timezone.utc)
trigger_at = trigger_at - timedelta(hours=offset_hours, minutes=offset_minutes)
else:
return ToolResult(
success=False,
output="'when' is required for once/recurring recalls.",
error="missing when",
)
if not additional_context_message:
return ToolResult(
success=False,
output="additional_context_message is required.",
error="missing context",
)
if call_type != "immediate" and trigger_at < datetime.now(timezone.utc):
return ToolResult(
success=False,
output="The scheduled time is in the past. Please provide a future time.",
error="past_time",
)
try:
recall = await scheduler.schedule_recall(
session_id=session_id,
call_type=call_type,
trigger_at=trigger_at,
interval_seconds=interval_seconds,
internal_comment=internal_comment,
additional_context_message=additional_context_message,
)
except RecallExistsError as exc:
return ToolResult(
success=False,
output=f"This session already has a pending recall. Cancel it first: {exc}",
error="recall_exists",
)
except Exception as exc:
return ToolResult(
success=False,
output=f"Failed to schedule recall: {exc}",
error="schedule_failed",
)
# Notify all connected clients so the banner refreshes live
try:
from navi.core.event_bus import get_event_bus
from navi.core.events import RecallUpdate
await get_event_bus().publish(
RecallUpdate(
session_id=session_id,
recall_id=recall.id,
call_type=recall.call_type,
trigger_at=recall.trigger_at.isoformat(),
status="pending",
action="scheduled",
)
)
except Exception:
pass
return ToolResult(
success=True,
output=(
f"Recall scheduled.\n"
f" ID: {recall.id}\n"
f" Type: {recall.call_type}\n"
f" Trigger (UTC): {recall.trigger_at.isoformat()}\n"
+ (
f" Interval: {recall.interval_seconds}s\n"
if recall.interval_seconds
else ""
)
+ (f" Comment: {recall.internal_comment}\n" if recall.internal_comment else "")
+ (
f" Timezone offset applied: {timezone_offset}\n"
if timezone_offset
else ""
)
),
)