mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-10 16:23:44 +00:00
🕰️ feat: Resolve Agent Prompt Time Variables in User's Timezone (#13815)
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.
This commit is contained in:
parent
58647bc08b
commit
d8474864e9
7 changed files with 132 additions and 2 deletions
|
|
@ -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}}.';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ export type RequestBody = {
|
|||
model?: string;
|
||||
key?: string;
|
||||
endpointOption?: Partial<TEndpointOption>;
|
||||
/** Browser IANA timezone used to resolve local-time prompt variables (e.g. `{{current_datetime}}`). */
|
||||
timezone?: string;
|
||||
};
|
||||
|
||||
export type ServerRequest = Request<unknown, unknown, RequestBody> & {
|
||||
|
|
|
|||
66
packages/data-provider/specs/parsers.timezone.spec.ts
Normal file
66
packages/data-provider/specs/parsers.timezone.spec.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -127,6 +127,8 @@ export type TPayload = Partial<TMessage> &
|
|||
* 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 =
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue