Agent: resolve "today" in the browser's timezone, not hardcoded CR

The CR-hardcoded today broke two scenarios: the owner traveling, and
any future non-CR user. Now the provider sends the browser's IANA
timezone (X-Client-Timezone) with every CopilotKit request — the
runtime forwards x-* headers to the agent on its own — and the backend
binds it to a per-request ContextVar next to the DB session.
get_current_date and the future-cuota bounds use it; the tool also
reports the timezone so the model can echo it. Unknown or absent zones
(n8n posts, tests) fall back to Costa Rica; comma-joined duplicate
header values are tolerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-07-04 19:33:27 -06:00
parent 8f51e33fe0
commit 7b7c741ba2
6 changed files with 96 additions and 18 deletions

View File

@@ -21,9 +21,10 @@ Context you can rely on:
qualifiers, assume they mean the calendar month unless they mention qualifiers, assume they mean the calendar month unless they mention
"cycle", "corte", or their credit card. "cycle", "corte", or their credit card.
- You do NOT have a reliable clock, and the server runs in UTC while the - You do NOT have a reliable clock, and the server runs in UTC while the
user lives in Costa Rica (UTC-6). Whenever the question involves a user may be anywhere (their browser reports their timezone per request).
relative date — "hoy", "ayer", "este mes", "este ciclo", "el año Whenever the question involves a relative date — "hoy", "ayer", "este
pasado" — call get_current_date FIRST and derive ranges from its answer. mes", "este ciclo", "el año pasado" — call get_current_date FIRST and
derive ranges from its answer.
- Amounts are stored as raw numbers in their native currency (see `currency` - Amounts are stored as raw numbers in their native currency (see `currency`
field on transactions/accounts). Tools that return `total_crc` are already field on transactions/accounts). Tools that return `total_crc` are already
converted; tools that return per-transaction amounts are NOT. converted; tools that return per-transaction amounts are NOT.

View File

