From cb55ecce7f8a006f9072e5d248ab84014b01d543 Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Tue, 15 Sep 2026 07:32:34 +0000 Subject: [PATCH] feat(core): Add option to disable mid-run observation for Instance AI (no-changelog) (#38664) Co-authored-by: Cursor --- .../memory-orchestrator-mid-run.test.ts | 22 ++++++++++++++ .../src/runtime/memory/memory-orchestrator.ts | 3 ++ .../memory-builder-observational.test.ts | 20 +++++++++++++ packages/@n8n/agents/src/sdk/memory.ts | 3 ++ packages/@n8n/agents/src/types/sdk/memory.ts | 5 ++++ .../config/src/configs/instance-ai.config.ts | 8 +++++ packages/@n8n/config/test/config.test.ts | 1 + .../agent/__tests__/instance-agent.test.ts | 30 +++++++++++++++++++ .../instance-ai/src/agent/instance-agent.ts | 3 +- packages/@n8n/instance-ai/src/types.ts | 2 ++ .../instance-ai/instance-ai.service.ts | 1 + 11 files changed, 97 insertions(+), 1 deletion(-) diff --git a/packages/@n8n/agents/src/runtime/__tests__/memory-orchestrator-mid-run.test.ts b/packages/@n8n/agents/src/runtime/__tests__/memory-orchestrator-mid-run.test.ts index b3140de86e2..2bdfa5206f9 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/memory-orchestrator-mid-run.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/memory-orchestrator-mid-run.test.ts @@ -75,6 +75,28 @@ describe('MemoryOrchestrator.maybeObserveMidRun', () => { expect(list.forLlm('base').messages).toHaveLength(1); }); + it('does nothing when midRunObservation is disabled, even past the threshold', async () => { + const store = new InMemoryMemory(); + const observe = vi.fn( + async () => await Promise.resolve('* CRITICAL (14:30) Should not appear.'), + ); + const { orchestrator } = buildOrchestrator(store, { + observerThresholdTokens: 10, + midRunObservation: false, + observe, + observationLogTailLimit: 20, + }); + const list = new AgentMessageList(); + list.addInput([userMsg('a user message crossing the threshold')]); + list.addResponse([assistantMsg('an assistant reply with more work')]); + + await orchestrator.maybeObserveMidRun(list, runOptions()); + + expect(observe).not.toHaveBeenCalled(); + expect(await store.getMessagesForObservationScope(THREAD_ID)).toHaveLength(0); + expect(list.forLlm('base').messages).toHaveLength(2); + }); + it('persists the turn, writes observations, advances the cursor, and masks the window on crossing', async () => { const store = new InMemoryMemory(); const { orchestrator } = buildOrchestrator(store, { diff --git a/packages/@n8n/agents/src/runtime/memory/memory-orchestrator.ts b/packages/@n8n/agents/src/runtime/memory/memory-orchestrator.ts index 5e45298bcaf..2010409f892 100644 --- a/packages/@n8n/agents/src/runtime/memory/memory-orchestrator.ts +++ b/packages/@n8n/agents/src/runtime/memory/memory-orchestrator.ts @@ -509,6 +509,9 @@ export class MemoryOrchestrator { ): Promise { const { memory, observationalMemory } = this.config; if (!memory || !options?.persistence || !hasObservationLogObserverMemory(memory)) return; + // Hosts can restrict the Observer to the post-turn path, so the visible + // window stays byte-stable within a turn (better prompt-cache reuse). + if (observationalMemory?.midRunObservation === false) return; const observerThresholdTokens = observationalMemory?.observerThresholdTokens; if (!observationalMemory?.observe || observerThresholdTokens === undefined) return; diff --git a/packages/@n8n/agents/src/sdk/__tests__/memory-builder-observational.test.ts b/packages/@n8n/agents/src/sdk/__tests__/memory-builder-observational.test.ts index 14ae475cdab..9945a1d6040 100644 --- a/packages/@n8n/agents/src/sdk/__tests__/memory-builder-observational.test.ts +++ b/packages/@n8n/agents/src/sdk/__tests__/memory-builder-observational.test.ts @@ -71,6 +71,7 @@ describe('Memory builder — observation log memory', () => { observationLogTailLimit: 20, lockTtlMs: DEFAULT_OBSERVATION_LOG_LOCK_TTL_MS, }); + expect(resolved.midRunObservation).toBeUndefined(); expect(typeof resolved.observe).toBe('function'); expect(typeof resolved.reflect).toBe('function'); }); @@ -85,6 +86,7 @@ describe('Memory builder — observation log memory', () => { renderTokenBudget: 789, observationLogTailLimit: 3, lockTtlMs: 1_000, + midRunObservation: false, observe, reflect, }, @@ -97,6 +99,7 @@ describe('Memory builder — observation log memory', () => { renderTokenBudget: 789, observationLogTailLimit: 3, lockTtlMs: 1_000, + midRunObservation: false, }); expect(resolved.observe).toBe(observe); expect(resolved.reflect).toBe(reflect); @@ -148,10 +151,27 @@ describe('Memory builder — observation log memory', () => { observerThresholdTokens: 8_000, reflectorThresholdTokens: 12_000, }); + expect(runtimeConfig.observationalMemory?.midRunObservation).toBeUndefined(); expect(typeof runtimeConfig.observationalMemory?.observe).toBe('function'); expect(typeof runtimeConfig.observationalMemory?.reflect).toBe('function'); }); + it('passes midRunObservation through agent build', async () => { + const memory = new Memory() + .storage(new InMemoryMemory()) + .observationalMemory({ midRunObservation: false }); + const agent = new Agent('a') + .model('openai/gpt-4o-mini') + .instructions('You are a test assistant.') + .memory(memory); + + const runtimeConfig = await ( + agent as unknown as { build(): Promise } + ).build(); + + expect(runtimeConfig.observationalMemory?.midRunObservation).toBe(false); + }); + it('rejects backends that do not implement the observation-log store', () => { const minimalBackend = { getThread: vi.fn().mockResolvedValue(null), diff --git a/packages/@n8n/agents/src/sdk/memory.ts b/packages/@n8n/agents/src/sdk/memory.ts index d7dd74e2426..e5ba51104fb 100644 --- a/packages/@n8n/agents/src/sdk/memory.ts +++ b/packages/@n8n/agents/src/sdk/memory.ts @@ -49,6 +49,9 @@ export function resolveObservationalMemoryConfig( renderTokenBudget: config.renderTokenBudget ?? DEFAULT_OBSERVATION_LOG_RENDER_TOKEN_BUDGET, observationLogTailLimit: config.observationLogTailLimit ?? DEFAULT_OBSERVATION_LOG_TAIL_LIMIT, lockTtlMs: config.lockTtlMs ?? DEFAULT_OBSERVATION_LOG_LOCK_TTL_MS, + ...(config.midRunObservation !== undefined + ? { midRunObservation: config.midRunObservation } + : {}), observe: config.observe ?? createObservationLogObserveFn(observerModel), reflect: config.reflect ?? createObservationLogReflectFn(reflectorModel), }; diff --git a/packages/@n8n/agents/src/types/sdk/memory.ts b/packages/@n8n/agents/src/types/sdk/memory.ts index b1e8d5d44db..db6a7c661e3 100644 --- a/packages/@n8n/agents/src/types/sdk/memory.ts +++ b/packages/@n8n/agents/src/types/sdk/memory.ts @@ -315,6 +315,11 @@ export interface ObservationLogMemoryConfig { export interface ObservationalMemoryConfig { /** Estimated visible-window tokens at which the Observer is scheduled mid-run and post-turn. */ observerThresholdTokens?: number; + /** + * Run the Observer at tool-loop boundaries inside a turn. Default `true`. + * When `false`, the Observer runs only after the turn completes. + */ + midRunObservation?: boolean; /** Estimated active observation-log tokens required before the Reflector runs. */ reflectorThresholdTokens?: number; /** Maximum estimated tokens to render into the system prompt. */ diff --git a/packages/@n8n/config/src/configs/instance-ai.config.ts b/packages/@n8n/config/src/configs/instance-ai.config.ts index ed2745a9f2e..e54a07f853e 100644 --- a/packages/@n8n/config/src/configs/instance-ai.config.ts +++ b/packages/@n8n/config/src/configs/instance-ai.config.ts @@ -50,6 +50,14 @@ export class InstanceAiConfig { @Env('N8N_INSTANCE_AI_REFLECTOR_OBSERVATION_TOKENS') reflectorObservationTokens: number = 40_000; + /** + * Run the Observer inside a turn (at tool-loop boundaries). When false the + * Observer runs only after the turn, which keeps the prompt prefix stable + * within a turn and improves provider prompt-cache reuse. + */ + @Env('N8N_INSTANCE_AI_MID_RUN_OBSERVATION') + midRunObservation: boolean = false; + /** Disable the local gateway (filesystem, shell, browser, etc.) for all users. */ @Env('N8N_INSTANCE_AI_LOCAL_GATEWAY_DISABLED') localGatewayDisabled: boolean = false; diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index f0f5911d415..427412f2d3c 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -347,6 +347,7 @@ describe('GlobalConfig', () => { browserUseEnabled: true, observerMessageTokens: 30_000, reflectorObservationTokens: 40_000, + midRunObservation: false, sandboxEnabled: false, sandboxProvider: 'n8n-sandbox', sandboxImage: 'daytonaio/sandbox:0.5.0', diff --git a/packages/@n8n/instance-ai/src/agent/__tests__/instance-agent.test.ts b/packages/@n8n/instance-ai/src/agent/__tests__/instance-agent.test.ts index 87f981e54ac..f2b5dbadaf4 100644 --- a/packages/@n8n/instance-ai/src/agent/__tests__/instance-agent.test.ts +++ b/packages/@n8n/instance-ai/src/agent/__tests__/instance-agent.test.ts @@ -868,6 +868,36 @@ describe('createInstanceAgent', () => { expect(mockAgentInstances[0]?.memory).toHaveBeenCalledWith(mockMemoryBuilder); }); + it('forwards midRunObservation to the Memory builder when provided', async () => { + await createInstanceAgent({ + modelId: 'test-model', + context: { + runLabel: 'memory-test', + localGatewayStatus: undefined, + licenseHints: undefined, + localMcpServer: undefined, + }, + orchestrationContext: { + runId: 'memory-test', + }, + memory: { id: 'memory-store' }, + memoryConfig: { + observationalMemory: { + observerThresholdTokens: 30_000, + reflectorThresholdTokens: 40_000, + midRunObservation: false, + }, + }, + mcpManager: createMcpManagerStub(), + } as never); + + expect(mockMemoryBuilder.observationalMemory).toHaveBeenCalledWith({ + observerThresholdTokens: 30_000, + reflectorThresholdTokens: 40_000, + midRunObservation: false, + }); + }); + it('enables adaptive thinking by default for Anthropic models', async () => { await createInstanceAgent({ modelId: 'anthropic/claude-opus-4-8', diff --git a/packages/@n8n/instance-ai/src/agent/instance-agent.ts b/packages/@n8n/instance-ai/src/agent/instance-agent.ts index d167eabf78a..d9660081c72 100644 --- a/packages/@n8n/instance-ai/src/agent/instance-agent.ts +++ b/packages/@n8n/instance-ai/src/agent/instance-agent.ts @@ -268,11 +268,12 @@ export async function createInstanceAgent( const mem = new Memory().storage(options.memory); if (memoryConfig.observationalMemory) { - const { observerThresholdTokens, reflectorThresholdTokens, onTaskUsage } = + const { observerThresholdTokens, reflectorThresholdTokens, midRunObservation, onTaskUsage } = memoryConfig.observationalMemory; mem.observationalMemory({ observerThresholdTokens, reflectorThresholdTokens, + ...(midRunObservation !== undefined ? { midRunObservation } : {}), ...(onTaskUsage ? { observe: createObservationLogObserveFn(modelId, { onUsage: onTaskUsage }), diff --git a/packages/@n8n/instance-ai/src/types.ts b/packages/@n8n/instance-ai/src/types.ts index e26dde74d20..35777231312 100644 --- a/packages/@n8n/instance-ai/src/types.ts +++ b/packages/@n8n/instance-ai/src/types.ts @@ -1780,6 +1780,8 @@ export interface InstanceAiMemoryConfig { observationalMemory?: { observerThresholdTokens: number; reflectorThresholdTokens: number; + /** Run the Observer inside a turn. Default `true`; `false` limits it to the post-turn path. */ + midRunObservation?: boolean; /** Called with token usage after each observer/reflector LLM call, so the host can meter it. */ onTaskUsage?: (report: MemoryTaskUsageReport) => void | Promise; }; diff --git a/packages/cli/src/modules/instance-ai/instance-ai.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.service.ts index c65ca6b677c..82ae184cc8e 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.service.ts @@ -2202,6 +2202,7 @@ export class InstanceAiService { observationalMemory: { observerThresholdTokens: this.instanceAiConfig.observerMessageTokens, reflectorThresholdTokens: this.instanceAiConfig.reflectorObservationTokens, + midRunObservation: this.instanceAiConfig.midRunObservation, // Observer/reflector calls run in the background outside the run's // finish-chunk usage, so they are claimed here per report. Best-effort: // a billing failure must never block observation persistence.