Fix calendar routing and user-local time context (#408)
* fix(chat): add user-local time context * fix(chat): route calendar follow-up phrasing * refactor(chat): log tool intent routing reasons * test(chat): align user time prompt shim --------- Co-authored-by: Alex Kenley <Alex.Kenley@threatvectorsecurity.com>
This commit is contained in:
@@ -8,74 +8,121 @@ user asks how a feature works.
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, Pattern
|
||||
|
||||
|
||||
_ACTION_QUESTION = r"\b(?:can|could|would|will)\s+you\s+"
|
||||
_PLEASE = r"^\s*(?:please\s+)?"
|
||||
@dataclass(frozen=True)
|
||||
class ToolIntent:
|
||||
"""A cheap, deterministic chat-to-agent routing decision."""
|
||||
|
||||
_CALENDAR_ACTION = r"(?:add|create|schedule|book|put|set\s+up|make)"
|
||||
needs_tools: bool
|
||||
category: str = ""
|
||||
reason: str = ""
|
||||
|
||||
|
||||
_ACTION_QUESTION = r"\b(?:can|could|would|will)\s+you\s+"
|
||||
_ACTION_FOLLOWUP = (
|
||||
r"\b(?:you\s+should\s+be\s+able\s+to|"
|
||||
r"(?:can|could|would|will|should)\s+you|"
|
||||
r"you\s+(?:can|could|would|will|should|need\s+to|have\s+to))\s+"
|
||||
)
|
||||
_PLEASE = r"^\s*(?:(?:please|ok(?:ay)?|alright|right|sure|cool|great|thanks)[\s,.!-]+)*"
|
||||
|
||||
_CALENDAR_ACTION = (
|
||||
r"(?:add|adding|create|creating|recreate|recreating|schedule|scheduling|"
|
||||
r"reschedule|rescheduling|book|booking|put|set\s+up|make|making|"
|
||||
r"delete|deleting|remove|removing|cancel|cancelling|canceling)"
|
||||
)
|
||||
_CALENDAR_THING = r"(?:calendar|calendar\s+(?:entry|item)|event|meeting|appointment|entry|call)"
|
||||
_EXPLANATORY_PREFIX = re.compile(
|
||||
r"^\s*(?:how\s+(?:do|can)\s+i|can\s+you\s+explain|what\s+about|tell\s+me\s+how|show\s+me\s+how)\b",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_PANEL = (
|
||||
r"(?:calendar|notes?|inbox|email|mail|documents?|docs|library|gallery|"
|
||||
r"settings|cookbook|sessions?|chats?|skills|memories|memory|brain)"
|
||||
)
|
||||
|
||||
_TOOL_INTENT_PATTERNS: tuple[Pattern[str], ...] = tuple(
|
||||
re.compile(pattern, re.I)
|
||||
for pattern in (
|
||||
_ROUTING_PATTERNS: tuple[tuple[str, str, Pattern[str]], ...] = tuple(
|
||||
(category, reason, re.compile(pattern, re.I))
|
||||
for category, reason, pattern in (
|
||||
# Calendar/event creation. Covers "Can you add an entry to my
|
||||
# calendar?" and imperatives like "add lunch to my calendar".
|
||||
rf"{_ACTION_QUESTION}{_CALENDAR_ACTION}\b.{{0,120}}\b{_CALENDAR_THING}\b",
|
||||
rf"{_PLEASE}{_CALENDAR_ACTION}\b.{{0,120}}\b(?:to|on|in|into|for)\s+(?:my\s+|the\s+|this\s+)?calendar\b",
|
||||
rf"{_PLEASE}{_CALENDAR_ACTION}\s+(?:a\s+|an\s+)?(?:calendar\s+)?(?:event|meeting|appointment|entry|item|call)\b",
|
||||
r"\bput\s+.+\bon\s+(?:my\s+)?calendar\b",
|
||||
# calendar?", imperatives like "add lunch to my calendar", and
|
||||
# follow-ups such as "you should be able to create that event now".
|
||||
("calendar", "assistant calendar action request", rf"{_ACTION_QUESTION}{_CALENDAR_ACTION}\b.{{0,120}}\b{_CALENDAR_THING}\b"),
|
||||
("calendar", "calendar follow-up action request", rf"{_ACTION_FOLLOWUP}{_CALENDAR_ACTION}\b.{{0,120}}\b{_CALENDAR_THING}\b"),
|
||||
("calendar", "calendar imperative action request", rf"{_PLEASE}{_CALENDAR_ACTION}\b.{{0,120}}\b{_CALENDAR_THING}\b"),
|
||||
("calendar", "calendar target action request", rf"{_PLEASE}{_CALENDAR_ACTION}\b.{{0,120}}\b(?:to|on|in|into|for)\s+(?:my\s+|the\s+|this\s+)?calendar\b"),
|
||||
("calendar", "calendar item action request", rf"{_PLEASE}{_CALENDAR_ACTION}\s+(?:it\s+)?(?:a\s+|an\s+)?(?:calendar\s+)?(?:event|meeting|appointment|entry|item|call)\b"),
|
||||
("calendar", "calendar target action request", rf"\b{_CALENDAR_ACTION}\b.{{0,120}}\b(?:to|on|in|into|for)\s+(?:my\s+|the\s+|this\s+)?calendar\b"),
|
||||
("calendar", "put item on calendar request", r"\bput\s+.+\bon\s+(?:my\s+)?calendar\b"),
|
||||
|
||||
# Notes, todos, checklists, and reminders.
|
||||
r"\bremind\s+me\b",
|
||||
rf"{_ACTION_QUESTION}(?:add|create|make|take|jot|write\s+down|set)\b.{{0,120}}\b(?:note|todo|task|checklist|reminder)\b",
|
||||
rf"{_PLEASE}(?:add|create|make)\s+(?:a\s+|an\s+)?(?:todo|task|reminder|note|checklist)\b",
|
||||
rf"{_PLEASE}(?:take|jot|write\s+down)\s+(?:a\s+|an\s+)?note\b",
|
||||
rf"{_PLEASE}(?:add|jot|write\s+down)\b.{{0,120}}\b(?:to|in|into)\s+(?:my\s+|the\s+)?(?:todo(?:\s+list)?|task\s+list|notes?|checklist)\b",
|
||||
rf"{_PLEASE}set\s+(?:a\s+)?reminder\b",
|
||||
rf"{_ACTION_QUESTION}set\s+(?:a\s+)?reminder\b",
|
||||
("notes", "reminder request", r"\bremind\s+me\b"),
|
||||
("notes", "assistant note/todo action request", rf"{_ACTION_QUESTION}(?:add|create|make|take|jot|write\s+down|set)\b.{{0,120}}\b(?:note|todo|task|checklist|reminder)\b"),
|
||||
("notes", "note/todo imperative request", rf"{_PLEASE}(?:add|create|make)\s+(?:a\s+|an\s+)?(?:todo|task|reminder|note|checklist)\b"),
|
||||
("notes", "take note request", rf"{_PLEASE}(?:take|jot|write\s+down)\s+(?:a\s+|an\s+)?note\b"),
|
||||
("notes", "add item to notes/todo request", rf"{_PLEASE}(?:add|jot|write\s+down)\b.{{0,120}}\b(?:to|in|into)\s+(?:my\s+|the\s+)?(?:todo(?:\s+list)?|task\s+list|notes?|checklist)\b"),
|
||||
("notes", "set reminder request", rf"{_PLEASE}set\s+(?:a\s+)?reminder\b"),
|
||||
("notes", "assistant reminder request", rf"{_ACTION_QUESTION}set\s+(?:a\s+)?reminder\b"),
|
||||
|
||||
# Email actions.
|
||||
rf"{_ACTION_QUESTION}(?:send|write|reply|email|message|archive|delete|mark)\b.{{0,120}}\b(?:emails?|mail|messages?|inbox|unread|read)\b",
|
||||
rf"{_PLEASE}(?:send|write|reply)\b.{{0,120}}\b(?:emails?|mail|messages?)\b",
|
||||
rf"{_PLEASE}(?:archive|delete|mark)\b.{{0,120}}\b(?:emails?|mail|messages?|inbox)\b",
|
||||
r"\b(?:send|write|reply)\s+(?:an?\s+)?(?:email|message|mail)\b",
|
||||
r"\bemail\s+\w+\b",
|
||||
r"\bcheck\s+(?:my\s+)?(?:email|inbox|mail)\b",
|
||||
r"\bunread\s+(?:email|mail)s?\b",
|
||||
("email", "assistant email action request", rf"{_ACTION_QUESTION}(?:send|write|reply|email|message|archive|delete|mark)\b.{{0,120}}\b(?:emails?|mail|messages?|inbox|unread|read)\b"),
|
||||
("email", "send/write/reply email request", rf"{_PLEASE}(?:send|write|reply)\b.{{0,120}}\b(?:emails?|mail|messages?)\b"),
|
||||
("email", "archive/delete/mark email request", rf"{_PLEASE}(?:archive|delete|mark)\b.{{0,120}}\b(?:emails?|mail|messages?|inbox)\b"),
|
||||
("email", "email composition request", r"\b(?:send|write|reply)\s+(?:an?\s+)?(?:email|message|mail)\b"),
|
||||
("email", "email contact request", r"\bemail\s+\w+\b"),
|
||||
("email", "check inbox request", r"\bcheck\s+(?:my\s+)?(?:email|inbox|mail)\b"),
|
||||
("email", "unread email request", r"\bunread\s+(?:email|mail)s?\b"),
|
||||
|
||||
# UI/control-plane actions that should open panels or flip toggles.
|
||||
rf"{_PLEASE}(?:open|show|bring\s+up)\s+(?:me\s+)?(?:my\s+|the\s+)?{_PANEL}\b",
|
||||
r"\b(?:disable|enable|turn\s+(?:on|off))\s+(?:the\s+)?(?:shell|search|web|browser|documents?|memory|skills|images?|calendar|email|mail|research|incognito)\b",
|
||||
("ui", "open/show panel request", rf"{_PLEASE}(?:open|show|bring\s+up)\s+(?:me\s+)?(?:my\s+|the\s+)?{_PANEL}\b"),
|
||||
("ui", "tool or feature toggle request", r"\b(?:disable|enable|turn\s+(?:on|off))\s+(?:the\s+)?(?:shell|search|web|browser|documents?|memory|skills|images?|calendar|email|mail|research|incognito)\b"),
|
||||
|
||||
# Deep research jobs, not quick conceptual mentions of research.
|
||||
rf"{_PLEASE}(?:research|deep\s+dive|look\s+into|investigate)\s+.+",
|
||||
rf"{_ACTION_QUESTION}(?:research|do\s+research|deep\s+dive|look\s+into|investigate)\s+.+",
|
||||
("research", "deep research imperative request", rf"{_PLEASE}(?:research|deep\s+dive|look\s+into|investigate)\s+.+"),
|
||||
("research", "assistant deep research request", rf"{_ACTION_QUESTION}(?:research|do\s+research|deep\s+dive|look\s+into|investigate)\s+.+"),
|
||||
|
||||
# Shell / remote-host intent.
|
||||
r"\bssh\s+(?:in)?to\b",
|
||||
r"\bssh\s+\w+",
|
||||
r"\b(run|execute)\s+.{1,40}\bon\s+\w+",
|
||||
r"\b(can|could|please|would)\s+you\s+(run|execute|exec)\b",
|
||||
("shell", "ssh request", r"\bssh\s+(?:in)?to\b"),
|
||||
("shell", "ssh target request", r"\bssh\s+\w+"),
|
||||
("shell", "remote command request", r"\b(run|execute)\s+.{1,40}\bon\s+\w+"),
|
||||
("shell", "assistant command execution request", r"\b(can|could|please|would)\s+you\s+(run|execute|exec)\b"),
|
||||
# Shell verbs only count in imperative position (start of message,
|
||||
# optionally after "please") or as a "can you ..." request. A bare
|
||||
# word match promoted informational questions ("What does the grep
|
||||
# command do?") and incidental uses ("My cat ate my homework").
|
||||
rf"{_PLEASE}(deploy|build|install|restart|reboot|kill|tail|grep|cat|ls|cd|cp|mv|rm)\b\s+\S+",
|
||||
rf"{_ACTION_QUESTION}(deploy|build|install|restart|reboot|kill|tail|grep|cat|ls|cd|cp|mv|rm)\b\s+\S+",
|
||||
r"\b(check|see)\s+(if|whether|what)\s+.{1,40}\b(running|process|service|port|file|exists?)\b",
|
||||
("shell", "imperative shell command request", rf"{_PLEASE}(deploy|build|install|restart|reboot|kill|tail|grep|cat|ls|cd|cp|mv|rm)\b\s+\S+"),
|
||||
("shell", "assistant shell command request", rf"{_ACTION_QUESTION}(deploy|build|install|restart|reboot|kill|tail|grep|cat|ls|cd|cp|mv|rm)\b\s+\S+"),
|
||||
("shell", "system/file check request", r"\b(check|see)\s+(if|whether|what)\s+.{1,40}\b(running|process|service|port|file|exists?)\b"),
|
||||
)
|
||||
)
|
||||
|
||||
_TOOL_INTENT_PATTERNS: tuple[Pattern[str], ...] = tuple(
|
||||
pattern for _, _, pattern in _ROUTING_PATTERNS
|
||||
)
|
||||
|
||||
|
||||
def classify_tool_intent(text: str) -> ToolIntent:
|
||||
"""Classify whether a chat message should be promoted to agent mode."""
|
||||
if not text:
|
||||
return ToolIntent(False, reason="empty message")
|
||||
if _EXPLANATORY_PREFIX.search(text):
|
||||
return ToolIntent(False, reason="explanatory feature question")
|
||||
for category, reason, pattern in _ROUTING_PATTERNS:
|
||||
if pattern.search(text):
|
||||
return ToolIntent(True, category=category, reason=reason)
|
||||
return ToolIntent(False, reason="no tool-action pattern matched")
|
||||
|
||||
|
||||
def message_needs_tools(text: str, patterns: Iterable[Pattern[str]] = _TOOL_INTENT_PATTERNS) -> bool:
|
||||
"""Return True when a plain chat message should be promoted to agent mode."""
|
||||
if not text:
|
||||
return False
|
||||
if _EXPLANATORY_PREFIX.search(text):
|
||||
return False
|
||||
if patterns is _TOOL_INTENT_PATTERNS:
|
||||
return classify_tool_intent(text).needs_tools
|
||||
return any(pattern.search(text) for pattern in patterns)
|
||||
|
||||
@@ -636,28 +636,11 @@ def _build_system_prompt(
|
||||
|
||||
set_active_model(model)
|
||||
|
||||
# Current date/time — every request. Models default to their
|
||||
# training-cutoff date when "today" is asked otherwise (was
|
||||
# rendering April 2026 dates as "today" when the actual date is
|
||||
# May 19, 2026). System TZ-local so calendar/email date math
|
||||
# matches what the user sees.
|
||||
# Current date/time for every agent request. This is user-local when the
|
||||
# browser provided timezone headers, with a server-local fallback.
|
||||
try:
|
||||
from datetime import datetime as _dt, timezone as _tz
|
||||
_now = _dt.now().astimezone()
|
||||
_utc = _dt.now(_tz.utc)
|
||||
_off = _now.strftime('%z') # e.g. +0900
|
||||
_off_fmt = (f"{_off[:3]}:{_off[3:]}" if _off else "+00:00")
|
||||
agent_prompt = (
|
||||
f"## Current date and time\n"
|
||||
f"Today is {_now.strftime('%A, %B %-d, %Y')} ({_now.strftime('%Y-%m-%d')}). "
|
||||
f"Local time is {_now.strftime('%-I:%M %p')} ({_now.strftime('%Z')}, UTC{_off_fmt}); "
|
||||
f"current UTC time is {_utc.strftime('%H:%M')}. "
|
||||
f"Use this for any 'today'/'tomorrow'/'this week' reasoning — do NOT "
|
||||
f"infer the date from training data or from event timestamps.\n"
|
||||
f"When scheduling a task (manage_tasks), scheduled_time is in UTC: "
|
||||
f"subtract the offset above from the user's local time "
|
||||
f"(local {_now.strftime('%H:%M')} = {_utc.strftime('%H:%M')} UTC right now).\n\n"
|
||||
) + agent_prompt
|
||||
from src.user_time import current_datetime_prompt
|
||||
agent_prompt = current_datetime_prompt() + agent_prompt
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -185,6 +185,15 @@ class ChatProcessor:
|
||||
"role": "system",
|
||||
"content": preset_system_prompt
|
||||
})
|
||||
if not agent_mode:
|
||||
try:
|
||||
from src.user_time import current_datetime_prompt
|
||||
preface.append({
|
||||
"role": "system",
|
||||
"content": current_datetime_prompt(),
|
||||
})
|
||||
except Exception:
|
||||
logger.debug("Failed to add current date/time context", exc_info=True)
|
||||
preface.append({
|
||||
"role": "system",
|
||||
"content": UNTRUSTED_CONTEXT_POLICY,
|
||||
|
||||
@@ -102,7 +102,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
||||
"resolve_contact": "Look up a contact's email address by name. Searches CardDAV address book and sent email history. Use when the user says 'message [name]', 'email [name]', or 'send to [name]' without an email address.",
|
||||
"manage_contact": "Create, update, delete, or list CardDAV contacts. Use to save a new contact, change an existing one's email/phone, or remove one. Action=list returns uids needed for update/delete. Use when the user says 'save this contact', 'add [name] to contacts', 'update [name]'s email', 'delete [name] from contacts'. Do not use for user identity facts like 'my name is <name>'; those are memory.",
|
||||
"manage_notes": "Create and manage notes and checklists (Google Keep-style). ALWAYS use this for note/todo/checklist/reminder creation — NEVER hit /api/notes via app_api. Accepts natural-language `due_date` like 'tomorrow at 9am' or '11pm today' (parsed in the USER'S timezone). The due_date IS the reminder — it fires a notification at that time, so do NOT also create a calendar event for the same reminder. Set colors, labels, pin, archive. Do NOT use manage_memory for note content.",
|
||||
"manage_calendar": "Calendar event management: list, create, update, delete. Each event can carry a tag/category (event_type — work/personal/health/travel/meal/social/admin/other) and importance (low/normal/high/critical). Use ISO datetimes; supports all-day events. For event reminders/alarms, pass reminder_minutes; this creates the Notes reminder, so do not also call manage_notes for the same reminder.",
|
||||
"manage_calendar": "Calendar event management: list, create, update, delete. Each event can carry a tag/category (event_type — work/personal/health/travel/meal/social/admin/other) and importance (low/normal/high/critical). Resolve today/tomorrow using the Current date and time context, then use ISO datetimes in the user's local wall time; supports all-day events. For event reminders/alarms, pass reminder_minutes; this creates the Notes reminder, so do not also call manage_notes for the same reminder.",
|
||||
"download_model": "Download a HuggingFace model to a local or remote server. Specify repo_id (e.g. 'Qwen/Qwen3-8B'), optional server host, and optional include filter for specific files.",
|
||||
"serve_model": "Start serving a model with vLLM, SGLang, llama.cpp, Ollama, or Diffusers. For image/inpainting/diffusion use python3 scripts/diffusion_server.py --model <repo> --port 8100. After launch, call list_served_models for readiness/errors and retry suggestions.",
|
||||
"list_served_models": "List currently running model servers in the Cookbook — shows status (loading, ready, idle, error), model name, port, throughput, and serve failure diagnosis/retry suggestions. Use when the user asks 'what's running', 'show my cookbook', 'which models are up', 'what's serving'.",
|
||||
|
||||
@@ -422,7 +422,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "manage_calendar",
|
||||
"description": "Manage calendar events: list events in a date range, create, update, delete. Each event can carry a tag/category (event_type) and importance level. Use ISO 8601 datetimes; for all-day events set all_day=true and pass YYYY-MM-DD. For event reminders/alarms, pass reminder_minutes; the tool creates the Odysseus note reminder, so do not also call manage_notes for the same reminder.",
|
||||
"description": "Manage calendar events: list events in a date range, create, update, delete. Each event can carry a tag/category (event_type) and importance level. Resolve relative dates like today/tomorrow against the 'Current date and time' system context, then pass ISO 8601 datetimes in the user's local wall time; for all-day events set all_day=true and pass YYYY-MM-DD. For event reminders/alarms, pass reminder_minutes; the tool creates the Odysseus note reminder, so do not also call manage_notes for the same reminder.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
138
src/user_time.py
Normal file
138
src/user_time.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""Per-request user-local time helpers.
|
||||
|
||||
Chat routes set this context from browser headers. Prompt builders and tools
|
||||
can then resolve relative dates against the user's clock instead of the server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from contextvars import ContextVar
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
|
||||
_USER_TZ_OFFSET_MIN: ContextVar[Optional[int]] = ContextVar("user_tz_offset_min", default=None)
|
||||
_USER_TZ_NAME: ContextVar[Optional[str]] = ContextVar("user_tz_name", default=None)
|
||||
|
||||
|
||||
def set_user_tz_offset(offset_min) -> None:
|
||||
"""Set the current user's UTC offset in minutes east of UTC."""
|
||||
if offset_min in (None, ""):
|
||||
_USER_TZ_OFFSET_MIN.set(None)
|
||||
return
|
||||
try:
|
||||
value = int(offset_min)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
if -14 * 60 <= value <= 14 * 60:
|
||||
_USER_TZ_OFFSET_MIN.set(value)
|
||||
|
||||
|
||||
def get_user_tz_offset() -> Optional[int]:
|
||||
"""Return minutes east of UTC for the current user, if known."""
|
||||
return _USER_TZ_OFFSET_MIN.get()
|
||||
|
||||
|
||||
def set_user_tz_name(name) -> None:
|
||||
"""Set a safe IANA timezone label for the current request context."""
|
||||
if not name:
|
||||
_USER_TZ_NAME.set(None)
|
||||
return
|
||||
first_token = str(name).strip().split()[0] if str(name).strip() else ""
|
||||
cleaned = re.sub(r"[^A-Za-z0-9_+\-./]", "", first_token)[:80]
|
||||
_USER_TZ_NAME.set(cleaned or None)
|
||||
|
||||
|
||||
def get_user_tz_name() -> Optional[str]:
|
||||
"""Return the current user's browser timezone name, if provided."""
|
||||
return _USER_TZ_NAME.get()
|
||||
|
||||
|
||||
def clear_user_time_context() -> None:
|
||||
"""Clear user-local time context for tests and non-browser entry points."""
|
||||
_USER_TZ_OFFSET_MIN.set(None)
|
||||
_USER_TZ_NAME.set(None)
|
||||
|
||||
|
||||
def format_utc_offset(offset_min: Optional[int]) -> str:
|
||||
"""Format minutes east of UTC as +HH:MM or -HH:MM."""
|
||||
if offset_min is None:
|
||||
offset_min = 0
|
||||
sign = "+" if offset_min >= 0 else "-"
|
||||
total = abs(int(offset_min))
|
||||
hours, minutes = divmod(total, 60)
|
||||
return f"{sign}{hours:02d}:{minutes:02d}"
|
||||
|
||||
|
||||
def user_timezone() -> timezone:
|
||||
"""Return the best known user timezone as a fixed-offset tzinfo."""
|
||||
offset = get_user_tz_offset()
|
||||
if offset is None:
|
||||
name = get_user_tz_name()
|
||||
if name:
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
return ZoneInfo(name)
|
||||
except Exception:
|
||||
pass
|
||||
return datetime.now().astimezone().tzinfo or timezone.utc
|
||||
return timezone(timedelta(minutes=offset))
|
||||
|
||||
|
||||
def now_user_local(now_utc: Optional[datetime] = None) -> datetime:
|
||||
"""Return the current time in the user's timezone."""
|
||||
if now_utc is None:
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
elif now_utc.tzinfo is None:
|
||||
now_utc = now_utc.replace(tzinfo=timezone.utc)
|
||||
return now_utc.astimezone(user_timezone())
|
||||
|
||||
|
||||
def _date_label(dt: datetime) -> str:
|
||||
return f"{dt.strftime('%A')}, {dt.strftime('%B')} {dt.day}, {dt.year}"
|
||||
|
||||
|
||||
def _clock_label(dt: datetime) -> str:
|
||||
hour = dt.hour % 12 or 12
|
||||
return f"{hour}:{dt.minute:02d} {dt.strftime('%p')}"
|
||||
|
||||
|
||||
def timezone_label(dt: Optional[datetime] = None) -> str:
|
||||
"""Return a concise display label such as Australia/Brisbane, UTC+10:00."""
|
||||
offset = get_user_tz_offset()
|
||||
if offset is None:
|
||||
if dt is None:
|
||||
dt = datetime.now().astimezone()
|
||||
offset = int((dt.utcoffset() or timedelta()).total_seconds() // 60)
|
||||
offset_label = f"UTC{format_utc_offset(offset)}"
|
||||
name = get_user_tz_name()
|
||||
return f"{name}, {offset_label}" if name else offset_label
|
||||
|
||||
|
||||
def current_datetime_prompt(now_utc: Optional[datetime] = None) -> str:
|
||||
"""Build reusable system prompt text for date/time reasoning."""
|
||||
if now_utc is None:
|
||||
utc_now = datetime.now(timezone.utc)
|
||||
elif now_utc.tzinfo is None:
|
||||
utc_now = now_utc.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
utc_now = now_utc.astimezone(timezone.utc)
|
||||
|
||||
local_now = now_user_local(utc_now)
|
||||
tomorrow = local_now + timedelta(days=1)
|
||||
return (
|
||||
"## Current date and time\n"
|
||||
f"Today is {_date_label(local_now)} ({local_now.strftime('%Y-%m-%d')}). "
|
||||
f"User local time is {_clock_label(local_now)} ({timezone_label(local_now)}); "
|
||||
f"current UTC time is {utc_now.strftime('%H:%M')}.\n"
|
||||
f"Tomorrow is {_date_label(tomorrow)} ({tomorrow.strftime('%Y-%m-%d')}) "
|
||||
"in the user's local timezone.\n"
|
||||
"Use this for any 'today', 'tomorrow', 'tonight', 'this week', or other "
|
||||
"relative-date reasoning. Do not ask for an exact date just because the "
|
||||
"user used a relative date.\n"
|
||||
"When scheduling calendar events with manage_calendar, pass local ISO "
|
||||
"datetimes resolved against this user-local date/time.\n"
|
||||
"When scheduling a task with manage_tasks, scheduled_time is in UTC: "
|
||||
"convert the user's stated local time using the UTC offset above.\n\n"
|
||||
)
|
||||
Reference in New Issue
Block a user