🕰️ fix: Anchor Temporal Variables to Turn Start (#15419)

This commit is contained in:
Danny Avila 2026-08-31 10:38:10 -04:00 • committed by GitHub
parent 06ddaf8a3d
commit b7ae67307e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 79 additions and 48 deletions

View file

@ -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,

View file

@ -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(

View file

@ -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']);

View file

@ -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(

View file

@ -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', () => {

View file

@ -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'),

View file

@ -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

View file

@ -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'),

View file

@ -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

View file

@ -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);
}
/**

View file

@ -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(
{

View file

@ -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)) {

View 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);
});
});

View file

@ -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'),

View file

@ -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();

View file

@ -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. */