mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-21 17:44:11 +00:00
🕰️ fix: Anchor Temporal Variables to Turn Start (#15419)
This commit is contained in:
parent
06ddaf8a3d
commit
b7ae67307e
16 changed files with 79 additions and 48 deletions
|
|
@ -435,9 +435,7 @@ const loadTools = async ({
|
|||
);
|
||||
requestedTools[tool] = async () => {
|
||||
toolContextMap[tool] = buildWebSearchContext();
|
||||
dynamicToolContextMap[tool] = buildWebSearchDynamicContext(
|
||||
options.req?.conversationCreatedAt,
|
||||
);
|
||||
dynamicToolContextMap[tool] = buildWebSearchDynamicContext(options.req?.turnStartedAt);
|
||||
return createSearchTool({
|
||||
...result.authResult,
|
||||
httpAgent,
|
||||
|
|
|
|||
|
|
@ -1351,6 +1351,15 @@ describe('OpenAIChatCompletionController', () => {
|
|||
expect(mockCreateAgentRunEnvelope.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
initializeAgent.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(initializeAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runtime: expect.objectContaining({
|
||||
turnStartedAt: mockCreateAgentRunEnvelope.mock.results[0].value.receivedAt,
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(req.turnStartedAt).toBe(mockCreateAgentRunEnvelope.mock.results[0].value.receivedAt);
|
||||
expect(req.body).not.toBe(requestBody);
|
||||
expect(req.body).toEqual(requestBody);
|
||||
expect(JSON.stringify(mockCreateAgentRunEnvelope.mock.results[0].value)).not.toContain(
|
||||
|
|
|
|||
|
|
@ -937,6 +937,7 @@ describe('ResumableAgentController resume metadata', () => {
|
|||
expect(initializeClient).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ checkpointNamespace: '1000', jobCreatedAt: 1000 }),
|
||||
);
|
||||
expect(req.turnStartedAt).toBe(1000);
|
||||
expect(mockGenerationJobManager.updateMetadata).not.toHaveBeenCalled();
|
||||
const startupMilestones = mockStartupTelemetry.mark.mock.calls.map(([milestone]) => milestone);
|
||||
expect(startupMilestones.slice(0, 2)).toEqual(['request_admitted', 'job_created']);
|
||||
|
|
|
|||
|
|
@ -821,6 +821,9 @@ describe('createResponse controller', () => {
|
|||
);
|
||||
expect(initializeAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runtime: expect.objectContaining({
|
||||
turnStartedAt: mockCreateAgentRunEnvelope.mock.results[0].value.receivedAt,
|
||||
}),
|
||||
requestBody: {
|
||||
messageId: 'resp_mock-123',
|
||||
conversationId: expect.any(String),
|
||||
|
|
@ -828,6 +831,7 @@ describe('createResponse controller', () => {
|
|||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(req.turnStartedAt).toBe(mockCreateAgentRunEnvelope.mock.results[0].value.receivedAt);
|
||||
expect(req.body).not.toBe(requestBody);
|
||||
expect(req.body).toEqual(requestBody);
|
||||
expect(JSON.stringify(mockCreateAgentRunEnvelope.mock.results[0].value)).not.toContain(
|
||||
|
|
|
|||
|
|
@ -431,7 +431,7 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
|
|||
parentMessageId: req.body.parentMessageId,
|
||||
files: req.body.files,
|
||||
isTemporary: req.body.isTemporary,
|
||||
conversationCreatedAt: req.conversationCreatedAt,
|
||||
turnStartedAt: req.turnStartedAt,
|
||||
isScheduledFire: req._isScheduledFire,
|
||||
timezone: req.body.timezone,
|
||||
checkpointNamespace,
|
||||
|
|
@ -1356,14 +1356,12 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
|
|||
});
|
||||
|
||||
describe('temporal context restore', () => {
|
||||
it('restores req.conversationCreatedAt from the convo before initializeClient', async () => {
|
||||
// Temporal prompt vars must resolve against the paused anchor, not resume wall-clock.
|
||||
mockGetConvo.mockResolvedValue({ createdAt: new Date('2020-01-02T03:04:05.000Z') });
|
||||
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
|
||||
it('restores the paused turn start from the durable job before initializeClient', async () => {
|
||||
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob({ createdAt: 1234 }));
|
||||
const res = await post(approveBody());
|
||||
expect(res.status).toBe(200);
|
||||
await settled;
|
||||
expect(capturedInit.conversationCreatedAt).toBe('2020-01-02T03:04:05.000Z');
|
||||
expect(capturedInit.turnStartedAt).toBe(1234);
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -1390,15 +1388,6 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
|
|||
mockGenerationJobManager.rearmQueuedPreempts.mockResolvedValue(0);
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
it('leaves conversationCreatedAt unset when the convo lookup yields nothing', async () => {
|
||||
mockGetConvo.mockResolvedValue(null);
|
||||
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
|
||||
const res = await post(approveBody());
|
||||
expect(res.status).toBe(200);
|
||||
await settled;
|
||||
expect(capturedInit.conversationCreatedAt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('content policy preflight', () => {
|
||||
|
|
|
|||
|
|
@ -282,10 +282,12 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
|
|||
// Request-backed tool adapters still observe the validated envelope payload;
|
||||
// shared initialization receives the transport-free runtime below.
|
||||
req.body = request;
|
||||
req.turnStartedAt = envelope.receivedAt;
|
||||
const agentRuntime = createAgentExecutionContext({
|
||||
user: req.user,
|
||||
appConfig,
|
||||
requestBody: request,
|
||||
turnStartedAt: envelope.receivedAt,
|
||||
conversationCreatedAt: req.conversationCreatedAt,
|
||||
resolvedConversation: req.resolvedConversation,
|
||||
hasResolvedConversation: Object.prototype.hasOwnProperty.call(req, 'resolvedConversation'),
|
||||
|
|
|
|||
|
|
@ -1502,6 +1502,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
|
|||
startupTelemetry?.mark('job_created');
|
||||
generationProtocolVersion = negotiateExistingGenerationProtocol(req, job);
|
||||
jobCreatedAt = job.createdAt; // Capture creation time to detect job replacement
|
||||
req.turnStartedAt = jobCreatedAt;
|
||||
providerExecutionId = job.metadata?.providerExecutionId;
|
||||
|
||||
/** Authentication can precede a slow admission path. Recheck the durable
|
||||
|
|
|
|||
|
|
@ -511,10 +511,12 @@ const executeResponse = async (envelope, { req, res }) => {
|
|||
// Request-backed tool adapters still observe the validated envelope payload;
|
||||
// shared initialization receives the transport-free runtime below.
|
||||
req.body = request;
|
||||
req.turnStartedAt = envelope.receivedAt;
|
||||
const agentRuntime = createAgentExecutionContext({
|
||||
user: req.user,
|
||||
appConfig,
|
||||
requestBody: request,
|
||||
turnStartedAt: envelope.receivedAt,
|
||||
conversationCreatedAt: req.conversationCreatedAt,
|
||||
resolvedConversation: req.resolvedConversation,
|
||||
hasResolvedConversation: Object.prototype.hasOwnProperty.call(req, 'resolvedConversation'),
|
||||
|
|
|
|||
|
|
@ -1736,23 +1736,7 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle)
|
|||
generationProtocolVersion,
|
||||
);
|
||||
|
||||
// Restore the conversation's createdAt so temporal prompt vars ({{current_datetime}},
|
||||
// {{iso_datetime}}, ...) resolve against the SAME anchor the paused graph used rather
|
||||
// than the resume wall-clock. initializeAgent reads `req.conversationCreatedAt`; the
|
||||
// normal path sets it from the convo timestamp (resolveConversationCreatedAt), so mirror
|
||||
// that here. (The original `timezone` is replayed onto req.body via RESUME_CONTEXT_KEYS.)
|
||||
try {
|
||||
const resumedConvo = await getConvo(userId, conversationId);
|
||||
const createdAt = resumedConvo?.createdAt ? new Date(resumedConvo.createdAt) : null;
|
||||
if (createdAt && !Number.isNaN(createdAt.getTime())) {
|
||||
req.conversationCreatedAt = createdAt.toISOString();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
'[ResumeAgentController] Failed to restore conversation timestamp anchor',
|
||||
getSafeErrorMetadata(err),
|
||||
);
|
||||
}
|
||||
req.turnStartedAt = job.createdAt;
|
||||
|
||||
let client = null;
|
||||
/** Re-pause progress failures use the action/epoch-scoped terminal CAS. The
|
||||
|
|
|
|||
|
|
@ -1372,9 +1372,7 @@ async function loadToolDefinitionsWrapper({
|
|||
|
||||
if (hasWebSearch) {
|
||||
toolContextMap[Tools.web_search] = buildWebSearchContext();
|
||||
dynamicToolContextMap[Tools.web_search] = buildWebSearchDynamicContext(
|
||||
req.conversationCreatedAt,
|
||||
);
|
||||
dynamicToolContextMap[Tools.web_search] = buildWebSearchDynamicContext(req.turnStartedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -297,6 +297,7 @@ describe('initializeAgent — execution context', () => {
|
|||
user: { id: 'user-1' } as never,
|
||||
appConfig: appConfig as never,
|
||||
requestBody: { timezone: 'America/New_York' },
|
||||
turnStartedAt: 1000,
|
||||
},
|
||||
agent,
|
||||
loadTools,
|
||||
|
|
@ -1195,10 +1196,13 @@ describe('initializeAgent — stable and dynamic instruction fields', () => {
|
|||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('moves instructions with temporal special vars into the dynamic tail using the conversation anchor', async () => {
|
||||
it('moves instructions with temporal special vars into the dynamic tail using the turn start', async () => {
|
||||
const { agent, req, res, loadTools, db } = createMocks();
|
||||
agent.instructions = 'Conversation opened at {{iso_datetime}}';
|
||||
req.conversationCreatedAt = '2023-12-31T23:59:58.000Z';
|
||||
agent.instructions =
|
||||
'Today is {{current_date}}. The turn began at {{current_datetime}} ({{iso_datetime}}).';
|
||||
req.conversationCreatedAt = '2026-08-25T10:00:00.000Z';
|
||||
req.turnStartedAt = new Date('2026-08-31T06:20:00.000Z').getTime();
|
||||
req.body = { timezone: 'UTC' };
|
||||
|
||||
const result = await initializeAgent(
|
||||
{
|
||||
|
|
@ -1214,13 +1218,15 @@ describe('initializeAgent — stable and dynamic instruction fields', () => {
|
|||
);
|
||||
|
||||
expect(result.instructions).toBeUndefined();
|
||||
expect(result.additional_instructions).toBe('Conversation opened at 2023-12-31T23:59:58.000Z');
|
||||
expect(result.additional_instructions).toBe(
|
||||
'Today is 2026-08-31 (Monday). The turn began at 2026-08-31 06:20:00 +00:00 (Monday) (2026-08-31T06:20:00.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.turnStartedAt = new Date('2024-01-15T18:30:00.000Z').getTime();
|
||||
req.body = { timezone: 'America/New_York' };
|
||||
|
||||
const result = await initializeAgent(
|
||||
|
|
@ -1246,7 +1252,6 @@ describe('initializeAgent — stable and dynamic instruction fields', () => {
|
|||
const { agent, req, res, loadTools, db } = createMocks();
|
||||
agent.instructions = 'You are helping {{current_user}}.';
|
||||
req.user = { id: 'user-1', name: 'Test User' } as never;
|
||||
req.conversationCreatedAt = '2023-12-31T23:59:58.000Z';
|
||||
|
||||
const result = await initializeAgent(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1637,7 +1637,7 @@ export async function initializeAgent(
|
|||
const resolvedInstructions = replaceSpecialVars({
|
||||
text: agent.instructions,
|
||||
user: user ? (user as unknown as TUser) : null,
|
||||
now: runtime.conversationCreatedAt,
|
||||
now: runtime.turnStartedAt,
|
||||
timezone: runtime.requestBody.timezone,
|
||||
});
|
||||
if (hasTemporalSpecialVars(agent.instructions)) {
|
||||
|
|
|
|||
28
packages/api/src/agents/runtime.spec.ts
Normal file
28
packages/api/src/agents/runtime.spec.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { ServerRequest } from '~/types';
|
||||
import { createAgentExecutionContext, createRequestAgentExecutionContext } from './runtime';
|
||||
|
||||
describe('createRequestAgentExecutionContext', () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('captures one server timestamp and reuses it across request-backed initialization', () => {
|
||||
const now = jest.spyOn(Date, 'now').mockReturnValueOnce(1000).mockReturnValueOnce(2000);
|
||||
const req = { body: {} } as ServerRequest;
|
||||
|
||||
const first = createRequestAgentExecutionContext(req);
|
||||
const second = createRequestAgentExecutionContext(req);
|
||||
|
||||
expect(first.turnStartedAt).toBe(1000);
|
||||
expect(second.turnStartedAt).toBe(1000);
|
||||
expect(req.turnStartedAt).toBe(1000);
|
||||
expect(now).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('captures server time when a request-free caller omits the turn start', () => {
|
||||
const now = jest.spyOn(Date, 'now').mockReturnValue(3000);
|
||||
|
||||
const context = createAgentExecutionContext({ requestBody: {} });
|
||||
|
||||
expect(context.turnStartedAt).toBe(3000);
|
||||
expect(now).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -12,7 +12,9 @@ export interface AgentExecutionContext {
|
|||
user?: IUser;
|
||||
appConfig?: AppConfig;
|
||||
requestBody: RequestBody;
|
||||
/** Server-captured conversation creation time used by prompt variables. */
|
||||
/** Server-captured logical turn start time used by prompt variables. */
|
||||
turnStartedAt: number;
|
||||
/** Server-captured conversation creation time retained as historical metadata. */
|
||||
conversationCreatedAt?: string;
|
||||
/** Conversation already resolved by ingress. Presence distinguishes "not read" from absent. */
|
||||
resolvedConversation?: Partial<IConversation> | null;
|
||||
|
|
@ -23,6 +25,7 @@ export function createAgentExecutionContext({
|
|||
user,
|
||||
appConfig,
|
||||
requestBody,
|
||||
turnStartedAt = Date.now(),
|
||||
conversationCreatedAt,
|
||||
resolvedConversation,
|
||||
hasResolvedConversation = false,
|
||||
|
|
@ -30,6 +33,7 @@ export function createAgentExecutionContext({
|
|||
user?: IUser;
|
||||
appConfig?: AppConfig;
|
||||
requestBody: RequestBody;
|
||||
turnStartedAt?: number;
|
||||
conversationCreatedAt?: string;
|
||||
resolvedConversation?: Partial<IConversation> | null;
|
||||
hasResolvedConversation?: boolean;
|
||||
|
|
@ -38,6 +42,7 @@ export function createAgentExecutionContext({
|
|||
user,
|
||||
appConfig,
|
||||
requestBody,
|
||||
turnStartedAt,
|
||||
conversationCreatedAt,
|
||||
};
|
||||
if (hasResolvedConversation) {
|
||||
|
|
@ -51,10 +56,13 @@ export function createRequestAgentExecutionContext(
|
|||
req: ServerRequest,
|
||||
requestBody: RequestBody = req.body ?? {},
|
||||
): AgentExecutionContext {
|
||||
const turnStartedAt = req.turnStartedAt ?? Date.now();
|
||||
req.turnStartedAt = turnStartedAt;
|
||||
return createAgentExecutionContext({
|
||||
user: req.user,
|
||||
appConfig: req.config,
|
||||
requestBody,
|
||||
turnStartedAt,
|
||||
conversationCreatedAt: req.conversationCreatedAt,
|
||||
resolvedConversation: req.resolvedConversation,
|
||||
hasResolvedConversation: Object.prototype.hasOwnProperty.call(req, 'resolvedConversation'),
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ Anchor pattern: \\ue202turn{N}{type}{index} where N=turn number, type=search|new
|
|||
**CRITICAL:** Output escape sequences EXACTLY as shown. Do NOT substitute with † or other symbols. Place anchors AFTER punctuation. Cite every non-obvious fact/quote. NEVER use markdown links, [1], footnotes, or HTML tags.`.trim();
|
||||
}
|
||||
|
||||
/** Builds dynamic web search context scoped to the conversation anchor time. */
|
||||
/** Builds dynamic web search context scoped to the logical turn start time. */
|
||||
export function buildWebSearchDynamicContext(now?: string | number | Date): string {
|
||||
return `# \`${Tools.web_search}\` Runtime Context
|
||||
Conversation Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}', now })}`.trim();
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ export type RequestBody = {
|
|||
export type ServerRequest = Request<unknown, unknown, RequestBody> & {
|
||||
user?: IUser;
|
||||
config?: AppConfig;
|
||||
/** Server-captured conversation creation time used to anchor dynamic prompt variables. */
|
||||
/** Server-captured generation start time used to anchor dynamic prompt variables. */
|
||||
turnStartedAt?: number;
|
||||
/** Server-captured conversation creation time used when inserting conversation metadata. */
|
||||
conversationCreatedAt?: string;
|
||||
/** Conversation read by request middleware (`null` = looked up, absent), reused by the
|
||||
* subagent guard, agent initialization, and the first save instead of re-reading it. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue