feat(cron): inherit agent timezone for default schedules

Make cron use the configured agent timezone when a cron expression omits tz or a one-shot ISO time has no offset. This keeps runtime context, heartbeat, and scheduling aligned around the same notion of time.

Made-with: Cursor
This commit is contained in:
Xubin Ren 2026-03-25 10:24:26 +00:00 committed by Xubin Ren
parent 13d6c0ae52
commit 4a7d7b8823
4 changed files with 67 additions and 14 deletions

View File

@ -1361,7 +1361,7 @@ By default, nanobot uses `UTC` for runtime time context. If you want the agent t
} }
``` ```
This currently affects runtime time strings shown to the model, such as runtime context and heartbeat prompts. This affects runtime time strings shown to the model, such as runtime context and heartbeat prompts. It also becomes the default timezone for cron schedules when a cron expression omits `tz`, and for one-shot `at` times when the ISO datetime has no explicit offset.
Common examples: `UTC`, `America/New_York`, `America/Los_Angeles`, `Europe/London`, `Europe/Berlin`, `Asia/Tokyo`, `Asia/Shanghai`, `Asia/Singapore`, `Australia/Sydney`. Common examples: `UTC`, `America/New_York`, `America/Los_Angeles`, `Europe/London`, `Europe/Berlin`, `Asia/Tokyo`, `Asia/Shanghai`, `Asia/Singapore`, `Australia/Sydney`.

View File

@ -144,7 +144,7 @@ class AgentLoop:
self.tools.register(MessageTool(send_callback=self.bus.publish_outbound)) self.tools.register(MessageTool(send_callback=self.bus.publish_outbound))
self.tools.register(SpawnTool(manager=self.subagents)) self.tools.register(SpawnTool(manager=self.subagents))
if self.cron_service: if self.cron_service:
self.tools.register(CronTool(self.cron_service)) self.tools.register(CronTool(self.cron_service, default_timezone=timezone or "UTC"))
async def _connect_mcp(self) -> None: async def _connect_mcp(self) -> None:
"""Connect to configured MCP servers (one-time, lazy).""" """Connect to configured MCP servers (one-time, lazy)."""

View File

@ -12,8 +12,9 @@ from nanobot.cron.types import CronJobState, CronSchedule
class CronTool(Tool): class CronTool(Tool):
"""Tool to schedule reminders and recurring tasks.""" """Tool to schedule reminders and recurring tasks."""
def __init__(self, cron_service: CronService): def __init__(self, cron_service: CronService, default_timezone: str = "UTC"):
self._cron = cron_service self._cron = cron_service
self._default_timezone = default_timezone
self._channel = "" self._channel = ""
self._chat_id = "" self._chat_id = ""
self._in_cron_context: ContextVar[bool] = ContextVar("cron_in_context", default=False) self._in_cron_context: ContextVar[bool] = ContextVar("cron_in_context", default=False)
@ -31,13 +32,26 @@ class CronTool(Tool):
"""Restore previous cron context.""" """Restore previous cron context."""
self._in_cron_context.reset(token) self._in_cron_context.reset(token)
@staticmethod
def _validate_timezone(tz: str) -> str | None:
from zoneinfo import ZoneInfo
try:
ZoneInfo(tz)
except (KeyError, Exception):
return f"Error: unknown timezone '{tz}'"
return None
@property @property
def name(self) -> str: def name(self) -> str:
return "cron" return "cron"
@property @property
def description(self) -> str: def description(self) -> str:
return "Schedule reminders and recurring tasks. Actions: add, list, remove." return (
"Schedule reminders and recurring tasks. Actions: add, list, remove. "
f"If tz is omitted, cron expressions and naive ISO times default to {self._default_timezone}."
)
@property @property
def parameters(self) -> dict[str, Any]: def parameters(self) -> dict[str, Any]:
@ -60,11 +74,17 @@ class CronTool(Tool):
}, },
"tz": { "tz": {
"type": "string", "type": "string",
"description": "IANA timezone for cron expressions (e.g. 'America/Vancouver')", "description": (
"Optional IANA timezone for cron expressions "
f"(e.g. 'America/Vancouver'). Defaults to {self._default_timezone}."
),
}, },
"at": { "at": {
"type": "string", "type": "string",
"description": "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00')", "description": (
"ISO datetime for one-time execution "
f"(e.g. '2026-02-12T10:30:00'). Naive values default to {self._default_timezone}."
),
}, },
"job_id": {"type": "string", "description": "Job ID (for remove)"}, "job_id": {"type": "string", "description": "Job ID (for remove)"},
}, },
@ -107,26 +127,29 @@ class CronTool(Tool):
if tz and not cron_expr: if tz and not cron_expr:
return "Error: tz can only be used with cron_expr" return "Error: tz can only be used with cron_expr"
if tz: if tz:
from zoneinfo import ZoneInfo if err := self._validate_timezone(tz):
return err
try:
ZoneInfo(tz)
except (KeyError, Exception):
return f"Error: unknown timezone '{tz}'"
# Build schedule # Build schedule
delete_after = False delete_after = False
if every_seconds: if every_seconds:
schedule = CronSchedule(kind="every", every_ms=every_seconds * 1000) schedule = CronSchedule(kind="every", every_ms=every_seconds * 1000)
elif cron_expr: elif cron_expr:
schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz) effective_tz = tz or self._default_timezone
if err := self._validate_timezone(effective_tz):
return err
schedule = CronSchedule(kind="cron", expr=cron_expr, tz=effective_tz)
elif at: elif at:
from datetime import datetime from zoneinfo import ZoneInfo
try: try:
dt = datetime.fromisoformat(at) dt = datetime.fromisoformat(at)
except ValueError: except ValueError:
return f"Error: invalid ISO datetime format '{at}'. Expected format: YYYY-MM-DDTHH:MM:SS" return f"Error: invalid ISO datetime format '{at}'. Expected format: YYYY-MM-DDTHH:MM:SS"
if dt.tzinfo is None:
if err := self._validate_timezone(self._default_timezone):
return err
dt = dt.replace(tzinfo=ZoneInfo(self._default_timezone))
at_ms = int(dt.timestamp() * 1000) at_ms = int(dt.timestamp() * 1000)
schedule = CronSchedule(kind="at", at_ms=at_ms) schedule = CronSchedule(kind="at", at_ms=at_ms)
delete_after = True delete_after = True

