mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
feat(core): Add option to disable mid-run observation for Instance AI (no-changelog) (#38664)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7e1100d73f
commit
cb55ecce7f
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -509,6 +509,9 @@ export class MemoryOrchestrator {
|
|||
): Promise<void> {
|
||||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AgentRuntimeConfig> }
|
||||
).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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user