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:
Riqwan Thamir 2026-09-15 07:32:34 +00:00 committed by GitHub
parent 7e1100d73f
commit cb55ecce7f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 97 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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