From b7ae67307e1e07c74ed5cd648f04cc97c7666242 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 31 Aug 2026 10:38:10 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=95=B0=EF=B8=8F=20fix:=20Anchor=20Tempora?= =?UTF-8?q?l=20Variables=20to=20Turn=20Start=20(#15419)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/clients/tools/util/handleTools.js | 4 +-- .../agents/__tests__/openai.spec.js | 9 ++++++ .../__tests__/request.resumeMetadata.spec.js | 1 + .../agents/__tests__/responses.unit.spec.js | 4 +++ .../agents/__tests__/resume.spec.js | 19 +++---------- api/server/controllers/agents/openai.js | 2 ++ api/server/controllers/agents/request.js | 1 + api/server/controllers/agents/responses.js | 2 ++ api/server/controllers/agents/resume.js | 18 +----------- api/server/services/ToolService.js | 4 +-- .../src/agents/__tests__/initialize.test.ts | 17 +++++++---- packages/api/src/agents/initialize.ts | 2 +- packages/api/src/agents/runtime.spec.ts | 28 +++++++++++++++++++ packages/api/src/agents/runtime.ts | 10 ++++++- packages/api/src/tools/toolkits/web.ts | 2 +- packages/api/src/types/http.ts | 4 ++- 16 files changed, 79 insertions(+), 48 deletions(-) create mode 100644 packages/api/src/agents/runtime.spec.ts diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index a982d698e1..254c917fe9 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -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, diff --git a/api/server/controllers/agents/__tests__/openai.spec.js b/api/server/controllers/agents/__tests__/openai.spec.js index b1897a08cc..e290563b3e 100644 --- a/api/server/controllers/agents/__tests__/openai.spec.js +++ b/api/server/controllers/agents/__tests__/openai.spec.js @@ -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( diff --git a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js index 09d41af482..b1f49b8ab8 100644 --- a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js +++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js @@ -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']); diff --git a/api/server/controllers/agents/__tests__/responses.unit.spec.js b/api/server/controllers/agents/__tests__/responses.unit.spec.js index 2ad9558aa9..198b3f9f4d 100644 --- a/api/server/controllers/agents/__tests__/responses.unit.spec.js +++ b/api/server/controllers/agents/__tests__/responses.unit.spec.js @@ -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( diff --git a/api/server/controllers/agents/__tests__/resume.spec.js b/api/server/controllers/agents/__tests__/resume.spec.js index 8dee4b6aa4..2845c5b683 100644 --- a/api/server/controllers/agents/__tests__/resume.spec.js +++ b/api/server/controllers/agents/__tests__/resume.spec.js @@ -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', () => { diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index 8872f0aeab..5a48d5d999 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -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'), diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index 363012e674..5f57af82c5 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -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 diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index 481abaf8c2..8256cb6e3f 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -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'), diff --git a/api/server/controllers/agents/resume.js b/api/server/controllers/agents/resume.js index 74c50a1b42..cd19216c76 100644 --- a/api/server/controllers/agents/resume.js +++ b/api/server/controllers/agents/resume.js @@ -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 diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index f5655088a8..357a3255d8 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -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); } /** diff --git a/packages/api/src/agents/__tests__/initialize.test.ts b/packages/api/src/agents/__tests__/initialize.test.ts index 049a247235..12893f331f 100644 --- a/packages/api/src/agents/__tests__/initialize.test.ts +++ b/packages/api/src/agents/__tests__/initialize.test.ts @@ -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( { diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index 097aef6f32..0b7a6ad256 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -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)) { diff --git a/packages/api/src/agents/runtime.spec.ts b/packages/api/src/agents/runtime.spec.ts new file mode 100644 index 0000000000..18c5e5c00d --- /dev/null +++ b/packages/api/src/agents/runtime.spec.ts @@ -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); + }); +}); diff --git a/packages/api/src/agents/runtime.ts b/packages/api/src/agents/runtime.ts index d43f08c4f9..527ffa7c47 100644 --- a/packages/api/src/agents/runtime.ts +++ b/packages/api/src/agents/runtime.ts @@ -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 | 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 | 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'), diff --git a/packages/api/src/tools/toolkits/web.ts b/packages/api/src/tools/toolkits/web.ts index a1cdd1d413..34fab08ffe 100644 --- a/packages/api/src/tools/toolkits/web.ts +++ b/packages/api/src/tools/toolkits/web.ts @@ -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(); diff --git a/packages/api/src/types/http.ts b/packages/api/src/types/http.ts index df1d6893e2..f9ccb5dddf 100644 --- a/packages/api/src/types/http.ts +++ b/packages/api/src/types/http.ts @@ -23,7 +23,9 @@ export type RequestBody = { export type ServerRequest = Request & { 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. */