diff --git a/packages/cli/src/modules/agents/agents.service.ts b/packages/cli/src/modules/agents/agents.service.ts index b88c1ecbfef..d66a58ed65c 100644 --- a/packages/cli/src/modules/agents/agents.service.ts +++ b/packages/cli/src/modules/agents/agents.service.ts @@ -636,6 +636,20 @@ export class AgentsService { const { agent, agentId, projectId, credentialProvider, nodeToolsEnabled, integrationType } = params; + // Inject get_environment unconditionally. It surfaces info the model + // can't know on its own (current date, instance timezone, day of week) + // via a tool call rather than the system prompt — so values that change + // per request don't bust system-prompt prompt caching. + try { + const { createGetEnvironmentTool } = await import('./tools/environment-tool'); + agent.tool(createGetEnvironmentTool()); + } catch (toolError) { + this.logger.warn('Failed to inject get_environment tool', { + agentId, + error: toolError instanceof Error ? toolError.message : String(toolError), + }); + } + // Inject the rich_interaction tool only for platforms that can actually // render its suspend/resume HITL cards. Two gates: // - A registered integration in ChatIntegrationRegistry. The in-app diff --git a/packages/cli/src/modules/agents/tools/__tests__/environment-tool.test.ts b/packages/cli/src/modules/agents/tools/__tests__/environment-tool.test.ts new file mode 100644 index 00000000000..a3c73a22c6f --- /dev/null +++ b/packages/cli/src/modules/agents/tools/__tests__/environment-tool.test.ts @@ -0,0 +1,71 @@ +import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; +import { DateTime, Settings } from 'luxon'; + +import { createGetEnvironmentTool } from '../environment-tool'; + +describe('createGetEnvironmentTool', () => { + const FIXED_NOW_MS = DateTime.fromISO('2026-05-05T14:32:11.000Z', { zone: 'utc' }).toMillis(); + + beforeEach(() => { + Settings.now = () => FIXED_NOW_MS; + Container.set(GlobalConfig, { generic: { timezone: 'America/New_York' } } as GlobalConfig); + }); + + afterEach(() => { + Settings.now = () => Date.now(); + Container.reset(); + }); + + function makeCtx() { + return { + parentTelemetry: undefined, + }; + } + + it('builds a tool with the correct name', () => { + const tool = createGetEnvironmentTool().build(); + expect(tool.name).toBe('get_environment'); + }); + + it('returns now in the instance timezone with offset', async () => { + const tool = createGetEnvironmentTool().build(); + + const result = (await tool.handler!({}, makeCtx())) as { + now: string; + timezone: string; + dayOfWeek: string; + }; + + // 14:32:11Z in America/New_York (DST) = 10:32:11-04:00 + expect(result.now).toBe('2026-05-05T10:32:11.000-04:00'); + expect(result.timezone).toBe('America/New_York'); + expect(result.dayOfWeek).toBe('Tuesday'); + }); + + it('reflects a different configured timezone', async () => { + Container.set(GlobalConfig, { generic: { timezone: 'Asia/Tokyo' } } as GlobalConfig); + + const tool = createGetEnvironmentTool().build(); + const result = (await tool.handler!({}, makeCtx())) as { + now: string; + timezone: string; + dayOfWeek: string; + }; + + // 14:32:11Z in Asia/Tokyo = 23:32:11+09:00 + expect(result.now).toBe('2026-05-05T23:32:11.000+09:00'); + expect(result.timezone).toBe('Asia/Tokyo'); + expect(result.dayOfWeek).toBe('Tuesday'); + }); + + it('reads timezone at handler time, not creation time', async () => { + const tool = createGetEnvironmentTool().build(); + + // Mutate config after the tool is built; the handler must pick up the change. + Container.set(GlobalConfig, { generic: { timezone: 'Europe/London' } } as GlobalConfig); + + const result = (await tool.handler!({}, makeCtx())) as { timezone: string }; + expect(result.timezone).toBe('Europe/London'); + }); +}); diff --git a/packages/cli/src/modules/agents/tools/environment-tool.ts b/packages/cli/src/modules/agents/tools/environment-tool.ts new file mode 100644 index 00000000000..85a3c4078e2 --- /dev/null +++ b/packages/cli/src/modules/agents/tools/environment-tool.ts @@ -0,0 +1,28 @@ +import { Tool } from '@n8n/agents'; +import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; +import { DateTime } from 'luxon'; +import { z } from 'zod'; + +const DESCRIPTION = + 'Returns runtime info that the LLM cannot know on its own: ' + + 'current ISO date/time, instance timezone (IANA), and day of week. ' + + 'Call when reasoning about "today", deadlines, or scheduling.'; + +export function createGetEnvironmentTool() { + return ( + new Tool('get_environment') + .description(DESCRIPTION) + .input(z.object({})) + // eslint-disable-next-line @typescript-eslint/require-await -- Tool.handler() expects an async callback + .handler(async () => { + const timezone = Container.get(GlobalConfig).generic.timezone; + const now = DateTime.now().setZone(timezone); + return { + now: now.toISO(), + timezone, + dayOfWeek: now.weekdayLong, + }; + }) + ); +}