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 =