View File

@ -1,5 +1,7 @@
"""Tests for CronTool._list_jobs() output formatting.""" """Tests for CronTool._list_jobs() output formatting."""
from datetime import datetime, timezone
from nanobot.agent.tools.cron import CronTool from nanobot.agent.tools.cron import CronTool
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
from nanobot.cron.types import CronJobState, CronSchedule from nanobot.cron.types import CronJobState, CronSchedule
@ -10,6 +12,11 @@ def _make_tool(tmp_path) -> CronTool:
return CronTool(service) return CronTool(service)
def _make_tool_with_tz(tmp_path, tz: str) -> CronTool:
service = CronService(tmp_path / "cron" / "jobs.json")
return CronTool(service, default_timezone=tz)
# -- _format_timing tests -- # -- _format_timing tests --
@ -236,6 +243,29 @@ def test_list_shows_next_run(tmp_path) -> None:
assert "Next run:" in result assert "Next run:" in result
def test_add_cron_job_defaults_to_tool_timezone(tmp_path) -> None:
tool = _make_tool_with_tz(tmp_path, "Asia/Shanghai")
tool.set_context("telegram", "chat-1")
result = tool._add_job("Morning standup", None, "0 8 * * *", None, None)
assert result.startswith("Created job")
job = tool._cron.list_jobs()[0]
assert job.schedule.tz == "Asia/Shanghai"
def test_add_at_job_uses_default_timezone_for_naive_datetime(tmp_path) -> None:
tool = _make_tool_with_tz(tmp_path, "Asia/Shanghai")
tool.set_context("telegram", "chat-1")
result = tool._add_job("Morning reminder", None, None, None, "2026-03-25T08:00:00")
assert result.startswith("Created job")
job = tool._cron.list_jobs()[0]
expected = int(datetime(2026, 3, 25, 0, 0, 0, tzinfo=timezone.utc).timestamp() * 1000)
assert job.schedule.at_ms == expected
def test_list_excludes_disabled_jobs(tmp_path) -> None: def test_list_excludes_disabled_jobs(tmp_path) -> None:
tool = _make_tool(tmp_path) tool = _make_tool(tmp_path)
job = tool._cron.add_job( job = tool._cron.add_job(