mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
chore(core): Langsmith OTel telemetry for agent builder (#30259)
This commit is contained in:
parent
bb73952fcc
commit
e968723808
|
|
@ -28,8 +28,8 @@ export type {
|
|||
CodeBuilderSearchResult,
|
||||
CodeBuilderSearchToolOptions,
|
||||
} from './tools/code-builder-search.tool';
|
||||
export { createCodeBuilderGetTool } from './tools/code-builder-get.tool';
|
||||
export type { CodeBuilderGetToolOptions } from './tools/code-builder-get.tool';
|
||||
export { createCodeBuilderGetTool, getNodeTypes } from './tools/code-builder-get.tool';
|
||||
export type { CodeBuilderGetToolOptions, NodeRequest } from './tools/code-builder-get.tool';
|
||||
export { createGetSuggestedNodesTool } from './tools/get-suggested-nodes.tool';
|
||||
export { stripImportStatements, SDK_IMPORT_STATEMENT } from './utils/extract-code';
|
||||
|
||||
|
|
|
|||
|
|
@ -523,7 +523,7 @@ function getNodeTypeDefinition(
|
|||
}
|
||||
|
||||
/** Node request can be a simple string or an object with optional version and discriminators */
|
||||
type NodeRequest =
|
||||
export type NodeRequest =
|
||||
| string
|
||||
| {
|
||||
nodeId: string;
|
||||
|
|
@ -544,92 +544,95 @@ export interface CodeBuilderGetToolOptions {
|
|||
nodeDefinitionDirs?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain (non-LangChain) implementation of node-type lookup. Callers that
|
||||
* want their own tracing (e.g. OTel via `@n8n/agents`) can call this
|
||||
* directly and skip the LangChain `tool(...)` wrapper, which would
|
||||
* otherwise create its own LangSmith root run via the global LangChain tracer.
|
||||
*/
|
||||
export function getNodeTypes(
|
||||
nodeIds: NodeRequest[],
|
||||
options: CodeBuilderGetToolOptions = {},
|
||||
): string {
|
||||
const { nodeDefinitionDirs } = options;
|
||||
const results: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const nodeRequest of nodeIds) {
|
||||
// Support both string and object formats
|
||||
const nodeId = typeof nodeRequest === 'string' ? nodeRequest : nodeRequest.nodeId;
|
||||
const version = typeof nodeRequest === 'string' ? undefined : nodeRequest.version;
|
||||
|
||||
// Extract discriminators from object format
|
||||
const discriminators =
|
||||
typeof nodeRequest === 'string'
|
||||
? undefined
|
||||
: {
|
||||
resource: nodeRequest.resource,
|
||||
operation: nodeRequest.operation,
|
||||
mode: nodeRequest.mode,
|
||||
};
|
||||
|
||||
const result = getNodeTypeDefinition(nodeId, version, nodeDefinitionDirs, discriminators);
|
||||
if (result.error) {
|
||||
errors.push(result.error);
|
||||
} else {
|
||||
const versionLabel = result.version ? ` (${result.version})` : '';
|
||||
results.push(`## ${nodeId}${versionLabel}\n\n\`\`\`typescript\n${result.content}\n\`\`\``);
|
||||
}
|
||||
}
|
||||
|
||||
let response = '';
|
||||
|
||||
if (results.length > 0) {
|
||||
response += `# TypeScript Type Definitions\n\n${results.join('\n\n---\n\n')}`;
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
response += `\n\n# Errors\n\n${errors.join('\n')}`;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the simplified node get tool for code builder
|
||||
* Accepts a list of node IDs (with optional versions) and returns all type definitions in a single call
|
||||
*/
|
||||
export function createCodeBuilderGetTool(options: CodeBuilderGetToolOptions = {}) {
|
||||
const { nodeDefinitionDirs } = options;
|
||||
|
||||
return tool(
|
||||
async (input: { nodeIds: NodeRequest[] }) => {
|
||||
const results: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const nodeRequest of input.nodeIds) {
|
||||
// Support both string and object formats
|
||||
const nodeId = typeof nodeRequest === 'string' ? nodeRequest : nodeRequest.nodeId;
|
||||
const version = typeof nodeRequest === 'string' ? undefined : nodeRequest.version;
|
||||
|
||||
// Extract discriminators from object format
|
||||
const discriminators =
|
||||
typeof nodeRequest === 'string'
|
||||
? undefined
|
||||
: {
|
||||
resource: nodeRequest.resource,
|
||||
operation: nodeRequest.operation,
|
||||
mode: nodeRequest.mode,
|
||||
};
|
||||
|
||||
const result = getNodeTypeDefinition(nodeId, version, nodeDefinitionDirs, discriminators);
|
||||
if (result.error) {
|
||||
errors.push(result.error);
|
||||
} else {
|
||||
const versionLabel = result.version ? ` (${result.version})` : '';
|
||||
results.push(
|
||||
`## ${nodeId}${versionLabel}\n\n\`\`\`typescript\n${result.content}\n\`\`\``,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let response = '';
|
||||
|
||||
if (results.length > 0) {
|
||||
response += `# TypeScript Type Definitions\n\n${results.join('\n\n---\n\n')}`;
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
response += `\n\n# Errors\n\n${errors.join('\n')}`;
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
{
|
||||
name: 'get_node_types',
|
||||
description:
|
||||
'Get the full TypeScript type definitions for one or more nodes. Returns the complete type information including parameters, credentials, and node type variants. By default returns the latest version. For nodes with resource/operation or mode discriminators, you MUST specify them. Use search_nodes first to discover available discriminators. ALWAYS call this with ALL node types you plan to use BEFORE generating workflow code.',
|
||||
schema: z.object({
|
||||
nodeIds: z
|
||||
.array(
|
||||
z.union([
|
||||
z.string(),
|
||||
z.object({
|
||||
nodeId: z.string().describe('The node ID (e.g., "n8n-nodes-base.httpRequest")'),
|
||||
version: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional version (e.g., "34" for v34). Omit for latest version.'),
|
||||
resource: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Resource discriminator for REST API nodes (e.g., "ticket", "contact")',
|
||||
),
|
||||
operation: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Operation discriminator (e.g., "get", "create", "update")'),
|
||||
mode: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Mode discriminator for nodes like Code (e.g., "runOnceForAllItems")'),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.describe(
|
||||
'Array of nodes to fetch. Can be simple strings for flat nodes (e.g., ["n8n-nodes-base.aggregate"]) or objects with discriminators for split nodes (e.g., [{ nodeId: "n8n-nodes-base.freshservice", resource: "ticket", operation: "get" }] or [{ nodeId: "n8n-nodes-base.code", mode: "runOnceForAllItems" }]). Use search_nodes to discover which nodes require discriminators.',
|
||||
),
|
||||
}),
|
||||
},
|
||||
);
|
||||
return tool(async (input: { nodeIds: NodeRequest[] }) => getNodeTypes(input.nodeIds, options), {
|
||||
name: 'get_node_types',
|
||||
description:
|
||||
'Get the full TypeScript type definitions for one or more nodes. Returns the complete type information including parameters, credentials, and node type variants. By default returns the latest version. For nodes with resource/operation or mode discriminators, you MUST specify them. Use search_nodes first to discover available discriminators. ALWAYS call this with ALL node types you plan to use BEFORE generating workflow code.',
|
||||
schema: z.object({
|
||||
nodeIds: z
|
||||
.array(
|
||||
z.union([
|
||||
z.string(),
|
||||
z.object({
|
||||
nodeId: z.string().describe('The node ID (e.g., "n8n-nodes-base.httpRequest")'),
|
||||
version: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional version (e.g., "34" for v34). Omit for latest version.'),
|
||||
resource: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Resource discriminator for REST API nodes (e.g., "ticket", "contact")'),
|
||||
operation: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Operation discriminator (e.g., "get", "create", "update")'),
|
||||
mode: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Mode discriminator for nodes like Code (e.g., "runOnceForAllItems")'),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.describe(
|
||||
'Array of nodes to fetch. Can be simple strings for flat nodes (e.g., ["n8n-nodes-base.aggregate"]) or objects with discriminators for split nodes (e.g., [{ nodeId: "n8n-nodes-base.freshservice", resource: "ticket", operation: "get" }] or [{ nodeId: "n8n-nodes-base.code", mode: "runOnceForAllItems" }]). Use search_nodes to discover which nodes require discriminators.',
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -576,9 +576,8 @@ export function createCodeBuilderSearchTool(
|
|||
options?: CodeBuilderSearchToolOptions,
|
||||
) {
|
||||
return tool(
|
||||
async (input: { queries: string[] }) => {
|
||||
return searchCodeBuilderNodes(nodeTypeParser, input.queries, options).results;
|
||||
},
|
||||
async (input: { queries: string[] }) =>
|
||||
searchCodeBuilderNodes(nodeTypeParser, input.queries, options).results,
|
||||
{
|
||||
name: 'search_nodes',
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ export {
|
|||
searchCodeBuilderNodes,
|
||||
type CodeBuilderSearchToolOptions,
|
||||
createCodeBuilderGetTool,
|
||||
getNodeTypes,
|
||||
type NodeRequest,
|
||||
createGetSuggestedNodesTool,
|
||||
stripImportStatements,
|
||||
SDK_IMPORT_STATEMENT,
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@
|
|||
"@opentelemetry/instrumentation": "^0.213.0",
|
||||
"@opentelemetry/resources": "^2.6.0",
|
||||
"@opentelemetry/sdk-node": "^0.213.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.6.0",
|
||||
"@opentelemetry/sdk-trace-node": "^2.6.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.40.0",
|
||||
"@parcel/watcher": "^2.5.1",
|
||||
|
|
@ -177,6 +178,7 @@
|
|||
"json-diff": "1.0.6",
|
||||
"jsonschema": "1.4.1",
|
||||
"jsonwebtoken": "catalog:",
|
||||
"langsmith": "catalog:",
|
||||
"ldapts": "4.2.6",
|
||||
"lodash": "catalog:",
|
||||
"luxon": "catalog:",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { AgentsBuilderToolsService, getAgentConfigHash } from './agents-builder-
|
|||
import { AGENT_THREAD_PREFIX } from './builder-tool-names';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { AgentsBuilderSettingsService } from './agents-builder-settings.service';
|
||||
import { buildBuilderTelemetry } from '../tracing/builder-telemetry';
|
||||
|
||||
const BUILDER_MODEL = 'anthropic/claude-sonnet-4-5';
|
||||
|
||||
|
|
@ -184,6 +185,15 @@ export class AgentsBuilderService {
|
|||
.memory(builderMemory)
|
||||
.checkpoint(this.n8nCheckpointStorage.getStorage(agentId));
|
||||
|
||||
const telemetry = buildBuilderTelemetry({
|
||||
agentId,
|
||||
projectId,
|
||||
userId: user.id,
|
||||
threadId: builderThreadId(agentId),
|
||||
model: modelConfig,
|
||||
});
|
||||
if (telemetry) builder.telemetry(telemetry);
|
||||
|
||||
for (const tool of [...tools.json, ...tools.shared]) {
|
||||
builder.tool(tool);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
import { LangSmithTelemetry } from '@n8n/agents';
|
||||
|
||||
import {
|
||||
buildBuilderTelemetry,
|
||||
isLangSmithEnabled,
|
||||
resolveModelIdForTelemetry,
|
||||
} from '../builder-telemetry';
|
||||
|
||||
const baseOptions = {
|
||||
agentId: 'agent-1',
|
||||
projectId: 'project-1',
|
||||
userId: 'user-1',
|
||||
threadId: 'thread-1',
|
||||
model: 'anthropic/claude-sonnet-4-5',
|
||||
} as const;
|
||||
|
||||
describe('isLangSmithEnabled', () => {
|
||||
it('returns false when no API key is set', () => {
|
||||
expect(isLangSmithEnabled({})).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when LANGSMITH_API_KEY is set', () => {
|
||||
expect(isLangSmithEnabled({ LANGSMITH_API_KEY: 'ls-key' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when LANGCHAIN_API_KEY is set', () => {
|
||||
expect(isLangSmithEnabled({ LANGCHAIN_API_KEY: 'lc-key' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when tracing flag is explicitly disabled', () => {
|
||||
expect(isLangSmithEnabled({ LANGSMITH_API_KEY: 'ls-key', LANGCHAIN_TRACING_V2: 'false' })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isLangSmithEnabled({ LANGSMITH_API_KEY: 'ls-key', LANGSMITH_TRACING: 'false' })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildBuilderTelemetry', () => {
|
||||
it('returns undefined when tracing is not enabled', () => {
|
||||
expect(buildBuilderTelemetry(baseOptions, {})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns a LangSmithTelemetry instance when an API key is present', () => {
|
||||
const telemetry = buildBuilderTelemetry(baseOptions, { LANGSMITH_API_KEY: 'ls-key' });
|
||||
expect(telemetry).toBeInstanceOf(LangSmithTelemetry);
|
||||
});
|
||||
|
||||
it('seeds identifying metadata for the run', () => {
|
||||
const telemetry = buildBuilderTelemetry(baseOptions, { LANGSMITH_API_KEY: 'ls-key' });
|
||||
// Calling build() would trigger dynamic OTel imports; inspect the builder's
|
||||
// internal state via a safe cast to a partial shape instead.
|
||||
const internal = telemetry as unknown as {
|
||||
functionIdValue?: string;
|
||||
metadataValue?: Record<string, unknown>;
|
||||
};
|
||||
expect(internal.functionIdValue).toBe('agent-builder');
|
||||
expect(internal.metadataValue).toEqual({
|
||||
agent_id: 'agent-1',
|
||||
project_id: 'project-1',
|
||||
user_id: 'user-1',
|
||||
thread_id: 'thread-1',
|
||||
model_id: 'anthropic/claude-sonnet-4-5',
|
||||
});
|
||||
});
|
||||
|
||||
it('records the resolved model id when given a typed config object', () => {
|
||||
const telemetry = buildBuilderTelemetry(
|
||||
{ ...baseOptions, model: { id: 'openai/gpt-4o', apiKey: 'redacted' } },
|
||||
{ LANGSMITH_API_KEY: 'ls-key' },
|
||||
);
|
||||
const internal = telemetry as unknown as { metadataValue?: Record<string, unknown> };
|
||||
expect(internal.metadataValue?.model_id).toBe('openai/gpt-4o');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveModelIdForTelemetry', () => {
|
||||
it('returns the string id as-is', () => {
|
||||
expect(resolveModelIdForTelemetry('anthropic/claude-sonnet-4-5')).toBe(
|
||||
'anthropic/claude-sonnet-4-5',
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts `id` from a typed model config', () => {
|
||||
expect(resolveModelIdForTelemetry({ id: 'openai/gpt-4o', apiKey: 'k' })).toBe('openai/gpt-4o');
|
||||
});
|
||||
|
||||
it('combines AI SDK LanguageModel provider + modelId', () => {
|
||||
const languageModel = { provider: 'anthropic', modelId: 'claude-3-5-sonnet' } as never;
|
||||
expect(resolveModelIdForTelemetry(languageModel)).toBe('anthropic/claude-3-5-sonnet');
|
||||
});
|
||||
|
||||
it('falls back to modelId alone when provider is missing', () => {
|
||||
const languageModel = { modelId: 'claude-3-5-sonnet' } as never;
|
||||
expect(resolveModelIdForTelemetry(languageModel)).toBe('claude-3-5-sonnet');
|
||||
});
|
||||
|
||||
it('returns "unknown" when neither id nor modelId is present', () => {
|
||||
expect(resolveModelIdForTelemetry({} as never)).toBe('unknown');
|
||||
});
|
||||
});
|
||||
77
packages/cli/src/modules/agents/tracing/builder-telemetry.ts
Normal file
77
packages/cli/src/modules/agents/tracing/builder-telemetry.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { LangSmithTelemetry } from '@n8n/agents';
|
||||
import type { ModelConfig } from '@n8n/agents';
|
||||
|
||||
const DEFAULT_PROJECT_NAME = 'agent-builder';
|
||||
const UNKNOWN_MODEL_ID = 'unknown';
|
||||
|
||||
/**
|
||||
* Tracing is on when an API key is present and the tracing flag is not
|
||||
* explicitly disabled. The OTLP exporter needs the key to authenticate, so
|
||||
* setting only the flag would silently drop spans.
|
||||
*/
|
||||
export function isLangSmithEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const tracingFlag = env.LANGCHAIN_TRACING_V2 ?? env.LANGSMITH_TRACING;
|
||||
if (tracingFlag?.toLowerCase() === 'false') return false;
|
||||
|
||||
return Boolean(env.LANGSMITH_API_KEY ?? env.LANGCHAIN_API_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten the `ModelConfig` union into a stable `provider/model` string for
|
||||
* trace metadata. Handles plain ids, typed configs with an `id` field, and
|
||||
* AI SDK `LanguageModel` instances that expose `modelId` (+ optional `provider`).
|
||||
*/
|
||||
export function resolveModelIdForTelemetry(modelConfig: ModelConfig): string {
|
||||
if (typeof modelConfig === 'string') return modelConfig;
|
||||
|
||||
if (typeof modelConfig === 'object' && modelConfig !== null) {
|
||||
const record = modelConfig as Record<string, unknown>;
|
||||
if (typeof record.id === 'string') return record.id;
|
||||
if (typeof record.modelId === 'string') {
|
||||
return typeof record.provider === 'string'
|
||||
? `${record.provider}/${record.modelId}`
|
||||
: record.modelId;
|
||||
}
|
||||
}
|
||||
|
||||
return UNKNOWN_MODEL_ID;
|
||||
}
|
||||
|
||||
export interface BuilderTelemetryOptions {
|
||||
agentId: string;
|
||||
projectId: string;
|
||||
userId: string;
|
||||
threadId: string;
|
||||
model: ModelConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `LangSmithTelemetry` for the agent builder. Returns `undefined` when
|
||||
* tracing is not enabled so callers can attach unconditionally with a guard.
|
||||
*
|
||||
* The agents SDK auto-wires AI SDK spans (generate/stream/tool calls) into the
|
||||
* tracer, so we only need to seed identifying metadata here — no manual
|
||||
* `RunTree` plumbing.
|
||||
*/
|
||||
export function buildBuilderTelemetry(
|
||||
options: BuilderTelemetryOptions,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): LangSmithTelemetry | undefined {
|
||||
if (!isLangSmithEnabled(env)) return undefined;
|
||||
|
||||
const project = env.LANGSMITH_PROJECT ?? env.LANGCHAIN_PROJECT ?? DEFAULT_PROJECT_NAME;
|
||||
const endpoint = env.LANGSMITH_ENDPOINT ?? env.LANGCHAIN_ENDPOINT;
|
||||
|
||||
return new LangSmithTelemetry({
|
||||
project,
|
||||
...(endpoint ? { endpoint } : {}),
|
||||
})
|
||||
.functionId('agent-builder')
|
||||
.metadata({
|
||||
agent_id: options.agentId,
|
||||
project_id: options.projectId,
|
||||
user_id: options.userId,
|
||||
thread_id: options.threadId,
|
||||
model_id: resolveModelIdForTelemetry(options.model),
|
||||
});
|
||||
}
|
||||
|
|
@ -9,13 +9,13 @@ import { NodeCatalogService } from '../node-catalog.service';
|
|||
const MockNodeTypeParser = jest.fn();
|
||||
const mockSetSchemaBaseDirs = jest.fn();
|
||||
const mockSearchCodeBuilderNodes = jest.fn();
|
||||
const mockGetInvoke = jest.fn().mockResolvedValue('get-result');
|
||||
const mockGetNodeTypes = jest.fn().mockReturnValue('get-result');
|
||||
const mockSuggestInvoke = jest.fn().mockResolvedValue('suggest-result');
|
||||
|
||||
jest.mock('@n8n/ai-workflow-builder', () => ({
|
||||
NodeTypeParser: MockNodeTypeParser,
|
||||
searchCodeBuilderNodes: (...args: unknown[]) => mockSearchCodeBuilderNodes(...args),
|
||||
createCodeBuilderGetTool: jest.fn(() => ({ invoke: mockGetInvoke })),
|
||||
getNodeTypes: (...args: unknown[]) => mockGetNodeTypes(...args),
|
||||
createGetSuggestedNodesTool: jest.fn(() => ({ invoke: mockSuggestInvoke })),
|
||||
}));
|
||||
|
||||
|
|
@ -241,7 +241,7 @@ describe('NodeCatalogService', () => {
|
|||
|
||||
expect(result1).toBe('get-result');
|
||||
expect(result2).toBe('get-result');
|
||||
expect(mockGetInvoke).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetNodeTypes).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('handles object nodeIds in cache key', async () => {
|
||||
|
|
@ -251,7 +251,7 @@ describe('NodeCatalogService', () => {
|
|||
await service.getNodeTypes([nodeId]);
|
||||
await service.getNodeTypes([nodeId]);
|
||||
|
||||
expect(mockGetInvoke).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetNodeTypes).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('is order-independent across nodeIds', async () => {
|
||||
|
|
@ -260,7 +260,7 @@ describe('NodeCatalogService', () => {
|
|||
await service.getNodeTypes(['n8n-nodes-base.gmail', 'n8n-nodes-base.slack']);
|
||||
await service.getNodeTypes(['n8n-nodes-base.slack', 'n8n-nodes-base.gmail']);
|
||||
|
||||
expect(mockGetInvoke).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetNodeTypes).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -287,7 +287,7 @@ describe('NodeCatalogService', () => {
|
|||
await service.getSuggestedNodes(['chatbot']);
|
||||
|
||||
expect(mockSearchCodeBuilderNodes).toHaveBeenCalledTimes(2);
|
||||
expect(mockGetInvoke).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetNodeTypes).toHaveBeenCalledTimes(1);
|
||||
expect(mockSuggestInvoke).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(postProcessorCallback).toBeDefined();
|
||||
|
|
@ -299,7 +299,7 @@ describe('NodeCatalogService', () => {
|
|||
await service.getSuggestedNodes(['chatbot']);
|
||||
|
||||
expect(mockSearchCodeBuilderNodes).toHaveBeenCalledTimes(4);
|
||||
expect(mockGetInvoke).toHaveBeenCalledTimes(2);
|
||||
expect(mockGetNodeTypes).toHaveBeenCalledTimes(2);
|
||||
expect(mockSuggestInvoke).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import type { CodeBuilderSearchResult, NodeTypeParser } from '@n8n/ai-workflow-builder';
|
||||
import type {
|
||||
CodeBuilderSearchResult,
|
||||
NodeRequest,
|
||||
NodeTypeParser,
|
||||
} from '@n8n/ai-workflow-builder';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { Service } from '@n8n/di';
|
||||
import * as fs from 'fs/promises';
|
||||
|
|
@ -6,16 +10,6 @@ import * as path from 'path';
|
|||
|
||||
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
|
||||
|
||||
type NodeRequest =
|
||||
| string
|
||||
| {
|
||||
nodeId: string;
|
||||
version?: string;
|
||||
resource?: string;
|
||||
operation?: string;
|
||||
mode?: string;
|
||||
};
|
||||
|
||||
export type NodeFilter = (nodeId: string) => boolean;
|
||||
|
||||
export interface SearchNodesOptions {
|
||||
|
|
@ -60,8 +54,6 @@ export class NodeCatalogService {
|
|||
*/
|
||||
private readonly searchStates = new Map<NodeFilter | typeof UNFILTERED, SearchState>();
|
||||
|
||||
private getTool: InvokableTool<{ nodeIds: NodeRequest[] }> | undefined;
|
||||
|
||||
private suggestTool: InvokableTool<{ categories: string[] }> | undefined;
|
||||
|
||||
private readonly getCache = new Map<string, string>();
|
||||
|
|
@ -94,6 +86,12 @@ export class NodeCatalogService {
|
|||
/**
|
||||
* Search the node catalog for node IDs matching `queries`.
|
||||
* Results are cached per `(filter, queries)` pair and invalidated on node-type refresh.
|
||||
*
|
||||
* Calls the plain `searchCodeBuilderNodes` helper from `@n8n/ai-workflow-builder`
|
||||
* rather than its LangChain `tool(...)` wrapper. When `LANGCHAIN_TRACING_V2` is on
|
||||
* (the agents SDK enables it for the OTel exporter), the wrapper would register a
|
||||
* separate LangSmith root run for every invocation — fragmenting traces. The plain
|
||||
* helper runs entirely inside the caller's OTel span.
|
||||
*/
|
||||
async searchNodes(
|
||||
queries: string[],
|
||||
|
|
@ -134,11 +132,8 @@ export class NodeCatalogService {
|
|||
const cached = this.getCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
if (!this.getTool) {
|
||||
const { createCodeBuilderGetTool } = await import('@n8n/ai-workflow-builder');
|
||||
this.getTool = createCodeBuilderGetTool({ nodeDefinitionDirs: this.nodeDefinitionDirs });
|
||||
}
|
||||
const result = await this.getTool.invoke({ nodeIds });
|
||||
const { getNodeTypes } = await import('@n8n/ai-workflow-builder');
|
||||
const result = getNodeTypes(nodeIds, { nodeDefinitionDirs: this.nodeDefinitionDirs });
|
||||
this.getCache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
|
@ -184,7 +179,6 @@ export class NodeCatalogService {
|
|||
this.nodeTypeParser = new NodeTypeParserClass(nodeTypeDescriptions);
|
||||
|
||||
this.searchStates.clear();
|
||||
this.getTool = undefined;
|
||||
this.suggestTool = undefined;
|
||||
|
||||
this.getCache.clear();
|
||||
|
|
|
|||
|
|
@ -882,7 +882,7 @@ importers:
|
|||
version: link:../vitest-config
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.1(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.21)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)))
|
||||
version: 4.1.1(vitest@4.1.1)
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(@vitest/browser-playwright@4.0.16)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.21)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3))
|
||||
|
|
@ -1056,7 +1056,7 @@ importers:
|
|||
version: link:../vitest-config
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.1(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.21)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)))
|
||||
version: 4.1.1(vitest@4.1.1)
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(@vitest/browser-playwright@4.0.16)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.21)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3))
|
||||
|
|
@ -1821,7 +1821,7 @@ importers:
|
|||
version: 7.0.15
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.1(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.21)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)))
|
||||
version: 4.1.1(vitest@4.1.1)
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(@vitest/browser-playwright@4.0.16)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.21)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3))
|
||||
|
|
@ -2309,7 +2309,7 @@ importers:
|
|||
version: 0.9.4
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.1(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.21)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)))
|
||||
version: 4.1.1(vitest@4.1.1)
|
||||
fast-glob:
|
||||
specifier: 'catalog:'
|
||||
version: 3.2.12
|
||||
|
|
@ -2337,7 +2337,7 @@ importers:
|
|||
version: link:../vitest-config
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.1(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.21)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)))
|
||||
version: 4.1.1(vitest@4.1.1)
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(@vitest/browser-playwright@4.0.16)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.21)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3))
|
||||
|
|
@ -2402,7 +2402,7 @@ importers:
|
|||
version: link:../vitest-config
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.1(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.21)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)))
|
||||
version: 4.1.1(vitest@4.1.1)
|
||||
rimraf:
|
||||
specifier: 'catalog:'
|
||||
version: 6.0.1
|
||||
|
|
@ -2495,7 +2495,7 @@ importers:
|
|||
version: 20.19.21
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.1(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.21)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)))
|
||||
version: 4.1.1(vitest@4.1.1)
|
||||
nodemon:
|
||||
specifier: ^2.0.20
|
||||
version: 2.0.22
|
||||
|
|
@ -2735,6 +2735,9 @@ importers:
|
|||
'@opentelemetry/sdk-node':
|
||||
specifier: ^0.213.0
|
||||
version: 0.213.0(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/sdk-trace-base':
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.0(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/sdk-trace-node':
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.0(@opentelemetry/api@1.9.0)
|
||||
|
|
@ -2858,6 +2861,9 @@ importers:
|
|||
jsonwebtoken:
|
||||
specifier: 'catalog:'
|
||||
version: 9.0.3
|
||||
langsmith:
|
||||
specifier: 0.5.19
|
||||
version: 0.5.19(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.213.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(openai@6.34.0(ws@8.17.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))(ws@8.17.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))
|
||||
ldapts:
|
||||
specifier: 4.2.6
|
||||
version: 4.2.6
|
||||
|
|
@ -3335,7 +3341,7 @@ importers:
|
|||
version: 5.2.4(vite@8.0.2(@types/node@20.19.21)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3))(vue@3.5.26(typescript@6.0.2))
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.1(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.21)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)))
|
||||
version: 4.1.1(vitest@4.1.1)
|
||||
unplugin-icons:
|
||||
specifier: ^0.19.0
|
||||
version: 0.19.0(@vue/compiler-sfc@3.5.26)
|
||||
|
|
@ -3775,7 +3781,7 @@ importers:
|
|||
version: 4.0.16(bufferutil@4.0.9)(playwright@1.58.0)(utf-8-validate@5.0.10)(vite@8.0.2(@types/node@20.19.21)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3))(vitest@4.1.1)
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.1(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.21)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)))
|
||||
version: 4.1.1(vitest@4.1.1)
|
||||
'@vue/tsconfig':
|
||||
specifier: catalog:frontend
|
||||
version: 0.7.0(typescript@6.0.2)(vue@3.5.26(typescript@6.0.2))
|
||||
|
|
@ -4142,7 +4148,7 @@ importers:
|
|||
version: 5.2.4(vite@8.0.2(@types/node@20.19.21)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3))(vue@3.5.26(typescript@6.0.2))
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.1(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.21)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)))
|
||||
version: 4.1.1(vitest@4.1.1)
|
||||
browserslist-to-esbuild:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1(browserslist@4.28.1)
|
||||
|
|
@ -4567,7 +4573,7 @@ importers:
|
|||
version: link:../../@n8n/vitest-config
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.1(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.21)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)))
|
||||
version: 4.1.1(vitest@4.1.1)
|
||||
fast-glob:
|
||||
specifier: 'catalog:'
|
||||
version: 3.2.12
|
||||
|
|
@ -4631,7 +4637,7 @@ importers:
|
|||
version: 20.19.21
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.1(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.21)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)))
|
||||
version: 4.1.1(vitest@4.1.1)
|
||||
ts-morph:
|
||||
specifier: 'catalog:'
|
||||
version: 27.0.2
|
||||
|
|
@ -4763,7 +4769,7 @@ importers:
|
|||
version: link:../../@n8n/vitest-config
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.1(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.21)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)))
|
||||
version: 4.1.1(vitest@4.1.1)
|
||||
tsx:
|
||||
specifier: 'catalog:'
|
||||
version: 4.19.3
|
||||
|
|
@ -32821,7 +32827,7 @@ snapshots:
|
|||
tinyrainbow: 3.0.3
|
||||
vitest: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.21)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.89.2)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3))
|
||||
|
||||
'@vitest/coverage-v8@4.1.1(vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.21)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.21)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)))':
|
||||
'@vitest/coverage-v8@4.1.1(vitest@4.1.1)':
|
||||
dependencies:
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
'@vitest/utils': 4.1.1
|
||||
|
|
@ -39227,6 +39233,17 @@ snapshots:
|
|||
- ws
|
||||
- zod-to-json-schema
|
||||
|
||||
langsmith@0.5.19(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.213.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(openai@6.34.0(ws@8.17.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))(ws@8.17.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)):
|
||||
dependencies:
|
||||
p-queue: 6.6.2
|
||||
uuid: 10.0.0
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@opentelemetry/exporter-trace-otlp-proto': 0.213.0(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0)
|
||||
openai: 6.34.0(ws@8.17.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67)
|
||||
ws: 8.17.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)
|
||||
|
||||
langsmith@0.5.19(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.213.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0))(openai@6.34.0(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67))(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)):
|
||||
dependencies:
|
||||
p-queue: 6.6.2
|
||||
|
|
@ -41307,6 +41324,12 @@ snapshots:
|
|||
is-docker: 2.2.1
|
||||
is-wsl: 2.2.0
|
||||
|
||||
openai@6.34.0(ws@8.17.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67):
|
||||
optionalDependencies:
|
||||
ws: 8.17.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)
|
||||
zod: 3.25.67
|
||||
optional: true
|
||||
|
||||
openai@6.34.0(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.67):
|
||||
optionalDependencies:
|
||||
ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user