mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-24 03:34:03 +00:00
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:
parent
13d6c0ae52
commit
4a7d7b8823
@ -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`.
|
||||||
|
|
||||||
|
|||||||
@ -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)."""
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user