diff --git a/README.md b/README.md index 9d292c49f..b6b212d4e 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index f3ee1b40a..0ae4e23de 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -144,7 +144,7 @@ class AgentLoop: self.tools.register(MessageTool(send_callback=self.bus.publish_outbound)) self.tools.register(SpawnTool(manager=self.subagents)) 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: """Connect to configured MCP servers (one-time, lazy).""" diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index 8bedea5a4..ac711d2ed 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -12,8 +12,9 @@ from nanobot.cron.types import CronJobState, CronSchedule class CronTool(Tool): """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._default_timezone = default_timezone self._channel = "" self._chat_id = "" self._in_cron_context: ContextVar[bool] = ContextVar("cron_in_context", default=False) @@ -31,13 +32,26 @@ class CronTool(Tool): """Restore previous cron context.""" 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 def name(self) -> str: return "cron" @property 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 def parameters(self) -> dict[str, Any]: @@ -60,11 +74,17 @@ class CronTool(Tool): }, "tz": { "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": { "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)"}, }, @@ -107,26 +127,29 @@ class CronTool(Tool): if tz and not cron_expr: return "Error: tz can only be used with cron_expr" if tz: - from zoneinfo import ZoneInfo - - try: - ZoneInfo(tz) - except (KeyError, Exception): - return f"Error: unknown timezone '{tz}'" + if err := self._validate_timezone(tz): + return err # Build schedule delete_after = False if every_seconds: schedule = CronSchedule(kind="every", every_ms=every_seconds * 1000) 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: - from datetime import datetime + from zoneinfo import ZoneInfo try: dt = datetime.fromisoformat(at) except ValueError: 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) schedule = CronSchedule(kind="at", at_ms=at_ms) delete_after = True diff --git a/tests/cron/test_cron_tool_list.py b/tests/cron/test_cron_tool_list.py index 5d882ad8f..c55dc589b 100644 --- a/tests/cron/test_cron_tool_list.py +++ b/tests/cron/test_cron_tool_list.py @@ -1,5 +1,7 @@ """Tests for CronTool._list_jobs() output formatting.""" +from datetime import datetime, timezone + from nanobot.agent.tools.cron import CronTool from nanobot.cron.service import CronService from nanobot.cron.types import CronJobState, CronSchedule @@ -10,6 +12,11 @@ def _make_tool(tmp_path) -> CronTool: 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 -- @@ -236,6 +243,29 @@ def test_list_shows_next_run(tmp_path) -> None: 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: tool = _make_tool(tmp_path) job = tool._cron.add_job(