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
"cycle", "corte", or their credit card.
- 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
relative date — "hoy", "ayer", "este mes", "este ciclo", "el año
pasado" — call get_current_date FIRST and derive ranges from its answer.
user may be anywhere (their browser reports their timezone per request).
Whenever the question involves a relative date — "hoy", "ayer", "este
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`
field on transactions/accounts). Tools that return `total_crc` are already
converted; tools that return per-transaction amounts are NOT.

View File

@@ -41,7 +41,7 @@ from app.services.budget_projection import (
get_cycle_range,
)
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 (
get_converted_amount_expr,
get_current_rate,
@@ -145,8 +145,8 @@ def get_recent_transactions(
),
# Tasa Cero generates future-dated cuotas; "recent" means already
# billed (same rule as /transactions/recent). Bound is end of the
# user's today, Costa Rica time.
Transaction.date < datetime.combine(today_cr() + timedelta(days=1), time.min),
# user's today in their request timezone (CR fallback).
Transaction.date < datetime.combine(today_client() + timedelta(days=1), time.min),
)
if 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(end_date),
# 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))
.order_by(func.date(Transaction.date))
@@ -531,12 +531,12 @@ def get_daily_spending(
def get_current_date() -> dict:
"""Today's date in the user's timezone (Costa Rica, UTC-6) and the
active credit-card billing cycle. ALWAYS call this before resolving any
relative date reference — 'hoy', 'ayer', 'este mes', 'este ciclo',
'el ciclo pasado' — the server clock and your own assumptions about
today are unreliable."""
today = today_cr()
"""Today's date in the user's own timezone (sent by their browser;
Costa Rica fallback) and the active credit-card billing cycle. ALWAYS
call this before resolving any relative date reference — 'hoy', 'ayer',
'este mes', 'este ciclo', 'el ciclo pasado' — the server clock and your
own assumptions about today are unreliable."""
today = today_client()
# 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.
if today.day >= 18:
@@ -549,6 +549,7 @@ def get_current_date() -> dict:
return {
"date": today.isoformat(),
"weekday": today.strftime("%A"),
"timezone": str(client_tz()),
"cycle_year": cycle_year,
"cycle_month": cycle_month,
"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.db import get_session, run_alembic_upgrade
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
@@ -136,9 +137,13 @@ async def agent_auth_and_session(request: Request, call_next):
session_gen = get_session()
session = next(session_gen)
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:
return await call_next(request)
finally:
reset_client_timezone(tz_token)
reset_session(token_var)
try:
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))
# 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"
in UTC. Anything user-facing that means "today" (agent answers, date
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()