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:
Alexander Kenley
2026-06-04 22:20:04 +10:00
committed by GitHub
parent f59edee611
commit 7b45a94b6d
12 changed files with 463 additions and 106 deletions

View File

@@ -161,26 +161,18 @@ def _ensure_default_calendar(db, owner: str = None) -> CalendarCal:
return cal return cal
# Per-request user UTC offset (in minutes east of UTC). chat_routes sets this # Per-request user time context. chat_routes sets this from browser timezone
# from the `X-Tz-Offset` header so naive natural-language times the LLM # headers so natural-language times the LLM emits ("today at 9pm") are parsed
# emits ("today at 9pm") are parsed in the USER's timezone, not the server's # in the user's timezone, not the server's clock. None = unknown, fall back to
# clock. None = unknown, fall back to legacy server-local behavior. # legacy server-local behavior.
from contextvars import ContextVar from src.user_time import (
_USER_TZ_OFFSET_MIN: ContextVar = ContextVar("user_tz_offset_min", default=None) get_user_tz_name,
get_user_tz_offset,
now_user_local,
def set_user_tz_offset(offset_min): set_user_tz_name,
"""Set the current user's UTC offset for this async context.""" set_user_tz_offset,
try: user_timezone,
v = int(offset_min) )
except (TypeError, ValueError):
return
_USER_TZ_OFFSET_MIN.set(v)
def get_user_tz_offset():
"""Read the current user's UTC offset (minutes east of UTC), or None."""
return _USER_TZ_OFFSET_MIN.get()
def parse_due_for_user(s: str) -> str: def parse_due_for_user(s: str) -> str:
@@ -199,6 +191,7 @@ def parse_due_for_user(s: str) -> str:
""" """
from datetime import timezone as _tz, timedelta as _td from datetime import timezone as _tz, timedelta as _td
offset = get_user_tz_offset() offset = get_user_tz_offset()
tz_name = get_user_tz_name()
s = (s or "").strip() s = (s or "").strip()
if not s: if not s:
return s return s
@@ -212,11 +205,11 @@ def parse_due_for_user(s: str) -> str:
except ValueError: except ValueError:
parsed = None parsed = None
if offset is None: if offset is None and not tz_name:
# No user tz known — preserve legacy behavior (naive server-local). # No user tz known — preserve legacy behavior (naive server-local).
return _parse_dt(s).isoformat() return _parse_dt(s).isoformat()
user_tz = _tz(_td(minutes=offset)) user_tz = user_timezone()
# Naive ISO → tag with user tz. # Naive ISO → tag with user tz.
if parsed is not None and parsed.tzinfo is None: if parsed is not None and parsed.tzinfo is None:
@@ -224,7 +217,7 @@ def parse_due_for_user(s: str) -> str:
# Natural language — evaluate against user's "now". # Natural language — evaluate against user's "now".
server_now_utc = datetime.now(_tz.utc) server_now_utc = datetime.now(_tz.utc)
user_now = server_now_utc.astimezone(user_tz) user_now = now_user_local(server_now_utc)
# Patch datetime.now() inside _parse_dt by leveraging the user's clock: # Patch datetime.now() inside _parse_dt by leveraging the user's clock:
# we re-implement the small natural-language phrases here against user_now # we re-implement the small natural-language phrases here against user_now
# so the result is naturally in the user's tz. # so the result is naturally in the user's tz.
@@ -232,6 +225,7 @@ def parse_due_for_user(s: str) -> str:
lower = s.lower().strip() lower = s.lower().strip()
def _parse_time(t): def _parse_time(t):
t = _re.sub(r'\b([ap])\s*\.?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE)
m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE) m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE)
if not m: return None if not m: return None
h = int(m.group(1)); mn = int(m.group(2) or 0); ampm = (m.group(3) or "").lower() h = int(m.group(1)); mn = int(m.group(2) or 0); ampm = (m.group(3) or "").lower()
@@ -341,6 +335,7 @@ def _parse_dt(s: str) -> datetime:
def _parse_time(t: str): def _parse_time(t: str):
"""Return (hour, minute) from '1pm', '1:30 PM', '13:00', etc., or None.""" """Return (hour, minute) from '1pm', '1:30 PM', '13:00', etc., or None."""
t = _re.sub(r'\b([ap])\s*\.?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE)
m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE) m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE)
if not m: if not m:
return None return None
@@ -1210,7 +1205,20 @@ def setup_calendar_routes() -> APIRouter:
text = (body.get("text") or "").strip() text = (body.get("text") or "").strip()
if not text: if not text:
raise HTTPException(400, "text is required") raise HTTPException(400, "text is required")
from src.user_time import (
clear_user_time_context,
current_datetime_prompt,
now_user_local,
set_user_tz_name,
set_user_tz_offset,
)
clear_user_time_context()
tz_hint = (body.get("tz") or "").strip() tz_hint = (body.get("tz") or "").strip()
if body.get("tz_offset") is not None:
set_user_tz_offset(body.get("tz_offset"))
if tz_hint:
set_user_tz_name(tz_hint)
url, model, headers = resolve_endpoint("utility") url, model, headers = resolve_endpoint("utility")
if not url: if not url:
@@ -1218,15 +1226,15 @@ def setup_calendar_routes() -> APIRouter:
if not url or not model: if not url or not model:
return {"ok": False, "error": "No LLM endpoint configured"} return {"ok": False, "error": "No LLM endpoint configured"}
now = datetime.now() now = now_user_local()
now_iso = now.strftime("%Y-%m-%dT%H:%M:%S") now_iso = now.strftime("%Y-%m-%dT%H:%M:%S")
# The model gets only the schema it needs to fill out; we re-validate # The model gets only the schema it needs to fill out; we re-validate
# everything client-side too. # everything client-side too.
system_prompt = ( system_prompt = (
"You are a calendar event parser. Read the user's one-line " current_datetime_prompt()
+ "You are a calendar event parser. Read the user's one-line "
"description and emit STRICT JSON describing the event. " "description and emit STRICT JSON describing the event. "
f"Today is {now.strftime('%A, %Y-%m-%d')} ({now_iso}). " f"The current user-local timestamp is {now_iso}. "
+ (f"User timezone: {tz_hint}. " if tz_hint else "")
+ "Resolve relative dates (\"tomorrow\", \"friday\", \"next monday\", " + "Resolve relative dates (\"tomorrow\", \"friday\", \"next monday\", "
"\"in 30 minutes\") against today. Default duration is 60 minutes " "\"in 30 minutes\") against today. Default duration is 60 minutes "
"when no end time is given. If the text mentions a date with no " "when no end time is given. If the text mentions a date with no "

View File

@@ -37,7 +37,7 @@ from routes.chat_helpers import (
clean_thinking_for_save, clean_thinking_for_save,
_enforce_chat_privileges, _enforce_chat_privileges,
) )
from src.action_intents import message_needs_tools as _message_needs_tools from src.action_intents import classify_tool_intent as _classify_tool_intent
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -229,6 +229,26 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
db.close() db.close()
def _set_user_time_from_request(request: Request) -> None:
"""Copy browser timezone headers into the per-request context.
This is intentionally ephemeral: it is used only while building prompts
and running tools for this request. It is not persisted or logged.
"""
try:
tz_offset = request.headers.get("x-tz-offset")
tz_name = request.headers.get("x-tz-name")
from src.user_time import clear_user_time_context, set_user_tz_name, set_user_tz_offset
clear_user_time_context()
if tz_offset is not None:
set_user_tz_offset(tz_offset)
if tz_name:
set_user_tz_name(tz_name)
except Exception:
pass
def setup_chat_routes( def setup_chat_routes(
session_manager, session_manager,
chat_handler, chat_handler,
@@ -247,6 +267,8 @@ def setup_chat_routes(
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@router.post("/api/chat", response_model=Dict[str, str]) @router.post("/api/chat", response_model=Dict[str, str])
async def chat_endpoint(request: Request, chat_request: ChatRequest) -> Dict[str, str]: async def chat_endpoint(request: Request, chat_request: ChatRequest) -> Dict[str, str]:
_set_user_time_from_request(request)
message = chat_request.message message = chat_request.message
session = chat_request.session session = chat_request.session
att_ids = chat_request.attachments or [] att_ids = chat_request.attachments or []
@@ -355,16 +377,7 @@ def setup_chat_routes(
except Exception as e: except Exception as e:
raise HTTPException(400, f"Request parsing error: {e}") raise HTTPException(400, f"Request parsing error: {e}")
# Stash the user's UTC offset (in minutes east of UTC) from the _set_user_time_from_request(request)
# frontend so tools like manage_notes interpret natural-language
# times in the USER's tz, not the server's. See calendar_routes.
try:
_tz_hdr = request.headers.get("x-tz-offset")
if _tz_hdr is not None:
from routes.calendar_routes import set_user_tz_offset
set_user_tz_offset(_tz_hdr)
except Exception:
pass
form_data = await request.form() form_data = await request.form()
message = form_data.get("message") message = form_data.get("message")
@@ -393,10 +406,15 @@ def setup_chat_routes(
# its way through a plain chat request (and fail, especially with the # its way through a plain chat request (and fail, especially with the
# shell disabled). # shell disabled).
auto_escalated = False auto_escalated = False
if chat_mode == "chat" and isinstance(message, str) and _message_needs_tools(message): _tool_intent = _classify_tool_intent(message) if isinstance(message, str) else None
if chat_mode == "chat" and _tool_intent and _tool_intent.needs_tools:
chat_mode = "agent" chat_mode = "agent"
auto_escalated = True auto_escalated = True
logger.info("chat→agent auto-escalation: message matched tool-intent pattern") logger.info(
"chat→agent auto-escalation: category=%s reason=%s",
_tool_intent.category,
_tool_intent.reason,
)
active_doc_id = form_data.get("active_doc_id", "").strip() active_doc_id = form_data.get("active_doc_id", "").strip()
logger.info(f"[doc-inject] chat_mode={chat_mode}, active_doc_id={active_doc_id!r}") logger.info(f"[doc-inject] chat_mode={chat_mode}, active_doc_id={active_doc_id!r}")

View File

@@ -8,74 +8,121 @@ user asks how a feature works.
from __future__ import annotations from __future__ import annotations
import re import re
from dataclasses import dataclass
from typing import Iterable, Pattern from typing import Iterable, Pattern
_ACTION_QUESTION = r"\b(?:can|could|would|will)\s+you\s+" @dataclass(frozen=True)
_PLEASE = r"^\s*(?:please\s+)?" 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)" _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 = ( _PANEL = (
r"(?:calendar|notes?|inbox|email|mail|documents?|docs|library|gallery|" r"(?:calendar|notes?|inbox|email|mail|documents?|docs|library|gallery|"
r"settings|cookbook|sessions?|chats?|skills|memories|memory|brain)" r"settings|cookbook|sessions?|chats?|skills|memories|memory|brain)"
) )
_TOOL_INTENT_PATTERNS: tuple[Pattern[str], ...] = tuple( _ROUTING_PATTERNS: tuple[tuple[str, str, Pattern[str]], ...] = tuple(
re.compile(pattern, re.I) (category, reason, re.compile(pattern, re.I))
for pattern in ( for category, reason, pattern in (
# Calendar/event creation. Covers "Can you add an entry to my # Calendar/event creation. Covers "Can you add an entry to my
# calendar?" and imperatives like "add lunch to my calendar". # calendar?", imperatives like "add lunch to my calendar", and
rf"{_ACTION_QUESTION}{_CALENDAR_ACTION}\b.{{0,120}}\b{_CALENDAR_THING}\b", # follow-ups such as "you should be able to create that event now".
rf"{_PLEASE}{_CALENDAR_ACTION}\b.{{0,120}}\b(?:to|on|in|into|for)\s+(?:my\s+|the\s+|this\s+)?calendar\b", ("calendar", "assistant calendar action request", rf"{_ACTION_QUESTION}{_CALENDAR_ACTION}\b.{{0,120}}\b{_CALENDAR_THING}\b"),
rf"{_PLEASE}{_CALENDAR_ACTION}\s+(?:a\s+|an\s+)?(?:calendar\s+)?(?:event|meeting|appointment|entry|item|call)\b", ("calendar", "calendar follow-up action request", rf"{_ACTION_FOLLOWUP}{_CALENDAR_ACTION}\b.{{0,120}}\b{_CALENDAR_THING}\b"),
r"\bput\s+.+\bon\s+(?:my\s+)?calendar\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. # Notes, todos, checklists, and reminders.
r"\bremind\s+me\b", ("notes", "reminder request", 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", ("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"),
rf"{_PLEASE}(?:add|create|make)\s+(?:a\s+|an\s+)?(?:todo|task|reminder|note|checklist)\b", ("notes", "note/todo imperative request", 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", ("notes", "take note request", 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", ("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"),
rf"{_PLEASE}set\s+(?:a\s+)?reminder\b", ("notes", "set reminder request", rf"{_PLEASE}set\s+(?:a\s+)?reminder\b"),
rf"{_ACTION_QUESTION}set\s+(?:a\s+)?reminder\b", ("notes", "assistant reminder request", rf"{_ACTION_QUESTION}set\s+(?:a\s+)?reminder\b"),
# Email actions. # Email actions.
rf"{_ACTION_QUESTION}(?:send|write|reply|email|message|archive|delete|mark)\b.{{0,120}}\b(?:emails?|mail|messages?|inbox|unread|read)\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"),
rf"{_PLEASE}(?:send|write|reply)\b.{{0,120}}\b(?:emails?|mail|messages?)\b", ("email", "send/write/reply email request", 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", ("email", "archive/delete/mark email request", 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", ("email", "email composition request", r"\b(?:send|write|reply)\s+(?:an?\s+)?(?:email|message|mail)\b"),
r"\bemail\s+\w+\b", ("email", "email contact request", r"\bemail\s+\w+\b"),
r"\bcheck\s+(?:my\s+)?(?:email|inbox|mail)\b", ("email", "check inbox request", r"\bcheck\s+(?:my\s+)?(?:email|inbox|mail)\b"),
r"\bunread\s+(?:email|mail)s?\b", ("email", "unread email request", r"\bunread\s+(?:email|mail)s?\b"),
# UI/control-plane actions that should open panels or flip toggles. # 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", ("ui", "open/show panel request", 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", "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. # Deep research jobs, not quick conceptual mentions of research.
rf"{_PLEASE}(?: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+.+"),
rf"{_ACTION_QUESTION}(?:research|do\s+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. # Shell / remote-host intent.
r"\bssh\s+(?:in)?to\b", ("shell", "ssh request", r"\bssh\s+(?:in)?to\b"),
r"\bssh\s+\w+", ("shell", "ssh target request", r"\bssh\s+\w+"),
r"\b(run|execute)\s+.{1,40}\bon\s+\w+", ("shell", "remote command request", r"\b(run|execute)\s+.{1,40}\bon\s+\w+"),
r"\b(can|could|please|would)\s+you\s+(run|execute|exec)\b", ("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, # Shell verbs only count in imperative position (start of message,
# optionally after "please") or as a "can you ..." request. A bare # optionally after "please") or as a "can you ..." request. A bare
# word match promoted informational questions ("What does the grep # word match promoted informational questions ("What does the grep
# command do?") and incidental uses ("My cat ate my homework"). # 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+", ("shell", "imperative shell command request", 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+", ("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+"),
r"\b(check|see)\s+(if|whether|what)\s+.{1,40}\b(running|process|service|port|file|exists?)\b", ("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: 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.""" """Return True when a plain chat message should be promoted to agent mode."""
if not text: if not text:
return False 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) return any(pattern.search(text) for pattern in patterns)

View File

@@ -636,28 +636,11 @@ def _build_system_prompt(
set_active_model(model) set_active_model(model)
# Current date/time every request. Models default to their # Current date/time for every agent request. This is user-local when the
# training-cutoff date when "today" is asked otherwise (was # browser provided timezone headers, with a server-local fallback.
# 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.
try: try:
from datetime import datetime as _dt, timezone as _tz from src.user_time import current_datetime_prompt
_now = _dt.now().astimezone() agent_prompt = current_datetime_prompt() + agent_prompt
_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
except Exception: except Exception:
pass pass

View File

@@ -185,6 +185,15 @@ class ChatProcessor:
"role": "system", "role": "system",
"content": preset_system_prompt "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({ preface.append({
"role": "system", "role": "system",
"content": UNTRUSTED_CONTEXT_POLICY, "content": UNTRUSTED_CONTEXT_POLICY,

View File

@@ -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.", "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_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_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.", "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.", "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'.", "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'.",

View File

@@ -422,7 +422,7 @@ FUNCTION_TOOL_SCHEMAS = [
"type": "function", "type": "function",
"function": { "function": {
"name": "manage_calendar", "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": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {

138
src/user_time.py Normal file
View 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"
)

View File

@@ -1876,11 +1876,12 @@ function _wireAll(body) {
} }
try { try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
const tzOffset = -new Date().getTimezoneOffset();
const res = await fetch(`${API_BASE}/api/calendar/quick-parse`, { const res = await fetch(`${API_BASE}/api/calendar/quick-parse`, {
method: 'POST', method: 'POST',
credentials: 'same-origin', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, tz }), body: JSON.stringify({ text, tz, tz_offset: tzOffset }),
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) { if (!res.ok || !data.ok) {

View File

@@ -530,6 +530,9 @@ import createResearchSynapse from './researchSynapse.js';
let _renderStream = () => {}; let _renderStream = () => {};
let _cancelThinkingTimer = () => {}; let _cancelThinkingTimer = () => {};
let _removeThinkingSpinner = () => {}; let _removeThinkingSpinner = () => {};
let timeoutId = null;
let responseTimeoutCleared = false;
let clearResponseTimeout = () => {};
const clearProcessingProbe = () => { const clearProcessingProbe = () => {
if (processingProbeTimer) { if (processingProbeTimer) {
clearTimeout(processingProbeTimer); clearTimeout(processingProbeTimer);
@@ -790,13 +793,26 @@ import createResearchSynapse from './researchSynapse.js';
// Timeout: 6 min for research and agent mode, 3 min otherwise // Timeout: 6 min for research and agent mode, 3 min otherwise
const timeoutMs = el('research-toggle').checked || _isAgent ? RESEARCH_TIMEOUT_MS : DEFAULT_TIMEOUT_MS; const timeoutMs = el('research-toggle').checked || _isAgent ? RESEARCH_TIMEOUT_MS : DEFAULT_TIMEOUT_MS;
const timeoutId = setTimeout(() => { timeoutId = setTimeout(() => {
if (!abortCtrl.signal.aborted) { if (!abortCtrl.signal.aborted) {
timedOut = true; timedOut = true;
abortCtrl._reason = 'timeout'; abortCtrl._reason = 'timeout';
try {
if (streamSessionId) {
fetch(`/api/chat/stop/${encodeURIComponent(streamSessionId)}`, {
method: 'POST',
credentials: 'same-origin',
}).catch(() => {});
}
} catch (_) {}
abortCtrl.abort(); abortCtrl.abort();
} }
}, timeoutMs); }, timeoutMs);
clearResponseTimeout = () => {
if (responseTimeoutCleared) return;
responseTimeoutCleared = true;
clearTimeout(timeoutId);
};
const box = el('chat-history'); const box = el('chat-history');
holder = document.createElement('div'); holder = document.createElement('div');
@@ -922,16 +938,19 @@ import createResearchSynapse from './researchSynapse.js';
// the agent so natural-language times like "today at 9pm" are // the agent so natural-language times like "today at 9pm" are
// interpreted in YOUR timezone, not the server's. // interpreted in YOUR timezone, not the server's.
const _tzOffsetMin = -new Date().getTimezoneOffset(); const _tzOffsetMin = -new Date().getTimezoneOffset();
const _tzName = (() => {
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || ''; }
catch { return ''; }
})();
const res = await fetch(`${API_BASE}/api/chat_stream`, { const res = await fetch(`${API_BASE}/api/chat_stream`, {
method: 'POST', method: 'POST',
body: fd, body: fd,
headers: { 'X-Tz-Offset': String(_tzOffsetMin) }, headers: { 'X-Tz-Offset': String(_tzOffsetMin), 'X-Tz-Name': _tzName },
signal: abortCtrl.signal signal: abortCtrl.signal
}); });
clearTimeout(timeoutId);
if (!res.ok) { if (!res.ok) {
clearResponseTimeout();
if (res.status === 404) { if (res.status === 404) {
// Session was deleted (e.g. by AI) — reload and go to welcome // Session was deleted (e.g. by AI) — reload and go to welcome
holder.remove(); holder.remove();
@@ -1359,7 +1378,8 @@ import createResearchSynapse from './researchSynapse.js';
typewriterInto(roundHolder.querySelector('.body'), errMsg); typewriterInto(roundHolder.querySelector('.body'), errMsg);
break; break;
} }
if (json.delta || json.type === 'tool_start' || json.type === 'agent_step' || json.type === 'doc_stream_delta') { if (json.delta || json.type === 'tool_start' || json.type === 'tool_output' || json.type === 'tool_progress' || json.type === 'agent_step' || json.type === 'doc_stream_open' || json.type === 'doc_stream_delta' || json.type === 'research_progress') {
clearResponseTimeout();
clearProcessingProbe(); clearProcessingProbe();
} }
if (json.delta) { if (json.delta) {
@@ -2710,6 +2730,7 @@ import createResearchSynapse from './researchSynapse.js';
} }
} }
} finally { } finally {
clearResponseTimeout();
clearProcessingProbe(); clearProcessingProbe();
// Streaming done — let screen readers announce the settled response. // Streaming done — let screen readers announce the settled response.
const _chatLogDone = document.getElementById('chat-history'); const _chatLogDone = document.getElementById('chat-history');

View File

@@ -1,14 +1,26 @@
from src.action_intents import message_needs_tools from src.action_intents import classify_tool_intent, message_needs_tools
def test_calendar_entry_request_promotes_to_agent(): def test_calendar_entry_request_promotes_to_agent():
assert message_needs_tools("Can you add an entry to my calendar?") assert message_needs_tools("Can you add an entry to my calendar?")
intent = classify_tool_intent("Can you add an entry to my calendar?")
assert intent.needs_tools
assert intent.category == "calendar"
def test_calendar_imperative_variants_promote_to_agent(): def test_calendar_imperative_variants_promote_to_agent():
assert message_needs_tools("add lunch with Sam to my calendar tomorrow at noon") assert message_needs_tools("add lunch with Sam to my calendar tomorrow at noon")
assert message_needs_tools("schedule a call with Mina next Friday") assert message_needs_tools("schedule a call with Mina next Friday")
assert message_needs_tools("put dentist appointment on my calendar") assert message_needs_tools("put dentist appointment on my calendar")
assert message_needs_tools("Alright. Recreate that same appointment")
assert message_needs_tools("Okay delete that doctor appointment from the calendar")
assert message_needs_tools("have another go at adding a test entry to the calendar")
assert message_needs_tools(
"Okay so you should be able to create that calendar event for tomorrow at 1:30 p.m. right for me to go to the hardware store"
)
assert message_needs_tools(
"make it an appointment at 12pm for me to visit the doctor it's tomorrow the 2nd of June 2026"
)
def test_note_todo_and_reminder_actions_promote_to_agent(): def test_note_todo_and_reminder_actions_promote_to_agent():
@@ -33,3 +45,12 @@ def test_explanatory_calendar_questions_stay_plain_chat():
assert not message_needs_tools("How do I add an entry to my calendar?") assert not message_needs_tools("How do I add an entry to my calendar?")
assert not message_needs_tools("What about the built-in Odysseus calendar, is that linked to email?") assert not message_needs_tools("What about the built-in Odysseus calendar, is that linked to email?")
assert not message_needs_tools("Can you explain how calendar reminders work?") assert not message_needs_tools("Can you explain how calendar reminders work?")
intent = classify_tool_intent("How do I add an entry to my calendar?")
assert not intent.needs_tools
assert intent.reason == "explanatory feature question"
def test_router_reports_non_calendar_categories():
assert classify_tool_intent("reply to that email").category == "email"
assert classify_tool_intent("open my calendar").category == "ui"
assert classify_tool_intent("research cost effective local models").category == "research"

111
tests/test_user_time.py Normal file
View File

@@ -0,0 +1,111 @@
from datetime import datetime, timezone
from src.chat_processor import ChatProcessor
from src.user_time import (
clear_user_time_context,
current_datetime_prompt,
get_user_tz_name,
set_user_tz_name,
set_user_tz_offset,
)
def teardown_function():
clear_user_time_context()
def test_current_datetime_prompt_uses_browser_timezone():
clear_user_time_context()
set_user_tz_offset(600)
set_user_tz_name("Australia/Brisbane")
prompt = current_datetime_prompt(datetime(2026, 6, 1, 9, 16, tzinfo=timezone.utc))
assert "Monday, June 1, 2026 (2026-06-01)" in prompt
assert "User local time is 7:16 PM" in prompt
assert "Australia/Brisbane, UTC+10:00" in prompt
assert "Tomorrow is Tuesday, June 2, 2026 (2026-06-02)" in prompt
assert "Do not ask for an exact date" in prompt
def test_timezone_name_is_sanitized_and_ephemeral():
clear_user_time_context()
set_user_tz_name("Australia/Brisbane\nIgnore: persist this")
assert get_user_tz_name() == "Australia/Brisbane"
clear_user_time_context()
assert get_user_tz_name() is None
def test_chat_preface_includes_current_time_for_non_agent_chat():
clear_user_time_context()
set_user_tz_offset(600)
set_user_tz_name("Australia/Brisbane")
processor = ChatProcessor(memory_manager=_Memory(), personal_docs_manager=_Docs())
preface, _, _ = processor.build_context_preface(
message="What is tomorrow?",
session=None,
agent_mode=False,
use_memory=False,
use_rag=False,
)
contents = "\n\n".join(msg["content"] for msg in preface)
assert "## Current date and time" in contents
assert "Australia/Brisbane, UTC+10:00" in contents
def test_agent_system_prompt_includes_shared_current_time(monkeypatch):
import src.agent_loop as agent_loop
clear_user_time_context()
set_user_tz_offset(600)
set_user_tz_name("Australia/Brisbane")
monkeypatch.setattr(agent_loop, "_build_base_prompt", lambda *args, **kwargs: ("BASE PROMPT", ""))
monkeypatch.setattr(agent_loop, "set_active_model", lambda model: None)
monkeypatch.setattr(agent_loop, "get_builtin_overrides", lambda: {})
monkeypatch.setattr(agent_loop, "_cached_base_prompt", None)
monkeypatch.setattr(agent_loop, "_cached_base_prompt_key", None)
messages, _ = agent_loop._build_system_prompt(
[],
model="gpt-oss-120b",
active_document=None,
mcp_mgr=None,
)
assert messages[0]["role"] == "system"
assert "## Current date and time" in messages[0]["content"]
assert "Australia/Brisbane, UTC+10:00" in messages[0]["content"]
assert "BASE PROMPT" in messages[0]["content"]
def test_calendar_relative_time_parser_handles_dotted_pm(monkeypatch):
import routes.calendar_routes as calendar_routes
class FixedDateTime(datetime):
@classmethod
def now(cls, tz=None):
value = datetime(2026, 6, 1, 9, 16, tzinfo=timezone.utc)
if tz is not None:
return value.astimezone(tz)
return value.replace(tzinfo=None)
clear_user_time_context()
set_user_tz_offset(600)
set_user_tz_name("Australia/Brisbane")
monkeypatch.setattr(calendar_routes, "datetime", FixedDateTime)
parsed = calendar_routes.parse_due_for_user("tomorrow at 1:30 p.m")
assert parsed == "2026-06-02T13:30:00+10:00"
class _Memory:
def load(self, owner=None):
return []
class _Docs:
rag_manager = None