From e968723808fc26fe1ac879e0678b7ed9e3a7b69e Mon Sep 17 00:00:00 2001 From: Michael Drury Date: Mon, 11 May 2026 19:29:33 +0100 Subject: [PATCH] chore(core): Langsmith OTel telemetry for agent builder (#30259) --- .../src/code-builder/index.ts | 4 +- .../tools/code-builder-get.tool.ts | 171 +++++++++--------- .../tools/code-builder-search.tool.ts | 5 +- .../@n8n/ai-workflow-builder.ee/src/index.ts | 2 + packages/cli/package.json | 2 + .../agents/builder/agents-builder.service.ts | 10 + .../__tests__/builder-telemetry.test.ts | 102 +++++++++++ .../agents/tracing/builder-telemetry.ts | 77 ++++++++ .../__tests__/node-catalog.service.test.ts | 14 +- .../src/node-catalog/node-catalog.service.ts | 32 ++-- pnpm-lock.yaml | 51 ++++-- 11 files changed, 341 insertions(+), 129 deletions(-) create mode 100644 packages/cli/src/modules/agents/tracing/__tests__/builder-telemetry.test.ts create mode 100644 packages/cli/src/modules/agents/tracing/builder-telemetry.ts diff --git a/packages/@n8n/ai-workflow-builder.ee/src/code-builder/index.ts b/packages/@n8n/ai-workflow-builder.ee/src/code-builder/index.ts index 9447ab989a2..c28440f442f 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/code-builder/index.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/code-builder/index.ts @@ -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'; diff --git a/packages/@n8n/ai-workflow-builder.ee/src/code-builder/tools/code-builder-get.tool.ts b/packages/@n8n/ai-workflow-builder.ee/src/code-builder/tools/code-builder-get.tool.ts index a2e00f42bb0..3cfdbfaf315 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/code-builder/tools/code-builder-get.tool.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/code-builder/tools/code-builder-get.tool.ts @@ -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.', + ), + }), + }); } diff --git a/packages/@n8n/ai-workflow-builder.ee/src/code-builder/tools/code-builder-search.tool.ts b/packages/@n8n/ai-workflow-builder.ee/src/code-builder/tools/code-builder-search.tool.ts index 1bf21a8d7ef..7d3bda75abd 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/code-builder/tools/code-builder-search.tool.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/code-builder/tools/code-builder-search.tool.ts @@ -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: diff --git a/packages/@n8n/ai-workflow-builder.ee/src/index.ts b/packages/@n8n/ai-workflow-builder.ee/src/index.ts index a642076c6e0..0ea69fb9d73 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/index.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/index.ts @@ -21,6 +21,8 @@ export { searchCodeBuilderNodes, type CodeBuilderSearchToolOptions, createCodeBuilderGetTool, + getNodeTypes, + type NodeRequest, createGetSuggestedNodesTool, stripImportStatements, SDK_IMPORT_STATEMENT, diff --git a/packages/cli/package.json b/packages/cli/package.json index f3d9e77d25c..d485f6b4f4e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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:", diff --git a/packages/cli/src/modules/agents/builder/agents-builder.service.ts b/packages/cli/src/modules/agents/builder/agents-builder.service.ts index 6fbefe35190..11a0714b6d8 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder.service.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder.service.ts @@ -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); } diff --git a/packages/cli/src/modules/agents/tracing/__tests__/builder-telemetry.test.ts b/packages/cli/src/modules/agents/tracing/__tests__/builder-telemetry.test.ts new file mode 100644 index 00000000000..5f2ce0a0a3b --- /dev/null +++ b/packages/cli/src/modules/agents/tracing/__tests__/builder-telemetry.test.ts @@ -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; + }; + 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 }; + 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'); + }); +}); diff --git a/packages/cli/src/modules/agents/tracing/builder-telemetry.ts b/packages/cli/src/modules/agents/tracing/builder-telemetry.ts new file mode 100644 index 00000000000..07b6307d888 --- /dev/null +++ b/packages/cli/src/modules/agents/tracing/builder-telemetry.ts @@ -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; + 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), + }); +} diff --git a/packages/cli/src/node-catalog/__tests__/node-catalog.service.test.ts b/packages/cli/src/node-catalog/__tests__/node-catalog.service.test.ts index 45c86423564..06e0e02a11d 100644 --- a/packages/cli/src/node-catalog/__tests__/node-catalog.service.test.ts +++ b/packages/cli/src/node-catalog/__tests__/node-catalog.service.test.ts @@ -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); }); }); diff --git a/packages/cli/src/node-catalog/node-catalog.service.ts b/packages/cli/src/node-catalog/node-catalog.service.ts index eabed8122cf..a48b156f07b 100644 --- a/packages/cli/src/node-catalog/node-catalog.service.ts +++ b/packages/cli/src/node-catalog/node-catalog.service.ts @@ -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(); - private getTool: InvokableTool<{ nodeIds: NodeRequest[] }> | undefined; - private suggestTool: InvokableTool<{ categories: string[] }> | undefined; private readonly getCache = new Map(); @@ -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(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b39467d04e5..2f71713a50a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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)