perf: Reduce Instance AI memory usage (#31656)

Co-authored-by: Jaakko Husso <jaakko@n8n.io>
This commit is contained in:
Albert Alises 2026-06-03 17:54:42 +02:00 committed by GitHub
parent 68b205317a
commit 7e8c04299c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1001 additions and 147 deletions

View File

@ -0,0 +1,393 @@
type Callable = (...args: never[]) => unknown;
type Constructable = new (...args: never[]) => unknown;
const call = (value: unknown) => (value as Callable)();
const construct = (value: unknown) => new (value as Constructable)();
vi.mock('../tracing/langsmith-tracing', () => ({
appendGeneratedWorkflowIdToRootMetadata: () => 'appendGeneratedWorkflowIdToRootMetadata',
appendRootRunMetadata: () => 'appendRootRunMetadata',
createInstanceAiTraceContext: () => 'createInstanceAiTraceContext',
createInternalOperationTraceContext: () => 'createInternalOperationTraceContext',
createTraceReplayOnlyContext: () => 'createTraceReplayOnlyContext',
continueInstanceAiTraceContext: () => 'continueInstanceAiTraceContext',
releaseTraceClient: () => 'releaseTraceClient',
submitLangsmithUserFeedback: () => 'submitLangsmithUserFeedback',
withCurrentTraceSpan: () => 'withCurrentTraceSpan',
}));
vi.mock('../tracing/trace-replay', () => {
class IdRemapper {
remapInput(input: unknown) {
return input;
}
}
class TraceIndex {}
class TraceWriter {}
return {
IdRemapper,
TraceIndex,
TraceWriter,
parseTraceJsonl: () => [],
PURE_REPLAY_TOOLS: new Set(['web-search']),
};
});
vi.mock('../agent/instance-agent', () => ({ createInstanceAgent: () => 'instance-agent' }));
vi.mock('../domain-access', () => ({
createDomainAccessTracker: () => ({ type: 'domain-access-tracker' }),
}));
vi.mock('../agent/sub-agent-factory', () => ({ createSubAgent: () => 'sub-agent' }));
vi.mock('../tools/web-research/sanitize-web-content', () => ({
wrapUntrustedData: (content: string, source: string) =>
`<untrusted_data source="${source}">${content}</untrusted_data>`,
}));
vi.mock('../tools/orchestration/delegate.tool', () => ({
startDetachedDelegateTask: () => 'delegate-task',
}));
vi.mock('../tools', () => ({
createAllTools: () => ['all-tools'],
createOrchestrationTools: () => ['orchestration-tools'],
}));
vi.mock('../tools/orchestration/agent-persistence', () => ({
SUB_AGENT_RESOURCE_PREFIX: 'instance-ai-subagent',
createSubAgentResourceId: (threadId: string, kind: string) =>
`instance-ai-subagent:${threadId}:${kind.toLowerCase().replace(/\s+/g, '-')}`,
createSubAgentResourceIdPrefix: (threadId: string) => `instance-ai-subagent:${threadId}:`,
}));
vi.mock('../memory/title-utils', () => ({
truncateToTitle: (title: string) => title,
generateTitleForRun: () => 'generated-title',
}));
vi.mock('../mcp/mcp-client-manager', () => ({ McpClientManager: class McpClientManager {} }));
vi.mock('../utils/stream-helpers', () => ({
isRecord: (value: unknown) =>
value !== null && typeof value === 'object' && !Array.isArray(value),
parseSuspension: () => ({
toolCallId: 'tool-call-1',
requestId: 'request-1',
suspendPayload: { requestId: 'request-1' },
}),
asResumable: (agent: unknown) => agent,
}));
vi.mock('../storage', () => ({
iterationEntrySchema: { safeParse: () => ({ success: true }) },
formatPreviousAttempts: () => 'previous attempts',
ThreadIterationLogStorage: class ThreadIterationLogStorage {},
ThreadTaskStorage: class ThreadTaskStorage {},
PlannedTaskStorage: class PlannedTaskStorage {},
getThread: () => ({ id: 'thread-1' }),
TerminalOutcomeStorage: class TerminalOutcomeStorage {},
patchThread: () => ({ id: 'thread-1' }),
WorkflowLoopStorage: class WorkflowLoopStorage {},
}));
vi.mock('../stream/map-chunk', () => ({ mapAgentChunkToEvent: () => ({ type: 'event' }) }));
vi.mock('../skills/runtime-skills', () => ({
INSTANCE_AI_SKILLS_DIR: '/instance-ai-skills',
hasRuntimeSkills: () => true,
loadInstanceAiRuntimeSkillSource: () => 'runtime-skill-source',
}));
vi.mock('../skills/materialize-runtime-skills', () => ({
SANDBOX_RUNTIME_SKILLS_DIR: '/sandbox-skills',
SANDBOX_RUNTIME_SKILL_REGISTRY_FILE: 'registry.json',
RUNTIME_SKILL_MANIFEST_FILE: 'manifest.json',
RUNTIME_SKILL_MANIFEST_SCHEMA_VERSION: 1,
N8N_SKILLS_DIR_ENV: 'N8N_SKILLS_DIR',
N8N_SKILL_DIR_ENV: 'N8N_SKILL_DIR',
N8N_WORKSPACE_DIR_ENV: 'N8N_WORKSPACE_DIR',
createLazyWorkspaceRuntimeSkillSource: () => 'lazy-skill-source',
buildRuntimeSkillWorkspaceBundle: () => ({ manifest: [] }),
materializeRuntimeSkillsIntoWorkspace: () => undefined,
loadPrebakedRuntimeSkillsBundle: () => ({ manifest: [] }),
}));
vi.mock('../utils/eval-agents', () => ({
SONNET_MODEL: 'sonnet',
HAIKU_MODEL: 'haiku',
createEvalAgent: () => 'eval-agent',
extractText: () => 'text',
Tool: class Tool {},
}));
vi.mock('../utils/agent-tree', () => ({
buildAgentTreeFromEvents: () => ({ agentId: 'root', children: [] }),
findAgentNodeInTree: () => ({ agentId: 'root', children: [] }),
}));
vi.mock('../workspace/builder-templates-service', () => ({
BuilderTemplatesService: class BuilderTemplatesService {},
builderTemplatesOptionsFromEnv: () => ({ enabled: true }),
}));
vi.mock('../workspace/create-workspace', () => ({
createSandbox: () => ({ type: 'sandbox' }),
createWorkspace: () => ({ type: 'workspace' }),
}));
vi.mock('../workspace/lazy-runtime-workspace', () => ({
createLazyRuntimeWorkspace: () => ({ type: 'lazy-workspace' }),
}));
vi.mock('../workspace/sandbox-setup', () => ({
getWorkspaceRoot: () => '/workspace',
setupSandboxWorkspace: () => undefined,
}));
vi.mock('../workspace/snapshot-manager', () => ({ SnapshotManager: class SnapshotManager {} }));
vi.mock('../runtime/background-task-manager', () => ({
BackgroundTaskManager: class BackgroundTaskManager {},
enrichMessageWithRunningTasks: () => ({ content: 'message' }),
}));
vi.mock('../runtime/run-state-registry', () => ({ RunStateRegistry: class RunStateRegistry {} }));
vi.mock('../runtime/terminal-response-guard', () => ({
InstanceAiTerminalResponseGuard: class InstanceAiTerminalResponseGuard {},
}));
vi.mock('../runtime/resumable-stream-executor', () => ({
executeResumableStream: () => ({ status: 'done' }),
}));
vi.mock('../runtime/stream-runner', () => ({
resumeAgentRun: () => ({ status: 'resumed' }),
streamAgentRun: () => ({ status: 'streamed' }),
}));
vi.mock('../runtime/liveness-policy', () => {
const INSTANCE_AI_DEFAULT_LIVENESS_POLICY_CONFIG = {
confirmationTimeoutMs: 1,
backgroundTaskIdleTimeoutMs: 1,
backgroundTaskMaxLifetimeMs: 1,
activeRunIdleTimeoutMs: 0,
activeRunMaxLifetimeMs: 1,
};
class InstanceAiLivenessPolicy {
hasEnabledTimeouts() {
return true;
}
}
return {
INSTANCE_AI_DEFAULT_LIVENESS_POLICY_CONFIG,
createInstanceAiLivenessPolicyConfig: () => INSTANCE_AI_DEFAULT_LIVENESS_POLICY_CONFIG,
InstanceAiLivenessPolicy,
};
});
vi.mock('../workflow-loop', () => ({
workflowBuildOutcomeSchema: { safeParse: () => ({ success: true }) },
attemptRecordSchema: { safeParse: () => ({ success: false }) },
workflowLoopStateSchema: { parse: (value: unknown) => value },
verificationResultSchema: { safeParse: () => ({ success: true }) },
createWorkItem: () => ({ workItemId: 'work-item-1' }),
formatWorkflowLoopGuidance: () => 'guidance',
handleBuildOutcome: () => ({ action: { type: 'verify' } }),
handleVerificationVerdict: () => ({ action: { type: 'done' } }),
formatAttemptHistory: () => 'attempt history',
WorkflowTaskCoordinator: class WorkflowTaskCoordinator {},
}));
vi.mock('../workflow-loop/runtime', () => ({
WorkflowLoopRuntime: class WorkflowLoopRuntime {},
}));
vi.mock('../planned-tasks/planned-task-service', () => ({
PlannedTaskCoordinator: class PlannedTaskCoordinator {},
}));
vi.mock('../planned-tasks/planned-task-permissions', () => ({
PLANNED_TASK_PERMISSION_OVERRIDES: { checkpoint: { runWorkflow: 'always_allow' } },
applyPlannedTaskPermissions: (context: { permissions: Record<string, unknown> }) => ({
...context,
permissions: { ...context.permissions, runWorkflow: 'always_allow' },
}),
}));
vi.mock('../parsers/structured-file-parser', () => ({
classifyAttachments: () => [{ index: 0, parseable: true, format: 'csv' }],
buildAttachmentManifest: () => '[ATTACHMENTS] parse-file [/ATTACHMENTS]',
isStructuredAttachment: () => true,
isParseableAttachment: () => true,
}));
vi.mock('../parsers/validate-attachments', () => {
class UnsupportedAttachmentError extends Error {}
return {
getParseableAttachmentMimeTypes: () => ['text/csv'],
getSupportedAttachmentMimeTypes: () => ['text/csv', 'image/*'],
isSupportedAttachmentMimeType: (mimeType: string) =>
mimeType === 'text/csv' || mimeType.startsWith('image/'),
validateAttachmentMimeTypes: () => {
throw new UnsupportedAttachmentError();
},
UnsupportedAttachmentError,
};
});
describe('@n8n/instance-ai public entrypoint', () => {
it('exposes representative lazy exports without invoking them', async () => {
const entrypoint = await import('../index');
expect(entrypoint.MAX_STEPS.ORCHESTRATOR).toBeGreaterThan(0);
expect(entrypoint.createAllTools).toEqual(expect.any(Function));
expect(entrypoint.createInstanceAgent).toEqual(expect.any(Function));
expect(entrypoint.createLazyRuntimeWorkspace).toEqual(expect.any(Function));
expect(entrypoint.createWorkItem).toEqual(expect.any(Function));
expect(entrypoint.getParseableAttachmentMimeTypes).toEqual(expect.any(Function));
expect(entrypoint.mapAgentChunkToEvent).toEqual(expect.any(Function));
expect(entrypoint.parseTraceJsonl).toEqual(expect.any(Function));
expect(entrypoint.wrapUntrustedData).toEqual(expect.any(Function));
expect(entrypoint.BackgroundTaskManager).toEqual(expect.any(Function));
expect(entrypoint.IdRemapper).toEqual(expect.any(Function));
expect(entrypoint.McpClientManager).toEqual(expect.any(Function));
expect(entrypoint.UnsupportedAttachmentError).toEqual(expect.any(Function));
expect(entrypoint.WorkflowLoopRuntime).toEqual(expect.any(Function));
});
it('loads lazy functions, classes, and getters through the public barrel', async () => {
const entrypoint = await import('../index');
expect(call(entrypoint.appendGeneratedWorkflowIdToRootMetadata)).toBe(
'appendGeneratedWorkflowIdToRootMetadata',
);
expect(call(entrypoint.appendRootRunMetadata)).toBe('appendRootRunMetadata');
expect(call(entrypoint.createInstanceAiTraceContext)).toBe('createInstanceAiTraceContext');
expect(call(entrypoint.createInternalOperationTraceContext)).toBe(
'createInternalOperationTraceContext',
);
expect(call(entrypoint.createTraceReplayOnlyContext)).toBe('createTraceReplayOnlyContext');
expect(call(entrypoint.continueInstanceAiTraceContext)).toBe('continueInstanceAiTraceContext');
expect(call(entrypoint.releaseTraceClient)).toBe('releaseTraceClient');
expect(call(entrypoint.submitLangsmithUserFeedback)).toBe('submitLangsmithUserFeedback');
expect(call(entrypoint.withCurrentTraceSpan)).toBe('withCurrentTraceSpan');
expect(call(entrypoint.createInstanceAgent)).toBe('instance-agent');
expect(call(entrypoint.createDomainAccessTracker)).toEqual({ type: 'domain-access-tracker' });
expect(call(entrypoint.createSubAgent)).toBe('sub-agent');
expect(entrypoint.wrapUntrustedData('hello', 'https://example.com')).toContain(
'<untrusted_data source="https://example.com">',
);
expect(call(entrypoint.startDetachedDelegateTask)).toBe('delegate-task');
expect(call(entrypoint.createAllTools)).toEqual(['all-tools']);
expect(call(entrypoint.createOrchestrationTools)).toEqual(['orchestration-tools']);
expect(entrypoint.PURE_REPLAY_TOOLS.has('web-search')).toBe(true);
expect(entrypoint.createSubAgentResourceId('thread-1', 'Research Agent')).toBe(
'instance-ai-subagent:thread-1:research-agent',
);
expect(entrypoint.createSubAgentResourceIdPrefix('thread-1')).toBe(
'instance-ai-subagent:thread-1:',
);
expect(entrypoint.SUB_AGENT_RESOURCE_PREFIX).toBe('instance-ai-subagent');
const remapper = new entrypoint.IdRemapper();
expect(remapper.remapInput({ id: 'old-id' })).toEqual({ id: 'old-id' });
expect(remapper).toBeInstanceOf(entrypoint.IdRemapper);
expect(construct(entrypoint.TraceIndex)).toBeInstanceOf(entrypoint.TraceIndex);
expect(construct(entrypoint.TraceWriter)).toBeInstanceOf(entrypoint.TraceWriter);
expect(call(entrypoint.parseTraceJsonl)).toEqual([]);
expect(call(entrypoint.hasRuntimeSkills)).toBe(true);
expect(call(entrypoint.loadInstanceAiRuntimeSkillSource)).toBe('runtime-skill-source');
expect(call(entrypoint.createLazyWorkspaceRuntimeSkillSource)).toBe('lazy-skill-source');
expect(call(entrypoint.buildRuntimeSkillWorkspaceBundle)).toEqual({ manifest: [] });
expect(call(entrypoint.materializeRuntimeSkillsIntoWorkspace)).toBeUndefined();
expect(call(entrypoint.loadPrebakedRuntimeSkillsBundle)).toEqual({ manifest: [] });
expect(entrypoint.INSTANCE_AI_SKILLS_DIR).toBe('/instance-ai-skills');
expect(entrypoint.SANDBOX_RUNTIME_SKILLS_DIR).toBe('/sandbox-skills');
expect(entrypoint.SANDBOX_RUNTIME_SKILL_REGISTRY_FILE).toBe('registry.json');
expect(entrypoint.RUNTIME_SKILL_MANIFEST_FILE).toBe('manifest.json');
expect(entrypoint.RUNTIME_SKILL_MANIFEST_SCHEMA_VERSION).toBe(1);
expect(entrypoint.N8N_SKILLS_DIR_ENV).toBe('N8N_SKILLS_DIR');
expect(entrypoint.N8N_SKILL_DIR_ENV).toBe('N8N_SKILL_DIR');
expect(entrypoint.N8N_WORKSPACE_DIR_ENV).toBe('N8N_WORKSPACE_DIR');
expect(call(entrypoint.formatPreviousAttempts)).toBe('previous attempts');
expect(construct(entrypoint.ThreadIterationLogStorage)).toBeInstanceOf(
entrypoint.ThreadIterationLogStorage,
);
expect(construct(entrypoint.ThreadTaskStorage)).toBeInstanceOf(entrypoint.ThreadTaskStorage);
expect(construct(entrypoint.PlannedTaskStorage)).toBeInstanceOf(entrypoint.PlannedTaskStorage);
expect(call(entrypoint.getThread)).toEqual({ id: 'thread-1' });
expect(construct(entrypoint.TerminalOutcomeStorage)).toBeInstanceOf(
entrypoint.TerminalOutcomeStorage,
);
expect(call(entrypoint.patchThread)).toEqual({ id: 'thread-1' });
expect(construct(entrypoint.WorkflowLoopStorage)).toBeInstanceOf(
entrypoint.WorkflowLoopStorage,
);
expect(entrypoint.iterationEntrySchema.safeParse({}).success).toBe(true);
expect(call(entrypoint.truncateToTitle)).toBeUndefined();
expect(call(entrypoint.generateTitleForRun)).toBe('generated-title');
expect(construct(entrypoint.McpClientManager)).toBeInstanceOf(entrypoint.McpClientManager);
expect(call(entrypoint.mapAgentChunkToEvent)).toEqual({ type: 'event' });
expect(entrypoint.isRecord({ ok: true })).toBe(true);
expect(entrypoint.parseSuspension({})).toEqual({
toolCallId: 'tool-call-1',
requestId: 'request-1',
suspendPayload: { requestId: 'request-1' },
});
expect(entrypoint.asResumable({ resume: true })).toEqual({ resume: true });
expect(call(entrypoint.createEvalAgent)).toBe('eval-agent');
expect(call(entrypoint.extractText)).toBe('text');
expect(construct(entrypoint.Tool)).toBeInstanceOf(entrypoint.Tool);
expect(entrypoint.SONNET_MODEL).toBe('sonnet');
expect(entrypoint.HAIKU_MODEL).toBe('haiku');
expect(call(entrypoint.buildAgentTreeFromEvents)).toEqual({ agentId: 'root', children: [] });
expect(call(entrypoint.findAgentNodeInTree)).toEqual({ agentId: 'root', children: [] });
expect(call(entrypoint.createLazyRuntimeWorkspace)).toEqual({ type: 'lazy-workspace' });
expect(call(entrypoint.getWorkspaceRoot)).toBe('/workspace');
expect(call(entrypoint.setupSandboxWorkspace)).toBeUndefined();
expect(construct(entrypoint.BuilderTemplatesService)).toBeInstanceOf(
entrypoint.BuilderTemplatesService,
);
expect(call(entrypoint.builderTemplatesOptionsFromEnv)).toEqual({ enabled: true });
expect(call(entrypoint.createSandbox)).toEqual({ type: 'sandbox' });
expect(call(entrypoint.createWorkspace)).toEqual({ type: 'workspace' });
expect(construct(entrypoint.SnapshotManager)).toBeInstanceOf(entrypoint.SnapshotManager);
expect(construct(entrypoint.BackgroundTaskManager)).toBeInstanceOf(
entrypoint.BackgroundTaskManager,
);
expect(call(entrypoint.enrichMessageWithRunningTasks)).toEqual({ content: 'message' });
expect(call(entrypoint.enrichMessageWithBackgroundTasks)).toEqual({ content: 'message' });
expect(construct(entrypoint.RunStateRegistry)).toBeInstanceOf(entrypoint.RunStateRegistry);
expect(construct(entrypoint.InstanceAiTerminalResponseGuard)).toBeInstanceOf(
entrypoint.InstanceAiTerminalResponseGuard,
);
expect(call(entrypoint.executeResumableStream)).toEqual({ status: 'done' });
expect(call(entrypoint.resumeAgentRun)).toEqual({ status: 'resumed' });
expect(call(entrypoint.streamAgentRun)).toEqual({ status: 'streamed' });
expect(call(entrypoint.createInstanceAiLivenessPolicyConfig)).toEqual(
entrypoint.INSTANCE_AI_DEFAULT_LIVENESS_POLICY_CONFIG,
);
expect(construct(entrypoint.InstanceAiLivenessPolicy)).toBeInstanceOf(
entrypoint.InstanceAiLivenessPolicy,
);
expect(call(entrypoint.createWorkItem)).toEqual({ workItemId: 'work-item-1' });
expect(call(entrypoint.formatWorkflowLoopGuidance)).toBe('guidance');
expect(call(entrypoint.handleBuildOutcome)).toEqual({ action: { type: 'verify' } });
expect(call(entrypoint.handleVerificationVerdict)).toEqual({ action: { type: 'done' } });
expect(call(entrypoint.formatAttemptHistory)).toBe('attempt history');
expect(construct(entrypoint.WorkflowTaskCoordinator)).toBeInstanceOf(
entrypoint.WorkflowTaskCoordinator,
);
expect(entrypoint.workflowBuildOutcomeSchema.safeParse({}).success).toBe(true);
expect(entrypoint.attemptRecordSchema.safeParse({}).success).toBe(false);
expect(entrypoint.workflowLoopStateSchema.parse({ workItemId: 'work-item-1' })).toEqual({
workItemId: 'work-item-1',
});
expect(entrypoint.verificationResultSchema.safeParse({}).success).toBe(true);
expect(construct(entrypoint.WorkflowLoopRuntime)).toBeInstanceOf(
entrypoint.WorkflowLoopRuntime,
);
expect(construct(entrypoint.PlannedTaskCoordinator)).toBeInstanceOf(
entrypoint.PlannedTaskCoordinator,
);
expect(
entrypoint.applyPlannedTaskPermissions({ permissions: {} } as never, 'checkpoint')
.permissions!.runWorkflow,
).toBe('always_allow');
expect(entrypoint.PLANNED_TASK_PERMISSION_OVERRIDES.checkpoint!.runWorkflow).toBe(
'always_allow',
);
const classified = entrypoint.classifyAttachments([]);
expect(classified[0]).toMatchObject({ index: 0, parseable: true, format: 'csv' });
expect(entrypoint.buildAttachmentManifest(classified)).toContain('parse-file');
expect(entrypoint.isStructuredAttachment({} as never)).toBe(true);
expect(entrypoint.isParseableAttachment({} as never)).toBe(true);
expect(entrypoint.getParseableAttachmentMimeTypes()).toContain('text/csv');
expect(entrypoint.getSupportedAttachmentMimeTypes()).toContain('image/*');
expect(entrypoint.isSupportedAttachmentMimeType('image/png')).toBe(true);
expect(() => entrypoint.validateAttachmentMimeTypes([])).toThrow(
entrypoint.UnsupportedAttachmentError,
);
});
});

View File

@ -3,15 +3,39 @@ import './source-map-filter';
import type * as InstanceAgentMod from './agent/instance-agent';
import type * as SubAgentFactoryMod from './agent/sub-agent-factory';
import type * as DomainAccessMod from './domain-access';
import type * as McpClientManagerMod from './mcp/mcp-client-manager';
import type * as TitleUtilsMod from './memory/title-utils';
import type * as StructuredFileParserMod from './parsers/structured-file-parser';
import type * as ValidateAttachmentsMod from './parsers/validate-attachments';
import type * as PlannedTaskPermissionsMod from './planned-tasks/planned-task-permissions';
import type * as PlannedTaskServiceMod from './planned-tasks/planned-task-service';
import type * as BackgroundTaskManagerMod from './runtime/background-task-manager';
import type * as LivenessPolicyMod from './runtime/liveness-policy';
import type * as ResumableStreamExecutorMod from './runtime/resumable-stream-executor';
import type * as RunStateRegistryMod from './runtime/run-state-registry';
import type * as StreamRunnerMod from './runtime/stream-runner';
import type * as TerminalResponseGuardMod from './runtime/terminal-response-guard';
import type * as MaterializeRuntimeSkillsMod from './skills/materialize-runtime-skills';
import type * as RuntimeSkillsMod from './skills/runtime-skills';
import type * as StorageMod from './storage';
import type * as MapChunkMod from './stream/map-chunk';
import type * as ToolsMod from './tools';
import type * as AgentPersistenceMod from './tools/orchestration/agent-persistence';
import type * as DelegateToolMod from './tools/orchestration/delegate.tool';
import type * as SanitizeWebContentMod from './tools/web-research/sanitize-web-content';
import type * as LangsmithTracingMod from './tracing/langsmith-tracing';
import type * as TraceReplayMod from './tracing/trace-replay';
import type * as AgentTreeMod from './utils/agent-tree';
import type * as EvalAgentsMod from './utils/eval-agents';
import type * as StreamHelpersMod from './utils/stream-helpers';
import type * as WorkflowLoopMod from './workflow-loop';
import type * as WorkflowLoopRuntimeMod from './workflow-loop/runtime';
import type * as BuilderTemplatesServiceMod from './workspace/builder-templates-service';
import type * as CreateWorkspaceMod from './workspace/create-workspace';
import type * as LazyRuntimeWorkspaceMod from './workspace/lazy-runtime-workspace';
import type * as SandboxSetupMod from './workspace/sandbox-setup';
import type * as SnapshotManagerMod from './workspace/snapshot-manager';
type LazyFunction = (...args: never[]) => unknown;
type LazyConstructor = abstract new (...args: never[]) => unknown;
@ -57,19 +81,35 @@ const defineLazyExport = <TValue>(name: string, load: () => TValue): void => {
const loadLangsmithTracing = lazyModule(
() => require('./tracing/langsmith-tracing') as typeof LangsmithTracingMod,
);
const loadTraceReplay = lazyModule(
() => require('./tracing/trace-replay') as typeof TraceReplayMod,
);
const loadInstanceAgent = lazyModule(
() => require('./agent/instance-agent') as typeof InstanceAgentMod,
);
const loadDomainAccess = lazyModule(() => require('./domain-access') as typeof DomainAccessMod);
const loadSubAgentFactory = lazyModule(
() => require('./agent/sub-agent-factory') as typeof SubAgentFactoryMod,
);
const loadSanitizeWebContent = lazyModule(
() => require('./tools/web-research/sanitize-web-content') as typeof SanitizeWebContentMod,
);
const loadDelegateTool = lazyModule(
() => require('./tools/orchestration/delegate.tool') as typeof DelegateToolMod,
);
const loadTools = lazyModule(() => require('./tools') as typeof ToolsMod);
const loadAgentPersistence = lazyModule(
() => require('./tools/orchestration/agent-persistence') as typeof AgentPersistenceMod,
);
const loadTitleUtils = lazyModule(() => require('./memory/title-utils') as typeof TitleUtilsMod);
const loadMcpClientManager = lazyModule(
() => require('./mcp/mcp-client-manager') as typeof McpClientManagerMod,
);
const loadStreamHelpers = lazyModule(
() => require('./utils/stream-helpers') as typeof StreamHelpersMod,
);
const loadStorage = lazyModule(() => require('./storage') as typeof StorageMod);
const loadMapChunk = lazyModule(() => require('./stream/map-chunk') as typeof MapChunkMod);
const loadRuntimeSkills = lazyModule(
() => require('./skills/runtime-skills') as typeof RuntimeSkillsMod,
);
@ -77,12 +117,56 @@ const loadMaterializeRuntimeSkills = lazyModule(
() => require('./skills/materialize-runtime-skills') as typeof MaterializeRuntimeSkillsMod,
);
const loadEvalAgents = lazyModule(() => require('./utils/eval-agents') as typeof EvalAgentsMod);
const loadAgentTree = lazyModule(() => require('./utils/agent-tree') as typeof AgentTreeMod);
const loadBuilderTemplatesService = lazyModule(
() => require('./workspace/builder-templates-service') as typeof BuilderTemplatesServiceMod,
);
const loadCreateWorkspace = lazyModule(
() => require('./workspace/create-workspace') as typeof CreateWorkspaceMod,
);
const loadLazyRuntimeWorkspace = lazyModule(
() => require('./workspace/lazy-runtime-workspace') as typeof LazyRuntimeWorkspaceMod,
);
const loadSandboxSetup = lazyModule(
() => require('./workspace/sandbox-setup') as typeof SandboxSetupMod,
);
const loadSnapshotManager = lazyModule(
() => require('./workspace/snapshot-manager') as typeof SnapshotManagerMod,
);
const loadRunStateRegistry = lazyModule(
() => require('./runtime/run-state-registry') as typeof RunStateRegistryMod,
);
const loadBackgroundTaskManager = lazyModule(
() => require('./runtime/background-task-manager') as typeof BackgroundTaskManagerMod,
);
const loadTerminalResponseGuard = lazyModule(
() => require('./runtime/terminal-response-guard') as typeof TerminalResponseGuardMod,
);
const loadResumableStreamExecutor = lazyModule(
() => require('./runtime/resumable-stream-executor') as typeof ResumableStreamExecutorMod,
);
const loadStreamRunner = lazyModule(
() => require('./runtime/stream-runner') as typeof StreamRunnerMod,
);
const loadLivenessPolicy = lazyModule(
() => require('./runtime/liveness-policy') as typeof LivenessPolicyMod,
);
const loadWorkflowLoop = lazyModule(() => require('./workflow-loop') as typeof WorkflowLoopMod);
const loadWorkflowLoopRuntime = lazyModule(
() => require('./workflow-loop/runtime') as typeof WorkflowLoopRuntimeMod,
);
const loadPlannedTaskService = lazyModule(
() => require('./planned-tasks/planned-task-service') as typeof PlannedTaskServiceMod,
);
const loadPlannedTaskPermissions = lazyModule(
() => require('./planned-tasks/planned-task-permissions') as typeof PlannedTaskPermissionsMod,
);
const loadStructuredFileParser = lazyModule(
() => require('./parsers/structured-file-parser') as typeof StructuredFileParserMod,
);
const loadValidateAttachments = lazyModule(
() => require('./parsers/validate-attachments') as typeof ValidateAttachmentsMod,
);
export { MAX_STEPS } from './constants/max-steps';
export type {
@ -95,9 +179,12 @@ export type {
SerializableAgentState,
Thread,
} from '@n8n/agents';
export { wrapUntrustedData } from './tools/web-research/sanitize-web-content';
export const wrapUntrustedData: typeof SanitizeWebContentMod.wrapUntrustedData = lazyFunction(
() => loadSanitizeWebContent().wrapUntrustedData,
);
export type { Logger } from './logger';
export { createDomainAccessTracker } from './domain-access';
export const createDomainAccessTracker: typeof DomainAccessMod.createDomainAccessTracker =
lazyFunction(() => loadDomainAccess().createDomainAccessTracker);
export type { DomainAccessTracker } from './domain-access';
export type { SubmitLangsmithUserFeedbackOptions } from './tracing/langsmith-tracing';
@ -130,13 +217,22 @@ export const submitLangsmithUserFeedback: typeof LangsmithTracingMod.submitLangs
export const withCurrentTraceSpan: typeof LangsmithTracingMod.withCurrentTraceSpan = lazyFunction(
() => loadLangsmithTracing().withCurrentTraceSpan,
);
export {
IdRemapper,
TraceIndex,
TraceWriter,
parseTraceJsonl,
PURE_REPLAY_TOOLS,
} from './tracing/trace-replay';
export type IdRemapper = TraceReplayMod.IdRemapper;
export const IdRemapper: typeof TraceReplayMod.IdRemapper = lazyClass(
() => loadTraceReplay().IdRemapper,
);
export type TraceIndex = TraceReplayMod.TraceIndex;
export const TraceIndex: typeof TraceReplayMod.TraceIndex = lazyClass(
() => loadTraceReplay().TraceIndex,
);
export type TraceWriter = TraceReplayMod.TraceWriter;
export const TraceWriter: typeof TraceReplayMod.TraceWriter = lazyClass(
() => loadTraceReplay().TraceWriter,
);
export const parseTraceJsonl: typeof TraceReplayMod.parseTraceJsonl = lazyFunction(
() => loadTraceReplay().parseTraceJsonl,
);
export declare const PURE_REPLAY_TOOLS: typeof TraceReplayMod.PURE_REPLAY_TOOLS;
export type {
TraceEvent,
TraceHeader,
@ -180,26 +276,48 @@ export const createInstanceAgent: typeof InstanceAgentMod.createInstanceAgent =
export const createSubAgent: typeof SubAgentFactoryMod.createSubAgent = lazyFunction(
() => loadSubAgentFactory().createSubAgent,
);
export { createAllTools, createOrchestrationTools } from './tools';
export {
createSubAgentResourceId,
createSubAgentResourceIdPrefix,
SUB_AGENT_RESOURCE_PREFIX,
} from './tools/orchestration/agent-persistence';
export const createAllTools: typeof ToolsMod.createAllTools = lazyFunction(
() => loadTools().createAllTools,
);
export const createOrchestrationTools: typeof ToolsMod.createOrchestrationTools = lazyFunction(
() => loadTools().createOrchestrationTools,
);
export const createSubAgentResourceId: typeof AgentPersistenceMod.createSubAgentResourceId =
lazyFunction(() => loadAgentPersistence().createSubAgentResourceId);
export const createSubAgentResourceIdPrefix: typeof AgentPersistenceMod.createSubAgentResourceIdPrefix =
lazyFunction(() => loadAgentPersistence().createSubAgentResourceIdPrefix);
export declare const SUB_AGENT_RESOURCE_PREFIX: typeof AgentPersistenceMod.SUB_AGENT_RESOURCE_PREFIX;
export const startDetachedDelegateTask: typeof DelegateToolMod.startDetachedDelegateTask =
lazyFunction(() => loadDelegateTool().startDetachedDelegateTask);
export {
iterationEntrySchema,
formatPreviousAttempts,
ThreadIterationLogStorage,
ThreadTaskStorage,
PlannedTaskStorage,
getThread,
TerminalOutcomeStorage,
patchThread,
WorkflowLoopStorage,
} from './storage';
export declare const iterationEntrySchema: typeof StorageMod.iterationEntrySchema;
export const formatPreviousAttempts: typeof StorageMod.formatPreviousAttempts = lazyFunction(
() => loadStorage().formatPreviousAttempts,
);
export type ThreadIterationLogStorage = StorageMod.ThreadIterationLogStorage;
export const ThreadIterationLogStorage: typeof StorageMod.ThreadIterationLogStorage = lazyClass(
() => loadStorage().ThreadIterationLogStorage,
);
export type ThreadTaskStorage = StorageMod.ThreadTaskStorage;
export const ThreadTaskStorage: typeof StorageMod.ThreadTaskStorage = lazyClass(
() => loadStorage().ThreadTaskStorage,
);
export type PlannedTaskStorage = StorageMod.PlannedTaskStorage;
export const PlannedTaskStorage: typeof StorageMod.PlannedTaskStorage = lazyClass(
() => loadStorage().PlannedTaskStorage,
);
export const getThread: typeof StorageMod.getThread = lazyFunction(() => loadStorage().getThread);
export type TerminalOutcomeStorage = StorageMod.TerminalOutcomeStorage;
export const TerminalOutcomeStorage: typeof StorageMod.TerminalOutcomeStorage = lazyClass(
() => loadStorage().TerminalOutcomeStorage,
);
export const patchThread: typeof StorageMod.patchThread = lazyFunction(
() => loadStorage().patchThread,
);
export type WorkflowLoopStorage = StorageMod.WorkflowLoopStorage;
export const WorkflowLoopStorage: typeof StorageMod.WorkflowLoopStorage = lazyClass(
() => loadStorage().WorkflowLoopStorage,
);
export type {
AgentTreeSnapshot,
IterationEntry,
@ -219,8 +337,18 @@ export type McpClientManager = McpClientManagerMod.McpClientManager;
export const McpClientManager: typeof McpClientManagerMod.McpClientManager = lazyClass(
() => loadMcpClientManager().McpClientManager,
);
export { mapAgentChunkToEvent } from './stream/map-chunk';
export { isRecord, parseSuspension, asResumable } from './utils/stream-helpers';
export const mapAgentChunkToEvent: typeof MapChunkMod.mapAgentChunkToEvent = lazyFunction(
() => loadMapChunk().mapAgentChunkToEvent,
);
export const isRecord: typeof StreamHelpersMod.isRecord = lazyFunction(
() => loadStreamHelpers().isRecord,
);
export const parseSuspension: typeof StreamHelpersMod.parseSuspension = lazyFunction(
() => loadStreamHelpers().parseSuspension,
);
export const asResumable: typeof StreamHelpersMod.asResumable = lazyFunction(
() => loadStreamHelpers().asResumable,
);
export const createEvalAgent: typeof EvalAgentsMod.createEvalAgent = lazyFunction(
() => loadEvalAgents().createEvalAgent,
);
@ -233,6 +361,12 @@ export declare const SONNET_MODEL: typeof EvalAgentsMod.SONNET_MODEL;
export declare const HAIKU_MODEL: typeof EvalAgentsMod.HAIKU_MODEL;
defineLazyExport('SONNET_MODEL', () => loadEvalAgents().SONNET_MODEL);
defineLazyExport('HAIKU_MODEL', () => loadEvalAgents().HAIKU_MODEL);
defineLazyExport('PURE_REPLAY_TOOLS', () => loadTraceReplay().PURE_REPLAY_TOOLS);
defineLazyExport(
'SUB_AGENT_RESOURCE_PREFIX',
() => loadAgentPersistence().SUB_AGENT_RESOURCE_PREFIX,
);
defineLazyExport('iterationEntrySchema', () => loadStorage().iterationEntrySchema);
defineLazyExport('INSTANCE_AI_SKILLS_DIR', () => loadRuntimeSkills().INSTANCE_AI_SKILLS_DIR);
defineLazyExport(
'SANDBOX_RUNTIME_SKILLS_DIR',
@ -256,12 +390,36 @@ defineLazyExport(
'N8N_WORKSPACE_DIR_ENV',
() => loadMaterializeRuntimeSkills().N8N_WORKSPACE_DIR_ENV,
);
defineLazyExport(
'INSTANCE_AI_DEFAULT_LIVENESS_POLICY_CONFIG',
() => loadLivenessPolicy().INSTANCE_AI_DEFAULT_LIVENESS_POLICY_CONFIG,
);
defineLazyExport('workflowBuildOutcomeSchema', () => loadWorkflowLoop().workflowBuildOutcomeSchema);
defineLazyExport('attemptRecordSchema', () => loadWorkflowLoop().attemptRecordSchema);
defineLazyExport('workflowLoopStateSchema', () => loadWorkflowLoop().workflowLoopStateSchema);
defineLazyExport('verificationResultSchema', () => loadWorkflowLoop().verificationResultSchema);
defineLazyExport(
'PLANNED_TASK_PERMISSION_OVERRIDES',
() => loadPlannedTaskPermissions().PLANNED_TASK_PERMISSION_OVERRIDES,
);
export type { SuspensionInfo, Resumable } from './utils/stream-helpers';
export { buildAgentTreeFromEvents, findAgentNodeInTree } from './utils/agent-tree';
export const buildAgentTreeFromEvents: typeof AgentTreeMod.buildAgentTreeFromEvents = lazyFunction(
() => loadAgentTree().buildAgentTreeFromEvents,
);
export const findAgentNodeInTree: typeof AgentTreeMod.findAgentNodeInTree = lazyFunction(
() => loadAgentTree().findAgentNodeInTree,
);
export type { SandboxConfig } from './workspace/create-workspace';
export { createLazyRuntimeWorkspace } from './workspace/lazy-runtime-workspace';
export const createLazyRuntimeWorkspace: typeof LazyRuntimeWorkspaceMod.createLazyRuntimeWorkspace =
lazyFunction(() => loadLazyRuntimeWorkspace().createLazyRuntimeWorkspace);
export type { RuntimeWorkspaceResolver } from './workspace/lazy-runtime-workspace';
export { getWorkspaceRoot, setupSandboxWorkspace } from './workspace/sandbox-setup';
export const getWorkspaceRoot: typeof SandboxSetupMod.getWorkspaceRoot = lazyFunction(
() => loadSandboxSetup().getWorkspaceRoot,
);
export const setupSandboxWorkspace: typeof SandboxSetupMod.setupSandboxWorkspace = lazyFunction(
() => loadSandboxSetup().setupSandboxWorkspace,
);
export type BuilderTemplatesService = BuilderTemplatesServiceMod.BuilderTemplatesService;
export const BuilderTemplatesService: typeof BuilderTemplatesServiceMod.BuilderTemplatesService =
lazyClass(() => loadBuilderTemplatesService().BuilderTemplatesService);
export const builderTemplatesOptionsFromEnv: typeof BuilderTemplatesServiceMod.builderTemplatesOptionsFromEnv =
@ -276,19 +434,27 @@ export const createSandbox: typeof CreateWorkspaceMod.createSandbox = lazyFuncti
export const createWorkspace: typeof CreateWorkspaceMod.createWorkspace = lazyFunction(
() => loadCreateWorkspace().createWorkspace,
);
export { SnapshotManager } from './workspace/snapshot-manager';
export type SnapshotManager = SnapshotManagerMod.SnapshotManager;
export const SnapshotManager: typeof SnapshotManagerMod.SnapshotManager = lazyClass(
() => loadSnapshotManager().SnapshotManager,
);
export type { InstanceAiEventBus, StoredEvent } from './event-bus';
export {
BackgroundTaskManager,
enrichMessageWithRunningTasks as enrichMessageWithBackgroundTasks,
enrichMessageWithRunningTasks,
} from './runtime/background-task-manager';
export type BackgroundTaskManager = BackgroundTaskManagerMod.BackgroundTaskManager;
export const BackgroundTaskManager: typeof BackgroundTaskManagerMod.BackgroundTaskManager =
lazyClass(() => loadBackgroundTaskManager().BackgroundTaskManager);
export const enrichMessageWithRunningTasks: typeof BackgroundTaskManagerMod.enrichMessageWithRunningTasks =
lazyFunction(() => loadBackgroundTaskManager().enrichMessageWithRunningTasks);
export const enrichMessageWithBackgroundTasks: typeof BackgroundTaskManagerMod.enrichMessageWithRunningTasks =
enrichMessageWithRunningTasks;
export type {
BackgroundTaskStatus,
ManagedBackgroundTask,
SpawnManagedBackgroundTaskOptions,
} from './runtime/background-task-manager';
export { RunStateRegistry } from './runtime/run-state-registry';
export type RunStateRegistry = RunStateRegistryMod.RunStateRegistry;
export const RunStateRegistry: typeof RunStateRegistryMod.RunStateRegistry = lazyClass(
() => loadRunStateRegistry().RunStateRegistry,
);
export type {
ActiveRunState,
BackgroundTaskStatusSnapshot,
@ -298,13 +464,17 @@ export type {
StartedRunState,
SuspendedRunState,
} from './runtime/run-state-registry';
export { InstanceAiTerminalResponseGuard } from './runtime/terminal-response-guard';
export type InstanceAiTerminalResponseGuard =
TerminalResponseGuardMod.InstanceAiTerminalResponseGuard;
export const InstanceAiTerminalResponseGuard: typeof TerminalResponseGuardMod.InstanceAiTerminalResponseGuard =
lazyClass(() => loadTerminalResponseGuard().InstanceAiTerminalResponseGuard);
export type {
TerminalResponseDecision,
TerminalResponseStatus,
TerminalVisibilitySource,
} from './runtime/terminal-response-guard';
export { executeResumableStream } from './runtime/resumable-stream-executor';
export const executeResumableStream: typeof ResumableStreamExecutorMod.executeResumableStream =
lazyFunction(() => loadResumableStreamExecutor().executeResumableStream);
export type {
AutoResumeControl,
ExecuteResumableStreamOptions,
@ -315,34 +485,51 @@ export type {
ResumableStreamSource,
} from './runtime/resumable-stream-executor';
export type { WorkSummary } from './stream/work-summary-accumulator';
export { resumeAgentRun, streamAgentRun } from './runtime/stream-runner';
export {
createInstanceAiLivenessPolicyConfig,
INSTANCE_AI_DEFAULT_LIVENESS_POLICY_CONFIG,
InstanceAiLivenessPolicy,
type InstanceAiLivenessDecision,
type InstanceAiLivenessInput,
type InstanceAiLivenessPolicyConfig,
type InstanceAiLivenessSurface,
type InstanceAiLivenessTimeoutReason,
export const resumeAgentRun: typeof StreamRunnerMod.resumeAgentRun = lazyFunction(
() => loadStreamRunner().resumeAgentRun,
);
export const streamAgentRun: typeof StreamRunnerMod.streamAgentRun = lazyFunction(
() => loadStreamRunner().streamAgentRun,
);
export const createInstanceAiLivenessPolicyConfig: typeof LivenessPolicyMod.createInstanceAiLivenessPolicyConfig =
lazyFunction(() => loadLivenessPolicy().createInstanceAiLivenessPolicyConfig);
export declare const INSTANCE_AI_DEFAULT_LIVENESS_POLICY_CONFIG: typeof LivenessPolicyMod.INSTANCE_AI_DEFAULT_LIVENESS_POLICY_CONFIG;
export type InstanceAiLivenessPolicy = LivenessPolicyMod.InstanceAiLivenessPolicy;
export const InstanceAiLivenessPolicy: typeof LivenessPolicyMod.InstanceAiLivenessPolicy =
lazyClass(() => loadLivenessPolicy().InstanceAiLivenessPolicy);
export type {
InstanceAiLivenessDecision,
InstanceAiLivenessInput,
InstanceAiLivenessPolicyConfig,
InstanceAiLivenessSurface,
InstanceAiLivenessTimeoutReason,
} from './runtime/liveness-policy';
export type {
StreamableAgent,
StreamRunOptions,
StreamRunResult,
} from './runtime/stream-runner';
export {
createWorkItem,
formatWorkflowLoopGuidance,
handleBuildOutcome,
handleVerificationVerdict,
formatAttemptHistory,
WorkflowTaskCoordinator,
workflowBuildOutcomeSchema,
attemptRecordSchema,
workflowLoopStateSchema,
verificationResultSchema,
} from './workflow-loop';
export const createWorkItem: typeof WorkflowLoopMod.createWorkItem = lazyFunction(
() => loadWorkflowLoop().createWorkItem,
);
export const formatWorkflowLoopGuidance: typeof WorkflowLoopMod.formatWorkflowLoopGuidance =
lazyFunction(() => loadWorkflowLoop().formatWorkflowLoopGuidance);
export const handleBuildOutcome: typeof WorkflowLoopMod.handleBuildOutcome = lazyFunction(
() => loadWorkflowLoop().handleBuildOutcome,
);
export const handleVerificationVerdict: typeof WorkflowLoopMod.handleVerificationVerdict =
lazyFunction(() => loadWorkflowLoop().handleVerificationVerdict);
export const formatAttemptHistory: typeof WorkflowLoopMod.formatAttemptHistory = lazyFunction(
() => loadWorkflowLoop().formatAttemptHistory,
);
export type WorkflowTaskCoordinator = WorkflowLoopMod.WorkflowTaskCoordinator;
export const WorkflowTaskCoordinator: typeof WorkflowLoopMod.WorkflowTaskCoordinator = lazyClass(
() => loadWorkflowLoop().WorkflowTaskCoordinator,
);
export declare const workflowBuildOutcomeSchema: typeof WorkflowLoopMod.workflowBuildOutcomeSchema;
export declare const attemptRecordSchema: typeof WorkflowLoopMod.attemptRecordSchema;
export declare const workflowLoopStateSchema: typeof WorkflowLoopMod.workflowLoopStateSchema;
export declare const verificationResultSchema: typeof WorkflowLoopMod.verificationResultSchema;
export type {
WorkflowLoopState,
WorkflowLoopAction,
@ -350,12 +537,16 @@ export type {
VerificationResult,
AttemptRecord,
} from './workflow-loop';
export { WorkflowLoopRuntime } from './workflow-loop/runtime';
export { PlannedTaskCoordinator } from './planned-tasks/planned-task-service';
export {
applyPlannedTaskPermissions,
PLANNED_TASK_PERMISSION_OVERRIDES,
} from './planned-tasks/planned-task-permissions';
export type WorkflowLoopRuntime = WorkflowLoopRuntimeMod.WorkflowLoopRuntime;
export const WorkflowLoopRuntime: typeof WorkflowLoopRuntimeMod.WorkflowLoopRuntime = lazyClass(
() => loadWorkflowLoopRuntime().WorkflowLoopRuntime,
);
export type PlannedTaskCoordinator = PlannedTaskServiceMod.PlannedTaskCoordinator;
export const PlannedTaskCoordinator: typeof PlannedTaskServiceMod.PlannedTaskCoordinator =
lazyClass(() => loadPlannedTaskService().PlannedTaskCoordinator);
export const applyPlannedTaskPermissions: typeof PlannedTaskPermissionsMod.applyPlannedTaskPermissions =
lazyFunction(() => loadPlannedTaskPermissions().applyPlannedTaskPermissions);
export declare const PLANNED_TASK_PERMISSION_OVERRIDES: typeof PlannedTaskPermissionsMod.PLANNED_TASK_PERMISSION_OVERRIDES;
export type {
InstanceAiContext,
InstanceAiWorkflowService,
@ -421,12 +612,15 @@ export type {
ServiceProxyConfig,
} from './types';
export type { DetachedDelegateTaskResult } from './tools/orchestration/delegate.tool';
export {
classifyAttachments,
buildAttachmentManifest,
isStructuredAttachment,
isParseableAttachment,
} from './parsers/structured-file-parser';
export const classifyAttachments: typeof StructuredFileParserMod.classifyAttachments = lazyFunction(
() => loadStructuredFileParser().classifyAttachments,
);
export const buildAttachmentManifest: typeof StructuredFileParserMod.buildAttachmentManifest =
lazyFunction(() => loadStructuredFileParser().buildAttachmentManifest);
export const isStructuredAttachment: typeof StructuredFileParserMod.isStructuredAttachment =
lazyFunction(() => loadStructuredFileParser().isStructuredAttachment);
export const isParseableAttachment: typeof StructuredFileParserMod.isParseableAttachment =
lazyFunction(() => loadStructuredFileParser().isParseableAttachment);
export type {
ClassifiedAttachment,
ParseableFormat,
@ -434,11 +628,15 @@ export type {
TextLikeFormat,
SupportedFormat,
} from './parsers/structured-file-parser';
export {
getParseableAttachmentMimeTypes,
getSupportedAttachmentMimeTypes,
isSupportedAttachmentMimeType,
validateAttachmentMimeTypes,
UnsupportedAttachmentError,
} from './parsers/validate-attachments';
export const getParseableAttachmentMimeTypes: typeof ValidateAttachmentsMod.getParseableAttachmentMimeTypes =
lazyFunction(() => loadValidateAttachments().getParseableAttachmentMimeTypes);
export const getSupportedAttachmentMimeTypes: typeof ValidateAttachmentsMod.getSupportedAttachmentMimeTypes =
lazyFunction(() => loadValidateAttachments().getSupportedAttachmentMimeTypes);
export const isSupportedAttachmentMimeType: typeof ValidateAttachmentsMod.isSupportedAttachmentMimeType =
lazyFunction(() => loadValidateAttachments().isSupportedAttachmentMimeType);
export const validateAttachmentMimeTypes: typeof ValidateAttachmentsMod.validateAttachmentMimeTypes =
lazyFunction(() => loadValidateAttachments().validateAttachmentMimeTypes);
export type UnsupportedAttachmentError = ValidateAttachmentsMod.UnsupportedAttachmentError;
export const UnsupportedAttachmentError: typeof ValidateAttachmentsMod.UnsupportedAttachmentError =
lazyClass(() => loadValidateAttachments().UnsupportedAttachmentError);
export type { UnsupportedAttachmentDetail } from './parsers/validate-attachments';

View File

@ -135,6 +135,94 @@ describe('nodes tool', () => {
});
});
describe('search action', () => {
it('should search nodes by query and reuse the searchable node list reference', async () => {
const searchableNodes = [
{
name: 'n8n-nodes-base.httpRequest',
displayName: 'HTTP Request',
description: 'Make HTTP requests',
inputs: ['main'],
outputs: ['main'],
version: 1,
codex: { alias: ['api'] },
},
];
const context = createMockContext();
(context.nodeService.listSearchable as Mock).mockResolvedValue(searchableNodes);
const tool = createNodesTool(context, 'full');
const first = await executeTool(
tool,
{ action: 'search', query: 'http', limit: 5 } as never,
{} as never,
);
const second = await executeTool(
tool,
{ action: 'search', query: 'http', limit: 5 } as never,
{} as never,
);
expect(context.nodeService.listSearchable).toHaveBeenCalledTimes(2);
expect(first).toMatchObject({
totalResults: 1,
results: [expect.objectContaining({ name: 'n8n-nodes-base.httpRequest' })],
});
expect(second).toMatchObject({
totalResults: 1,
results: [expect.objectContaining({ name: 'n8n-nodes-base.httpRequest' })],
});
});
it('should search nodes by connection type and enrich results with discriminators', async () => {
const searchableNodes = [
{
name: 'n8n-nodes-base.slackTool',
displayName: 'Slack Tool',
description: 'Send messages to Slack from an AI agent',
inputs: ['main'],
outputs: ['ai_tool'],
version: 1,
},
];
const context = createMockContext();
(context.nodeService.listSearchable as Mock).mockResolvedValue(searchableNodes);
context.nodeService.listDiscriminators = vi.fn().mockResolvedValue({
resource: ['message'],
});
const tool = createNodesTool(context, 'full');
const result = await executeTool(
tool,
{ action: 'search', connectionType: 'ai_tool', limit: 5 } as never,
{} as never,
);
expect(context.nodeService.listDiscriminators).toHaveBeenCalledWith(
'n8n-nodes-base.slackTool',
);
expect(result).toMatchObject({
totalResults: 1,
results: [
expect.objectContaining({
name: 'n8n-nodes-base.slackTool',
discriminators: { resource: ['message'] },
}),
],
});
});
it('should return no search results when neither query nor connection type is provided', async () => {
const context = createMockContext();
(context.nodeService.listSearchable as Mock).mockResolvedValue([]);
const tool = createNodesTool(context, 'full');
const result = await executeTool(tool, { action: 'search' } as never, {} as never);
expect(result).toEqual({ results: [], totalResults: 0 });
});
});
describe('explore-resources action', () => {
it('should return error when exploreResources is not available', async () => {
const context = createMockContext();

View File

@ -7,7 +7,7 @@ import { z } from 'zod';
import { sanitizeInputSchema } from '../agent/sanitize-mcp-schemas';
import type { InstanceAiContext } from '../types';
import { NodeSearchEngine } from './nodes/node-search-engine';
import { AI_CONNECTION_TYPES } from './nodes/node-search-engine.types';
import { AI_CONNECTION_TYPES, type SearchableNodeType } from './nodes/node-search-engine.types';
import { categoryList, suggestedNodesData } from './nodes/suggested-nodes-data';
// ── Action schemas ──────────────────────────────────────────────────────────
@ -123,6 +123,12 @@ const fullInputSchema = sanitizeInputSchema(
type FullInput = z.infer<typeof fullInputSchema>;
interface SearchEngineCache {
nodeTypes?: SearchableNodeType[];
nodeCount?: number;
engine?: NodeSearchEngine;
}
// ── Handlers ────────────────────────────────────────────────────────────────
async function handleList(
@ -138,9 +144,16 @@ async function handleList(
async function handleSearch(
context: InstanceAiContext,
input: Extract<FullInput, { action: 'search' }>,
cache: SearchEngineCache,
) {
const nodeTypes = await context.nodeService.listSearchable();
const engine = new NodeSearchEngine(nodeTypes);
let engine = cache.engine;
if (!engine || cache.nodeTypes !== nodeTypes || cache.nodeCount !== nodeTypes.length) {
cache.nodeTypes = nodeTypes;
cache.nodeCount = nodeTypes.length;
engine = new NodeSearchEngine(nodeTypes);
cache.engine = engine;
}
let results;
if (input.connectionType) {
@ -312,6 +325,8 @@ export function createNodesTool(
context: InstanceAiContext,
surface: 'full' | 'orchestrator' = 'full',
) {
const searchEngineCache: SearchEngineCache = {};
if (surface === 'orchestrator') {
const orchestratorExploreAction = z.object({
action: z
@ -385,7 +400,7 @@ export function createNodesTool(
case 'list':
return await handleList(context, input);
case 'search':
return await handleSearch(context, input);
return await handleSearch(context, input, searchEngineCache);
case 'describe':
return await handleDescribe(context, input);
case 'type-definition':

View File

@ -165,6 +165,23 @@ describe('NodeSearchEngine', () => {
expect(results[0].name).toBe('n8n-nodes-base.httpRequest');
});
it('should find nodes by description fallback', () => {
const results = engine.searchByName('requests');
expect(results.map((r) => r.name)).toContain('n8n-nodes-base.httpRequest');
});
it('should return fresh cached result objects', () => {
const first = engine.searchByName('AI Agent');
const agentResult = first.find((r) => r.name === '@n8n/n8n-nodes-langchain.agent');
expect(agentResult).toBeDefined();
agentResult!.displayName = 'Mutated Agent';
const second = engine.searchByName('AI Agent');
expect(second.find((r) => r.name === '@n8n/n8n-nodes-langchain.agent')?.displayName).toBe(
'AI Agent',
);
});
it('should respect the limit parameter', () => {
const results = engine.searchByName('n', 2);
expect(results.length).toBeLessThanOrEqual(2);

View File

@ -40,7 +40,6 @@ const NODE_SEARCH_KEYS = [
{ key: 'displayName', weight: 1.5 },
{ key: 'name', weight: 1.3 },
{ key: 'codex.alias', weight: 1.0 },
{ key: 'description', weight: 0.7 },
];
/**
@ -96,6 +95,38 @@ function dedupeNodes(nodes: SearchableNodeType[]): SearchableNodeType[] {
return Object.values(dedupeCache);
}
function cloneSearchResult(result: NodeSearchResult): NodeSearchResult {
return {
...result,
...(result.subnodeRequirements
? {
subnodeRequirements: result.subnodeRequirements.map((requirement) => ({
...requirement,
})),
}
: {}),
};
}
function cloneSearchResults(results: NodeSearchResult[]): NodeSearchResult[] {
return results.map(cloneSearchResult);
}
function descriptionMatchScore(
description: string | undefined,
queryLower: string,
queryTerms: string[],
): number {
if (!description) return 0;
const descriptionLower = description.toLowerCase();
if (queryTerms.length === 0) return descriptionLower.includes(queryLower) ? 1 : 0;
let matches = 0;
for (const term of queryTerms) {
if (descriptionLower.includes(term)) matches += 1;
}
return matches;
}
// ---------------------------------------------------------------------------
// Engine
// ---------------------------------------------------------------------------
@ -107,6 +138,10 @@ function dedupeNodes(nodes: SearchableNodeType[]): SearchableNodeType[] {
export class NodeSearchEngine {
private readonly nodeTypes: SearchableNodeType[];
private readonly nameSearchCache = new Map<string, NodeSearchResult[]>();
private readonly connectionSearchCache = new Map<string, NodeSearchResult[]>();
constructor(nodeTypes: SearchableNodeType[]) {
this.nodeTypes = dedupeNodes(nodeTypes);
}
@ -119,6 +154,7 @@ export class NodeSearchEngine {
private fuzzySearchNodes(
query: string,
candidates: SearchableNodeType[],
limit?: number,
): Array<{ item: SearchableNodeType; score: number }> {
const queryLower = query.toLowerCase().trim();
const queryTerms = queryLower.split(/\s+/).filter((t) => t.length > 1);
@ -133,7 +169,12 @@ export class NodeSearchEngine {
if (isMultiWord) {
const scoreMap = new Map<string, ScoredNode>();
for (const term of queryTerms) {
const termResults = sublimeSearch<SearchableNodeType>(term, candidates, NODE_SEARCH_KEYS);
const termResults = sublimeSearch<SearchableNodeType>(
term,
candidates,
NODE_SEARCH_KEYS,
limit,
);
for (const r of termResults) {
const existing = scoreMap.get(r.item.name);
if (!existing || r.score > existing.score) {
@ -143,10 +184,24 @@ export class NodeSearchEngine {
}
searchResults = [...scoreMap.values()];
} else {
searchResults = sublimeSearch<SearchableNodeType>(query, candidates, NODE_SEARCH_KEYS);
searchResults = sublimeSearch<SearchableNodeType>(query, candidates, NODE_SEARCH_KEYS, limit);
}
const fuzzyResultNames = new Set(searchResults.map((r) => r.item.name));
const remainingDescriptionMatches =
limit === undefined ? Number.POSITIVE_INFINITY : Math.max(0, limit - searchResults.length);
if (remainingDescriptionMatches > 0) {
const descriptionResults: ScoredNode[] = [];
for (const item of candidates) {
if (fuzzyResultNames.has(item.name)) continue;
const score = descriptionMatchScore(item.description, queryLower, queryTerms);
if (score === 0) continue;
descriptionResults.push({ item, score });
fuzzyResultNames.add(item.name);
if (descriptionResults.length >= remainingDescriptionMatches) break;
}
searchResults = [...searchResults, ...descriptionResults];
}
// Direct type name / display name match — catches nodes fuzzy search missed
const typeNameMatches = candidates
@ -196,7 +251,11 @@ export class NodeSearchEngine {
* @returns Array of matching nodes sorted by relevance
*/
searchByName(query: string, limit: number = 20): NodeSearchResult[] {
return this.fuzzySearchNodes(query, this.nodeTypes)
const cacheKey = `${query}\0${limit}`;
const cached = this.nameSearchCache.get(cacheKey);
if (cached) return cloneSearchResults(cached);
const results = this.fuzzySearchNodes(query, this.nodeTypes, limit)
.slice(0, limit)
.map(({ item, score }): NodeSearchResult => {
const subnodeRequirements = extractSubnodeRequirements(item.builderHint?.inputs);
@ -212,6 +271,8 @@ export class NodeSearchEngine {
...(subnodeRequirements.length > 0 && { subnodeRequirements }),
};
});
this.nameSearchCache.set(cacheKey, results);
return cloneSearchResults(results);
}
/**
@ -227,6 +288,10 @@ export class NodeSearchEngine {
limit: number = 20,
nameFilter?: string,
): NodeSearchResult[] {
const cacheKey = `${connectionType}\0${limit}\0${nameFilter ?? ''}`;
const cached = this.connectionSearchCache.get(cacheKey);
if (cached) return cloneSearchResults(cached);
// First, filter by connection type
const nodesWithConnectionType = this.nodeTypes
.map((nodeType) => {
@ -239,7 +304,7 @@ export class NodeSearchEngine {
// If no name filter, return connection matches sorted by score
if (!nameFilter) {
return nodesWithConnectionType
const results = nodesWithConnectionType
.sort((a, b) => b.connectionScore - a.connectionScore)
.slice(0, limit)
.map(({ nodeType, connectionScore }) => {
@ -258,14 +323,16 @@ export class NodeSearchEngine {
...(subnodeRequirements.length > 0 && { subnodeRequirements }),
};
});
this.connectionSearchCache.set(cacheKey, results);
return cloneSearchResults(results);
}
// Apply name filter using the same multi-word-aware fuzzy search as searchByName
const nodeTypesOnly = nodesWithConnectionType.map((result) => result.nodeType);
const nameFilteredResults = this.fuzzySearchNodes(nameFilter, nodeTypesOnly);
const nameFilteredResults = this.fuzzySearchNodes(nameFilter, nodeTypesOnly, limit);
// Combine connection score with name score
return nameFilteredResults.slice(0, limit).map(({ item, score: nameScore }) => {
const results = nameFilteredResults.slice(0, limit).map(({ item, score: nameScore }) => {
const connectionResult = nodesWithConnectionType.find(
(result) => result.nodeType.name === item.name,
);
@ -284,6 +351,8 @@ export class NodeSearchEngine {
...(subnodeRequirements.length > 0 && { subnodeRequirements }),
};
});
this.connectionSearchCache.set(cacheKey, results);
return cloneSearchResults(results);
}
/**

View File

@ -2,34 +2,82 @@ import path from 'node:path';
import { mergeConfig, type Plugin } from 'vite';
import { createVitestConfig } from '@n8n/vitest-config/node';
const INDEX_LAZY_REQUIRE_TEST_SPECS = new Set([
'./tracing/langsmith-tracing',
'./agent/instance-agent',
'./domain-access',
'./agent/sub-agent-factory',
'./tools/web-research/sanitize-web-content',
'./tools/orchestration/delegate.tool',
'./tools',
'./tools/orchestration/agent-persistence',
'./tracing/trace-replay',
'./memory/title-utils',
'./mcp/mcp-client-manager',
'./utils/stream-helpers',
'./storage',
'./stream/map-chunk',
'./skills/runtime-skills',
'./skills/materialize-runtime-skills',
'./utils/eval-agents',
'./utils/agent-tree',
'./workspace/builder-templates-service',
'./workspace/create-workspace',
'./workspace/lazy-runtime-workspace',
'./workspace/sandbox-setup',
'./workspace/snapshot-manager',
'./runtime/run-state-registry',
'./runtime/background-task-manager',
'./runtime/terminal-response-guard',
'./runtime/resumable-stream-executor',
'./runtime/stream-runner',
'./parsers/structured-file-parser',
'./parsers/validate-attachments',
'./runtime/liveness-policy',
'./workflow-loop',
'./workflow-loop/runtime',
'./planned-tasks/planned-task-service',
'./planned-tasks/planned-task-permissions',
]);
/**
* `src/tools/index.ts` lazy-loads each tool module with `require('./x.tool')`.
* Under the production `tsc` build that resolves to the compiled `dist/*.js`, but
* Vitest's module runner hands the (ESM) source a Node `createRequire`, which
* cannot resolve relative `.ts` files and `vi.mock` only intercepts ESM imports,
* not these `require()`s. This transform (test-time only; the source on disk is
* untouched) rewrites those `require('spec')` calls into eager static
* `import * as __lazymod_N` bindings so Vite resolves them and `vi.mock` applies.
* Lazy exports use `require('./module')` because production runs the compiled
* CommonJS output. Vitest's module runner hands source files a Node
* `createRequire`, which cannot resolve relative `.ts` files and `vi.mock`
* only intercepts ESM imports, not these `require()`s. This transform
* (test-time only; the source on disk is untouched) rewrites selected
* `require('spec')` calls into eager static `import * as __lazymod_N` bindings
* so Vite resolves them and `vi.mock` applies.
*/
function rewriteLazyRequireForTests(): Plugin {
return {
name: 'instance-ai-rewrite-lazy-require',
enforce: 'pre',
transform(code, id) {
if (!id.replace(/\\/g, '/').endsWith('/src/tools/index.ts')) return null;
const normalizedId = id.replace(/\\/g, '/');
const shouldRewriteAllRequires = normalizedId.endsWith('/src/tools/index.ts');
const shouldRewriteSelectedRequires = normalizedId.endsWith('/src/index.ts');
if (!shouldRewriteAllRequires && !shouldRewriteSelectedRequires) return null;
const requireRe = /require\((['"])([^'"]+)\1\)/g;
const specs: string[] = [];
for (const match of code.matchAll(requireRe)) {
if (!specs.includes(match[2])) specs.push(match[2]);
const spec = match[2];
if (
shouldRewriteAllRequires ||
(shouldRewriteSelectedRequires && INDEX_LAZY_REQUIRE_TEST_SPECS.has(spec))
) {
if (!specs.includes(spec)) specs.push(spec);
}
}
if (specs.length === 0) return null;
const imports = specs
.map((spec, index) => `import * as __lazymod_${index} from '${spec}';`)
.join('\n');
const rewritten = code.replace(
requireRe,
(_full, _quote, spec: string) => `__lazymod_${specs.indexOf(spec)}`,
);
const rewritten = code.replace(requireRe, (full, _quote, spec: string) => {
const index = specs.indexOf(spec);
return index === -1 ? full : `__lazymod_${index}`;
});
return { code: `${imports}\n${rewritten}`, map: null };
},
};

View File

@ -37,4 +37,22 @@ describe('sublimeSearch', () => {
},
);
});
it('should keep only the highest-scoring results when a limit is provided', () => {
const items = [{ name: 'x request archive' }, { name: 'request' }, { name: 'zz request' }];
const results = sublimeSearch('request', items, [{ key: 'name', weight: 1 }], 1);
expect(results).toHaveLength(1);
expect(results[0].item.name).toBe('request');
});
it('should score string array values and ignore non-string array entries', () => {
const items = [{ aliases: ['ordinary', 1, 'target alias'] }, { aliases: ['unrelated'] }];
const results = sublimeSearch('target', items, [{ key: 'aliases', weight: 1 }]);
expect(results).toHaveLength(1);
expect(results[0].item).toBe(items[0]);
});
});

View File

@ -219,60 +219,68 @@ function getValue<T extends object>(obj: T, prop: string): unknown {
return result;
}
function scoreSearchValue(filter: string, value: string, weight: number): number | undefined {
if (!fuzzyMatchSimple(filter, value)) return undefined;
const match = fuzzyMatch(filter, value);
if (!match.matched) return undefined;
return match.outScore * weight;
}
export function sublimeSearch<T extends object>(
filter: string,
data: readonly T[],
keys: Array<{ key: string; weight: number }> = DEFAULT_KEYS,
limit?: number,
): Array<{ score: number; item: T }> {
const results = data.reduce((accu: Array<{ score: number; item: T }>, item: T) => {
let values: Array<{ value: string; weight: number }> = [];
keys.forEach(({ key, weight }) => {
const results: Array<{ score: number; item: T }> = [];
for (const item of data) {
let itemMatchScore: number | undefined;
for (const { key, weight } of keys) {
const value = getValue(item, key);
if (Array.isArray(value)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
values = values.concat(value.map((v) => ({ value: v, weight })));
for (const entry of value) {
if (typeof entry !== 'string') continue;
const score = scoreSearchValue(filter, entry, weight);
if (score !== undefined && (itemMatchScore === undefined || score > itemMatchScore)) {
itemMatchScore = score;
}
}
} else if (typeof value === 'string') {
values.push({
value,
weight,
});
const score = scoreSearchValue(filter, value, weight);
if (score !== undefined && (itemMatchScore === undefined || score > itemMatchScore)) {
itemMatchScore = score;
}
}
});
// for each item, check every key and get maximum score
const itemMatch = values.reduce(
(
result: null | { matched: boolean; outScore: number },
{ value, weight }: { value: string; weight: number },
) => {
if (!fuzzyMatchSimple(filter, value)) {
return result;
}
const match = fuzzyMatch(filter, value);
match.outScore *= weight;
const { matched, outScore } = match;
if (!result && matched) {
return match;
}
if (matched && result && outScore > result.outScore) {
return match;
}
return result;
},
null,
);
if (itemMatch) {
accu.push({
score: itemMatch.outScore,
item,
});
}
return accu;
}, []);
if (itemMatchScore !== undefined) {
const result: { score: number; item: T } = {
score: itemMatchScore,
item,
};
if (limit === undefined || results.length < limit) {
results.push(result);
} else {
let lowestIndex = 0;
let lowestScore = results[0].score;
for (let i = 1; i < results.length; i++) {
if (results[i].score < lowestScore) {
lowestIndex = i;
lowestScore = results[i].score;
}
}
if (result.score > lowestScore) {
results[lowestIndex] = result;
}
}
}
}
results.sort((a, b) => {
return b.score - a.score;