From d8474864e978bbed3ed4c3dfff1bc8fc4ee88c01 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:39:56 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=95=B0=EF=B8=8F=20feat:=20Resolve=20Agent?= =?UTF-8?q?=20Prompt=20Time=20Variables=20in=20User's=20Timezone=20(#13815?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-side resolution of {{current_date}} and {{current_datetime}} for agent instructions used the server's timezone, so agents received UTC instead of the user's local time the variables are documented to provide. The browser's IANA timezone is now sent with each request and threaded through replaceSpecialVars, anchoring those variables to the user's local wall clock. {{iso_datetime}} stays UTC. Invalid or missing zones fall back to the previous behavior. --- .../src/agents/__tests__/initialize.test.ts | 25 +++++++ packages/api/src/agents/initialize.ts | 1 + packages/api/src/types/http.ts | 2 + .../specs/parsers.timezone.spec.ts | 66 +++++++++++++++++++ packages/data-provider/src/createPayload.ts | 10 +++ packages/data-provider/src/parsers.ts | 28 +++++++- packages/data-provider/src/types.ts | 2 + 7 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 packages/data-provider/specs/parsers.timezone.spec.ts diff --git a/packages/api/src/agents/__tests__/initialize.test.ts b/packages/api/src/agents/__tests__/initialize.test.ts index 1bec88f837..e770dbd630 100644 --- a/packages/api/src/agents/__tests__/initialize.test.ts +++ b/packages/api/src/agents/__tests__/initialize.test.ts @@ -664,6 +664,31 @@ describe('initializeAgent — stable and dynamic instruction fields', () => { expect(result.additional_instructions).toBe('Conversation opened at 2023-12-31T23:59:58.000Z'); }); + it('resolves temporal special vars in the request timezone', async () => { + const { agent, req, res, loadTools, db } = createMocks(); + agent.instructions = 'It is currently {{current_datetime}}.'; + req.conversationCreatedAt = '2024-01-15T18:30:00.000Z'; + req.body = { timezone: 'America/New_York' }; + + const result = await initializeAgent( + { + req, + res, + agent, + loadTools, + endpointOption: { endpoint: EModelEndpoint.agents }, + allowedProviders: new Set([Providers.OPENAI]), + isInitialAgent: true, + }, + db, + ); + + expect(result.instructions).toBeUndefined(); + expect(result.additional_instructions).toBe( + 'It is currently 2024-01-15 13:30:00 -05:00 (Monday).', + ); + }); + it('keeps non-temporal special vars in stable instructions', async () => { const { agent, req, res, loadTools, db } = createMocks(); agent.instructions = 'You are helping {{current_user}}.'; diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index b3a3205c00..ebbc6be73c 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -1112,6 +1112,7 @@ export async function initializeAgent( text: agent.instructions, user: req.user ? (req.user as unknown as TUser) : null, now: req.conversationCreatedAt, + timezone: req.body?.timezone, }); if (hasTemporalSpecialVars(agent.instructions)) { agent.instructions = undefined; diff --git a/packages/api/src/types/http.ts b/packages/api/src/types/http.ts index 219b2729f7..b3a34df383 100644 --- a/packages/api/src/types/http.ts +++ b/packages/api/src/types/http.ts @@ -16,6 +16,8 @@ export type RequestBody = { model?: string; key?: string; endpointOption?: Partial; + /** Browser IANA timezone used to resolve local-time prompt variables (e.g. `{{current_datetime}}`). */ + timezone?: string; }; export type ServerRequest = Request & { diff --git a/packages/data-provider/specs/parsers.timezone.spec.ts b/packages/data-provider/specs/parsers.timezone.spec.ts new file mode 100644 index 0000000000..cd8583b95a --- /dev/null +++ b/packages/data-provider/specs/parsers.timezone.spec.ts @@ -0,0 +1,66 @@ +import { replaceSpecialVars } from '../src/parsers'; + +/** + * Exercises real `dayjs` timezone conversion (no module mock) so the assertions + * hold regardless of the machine's local timezone — the anchor instant and the + * requested zone are both fixed. + */ +describe('replaceSpecialVars timezone handling', () => { + const anchor = '2024-01-15T18:30:00.000Z'; + + test('resolves {{current_datetime}} in the supplied timezone', () => { + const result = replaceSpecialVars({ + text: '{{current_datetime}}', + now: anchor, + timezone: 'America/New_York', + }); + expect(result).toBe('2024-01-15 13:30:00 -05:00 (Monday)'); + }); + + test('resolves {{current_date}} across a day boundary into the local zone', () => { + const result = replaceSpecialVars({ + text: '{{current_date}}', + now: '2024-01-15T02:30:00.000Z', + timezone: 'America/New_York', + }); + expect(result).toBe('2024-01-14 (Sunday)'); + }); + + test('shifts the local day forward for zones ahead of UTC', () => { + const result = replaceSpecialVars({ + text: '{{current_date}} | {{current_datetime}}', + now: anchor, + timezone: 'Asia/Tokyo', + }); + expect(result).toBe('2024-01-16 (Tuesday) | 2024-01-16 03:30:00 +09:00 (Tuesday)'); + }); + + test('keeps {{iso_datetime}} in UTC regardless of timezone', () => { + const result = replaceSpecialVars({ + text: '{{iso_datetime}}', + now: anchor, + timezone: 'Asia/Tokyo', + }); + expect(result).toBe('2024-01-15T18:30:00.000Z'); + }); + + test('falls back to the un-zoned anchor when the timezone is invalid', () => { + const withInvalid = replaceSpecialVars({ + text: '{{current_datetime}}', + now: anchor, + timezone: 'Not/AZone', + }); + const withNone = replaceSpecialVars({ text: '{{current_datetime}}', now: anchor }); + expect(withInvalid).toBe(withNone); + }); + + test('ignores an empty timezone string', () => { + const withEmpty = replaceSpecialVars({ + text: '{{current_datetime}}', + now: anchor, + timezone: '', + }); + const withNone = replaceSpecialVars({ text: '{{current_datetime}}', now: anchor }); + expect(withEmpty).toBe(withNone); + }); +}); diff --git a/packages/data-provider/src/createPayload.ts b/packages/data-provider/src/createPayload.ts index 783ad7f9dd..da20e7d1ac 100644 --- a/packages/data-provider/src/createPayload.ts +++ b/packages/data-provider/src/createPayload.ts @@ -2,6 +2,15 @@ import type * as t from './types'; import { EndpointURLs } from './config'; import * as s from './schemas'; +/** Resolves the browser's IANA timezone so the server can localize prompt variables. */ +function getUserTimezone(): string | undefined { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined; + } catch { + return undefined; + } +} + export default function createPayload(submission: t.TSubmission) { const { isEdited, @@ -42,6 +51,7 @@ export default function createPayload(submission: t.TSubmission) { isContinued: !!(isEdited && isContinued), ephemeralAgent: s.isAssistantsEndpoint(endpoint) ? undefined : ephemeralAgent, manualSkills: s.isAssistantsEndpoint(endpoint) ? undefined : manualSkills, + timezone: getUserTimezone(), }; return { server, payload }; diff --git a/packages/data-provider/src/parsers.ts b/packages/data-provider/src/parsers.ts index 684b233e48..d34d068cbc 100644 --- a/packages/data-provider/src/parsers.ts +++ b/packages/data-provider/src/parsers.ts @@ -1,9 +1,10 @@ import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import timezonePlugin from 'dayjs/plugin/timezone'; import type { ZodIssue } from 'zod'; import type * as a from './types/assistants'; import type * as s from './schemas'; import type * as t from './types'; -import { ContentTypes } from './types/runs'; import { openAISchema, openRouterSchema, @@ -18,8 +19,12 @@ import { compactAssistantSchema, } from './schemas'; import { bedrockInputSchema } from './bedrock'; +import { ContentTypes } from './types/runs'; import { alternateName } from './config'; +dayjs.extend(utc); +dayjs.extend(timezonePlugin); + type EndpointSchema = | typeof openAISchema | typeof openRouterSchema @@ -424,21 +429,40 @@ export function findLastSeparatorIndex(text: string, separators = SEPARATORS): n return lastIndex; } +/** + * Anchors a dayjs instant to the user's IANA timezone when one is supplied, + * so local-time special vars reflect the user's wall clock rather than the + * server's. Falls back to the original instant for missing or invalid zones. + */ +function applyTimezone(value: dayjs.Dayjs, timezone?: string): dayjs.Dayjs { + if (!timezone) { + return value; + } + try { + const zoned = value.tz(timezone); + return zoned.isValid() ? zoned : value; + } catch { + return value; + } +} + export function replaceSpecialVars({ text, user, now: inputNow, + timezone, }: { text: string; user?: t.TUser | null; now?: string | number | Date; + timezone?: string; }) { let result = text; if (!result) { return result; } - const now = inputNow != null ? dayjs(inputNow) : dayjs(); + const now = applyTimezone(inputNow != null ? dayjs(inputNow) : dayjs(), timezone); const weekdayName = now.format('dddd'); const currentDate = now.format('YYYY-MM-DD'); diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 6eac428568..6a8db985e8 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -127,6 +127,8 @@ export type TPayload = Partial & * before the LLM turn runs. */ manualSkills?: string[]; + /** Browser IANA timezone (e.g. `America/New_York`) used to resolve local-time prompt variables server-side. */ + timezone?: string; }; export type TEditedContent =