@@ -41,7 +41,7 @@ from app.services.budget_projection import (
get_cycle_range, get_cycle_range,
) )
from app.services import exchange_rate as fx from app.services import exchange_rate as fx
from app.timeutil import today_cr from app.timeutil import client_tz, today_client
from app.services.exchange_rate import ( from app.services.exchange_rate import (
get_converted_amount_expr, get_converted_amount_expr,
get_current_rate, get_current_rate,
@@ -145,8 +145,8 @@ def get_recent_transactions(
), ),
# Tasa Cero generates future-dated cuotas; "recent" means already # Tasa Cero generates future-dated cuotas; "recent" means already
# billed (same rule as /transactions/recent). Bound is end of the # billed (same rule as /transactions/recent). Bound is end of the
# user's today, Costa Rica time. # user's today in their request timezone (CR fallback).
Transaction.date < datetime.combine(today_cr() + timedelta(days=1), time.min), Transaction.date < datetime.combine(today_client() + timedelta(days=1), time.min),
) )
if source: if source:
q = q.where(Transaction.source == TransactionSource(source)) q = q.where(Transaction.source == TransactionSource(source))
@@ -519,7 +519,7 @@ def get_daily_spending(
Transaction.date >= datetime.fromisoformat(start_date), Transaction.date >= datetime.fromisoformat(start_date),
Transaction.date < datetime.fromisoformat(end_date), Transaction.date < datetime.fromisoformat(end_date),
# Future-dated Tasa Cero cuotas are not money already spent. # Future-dated Tasa Cero cuotas are not money already spent.
Transaction.date < datetime.combine(today_cr() + timedelta(days=1), time.min), Transaction.date < datetime.combine(today_client() + timedelta(days=1), time.min),
) )
.group_by(func.date(Transaction.date)) .group_by(func.date(Transaction.date))
.order_by(func.date(Transaction.date)) .order_by(func.date(Transaction.date))
@@ -531,12 +531,12 @@ def get_daily_spending(
def get_current_date() -> dict: def get_current_date() -> dict:
"""Today's date in the user's timezone (Costa Rica, UTC-6) and the """Today's date in the user's own timezone (sent by their browser;
active credit-card billing cycle. ALWAYS call this before resolving any Costa Rica fallback) and the active credit-card billing cycle. ALWAYS
relative date reference — 'hoy', 'ayer', 'este mes', 'este ciclo', call this before resolving any relative date reference — 'hoy', 'ayer',
'el ciclo pasado' — the server clock and your own assumptions about 'este mes', 'este ciclo', 'el ciclo pasado' — the server clock and your
today are unreliable.""" own assumptions about today are unreliable."""
today = today_cr() today = today_client()
# get_cycle_range(y, m) is the cycle STARTING on the 18th of m; before # get_cycle_range(y, m) is the cycle STARTING on the 18th of m; before
# the 18th we are still in the cycle that started last month. # the 18th we are still in the cycle that started last month.
if today.day >= 18: if today.day >= 18:
@@ -549,6 +549,7 @@ def get_current_date() -> dict:
return { return {
"date": today.isoformat(), "date": today.isoformat(),
"weekday": today.strftime("%A"), "weekday": today.strftime("%A"),
"timezone": str(client_tz()),
"cycle_year": cycle_year, "cycle_year": cycle_year,
"cycle_month": cycle_month, "cycle_month": cycle_month,
"cycle_range": [start.date().isoformat(), end.date().isoformat()], "cycle_range": [start.date().isoformat(), end.date().isoformat()],

View File

@@ -23,6 +23,7 @@ from app.auth import (
from app.config import settings from app.config import settings
from app.db import get_session, run_alembic_upgrade from app.db import get_session, run_alembic_upgrade
from app.seed import seed_db from app.seed import seed_db
from app.timeutil import reset_client_timezone, set_client_timezone
from app.services.exchange_rate import refresh_rates_periodically from app.services.exchange_rate import refresh_rates_periodically
@@ -136,9 +137,13 @@ async def agent_auth_and_session(request: Request, call_next):
session_gen = get_session() session_gen = get_session()
session = next(session_gen) session = next(session_gen)
token_var = set_session(session) token_var = set_session(session)
# Browser's IANA timezone, forwarded by the BFF — lets tools resolve
# "today" wherever the user is (falls back to Costa Rica).
tz_token = set_client_timezone(request.headers.get("x-client-timezone"))
try: try:
return await call_next(request) return await call_next(request)
finally: finally:
reset_client_timezone(tz_token)
reset_session(token_var) reset_session(token_var)
try: try:
next(session_gen) next(session_gen)

View File

@@ -1,16 +1,54 @@
from datetime import date, datetime, timedelta, timezone import contextvars
from datetime import date, datetime, timedelta, timezone, tzinfo
from zoneinfo import ZoneInfo
# Costa Rica has no DST; a fixed offset is exact year-round. # Costa Rica has no DST; a fixed offset is exact year-round. This is the
# FALLBACK when the request doesn't declare a timezone (n8n posts, tests).
CR_TZ = timezone(timedelta(hours=-6)) CR_TZ = timezone(timedelta(hours=-6))
# Per-request client timezone, set by the agent middleware from the
# X-Client-Timezone header (IANA name sent by the browser). Falls back to
# Costa Rica so headless callers keep the owner's local semantics.
_client_tz: contextvars.ContextVar[tzinfo] = contextvars.ContextVar(
"client_tz", default=CR_TZ
)
def today_cr() -> date:
"""Current date in Costa Rica (UTC-6). def set_client_timezone(name: str | None) -> contextvars.Token | None:
"""Bind the request's IANA timezone; ignores unknown/absent names.
Proxies that see the header twice merge the values into a comma list
("Zone, Zone") — take the first entry.
"""
if not name:
return None
try:
return _client_tz.set(ZoneInfo(name.split(",")[0].strip()))
except (KeyError, ValueError):
return None
def reset_client_timezone(token: contextvars.Token | None) -> None:
if token is not None:
_client_tz.reset(token)
def client_tz() -> tzinfo:
return _client_tz.get()
def today_client() -> date:
"""Current date where the USER is (request timezone, CR fallback).
The server clock is UTC — at 6 pm in Costa Rica it is already "tomorrow" The server clock is UTC — at 6 pm in Costa Rica it is already "tomorrow"
in UTC. Anything user-facing that means "today" (agent answers, date in UTC. Anything user-facing that means "today" (agent answers, date
defaults) must come from here, never date.today(). defaults) must come from here, never date.today().
""" """
return datetime.now(client_tz()).date()
def today_cr() -> date:
"""Current date in Costa Rica (UTC-6) — timezone-independent fallback."""
return datetime.now(CR_TZ).date() return datetime.now(CR_TZ).date()

View File

@@ -214,3 +214,26 @@ def test_daily_spending_day_scoped(bound_session):
assert out[0]["date"] == today.date().isoformat() assert out[0]["date"] == today.date().isoformat()
assert out[0]["total_crc"] == 150.0 assert out[0]["total_crc"] == 150.0
assert out[0]["count"] == 2 assert out[0]["count"] == 2
def test_get_current_date_respects_client_timezone():
from datetime import datetime, timezone as tz
from app.timeutil import (
client_tz,
reset_client_timezone,
set_client_timezone,
)
token = set_client_timezone("Asia/Tokyo")
try:
out = tools.get_current_date()
assert out["timezone"] == "Asia/Tokyo"
assert out["date"] == datetime.now(client_tz()).date().isoformat()
finally:
reset_client_timezone(token)
# Unknown names are ignored — CR fallback stays active.
assert set_client_timezone("Not/AZone") is None
assert set_client_timezone(None) is None
assert tools.get_current_date()["timezone"] == "UTC-06:00"

View File

@@ -77,7 +77,17 @@ export default function App() {
<PrivacyProvider> <PrivacyProvider>
<AuthProvider> <AuthProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<CopilotKit runtimeUrl="/api/copilotkit" agent="wealthysmart"> <CopilotKit
runtimeUrl="/api/copilotkit"
agent="wealthysmart"
headers={{
// Lets the agent resolve "hoy"/"este mes" in the user's
// local time wherever they are; backend falls back to
// Costa Rica when absent.
"X-Client-Timezone":
Intl.DateTimeFormat().resolvedOptions().timeZone,
}}
>
<AppRoutes /> <AppRoutes />
<Toaster richColors position="top-right" closeButton /> <Toaster richColors position="top-right" closeButton />
</CopilotKit> </CopilotKit>