From 13d6c0ae52e8604009e79bbcf8975618551dcf3d Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Wed, 25 Mar 2026 10:15:47 +0000 Subject: [PATCH] feat(config): add configurable timezone for runtime context Add agent-level timezone configuration with a UTC default, propagate it into runtime context and heartbeat prompts, and document valid IANA timezone usage in the README. --- README.md | 22 ++++++++++++++++++++++ nanobot/agent/context.py | 11 +++++++---- nanobot/agent/loop.py | 3 ++- nanobot/cli/commands.py | 3 +++ nanobot/config/schema.py | 1 + nanobot/heartbeat/service.py | 4 +++- nanobot/utils/helpers.py | 23 ++++++++++++++++++----- 7 files changed, 56 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 270f61b62..9d292c49f 100644 --- a/README.md +++ b/README.md @@ -1345,6 +1345,28 @@ MCP tools are automatically discovered and registered on startup. The LLM can us | `channels.*.allowFrom` | `[]` (deny all) | Whitelist of user IDs. Empty denies all; use `["*"]` to allow everyone. | +### Timezone + +Time is context. Context should be precise. + +By default, nanobot uses `UTC` for runtime time context. If you want the agent to think in your local time, set `agents.defaults.timezone` to a valid [IANA timezone name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones): + +```json +{ + "agents": { + "defaults": { + "timezone": "Asia/Shanghai" + } + } +} +``` + +This currently affects runtime time strings shown to the model, such as runtime context and heartbeat prompts. + +Common examples: `UTC`, `America/New_York`, `America/Los_Angeles`, `Europe/London`, `Europe/Berlin`, `Asia/Tokyo`, `Asia/Shanghai`, `Asia/Singapore`, `Australia/Sydney`. + +> Need another timezone? Browse the full [IANA Time Zone Database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). + ## 🧩 Multiple Instances Run multiple nanobot instances simultaneously with separate configs and runtime data. Use `--config` as the main entrypoint. Optionally pass `--workspace` during `onboard` when you want to initialize or update the saved workspace for a specific instance. diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 9e547eebb..ce69d247b 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -19,8 +19,9 @@ class ContextBuilder: BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md"] _RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]" - def __init__(self, workspace: Path): + def __init__(self, workspace: Path, timezone: str | None = None): self.workspace = workspace + self.timezone = timezone self.memory = MemoryStore(workspace) self.skills = SkillsLoader(workspace) @@ -100,9 +101,11 @@ Reply directly with text for conversations. Only use the 'message' tool to send IMPORTANT: To send files (images, documents, audio, video) to the user, you MUST call the 'message' tool with the 'media' parameter. Do NOT use read_file to "send" a file — reading a file only shows its content to you, it does NOT deliver the file to the user. Example: message(content="Here is the file", media=["/path/to/file.png"])""" @staticmethod - def _build_runtime_context(channel: str | None, chat_id: str | None) -> str: + def _build_runtime_context( + channel: str | None, chat_id: str | None, timezone: str | None = None, + ) -> str: """Build untrusted runtime metadata block for injection before the user message.""" - lines = [f"Current Time: {current_time_str()}"] + lines = [f"Current Time: {current_time_str(timezone)}"] if channel and chat_id: lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"] return ContextBuilder._RUNTIME_CONTEXT_TAG + "\n" + "\n".join(lines) @@ -130,7 +133,7 @@ IMPORTANT: To send files (images, documents, audio, video) to the user, you MUST current_role: str = "user", ) -> list[dict[str, Any]]: """Build the complete message list for an LLM call.""" - runtime_ctx = self._build_runtime_context(channel, chat_id) + runtime_ctx = self._build_runtime_context(channel, chat_id, self.timezone) user_content = self._build_user_content(current_message, media) # Merge runtime context and user content into a single user message diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 03786c7b6..f3ee1b40a 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -65,6 +65,7 @@ class AgentLoop: session_manager: SessionManager | None = None, mcp_servers: dict | None = None, channels_config: ChannelsConfig | None = None, + timezone: str | None = None, ): from nanobot.config.schema import ExecToolConfig, WebSearchConfig @@ -83,7 +84,7 @@ class AgentLoop: self._start_time = time.time() self._last_usage: dict[str, int] = {} - self.context = ContextBuilder(workspace) + self.context = ContextBuilder(workspace, timezone=timezone) self.sessions = session_manager or SessionManager(workspace) self.tools = ToolRegistry() self.subagents = SubagentManager( diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 91c81d3de..cacb61ae6 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -549,6 +549,7 @@ def gateway( session_manager=session_manager, mcp_servers=config.tools.mcp_servers, channels_config=config.channels, + timezone=config.agents.defaults.timezone, ) # Set cron callback (needs agent) @@ -659,6 +660,7 @@ def gateway( on_notify=on_heartbeat_notify, interval_s=hb_cfg.interval_s, enabled=hb_cfg.enabled, + timezone=config.agents.defaults.timezone, ) if channels.enabled_channels: @@ -752,6 +754,7 @@ def agent( restrict_to_workspace=config.tools.restrict_to_workspace, mcp_servers=config.tools.mcp_servers, channels_config=config.channels, + timezone=config.agents.defaults.timezone, ) # Shared reference for progress callbacks diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 9ae662ec8..6f05e569e 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -40,6 +40,7 @@ class AgentDefaults(Base): temperature: float = 0.1 max_tool_iterations: int = 40 reasoning_effort: str | None = None # low / medium / high - enables LLM thinking mode + timezone: str = "UTC" # IANA timezone, e.g. "Asia/Shanghai", "America/New_York" class AgentsConfig(Base): diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py index 7be81ff4a..00f6b17e1 100644 --- a/nanobot/heartbeat/service.py +++ b/nanobot/heartbeat/service.py @@ -59,6 +59,7 @@ class HeartbeatService: on_notify: Callable[[str], Coroutine[Any, Any, None]] | None = None, interval_s: int = 30 * 60, enabled: bool = True, + timezone: str | None = None, ): self.workspace = workspace self.provider = provider @@ -67,6 +68,7 @@ class HeartbeatService: self.on_notify = on_notify self.interval_s = interval_s self.enabled = enabled + self.timezone = timezone self._running = False self._task: asyncio.Task | None = None @@ -93,7 +95,7 @@ class HeartbeatService: messages=[ {"role": "system", "content": "You are a heartbeat agent. Call the heartbeat tool to report your decision."}, {"role": "user", "content": ( - f"Current Time: {current_time_str()}\n\n" + f"Current Time: {current_time_str(self.timezone)}\n\n" "Review the following HEARTBEAT.md and decide whether there are active tasks.\n\n" f"{content}" )}, diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index f265870dd..a10a4f18b 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -55,11 +55,24 @@ def timestamp() -> str: return datetime.now().isoformat() -def current_time_str() -> str: - """Human-readable current time with weekday and timezone, e.g. '2026-03-15 22:30 (Saturday) (CST)'.""" - now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)") - tz = time.strftime("%Z") or "UTC" - return f"{now} ({tz})" +def current_time_str(timezone: str | None = None) -> str: + """Human-readable current time with weekday and UTC offset. + + When *timezone* is a valid IANA name (e.g. ``"Asia/Shanghai"``), the time + is converted to that zone. Otherwise falls back to the host local time. + """ + from zoneinfo import ZoneInfo + + try: + tz = ZoneInfo(timezone) if timezone else None + except (KeyError, Exception): + tz = None + + now = datetime.now(tz=tz) if tz else datetime.now().astimezone() + offset = now.strftime("%z") + offset_fmt = f"{offset[:3]}:{offset[3:]}" if len(offset) == 5 else offset + tz_name = timezone or (time.strftime("%Z") or "UTC") + return f"{now.strftime('%Y-%m-%d %H:%M (%A)')} ({tz_name}, UTC{offset_fmt})" _UNSAFE_CHARS = re.compile(r'[<>:"/\\|?*]')