diff --git a/packages/@n8n/agents/src/__tests__/integration/agent-runtime-conversion.test.ts b/packages/@n8n/agents/src/__tests__/integration/agent-runtime-conversion.test.ts index 972cdf1b5c7..46b7ccc69b7 100644 --- a/packages/@n8n/agents/src/__tests__/integration/agent-runtime-conversion.test.ts +++ b/packages/@n8n/agents/src/__tests__/integration/agent-runtime-conversion.test.ts @@ -63,6 +63,36 @@ describe('toAiMessages + fromAiMessages — round-trip', () => { expect(toolResultPart.output.value).toEqual({ result: 3 }); }); + it('preserves provider metadata on replayed assistant tool-call parts', () => { + const providerMetadata = { google: { thoughtSignature: 'gemini-signature' } }; + const input: Message[] = [ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'tc-1', + toolName: 'load_skill', + input: { skillId: 'agent-builder-tools' }, + providerMetadata, + state: 'resolved', + output: { ok: true }, + }, + ], + }, + ]; + + const aiMessages = toAiMessages(input); + const toolCallPart = ( + aiMessages[0] as { + role: string; + content: Array<{ type: string; providerMetadata?: unknown }>; + } + ).content[0]; + + expect(toolCallPart.providerMetadata).toEqual(providerMetadata); + }); + it('preserves content tool outputs when building tool ModelMessages', () => { const contentOutput = { type: 'content' as const, diff --git a/packages/@n8n/agents/src/runtime/messages.ts b/packages/@n8n/agents/src/runtime/messages.ts index 267f6ad889a..289e1508845 100644 --- a/packages/@n8n/agents/src/runtime/messages.ts +++ b/packages/@n8n/agents/src/runtime/messages.ts @@ -72,6 +72,10 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function getRecord(value: unknown): Record | undefined { + return isRecord(value) ? value : undefined; +} + type ContentToolResultOutput = Extract; function isContentToolResultOutput(value: JSONValue): value is ContentToolResultOutput { @@ -101,8 +105,15 @@ function toAiContent(block: MessageContent): AiContentPart | undefined { base = { type: 'reasoning', text: block.text }; } - if (base && block.providerOptions) { - return { ...base, providerOptions: block.providerOptions } as AiContentPart; + if (base) { + // Provider metadata can be required for replay. Gemini attaches + // `google.thoughtSignature` to function-call parts, and the next request + // is rejected if that signature is dropped from conversation history. + return { + ...base, + ...(block.providerMetadata && { providerMetadata: block.providerMetadata }), + ...(block.providerOptions && { providerOptions: block.providerOptions }), + } as AiContentPart; } return base; } @@ -141,6 +152,9 @@ function toolCallToResultPart( /** Convert a single AI SDK content part to an n8n MessageContent block. */ function fromAiContent(part: AiContentPart): MessageContent | undefined { + const providerMetadata = getRecord( + 'providerMetadata' in part ? part.providerMetadata : undefined, + ); const providerOptions = 'providerOptions' in part ? part.providerOptions : undefined; let base: MessageContent | undefined; @@ -182,8 +196,11 @@ function fromAiContent(part: AiContentPart): MessageContent | undefined { return undefined; } - if (base && providerOptions) { - return { ...base, providerOptions }; + if (base) { + // Keep provider metadata on persisted content parts so provider-specific + // replay data, such as Gemini thought signatures, survives memory/checkpoints. + if (providerMetadata) base.providerMetadata = providerMetadata; + if (providerOptions) base.providerOptions = providerOptions; } return base; } @@ -232,13 +249,21 @@ function toAiMessageList(msg: Message): ModelMessage[] { continue; } // Emit tool-call part (without result fields) - assistantParts.push({ + const toolCallPart: ToolCallPart = { type: 'tool-call', toolCallId: block.toolCallId, toolName: block.toolName, input: parseJsonValue(block.input), providerExecuted: block.providerExecuted, - }); + }; + // Replayed settled tool calls still need their original provider + // metadata. Gemini validates thought signatures on historical + // function-call parts, even after the tool result is available. + assistantParts.push({ + ...toolCallPart, + ...(block.providerMetadata && { providerMetadata: block.providerMetadata }), + ...(block.providerOptions && { providerOptions: block.providerOptions }), + } as ToolCallPart); // Emit corresponding tool-result message immediately after const resultPart = toolCallToResultPart(block); resultMessages.push({ role: 'tool', content: [resultPart] }); diff --git a/packages/@n8n/agents/src/skills/__tests__/runtime-skills.test.ts b/packages/@n8n/agents/src/skills/__tests__/runtime-skills.test.ts index 02e3d5e0c67..c4c08259aa5 100644 --- a/packages/@n8n/agents/src/skills/__tests__/runtime-skills.test.ts +++ b/packages/@n8n/agents/src/skills/__tests__/runtime-skills.test.ts @@ -14,6 +14,7 @@ import { renderSkillCatalogPrompt, } from '..'; import { Agent } from '../../sdk/agent'; +import { isZodSchema } from '../../utils/zod'; describe('runtime skills', () => { it('parses SKILL.md frontmatter into a runtime skill', () => { @@ -369,7 +370,12 @@ Use the workflow SDK.`, const listedSkill = (listOutput as { skills: Array> }).skills[0]; expect(listedSkill).not.toHaveProperty('content'); expect(listedSkill).not.toHaveProperty('instructions'); - + expect(loadTool.description).toContain('do not pass filePath'); + expect(isZodSchema(loadTool.inputSchema)).toBe(true); + if (!isZodSchema(loadTool.inputSchema)) throw new Error('Expected Zod input schema'); + expect( + loadTool.inputSchema.safeParse({ skillId: 'summarize_notes', filePath: '/' }).data, + ).toEqual({ skillId: 'summarize_notes' }); await expect(loadTool.handler?.({ skillId: 'summarize_notes' }, {})).resolves.toMatchObject({ ok: true, success: true, @@ -378,6 +384,16 @@ Use the workflow SDK.`, content: 'Extract decisions.', instructions: 'Extract decisions.', }); + await expect( + loadTool.handler?.({ skillId: 'summarize_notes', filePath: 'SKILL.md' }, {}), + ).resolves.toMatchObject({ + ok: true, + success: true, + skillId: 'summarize_notes', + name: 'Summarize notes', + content: 'Extract decisions.', + instructions: 'Extract decisions.', + }); await expect(loadTool.handler?.({ name: 'Summarize notes' }, {})).resolves.toMatchObject({ ok: true, success: true, @@ -549,24 +565,43 @@ Use the workflow SDK.`, ]); const unsupportedLoadTool = createSkillLoadTool(registeredFileSource); + expect(unsupportedLoadTool.description).toContain('do not pass filePath'); + expect(isZodSchema(unsupportedLoadTool.inputSchema)).toBe(true); + if (!isZodSchema(unsupportedLoadTool.inputSchema)) throw new Error('Expected Zod input schema'); + expect( + unsupportedLoadTool.inputSchema.safeParse({ + skillId: 'summarize_notes', + filePath: 'references/guide.md', + }).data, + ).toEqual({ skillId: 'summarize_notes' }); await expect( unsupportedLoadTool.handler?.( { skillId: 'summarize_notes', filePath: 'references/guide.md' }, {}, ), ).resolves.toMatchObject({ - ok: false, - success: false, - error: 'This skill source does not support loading linked files.', + ok: true, + success: true, + content: 'Extract decisions.', }); const loadTool = createSkillLoadTool(fileBackedSource); + expect(loadTool.description).toContain('use filePath only for a linked file path'); + expect(isZodSchema(loadTool.inputSchema)).toBe(true); + if (!isZodSchema(loadTool.inputSchema)) throw new Error('Expected Zod input schema'); + expect( + loadTool.inputSchema.safeParse({ + skillId: 'summarize_notes', + filePath: 'references/guide.md', + }).data, + ).toEqual({ skillId: 'summarize_notes', filePath: 'references/guide.md' }); await expect( loadTool.handler?.({ skillId: 'summarize_notes', filePath: 'references/missing.md' }, {}), ).resolves.toMatchObject({ ok: false, success: false, - error: 'File is not registered for skill Summarize notes: references/missing.md', + error: + 'File is not registered for skill Summarize notes: references/missing.md. To load the main skill instructions, retry without filePath.', }); expect(loadFile).not.toHaveBeenCalledWith('summarize_notes', 'references/missing.md'); diff --git a/packages/@n8n/agents/src/skills/tools.ts b/packages/@n8n/agents/src/skills/tools.ts index df5a6056de4..598cc42aa2a 100644 --- a/packages/@n8n/agents/src/skills/tools.ts +++ b/packages/@n8n/agents/src/skills/tools.ts @@ -4,6 +4,7 @@ import { Tool } from '../sdk/tool'; import type { BuiltTool } from '../types'; import { LIST_SKILLS_TOOL_NAME, + RUNTIME_SKILL_FILE_NAME, SKILL_LOAD_TOOL_NAME, type RuntimeSkillLinkedFile, type RuntimeSkillLinkedFiles, @@ -103,10 +104,21 @@ const skillsListOutputSchema = z.object({ skills: z.array(compactSkillSchema), }); -const skillLoadInputSchema = z - .object({ - skillId: z.string().min(1).optional().describe('Skill id from list_skills.'), - name: z.string().min(1).optional().describe('Skill name from list_skills.'), +const skillLoadBaseInputSchema = z.object({ + skillId: z.string().min(1).optional().describe('Skill id from list_skills.'), + name: z.string().min(1).optional().describe('Skill name from list_skills.'), +}); + +const skillLoadInputSchema = skillLoadBaseInputSchema.refine( + ({ skillId, name }) => skillId !== undefined || name !== undefined, + { + message: 'Either skillId or name is required.', + path: ['skillId'], + }, +); + +const skillLoadInputWithFilesSchema = skillLoadBaseInputSchema + .extend({ filePath: z .string() .min(1) @@ -139,6 +151,8 @@ const skillLoadOutputSchema = z.object({ availableSkills: z.array(z.string()).optional(), }); +type SkillLoadOutput = z.infer; + export function createRuntimeSkillTools(source: RuntimeSkillSource): BuiltTool[] { return [createListSkillsTool(source), createSkillLoadTool(source)]; } @@ -168,111 +182,136 @@ export function createListSkillsTool(source: RuntimeSkillSource): BuiltTool { } export function createSkillLoadTool(source: RuntimeSkillSource): BuiltTool { + if (!source.loadFile) { + return new Tool(SKILL_LOAD_TOOL_NAME) + .description( + 'Load a skill by skillId or name. This source does not support linked files, so do not pass filePath.', + ) + .input(skillLoadInputSchema) + .output(skillLoadOutputSchema) + .handler(async ({ skillId, name }) => await loadSkill(source, { skillId, name })) + .build(); + } + + const loadFile = source.loadFile; return new Tool(SKILL_LOAD_TOOL_NAME) .description( - 'Load an installed skill SKILL.md, or a registered linked file by relative filePath.', + 'Load a skill by skillId or name. Omit filePath to load the main skill instructions; use filePath only for a linked file path returned in linkedFiles.', ) - .input(skillLoadInputSchema) + .input(skillLoadInputWithFilesSchema) .output(skillLoadOutputSchema) - .handler(async ({ skillId, name, filePath }) => { - await source.prepare?.(); - const skillEntry = findSkillEntry(source.registry, { skillId, name }); - if (!skillEntry) { - return { - ok: false, - success: false, - error: `Unknown skill: ${skillId ?? name ?? ''}`, - availableSkills: source.registry.skills.map((entry) => entry.id).slice(0, 20), - }; - } + .handler( + async ({ skillId, name, filePath }) => + await loadSkill(source, { skillId, name, filePath, loadFile }), + ) + .build(); +} - if (filePath !== undefined) { - const linkedFile = findRegisteredLinkedFile(skillEntry.linkedFiles, filePath); - if (!linkedFile) { - return { - ok: false, - success: false, - skillId: skillEntry.id, - name: skillEntry.name, - filePath, - error: `File is not registered for skill ${skillEntry.name}: ${filePath}`, - linkedFiles: skillEntry.linkedFiles, - }; - } +async function loadSkill( + source: RuntimeSkillSource, + input: { + skillId?: string; + name?: string; + filePath?: string; + loadFile?: NonNullable; + }, +): Promise { + const { skillId, name, filePath, loadFile } = input; + await source.prepare?.(); + const skillEntry = findSkillEntry(source.registry, { skillId, name }); + if (!skillEntry) { + return { + ok: false, + success: false, + error: `Unknown skill: ${skillId ?? name ?? ''}`, + availableSkills: source.registry.skills.map((entry) => entry.id).slice(0, 20), + }; + } - if (!source.loadFile) { - return { - ok: false, - success: false, - skillId: skillEntry.id, - name: skillEntry.name, - filePath, - error: 'This skill source does not support loading linked files.', - linkedFiles: skillEntry.linkedFiles, - }; - } - - const file = await source.loadFile(skillEntry.id, linkedFile.path); - if (!file) { - return { - ok: false, - success: false, - skillId: skillEntry.id, - name: skillEntry.name, - filePath, - error: `File is not registered for skill ${skillEntry.name}: ${filePath}`, - linkedFiles: skillEntry.linkedFiles, - }; - } - - return { - ok: true, - success: true, - skillId: skillEntry.id, - name: skillEntry.name, - path: skillEntry.directory - ? `${skillEntry.directory}/${linkedFile.path}` - : linkedFile.path, - skillDir: skillEntry.directory, - hash: skillEntry.hash, - category: skillEntry.category, - filePath: linkedFile.path, - content: cap(file.content), - bytes: file.bytes ?? linkedFile.bytes, - sha256: file.sha256 ?? linkedFile.sha256, - }; - } - - const skill = await source.loadSkill(skillEntry.id); - if (!skill) { - return { - ok: false, - success: false, - skillId: skillEntry.id, - name: skillEntry.name, - error: `Skill "${skillEntry.name}" is not attached to this agent.`, - availableSkills: source.registry.skills.map((entry) => entry.id).slice(0, 20), - }; - } - - const content = cap(skill.instructions); + const loadMainSkill = filePath === undefined || filePath.trim() === RUNTIME_SKILL_FILE_NAME; + if (!loadMainSkill) { + const linkedFile = findRegisteredLinkedFile(skillEntry.linkedFiles, filePath); + if (!linkedFile) { return { - ok: true, - success: true, + ok: false, + success: false, skillId: skillEntry.id, name: skillEntry.name, - description: skill.description, - path: skillEntry.path ?? skillEntry.sourcePath, - skillDir: skillEntry.directory, - hash: skillEntry.hash, - category: skillEntry.category, - content, - instructions: content, - activation: activationEnvelope(skillEntry), + filePath, + error: `File is not registered for skill ${skillEntry.name}: ${filePath}. To load the main skill instructions, retry without filePath.`, linkedFiles: skillEntry.linkedFiles, }; - }) - .build(); + } + + if (!loadFile) { + return { + ok: false, + success: false, + skillId: skillEntry.id, + name: skillEntry.name, + filePath, + error: 'This skill source does not support loading linked files.', + linkedFiles: skillEntry.linkedFiles, + }; + } + + const file = await loadFile(skillEntry.id, linkedFile.path); + if (!file) { + return { + ok: false, + success: false, + skillId: skillEntry.id, + name: skillEntry.name, + filePath, + error: `File is not registered for skill ${skillEntry.name}: ${filePath}`, + linkedFiles: skillEntry.linkedFiles, + }; + } + + return { + ok: true, + success: true, + skillId: skillEntry.id, + name: skillEntry.name, + path: skillEntry.directory ? `${skillEntry.directory}/${linkedFile.path}` : linkedFile.path, + skillDir: skillEntry.directory, + hash: skillEntry.hash, + category: skillEntry.category, + filePath: linkedFile.path, + content: cap(file.content), + bytes: file.bytes ?? linkedFile.bytes, + sha256: file.sha256 ?? linkedFile.sha256, + }; + } + + const skill = await source.loadSkill(skillEntry.id); + if (!skill) { + return { + ok: false, + success: false, + skillId: skillEntry.id, + name: skillEntry.name, + error: `Skill "${skillEntry.name}" is not attached to this agent.`, + availableSkills: source.registry.skills.map((entry) => entry.id).slice(0, 20), + }; + } + + const content = cap(skill.instructions); + return { + ok: true, + success: true, + skillId: skillEntry.id, + name: skillEntry.name, + description: skill.description, + path: skillEntry.path ?? skillEntry.sourcePath, + skillDir: skillEntry.directory, + hash: skillEntry.hash, + category: skillEntry.category, + content, + instructions: content, + activation: activationEnvelope(skillEntry), + linkedFiles: skillEntry.linkedFiles, + }; } function compactSkill(skill: RuntimeSkillRegistryEntry) { diff --git a/packages/@n8n/ai-utilities/package.json b/packages/@n8n/ai-utilities/package.json index ffe5510d992..bacb05ad4e2 100644 --- a/packages/@n8n/ai-utilities/package.json +++ b/packages/@n8n/ai-utilities/package.json @@ -18,6 +18,9 @@ ], "http-proxy-agent": [ "dist/esm/utils/http-proxy-agent.d.ts" + ], + "web-search": [ + "dist/esm/web-search/index.d.ts" ] } }, @@ -47,6 +50,11 @@ "import": "./dist/esm/utils/http-proxy-agent.js", "require": "./dist/cjs/utils/http-proxy-agent.js" }, + "./web-search": { + "types": "./dist/esm/web-search/index.d.ts", + "import": "./dist/esm/web-search/index.js", + "require": "./dist/cjs/web-search/index.js" + }, "./*": "./*" }, "scripts": { diff --git a/packages/@n8n/ai-utilities/src/index.ts b/packages/@n8n/ai-utilities/src/index.ts index 3ef8b007b73..764b90815b0 100644 --- a/packages/@n8n/ai-utilities/src/index.ts +++ b/packages/@n8n/ai-utilities/src/index.ts @@ -54,6 +54,8 @@ export { proxyFetch, type AgentTimeoutOptions, } from './utils/http-proxy-agent'; +export { braveSearch, searxngSearch, type BraveSearchOptions } from './web-search'; +export type { WebSearchOptions, WebSearchResponse, WebSearchResult } from './web-search'; export { fetchFollowingRedirects, type FollowRedirectsOptions, diff --git a/packages/cli/src/modules/instance-ai/web-research/__tests__/brave-search.test.ts b/packages/@n8n/ai-utilities/src/web-search/__tests__/brave-search.test.ts similarity index 94% rename from packages/cli/src/modules/instance-ai/web-research/__tests__/brave-search.test.ts rename to packages/@n8n/ai-utilities/src/web-search/__tests__/brave-search.test.ts index e1ed2562ad6..0573c7b7ac7 100644 --- a/packages/cli/src/modules/instance-ai/web-research/__tests__/brave-search.test.ts +++ b/packages/@n8n/ai-utilities/src/web-search/__tests__/brave-search.test.ts @@ -1,9 +1,5 @@ import { braveSearch } from '../brave-search'; -// --------------------------------------------------------------------------- -// Mock fetch -// --------------------------------------------------------------------------- - const mockFetch = jest.fn(); global.fetch = mockFetch; @@ -29,10 +25,6 @@ const MOCK_BRAVE_RESPONSE = { }, }; -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - describe('braveSearch', () => { it('sends correct request to Brave API', async () => { mockFetch.mockResolvedValue({ diff --git a/packages/cli/src/modules/instance-ai/web-research/__tests__/searxng-search.test.ts b/packages/@n8n/ai-utilities/src/web-search/__tests__/searxng-search.test.ts similarity index 94% rename from packages/cli/src/modules/instance-ai/web-research/__tests__/searxng-search.test.ts rename to packages/@n8n/ai-utilities/src/web-search/__tests__/searxng-search.test.ts index 2275e3a01ce..8fa84ba32c6 100644 --- a/packages/cli/src/modules/instance-ai/web-research/__tests__/searxng-search.test.ts +++ b/packages/@n8n/ai-utilities/src/web-search/__tests__/searxng-search.test.ts @@ -1,9 +1,5 @@ import { searxngSearch } from '../searxng-search'; -// --------------------------------------------------------------------------- -// Mock fetch -// --------------------------------------------------------------------------- - const mockFetch = jest.fn(); global.fetch = mockFetch; @@ -27,10 +23,6 @@ const MOCK_SEARXNG_RESPONSE = { ], }; -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - describe('searxngSearch', () => { it('sends correct request to SearXNG', async () => { mockFetch.mockResolvedValue({ diff --git a/packages/cli/src/modules/instance-ai/web-research/brave-search.ts b/packages/@n8n/ai-utilities/src/web-search/brave-search.ts similarity index 69% rename from packages/cli/src/modules/instance-ai/web-research/brave-search.ts rename to packages/@n8n/ai-utilities/src/web-search/brave-search.ts index adff6c6e043..3f7c7274491 100644 --- a/packages/cli/src/modules/instance-ai/web-research/brave-search.ts +++ b/packages/@n8n/ai-utilities/src/web-search/brave-search.ts @@ -1,4 +1,4 @@ -import type { WebSearchResponse } from '@n8n/instance-ai'; +import type { WebSearchOptions, WebSearchResponse } from './types'; const BRAVE_SEARCH_PATH = '/res/v1/web/search'; const BRAVE_SEARCH_URL = `https://api.search.brave.com${BRAVE_SEARCH_PATH}`; @@ -16,25 +16,24 @@ interface BraveSearchApiResponse { }; } +export interface BraveSearchOptions extends WebSearchOptions { + proxyConfig?: { + apiUrl: string; + getAuthHeaders: () => Promise>; + }; +} + /** * Execute a web search using the Brave Search API. * * Domain filtering uses Brave's native `site:` query syntax: - * includeDomains: ["docs.stripe.com"] → query becomes "stripe webhooks (site:docs.stripe.com)" - * excludeDomains: ["reddit.com"] → query appends " -site:reddit.com" + * includeDomains: ["docs.stripe.com"] -> query becomes "stripe webhooks (site:docs.stripe.com)" + * excludeDomains: ["reddit.com"] -> query appends " -site:reddit.com" */ export async function braveSearch( apiKey: string, query: string, - options: { - maxResults?: number; - includeDomains?: string[]; - excludeDomains?: string[]; - proxyConfig?: { - apiUrl: string; - getAuthHeaders: () => Promise>; - }; - }, + options: BraveSearchOptions, ): Promise { let searchQuery = query; @@ -52,11 +51,9 @@ export async function braveSearch( count: String(options.maxResults ?? 5), }); - const useProxy = !!options.proxyConfig; - const baseUrl = useProxy - ? `${options.proxyConfig!.apiUrl}${BRAVE_SEARCH_PATH}` - : BRAVE_SEARCH_URL; - const proxyHeaders = useProxy ? await options.proxyConfig!.getAuthHeaders() : undefined; + const proxyConfig = options.proxyConfig; + const baseUrl = proxyConfig ? `${proxyConfig.apiUrl}${BRAVE_SEARCH_PATH}` : BRAVE_SEARCH_URL; + const proxyHeaders = proxyConfig ? await proxyConfig.getAuthHeaders() : undefined; const headers: Record = { Accept: 'application/json', 'Accept-Encoding': 'gzip', diff --git a/packages/@n8n/ai-utilities/src/web-search/index.ts b/packages/@n8n/ai-utilities/src/web-search/index.ts new file mode 100644 index 00000000000..0949f63521f --- /dev/null +++ b/packages/@n8n/ai-utilities/src/web-search/index.ts @@ -0,0 +1,3 @@ +export { braveSearch, type BraveSearchOptions } from './brave-search'; +export { searxngSearch } from './searxng-search'; +export type { WebSearchOptions, WebSearchResponse, WebSearchResult } from './types'; diff --git a/packages/cli/src/modules/instance-ai/web-research/searxng-search.ts b/packages/@n8n/ai-utilities/src/web-search/searxng-search.ts similarity index 78% rename from packages/cli/src/modules/instance-ai/web-research/searxng-search.ts rename to packages/@n8n/ai-utilities/src/web-search/searxng-search.ts index ca5db39d4c0..798d71a5c44 100644 --- a/packages/cli/src/modules/instance-ai/web-research/searxng-search.ts +++ b/packages/@n8n/ai-utilities/src/web-search/searxng-search.ts @@ -1,4 +1,4 @@ -import type { WebSearchResponse } from '@n8n/instance-ai'; +import type { WebSearchOptions, WebSearchResponse } from './types'; interface SearxngResult { url: string; @@ -14,19 +14,15 @@ interface SearxngApiResponse { /** * Execute a web search using a SearXNG instance. * - * Domain filtering uses `site:` / `-site:` query syntax (same as Brave), - * which SearXNG passes through to underlying search engines. + * Domain filtering uses `site:` / `-site:` query syntax, which SearXNG passes + * through to underlying search engines. * - * SearXNG has no server-side `count` parameter — results are sliced client-side. + * SearXNG has no server-side `count` parameter, so results are sliced client-side. */ export async function searxngSearch( baseUrl: string, query: string, - options: { - maxResults?: number; - includeDomains?: string[]; - excludeDomains?: string[]; - }, + options: WebSearchOptions, ): Promise { let searchQuery = query; @@ -39,7 +35,6 @@ export async function searxngSearch( searchQuery += options.excludeDomains.map((d) => ` -site:${d}`).join(''); } - // Normalize trailing slash const normalizedUrl = baseUrl.replace(/\/+$/, ''); const params = new URLSearchParams({ diff --git a/packages/@n8n/ai-utilities/src/web-search/types.ts b/packages/@n8n/ai-utilities/src/web-search/types.ts new file mode 100644 index 00000000000..0ef2f686ce0 --- /dev/null +++ b/packages/@n8n/ai-utilities/src/web-search/types.ts @@ -0,0 +1,17 @@ +export interface WebSearchResult { + title: string; + url: string; + snippet: string; + publishedDate?: string; +} + +export interface WebSearchResponse { + query: string; + results: WebSearchResult[]; +} + +export interface WebSearchOptions { + maxResults?: number; + includeDomains?: string[]; + excludeDomains?: string[]; +} diff --git a/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts b/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts index 93033f1cbb0..e70948f78c4 100644 --- a/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts +++ b/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts @@ -14,8 +14,28 @@ const SemanticRecallSchema = z.object({ embedder: z.string().optional(), }); +export const AgentModelSchema = z + .string() + .min(1) + .regex( + /** + * [a-z0-9-]+: Provider name (e.g. "anthropic") + * (?:[a-z0-9._-]+\/)*: Zero or more sub-providers (e.g. "openrouter/amazon/nova-micro-v1") + * [a-z0-9._-]+: Model name (e.g. "claude-sonnet-4-5") + */ + /^[a-z0-9-]+\/(?:[a-z0-9._-]+\/)*[a-z0-9._-]+$/i, + 'Model must be "provider/model-name" format (e.g. "anthropic/claude-sonnet-4-5" or "openrouter/amazon/nova-micro-v1")', + ); + +const MemoryWorkerModelSchema = z.object({ + model: AgentModelSchema, + credential: z.string().trim().min(1), +}); + const ObservationalMemoryConfigSchema = z.object({ enabled: z.boolean().optional(), + observerModel: MemoryWorkerModelSchema.optional(), + reflectorModel: MemoryWorkerModelSchema.optional(), observerThresholdTokens: z.number().int().min(1).optional(), reflectorThresholdTokens: z.number().int().min(1).optional(), renderTokenBudget: z.number().int().min(1).optional(), @@ -30,6 +50,8 @@ const EpisodicMemoryConfigSchema = z.discriminatedUnion('enabled', [ z.object({ enabled: z.literal(true), credential: z.string().trim().min(1), + extractorModel: MemoryWorkerModelSchema.optional(), + reflectorModel: MemoryWorkerModelSchema.optional(), topK: z.number().int().min(1).max(100).optional(), maxEntriesPerRun: z.number().int().min(1).max(50).optional(), }), @@ -50,24 +72,17 @@ const ThinkingConfigSchema = z.object({ reasoningEffort: z.string().optional(), }); +const WebSearchConfigSchema = z.object({ + enabled: z.boolean(), + provider: z.enum(['auto', 'native', 'brave', 'searxng']).optional(), + credential: z.string().optional(), +}); + const NodeToolCredentialSchema = z.object({ id: z.string(), name: z.string(), }); -export const AgentModelSchema = z - .string() - .min(1) - .regex( - /** - * [a-z0-9-]+: Provider name (e.g. "anthropic") - * (?:[a-z0-9._-]+\/)*: Zero or more sub-providers (e.g. "openrouter/amazon/nova-micro-v1") - * [a-z0-9._-]+: Model name (e.g. "claude-sonnet-4-5") - */ - /^[a-z0-9-]+\/(?:[a-z0-9._-]+\/)*[a-z0-9._-]+$/i, - 'Model must be "provider/model-name" format (e.g. "anthropic/claude-sonnet-4-5" or "openrouter/amazon/nova-micro-v1")', - ); - const DraftAgentModelSchema = z.union([z.literal(''), AgentModelSchema]); export const NodeConfigSchema = z.object({ @@ -132,6 +147,7 @@ export const AgentJsonConfigSchema = z.object({ config: z .object({ thinking: ThinkingConfigSchema.optional(), + webSearch: WebSearchConfigSchema.optional(), toolCallConcurrency: z.number().int().min(1).max(20).optional(), maxIterations: z .number() diff --git a/packages/@n8n/api-types/src/agents/index.ts b/packages/@n8n/api-types/src/agents/index.ts index 0161fea6790..8b44a675e4e 100644 --- a/packages/@n8n/api-types/src/agents/index.ts +++ b/packages/@n8n/api-types/src/agents/index.ts @@ -1,6 +1,7 @@ export * from './agent-integration.schema'; export * from './agent-json-config.schema'; export * from './dto'; +export * from './provider-capabilities'; export * from './types'; export type { AgentSseEvent, AgentSseMessage, ToolSuspendedPayload } from '../agent-sse'; export { diff --git a/packages/@n8n/api-types/src/agents/provider-capabilities.ts b/packages/@n8n/api-types/src/agents/provider-capabilities.ts new file mode 100644 index 00000000000..d034d91f799 --- /dev/null +++ b/packages/@n8n/api-types/src/agents/provider-capabilities.ts @@ -0,0 +1,86 @@ +/** + * Static capability map for LLM providers the agent runtime can target. + */ +export const NATIVE_WEB_SEARCH_PROVIDER_TOOLS = [ + 'anthropic.web_search', + 'anthropic.web_search_20250305', + 'anthropic.web_search_20260209', + 'openai.web_search', +] as const; + +export type NativeWebSearchProviderTool = (typeof NATIVE_WEB_SEARCH_PROVIDER_TOOLS)[number]; + +export const ANTHROPIC_NATIVE_WEB_SEARCH_PROVIDER_TOOLS = [ + 'anthropic.web_search', + 'anthropic.web_search_20250305', + 'anthropic.web_search_20260209', +] as const satisfies readonly NativeWebSearchProviderTool[]; + +export const NATIVE_WEB_SEARCH_TOOL_BY_PROVIDER = { + anthropic: 'anthropic.web_search', + openai: 'openai.web_search', +} as const satisfies Record; + +export type NativeWebSearchProvider = keyof typeof NATIVE_WEB_SEARCH_TOOL_BY_PROVIDER; +export type NativeWebSearchCanonicalTool = + (typeof NATIVE_WEB_SEARCH_TOOL_BY_PROVIDER)[NativeWebSearchProvider]; + +export const NATIVE_WEB_SEARCH_PROVIDER_BY_TOOL = { + 'anthropic.web_search': 'anthropic', + 'anthropic.web_search_20250305': 'anthropic', + 'anthropic.web_search_20260209': 'anthropic', + 'openai.web_search': 'openai', +} as const satisfies Record; + +export const NATIVE_WEB_SEARCH_DEFAULTS_BY_PROVIDER = { + anthropic: { toolName: 'anthropic.web_search', args: { maxUses: 5 } }, + openai: { + toolName: 'openai.web_search', + args: { externalWebAccess: true, searchContextSize: 'medium' }, + }, +} as const satisfies Record< + NativeWebSearchProvider, + { toolName: NativeWebSearchCanonicalTool; args: Record } +>; + +export interface ProviderCapabilities { + thinking: false | 'budgetTokens' | 'reasoningEffort'; + webSearch: false | NativeWebSearchCanonicalTool; + providerTools: ReadonlyArray; +} + +export const PROVIDER_CAPABILITIES: Record = { + anthropic: { + thinking: 'budgetTokens', + webSearch: 'anthropic.web_search', + providerTools: ['anthropic.web_search'], + }, + openai: { + thinking: 'reasoningEffort', + webSearch: 'openai.web_search', + providerTools: ['openai.web_search', 'openai.image_generation'], + }, + google: { + thinking: false, + webSearch: false, + providerTools: [], + }, + xai: { thinking: false, webSearch: false, providerTools: [] }, + groq: { thinking: false, webSearch: false, providerTools: [] }, + deepseek: { thinking: false, webSearch: false, providerTools: [] }, + mistral: { thinking: false, webSearch: false, providerTools: [] }, + openrouter: { thinking: false, webSearch: false, providerTools: [] }, + cohere: { thinking: false, webSearch: false, providerTools: [] }, + ollama: { thinking: false, webSearch: false, providerTools: [] }, +}; + +export const REASONING_EFFORT_OPTIONS = ['low', 'medium', 'high'] as const; +export type ReasoningEffort = (typeof REASONING_EFFORT_OPTIONS)[number]; + +export function getValidProviderToolNames(): string[] { + return [ + ...new Set( + Object.values(PROVIDER_CAPABILITIES).flatMap((capabilities) => capabilities.providerTools), + ), + ]; +} diff --git a/packages/cli/src/modules/agents/__tests__/agent-json-config.test.ts b/packages/cli/src/modules/agents/__tests__/agent-json-config.test.ts index 9ad1264b27e..b43361e9f03 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-json-config.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-json-config.test.ts @@ -132,6 +132,56 @@ describe('AgentJsonConfigSchema — memory.observationalMemory', () => { expect(parsed.success).toBe(true); }); + it('preserves observational memory task models', () => { + const parsed = AgentJsonConfigSchema.parse({ + ...baseConfig, + memory: { + ...memoryBase, + observationalMemory: { + observerModel: { model: 'openai/gpt-4o-mini', credential: 'openai-key' }, + reflectorModel: { + model: 'anthropic/claude-sonnet-4-5', + credential: 'anthropic-key', + }, + }, + }, + }); + + expect(parsed.memory?.observationalMemory).toMatchObject({ + observerModel: { model: 'openai/gpt-4o-mini', credential: 'openai-key' }, + reflectorModel: { + model: 'anthropic/claude-sonnet-4-5', + credential: 'anthropic-key', + }, + }); + }); + + it('rejects bare string observational memory task models', () => { + const parsed = AgentJsonConfigSchema.safeParse({ + ...baseConfig, + memory: { + ...memoryBase, + observationalMemory: { observerModel: 'openai/gpt-4o-mini' }, + }, + }); + + expect(parsed.success).toBe(false); + }); + + it('rejects observational memory task models with blank credentials', () => { + const parsed = AgentJsonConfigSchema.safeParse({ + ...baseConfig, + memory: { + ...memoryBase, + observationalMemory: { + observerModel: { model: 'openai/gpt-4o-mini', credential: ' ' }, + }, + }, + }); + + expect(parsed.success).toBe(false); + }); + it('rejects observer thresholds below one', () => { const parsed = AgentJsonConfigSchema.safeParse({ ...baseConfig, @@ -180,6 +230,48 @@ describe('AgentJsonConfigSchema — memory.observationalMemory', () => { describe('AgentJsonConfigSchema — memory.episodicMemory', () => { const memoryBase = { enabled: true, storage: 'n8n' as const }; + it('preserves episodic memory task models', () => { + const parsed = AgentJsonConfigSchema.parse({ + ...baseConfig, + memory: { + ...memoryBase, + episodicMemory: { + enabled: true, + credential: 'credential-id', + extractorModel: { model: 'openai/gpt-4o-mini', credential: 'openai-key' }, + reflectorModel: { + model: 'anthropic/claude-sonnet-4-5', + credential: 'anthropic-key', + }, + }, + }, + }); + + expect(parsed.memory?.episodicMemory).toMatchObject({ + extractorModel: { model: 'openai/gpt-4o-mini', credential: 'openai-key' }, + reflectorModel: { + model: 'anthropic/claude-sonnet-4-5', + credential: 'anthropic-key', + }, + }); + }); + + it('rejects bare string episodic memory task models', () => { + const parsed = AgentJsonConfigSchema.safeParse({ + ...baseConfig, + memory: { + ...memoryBase, + episodicMemory: { + enabled: true, + credential: 'credential-id', + extractorModel: 'openai/gpt-4o-mini', + }, + }, + }); + + expect(parsed.success).toBe(false); + }); + it('rejects enabled episodic memory with a blank credential', () => { const parsed = AgentJsonConfigSchema.safeParse({ ...baseConfig, @@ -191,4 +283,20 @@ describe('AgentJsonConfigSchema — memory.episodicMemory', () => { expect(parsed.success).toBe(false); }); + + it('rejects episodic memory task models with blank credentials', () => { + const parsed = AgentJsonConfigSchema.safeParse({ + ...baseConfig, + memory: { + ...memoryBase, + episodicMemory: { + enabled: true, + credential: 'credential-id', + extractorModel: { model: 'openai/gpt-4o-mini', credential: ' ' }, + }, + }, + }); + + expect(parsed.success).toBe(false); + }); }); diff --git a/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts b/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts index 1df569e04a6..ec2ebc781d3 100644 --- a/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts @@ -5,6 +5,7 @@ import { mock } from 'jest-mock-extended'; import type { AgentsToolsService } from '../agents-tools.service'; import type { AgentsService } from '../agents.service'; +import type { CredentialTypes } from '@/credential-types'; import { AgentsBuilderToolsService, getAgentConfigHash, @@ -26,7 +27,9 @@ function makeService() { const workflowRepository = mock(); const agentsToolsService = mock(); const builderModelLookupService = mock(); + const credentialTypes = mock(); agentsToolsService.getSharedTools.mockReturnValue([]); + credentialTypes.recognizes.mockReturnValue(true); const service = new AgentsBuilderToolsService( agentsService, @@ -34,6 +37,7 @@ function makeService() { workflowRepository, agentsToolsService, builderModelLookupService, + credentialTypes, ); return { service, agentsService, secureRuntime }; @@ -95,9 +99,14 @@ describe('AgentsBuilderToolsService', () => { const { service, agentsService } = makeService(); const currentConfig = { ...baseConfig, integrations: [] }; const updatedConfig = { ...currentConfig, description: 'Updated description' }; + const normalizedConfig = { + ...updatedConfig, + config: { webSearch: { enabled: true } }, + providerTools: { 'anthropic.web_search': { maxUses: 5 } }, + }; agentsService.findById.mockResolvedValue(makeAgent(baseConfig)); agentsService.updateConfig.mockResolvedValue({ - config: updatedConfig, + config: normalizedConfig, updatedAt: '2026-01-02T00:00:00.000Z', versionId: 'v2', }); @@ -112,11 +121,11 @@ describe('AgentsBuilderToolsService', () => { ctx, ); - expect(agentsService.updateConfig).toHaveBeenCalledWith(agentId, projectId, updatedConfig); + expect(agentsService.updateConfig).toHaveBeenCalledWith(agentId, projectId, normalizedConfig); expect(result).toEqual({ ok: true, - config: updatedConfig, - configHash: getAgentConfigHash(updatedConfig), + config: normalizedConfig, + configHash: getAgentConfigHash(normalizedConfig), updatedAt: '2026-01-02T00:00:00.000Z', versionId: 'v2', }); @@ -153,9 +162,14 @@ describe('AgentsBuilderToolsService', () => { const { service, agentsService } = makeService(); const currentConfig = { ...baseConfig, integrations: [] }; const updatedConfig = { ...currentConfig, instructions: 'Help with support tickets.' }; + const normalizedConfig = { + ...updatedConfig, + config: { webSearch: { enabled: true } }, + providerTools: { 'anthropic.web_search': { maxUses: 5 } }, + }; agentsService.findById.mockResolvedValue(makeAgent(baseConfig)); agentsService.updateConfig.mockResolvedValue({ - config: updatedConfig, + config: normalizedConfig, updatedAt: '2026-01-02T00:00:00.000Z', versionId: 'v2', }); @@ -168,16 +182,250 @@ describe('AgentsBuilderToolsService', () => { ctx, ); - expect(agentsService.updateConfig).toHaveBeenCalledWith(agentId, projectId, updatedConfig); + expect(agentsService.updateConfig).toHaveBeenCalledWith(agentId, projectId, normalizedConfig); expect(result).toEqual({ ok: true, - config: updatedConfig, - configHash: getAgentConfigHash(updatedConfig), + config: normalizedConfig, + configHash: getAgentConfigHash(normalizedConfig), updatedAt: '2026-01-02T00:00:00.000Z', versionId: 'v2', }); }); + it('write_config adds OpenAI native web search defaults', async () => { + const { service, agentsService } = makeService(); + const currentConfig = { ...baseConfig, integrations: [] }; + const updatedConfig = { + ...currentConfig, + model: 'openai/gpt-5', + credential: 'OpenAI Key', + }; + const normalizedConfig = { + ...updatedConfig, + config: { webSearch: { enabled: true } }, + providerTools: { + 'openai.web_search': { externalWebAccess: true, searchContextSize: 'medium' }, + }, + }; + agentsService.findById.mockResolvedValue(makeAgent(baseConfig)); + agentsService.updateConfig.mockResolvedValue({ + config: normalizedConfig, + updatedAt: '2026-01-02T00:00:00.000Z', + versionId: 'v2', + }); + + await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!( + { + baseConfigHash: getAgentConfigHash(currentConfig), + json: JSON.stringify(updatedConfig), + }, + ctx, + ); + + expect(agentsService.updateConfig).toHaveBeenCalledWith(agentId, projectId, normalizedConfig); + }); + + it('write_config fills missing native web search default settings', async () => { + const { service, agentsService } = makeService(); + const currentConfig = { ...baseConfig, integrations: [] }; + const updatedConfig: AgentJsonConfig = { + ...currentConfig, + config: { webSearch: { enabled: true } }, + providerTools: { 'anthropic.web_search': {} }, + }; + const normalizedConfig = { + ...updatedConfig, + providerTools: { 'anthropic.web_search': { maxUses: 5 } }, + }; + agentsService.findById.mockResolvedValue(makeAgent(baseConfig)); + agentsService.updateConfig.mockResolvedValue({ + config: normalizedConfig, + updatedAt: '2026-01-02T00:00:00.000Z', + versionId: 'v2', + }); + + await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!( + { + baseConfigHash: getAgentConfigHash(currentConfig), + json: JSON.stringify(updatedConfig), + }, + ctx, + ); + + expect(agentsService.updateConfig).toHaveBeenCalledWith(agentId, projectId, normalizedConfig); + }); + + it('patch_config swaps native web search defaults when changing supported providers', async () => { + const { service, agentsService } = makeService(); + const currentConfig: AgentJsonConfig = { + ...baseConfig, + model: 'openai/gpt-5', + credential: 'OpenAI Key', + config: { webSearch: { enabled: true } }, + providerTools: { + 'openai.web_search': { externalWebAccess: true, searchContextSize: 'medium' }, + }, + integrations: [], + }; + const normalizedConfig = { + ...currentConfig, + model: 'anthropic/claude-sonnet-4-5', + credential: 'Anthropic Key', + providerTools: { 'anthropic.web_search': { maxUses: 5 } }, + }; + agentsService.findById.mockResolvedValue(makeAgent(currentConfig)); + agentsService.updateConfig.mockResolvedValue({ + config: normalizedConfig, + updatedAt: '2026-01-02T00:00:00.000Z', + versionId: 'v2', + }); + + await getJsonTool(service, BUILDER_TOOLS.PATCH_CONFIG).handler!( + { + baseConfigHash: getAgentConfigHash(currentConfig), + operations: JSON.stringify([ + { op: 'replace', path: '/model', value: 'anthropic/claude-sonnet-4-5' }, + { op: 'replace', path: '/credential', value: 'Anthropic Key' }, + ]), + }, + ctx, + ); + + expect(agentsService.updateConfig).toHaveBeenCalledWith(agentId, projectId, normalizedConfig); + }); + + it('write_config rejects native web search for unsupported providers', async () => { + const { service, agentsService } = makeService(); + const currentConfig = { ...baseConfig, integrations: [] }; + const updatedConfig: AgentJsonConfig = { + ...currentConfig, + model: 'xai/grok-4', + config: { webSearch: { enabled: true }, toolCallConcurrency: 2 }, + providerTools: { + 'openai.web_search': { externalWebAccess: true, searchContextSize: 'medium' }, + 'openai.image_generation': {}, + }, + }; + agentsService.findById.mockResolvedValue(makeAgent(baseConfig)); + + const result = await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!( + { + baseConfigHash: getAgentConfigHash(currentConfig), + json: JSON.stringify(updatedConfig), + }, + ctx, + ); + + expect(result).toEqual({ + ok: false, + errors: [ + { + path: '/config/webSearch/provider', + message: + 'Native web search is only supported for Anthropic and OpenAI models. Use Brave or SearXNG fallback web search for this model.', + }, + ], + }); + expect(agentsService.updateConfig).not.toHaveBeenCalled(); + }); + + it('write_config rejects auto web search for unsupported providers', async () => { + const { service, agentsService } = makeService(); + const currentConfig = { ...baseConfig, integrations: [] }; + const updatedConfig: AgentJsonConfig = { + ...currentConfig, + model: 'xai/grok-4', + config: { webSearch: { enabled: true, provider: 'auto' } }, + }; + agentsService.findById.mockResolvedValue(makeAgent(baseConfig)); + + const result = await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!( + { + baseConfigHash: getAgentConfigHash(currentConfig), + json: JSON.stringify(updatedConfig), + }, + ctx, + ); + + expect(result).toEqual({ + ok: false, + errors: [ + { + path: '/config/webSearch/provider', + message: + 'Native web search is only supported for Anthropic and OpenAI models. Use Brave or SearXNG fallback web search for this model.', + }, + ], + }); + expect(agentsService.updateConfig).not.toHaveBeenCalled(); + }); + + it('write_config preserves fallback web search config for unsupported providers', async () => { + const { service, agentsService } = makeService(); + const currentConfig = { ...baseConfig, integrations: [] }; + const updatedConfig: AgentJsonConfig = { + ...currentConfig, + model: 'xai/grok-4', + config: { webSearch: { enabled: true, provider: 'brave', credential: 'brave-key' } }, + providerTools: { + 'anthropic.web_search': { maxUses: 5 }, + }, + }; + const normalizedConfig: AgentJsonConfig = { + ...currentConfig, + model: 'xai/grok-4', + config: { webSearch: { enabled: true, provider: 'brave', credential: 'brave-key' } }, + }; + agentsService.findById.mockResolvedValue(makeAgent(baseConfig)); + agentsService.updateConfig.mockResolvedValue({ + config: normalizedConfig, + updatedAt: '2026-01-02T00:00:00.000Z', + versionId: 'v2', + }); + + await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!( + { + baseConfigHash: getAgentConfigHash(currentConfig), + json: JSON.stringify(updatedConfig), + }, + ctx, + ); + + expect(agentsService.updateConfig).toHaveBeenCalledWith(agentId, projectId, normalizedConfig); + }); + + it('write_config preserves fallback web search config for native-capable providers', async () => { + const { service, agentsService } = makeService(); + const currentConfig = { ...baseConfig, integrations: [] }; + const updatedConfig: AgentJsonConfig = { + ...currentConfig, + config: { webSearch: { enabled: true, provider: 'brave', credential: 'brave-key' } }, + providerTools: { + 'anthropic.web_search': { maxUses: 5 }, + }, + }; + const normalizedConfig: AgentJsonConfig = { + ...currentConfig, + config: { webSearch: { enabled: true, provider: 'brave', credential: 'brave-key' } }, + }; + agentsService.findById.mockResolvedValue(makeAgent(baseConfig)); + agentsService.updateConfig.mockResolvedValue({ + config: normalizedConfig, + updatedAt: '2026-01-02T00:00:00.000Z', + versionId: 'v2', + }); + + await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!( + { + baseConfigHash: getAgentConfigHash(currentConfig), + json: JSON.stringify(updatedConfig), + }, + ctx, + ); + + expect(agentsService.updateConfig).toHaveBeenCalledWith(agentId, projectId, normalizedConfig); + }); + it('write_config rejects draft LLM config without updating', async () => { const { service, agentsService } = makeService(); const currentConfig = { ...baseConfig, integrations: [] }; diff --git a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts index dc945fef0c4..15904ac987f 100644 --- a/packages/cli/src/modules/agents/__tests__/agents.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents.service.test.ts @@ -1603,6 +1603,182 @@ describe('AgentsService', () => { expect(result.missing).not.toContain('episodicMemory.credential'); }); + it('flags missing fallback web search credential', async () => { + credentialProvider.list.mockResolvedValue([{ id: 'main-cred' }]); + const agent = makeAgent({ + schema: { + name: 'Test Agent', + model: 'anthropic/claude-sonnet-4-5', + credential: 'main-cred', + instructions: 'Do stuff', + config: { + webSearch: { + enabled: true, + provider: 'brave', + }, + }, + } as AgentJsonConfig, + }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + const result = await service.validateAgentIsRunnable( + agentId, + projectId, + credentialProvider as unknown as Parameters[2], + ); + + expect(result.missing).not.toContain('credential'); + expect(result.missing).toContain('webSearch.credential'); + }); + + it('flags fallback web search credential that does not exist', async () => { + credentialProvider.list.mockResolvedValue([{ id: 'main-cred' }]); + const agent = makeAgent({ + schema: { + name: 'Test Agent', + model: 'anthropic/claude-sonnet-4-5', + credential: 'main-cred', + instructions: 'Do stuff', + config: { + webSearch: { + enabled: true, + provider: 'brave', + credential: 'missing-web-search-cred', + }, + }, + } as AgentJsonConfig, + }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + const result = await service.validateAgentIsRunnable( + agentId, + projectId, + credentialProvider as unknown as Parameters[2], + ); + + expect(result.missing).not.toContain('credential'); + expect(result.missing).toContain('webSearch.credential'); + }); + + it('flags missing memory worker model credentials', async () => { + credentialProvider.list.mockResolvedValue([{ id: 'main-cred', type: 'openAiApi' }]); + const agent = makeAgent({ + schema: { + name: 'Test Agent', + model: 'openai/gpt-5', + credential: 'main-cred', + instructions: 'Do stuff', + memory: { + enabled: true, + storage: 'n8n', + observationalMemory: { + observerModel: { + model: 'anthropic/claude-sonnet-4-5', + credential: 'missing-worker-cred', + }, + }, + }, + } as AgentJsonConfig, + }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + const result = await service.validateAgentIsRunnable( + agentId, + projectId, + credentialProvider as unknown as Parameters[2], + ); + + expect(result.missing).toContain('memory.observationalMemory.observerModel.credential'); + }); + + it('flags memory worker credentials that do not match the worker model provider', async () => { + credentialProvider.list.mockResolvedValue([ + { id: 'main-cred', type: 'openAiApi' }, + { id: 'wrong-worker-cred', type: 'openAiApi' }, + ]); + const agent = makeAgent({ + schema: { + name: 'Test Agent', + model: 'openai/gpt-5', + credential: 'main-cred', + instructions: 'Do stuff', + memory: { + enabled: true, + storage: 'n8n', + observationalMemory: { + reflectorModel: { + model: 'anthropic/claude-sonnet-4-5', + credential: 'wrong-worker-cred', + }, + }, + }, + } as AgentJsonConfig, + }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + const result = await service.validateAgentIsRunnable( + agentId, + projectId, + credentialProvider as unknown as Parameters[2], + ); + + expect(result.missing).toContain('memory.observationalMemory.reflectorModel.credential'); + }); + + it('accepts cross-provider memory worker models with matching credentials', async () => { + credentialProvider.list.mockResolvedValue([ + { id: 'main-cred', type: 'openAiApi' }, + { id: 'embedding-cred', type: 'openAiApi' }, + { id: 'worker-cred', type: 'anthropicApi' }, + ]); + const agent = makeAgent({ + schema: { + name: 'Test Agent', + model: 'openai/gpt-5', + credential: 'main-cred', + instructions: 'Do stuff', + memory: { + enabled: true, + storage: 'n8n', + observationalMemory: { + observerModel: { + model: 'anthropic/claude-sonnet-4-5', + credential: 'worker-cred', + }, + reflectorModel: { + model: 'anthropic/claude-sonnet-4-5', + credential: 'worker-cred', + }, + }, + episodicMemory: { + enabled: true, + credential: 'embedding-cred', + extractorModel: { + model: 'anthropic/claude-sonnet-4-5', + credential: 'worker-cred', + }, + reflectorModel: { + model: 'anthropic/claude-sonnet-4-5', + credential: 'worker-cred', + }, + }, + }, + } as AgentJsonConfig, + }); + agentRepository.findByIdAndProjectId.mockResolvedValue(agent); + + const result = await service.validateAgentIsRunnable( + agentId, + projectId, + credentialProvider as unknown as Parameters[2], + ); + + expect(result.missing).not.toContain('memory.observationalMemory.observerModel.credential'); + expect(result.missing).not.toContain('memory.observationalMemory.reflectorModel.credential'); + expect(result.missing).not.toContain('memory.episodicMemory.extractorModel.credential'); + expect(result.missing).not.toContain('memory.episodicMemory.reflectorModel.credential'); + }); + it('flags config skill refs that have no stored body', async () => { const agent = makeAgent({ schema: { diff --git a/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts b/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts index 3b36b918bec..37faf36f3f1 100644 --- a/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts +++ b/packages/cli/src/modules/agents/__tests__/from-json-config.test.ts @@ -1,4 +1,5 @@ -import type { AgentSnapshot, ToolDescriptor } from '@n8n/agents'; +import * as AgentsRuntime from '@n8n/agents'; +import type { AgentSnapshot, BuiltProviderTool, BuiltTool, ToolDescriptor } from '@n8n/agents'; import type { JSONSchema7 } from 'json-schema'; import { @@ -41,6 +42,10 @@ jest.mock('@ai-sdk/openai', () => ({ // --------------------------------------------------------------------------- describe('buildFromJson()', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + const makeConfig = (overrides: Partial = {}): AgentJsonConfig => ({ name: 'test-agent', model: 'anthropic/claude-sonnet-4-5', @@ -104,6 +109,27 @@ describe('buildFromJson()', () => { } ).memoryConfig; + const getProviderToolNames = (agent: unknown): string[] => + ( + agent as { + providerTools?: BuiltProviderTool[]; + } + ).providerTools?.map((tool) => tool.name) ?? []; + + const getProviderTool = (agent: unknown, name: string): BuiltProviderTool | undefined => + ( + agent as { + providerTools?: BuiltProviderTool[]; + } + ).providerTools?.find((tool) => tool.name === name); + + const getLocalToolNames = (agent: unknown): string[] => + ( + agent as { + tools?: BuiltTool[]; + } + ).tools?.map((tool) => tool.name) ?? []; + const getDefaultExecutionOptions = (agent: unknown) => (agent as { defaultExecutionOptions?: { maxIterations?: number } }).defaultExecutionOptions; @@ -505,6 +531,186 @@ describe('buildFromJson()', () => { expect(snap.thinking).toMatchObject({ budgetTokens: 5000 }); }); + it.each([ + ['anthropic/claude-sonnet-4-5', 'anthropic.web_search_20250305'], + ['openai/gpt-4o', 'openai.web_search'], + ])('enables native web search when explicitly enabled for %s', async (model, expectedTool) => { + const agent = await buildFromJson( + makeConfig({ model, config: { webSearch: { enabled: true } } }), + {}, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider: makeMockCredentialProvider(), + memoryFactory: makeMockMemoryFactory(), + }, + ); + + expect(getProviderToolNames(agent)).toContain(expectedTool); + }); + + it('rejects native web search config for unsupported providers without fallback settings', async () => { + await expect( + buildFromJson( + makeConfig({ + model: 'google/gemini-2.5-flash', + config: { webSearch: { enabled: true } }, + providerTools: { 'anthropic.web_search': { maxUses: 5 } }, + }), + {}, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider: makeMockCredentialProvider(), + memoryFactory: makeMockMemoryFactory(), + }, + ), + ).rejects.toThrow('Web search is enabled but no fallback search provider is configured.'); + }); + + it('does not enable native web search when config is sparse', async () => { + const agent = await buildFromJson( + makeConfig(), + {}, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider: makeMockCredentialProvider(), + memoryFactory: makeMockMemoryFactory(), + }, + ); + + expect(getProviderToolNames(agent)).not.toContain('anthropic.web_search_20250305'); + }); + + it('does not enable native web search when explicitly disabled', async () => { + const agent = await buildFromJson( + makeConfig({ + config: { webSearch: { enabled: false } }, + providerTools: { + 'anthropic.web_search': { maxUses: 5 }, + 'openai.image_generation': {}, + }, + }), + {}, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider: makeMockCredentialProvider(), + memoryFactory: makeMockMemoryFactory(), + }, + ); + + expect(getProviderToolNames(agent)).not.toContain('anthropic.web_search_20250305'); + expect(getProviderToolNames(agent)).toContain('openai.image_generation'); + }); + + it('preserves native web search args when explicitly enabled', async () => { + const agent = await buildFromJson( + makeConfig({ + config: { webSearch: { enabled: true } }, + providerTools: { + 'anthropic.web_search': { maxUses: 3 }, + }, + }), + {}, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider: makeMockCredentialProvider(), + memoryFactory: makeMockMemoryFactory(), + }, + ); + + expect(getProviderTool(agent, 'anthropic.web_search_20250305')?.args).toEqual({ + maxUses: 3, + }); + }); + + it('preserves explicitly configured native web search versions for the selected provider', async () => { + const agent = await buildFromJson( + makeConfig({ + config: { webSearch: { enabled: true } }, + providerTools: { + 'anthropic.web_search_20260209': {}, + }, + }), + {}, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider: makeMockCredentialProvider(), + memoryFactory: makeMockMemoryFactory(), + }, + ); + + expect(getProviderToolNames(agent)).toContain('anthropic.web_search_20260209'); + expect(getProviderToolNames(agent)).not.toContain('anthropic.web_search_20250305'); + }); + + it('adds fallback web search tool for providers without native web search', async () => { + const agent = await buildFromJson( + makeConfig({ + model: 'deepseek/deepseek-chat', + config: { webSearch: { enabled: true, provider: 'brave', credential: 'brave-key' } }, + }), + {}, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider: makeMockCredentialProvider(), + memoryFactory: makeMockMemoryFactory(), + }, + ); + + expect(getProviderToolNames(agent)).toEqual([]); + expect(getLocalToolNames(agent)).toContain('web_search'); + }); + + it('uses fallback web search when configured for native-capable providers', async () => { + const agent = await buildFromJson( + makeConfig({ + config: { webSearch: { enabled: true, provider: 'brave', credential: 'brave-key' } }, + }), + {}, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider: makeMockCredentialProvider(), + memoryFactory: makeMockMemoryFactory(), + }, + ); + + expect(getProviderToolNames(agent)).toEqual([]); + expect(getLocalToolNames(agent)).toContain('web_search'); + }); + + it('uses native web search when native provider is explicitly configured', async () => { + const agent = await buildFromJson( + makeConfig({ + config: { webSearch: { enabled: true, provider: 'native' } }, + }), + {}, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider: makeMockCredentialProvider(), + memoryFactory: makeMockMemoryFactory(), + }, + ); + + expect(getProviderToolNames(agent)).toContain('anthropic.web_search_20250305'); + expect(getLocalToolNames(agent)).not.toContain('web_search'); + }); + + it('requires fallback web search credentials for providers without native web search', async () => { + await expect( + buildFromJson( + makeConfig({ + model: 'deepseek/deepseek-chat', + config: { webSearch: { enabled: true, provider: 'brave' } }, + }), + {}, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider: makeMockCredentialProvider(), + memoryFactory: makeMockMemoryFactory(), + }, + ), + ).rejects.toThrow('Web search is enabled but no search credential is configured.'); + }); + it('sets toolCallConcurrency', async () => { const config = makeConfig({ config: { toolCallConcurrency: 5 } }); @@ -580,6 +786,54 @@ describe('buildFromJson()', () => { expect(getMemoryConfig(agent)?.observationalMemory?.reflect).toBeUndefined(); }); + it('configures observational memory worker models with their own credentials', async () => { + const observeSpy = jest.spyOn(AgentsRuntime, 'createObservationLogObserveFn'); + const reflectSpy = jest.spyOn(AgentsRuntime, 'createObservationLogReflectFn'); + const credentialProvider = { + resolve: jest.fn(async (credentialId: string) => ({ + apiKey: `${credentialId}-api-key`, + url: `https://${credentialId}.example/v1`, + })), + list: jest.fn().mockResolvedValue([]), + }; + const config = makeConfig({ + memory: { + enabled: true, + storage: 'n8n', + observationalMemory: { + observerModel: { model: 'openai/gpt-4o-mini', credential: 'observer-key' }, + reflectorModel: { + model: 'anthropic/claude-sonnet-4-5', + credential: 'reflector-key', + }, + }, + }, + }); + + await buildFromJson( + config, + {}, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider, + memoryFactory: jest.fn().mockReturnValue(makeMockMemoryBackend()), + }, + ); + + expect(observeSpy).toHaveBeenCalledWith({ + id: 'openai/gpt-4o-mini', + apiKey: 'observer-key-api-key', + baseURL: 'https://observer-key.example/v1', + }); + expect(reflectSpy).toHaveBeenCalledWith({ + id: 'anthropic/claude-sonnet-4-5', + apiKey: 'reflector-key-api-key', + baseURL: 'https://reflector-key.example/v1', + }); + expect(credentialProvider.resolve).toHaveBeenCalledWith('observer-key'); + expect(credentialProvider.resolve).toHaveBeenCalledWith('reflector-key'); + }); + it('enables observational memory by default when memory is enabled', async () => { const config = makeConfig({ memory: { enabled: true, storage: 'n8n' }, @@ -644,6 +898,63 @@ describe('buildFromJson()', () => { expect(getMemoryConfig(agent)?.episodicMemory?.reflect).toBeUndefined(); }); + it('configures episodic memory worker models with separate credentials from embeddings', async () => { + const extractSpy = jest.spyOn(AgentsRuntime, 'createEpisodicMemoryExtractFn'); + const reflectSpy = jest.spyOn(AgentsRuntime, 'createEpisodicMemoryReflectFn'); + const credentialProvider = { + resolve: jest.fn(async (credentialId: string) => ({ + apiKey: `${credentialId}-api-key`, + url: `https://${credentialId}.example/v1`, + })), + list: jest.fn().mockResolvedValue([]), + }; + const config = makeConfig({ + memory: { + enabled: true, + storage: 'n8n', + episodicMemory: { + enabled: true, + credential: 'embedding-key', + extractorModel: { model: 'openai/gpt-4o-mini', credential: 'extractor-key' }, + reflectorModel: { + model: 'anthropic/claude-sonnet-4-5', + credential: 'episodic-reflector-key', + }, + }, + }, + }); + + const agent = await buildFromJson( + config, + {}, + { + toolExecutor: makeMockToolExecutor(), + credentialProvider, + memoryFactory: jest.fn().mockReturnValue(makeMockMemoryBackend()), + }, + ); + + expect(extractSpy).toHaveBeenCalledWith({ + id: 'openai/gpt-4o-mini', + apiKey: 'extractor-key-api-key', + baseURL: 'https://extractor-key.example/v1', + }); + expect(reflectSpy).toHaveBeenCalledWith({ + id: 'anthropic/claude-sonnet-4-5', + apiKey: 'episodic-reflector-key-api-key', + baseURL: 'https://episodic-reflector-key.example/v1', + }); + expect(getMemoryConfig(agent)?.episodicMemory).toMatchObject({ + embeddingProviderOptions: { + apiKey: 'embedding-key-api-key', + baseURL: 'https://embedding-key.example/v1', + }, + }); + expect(credentialProvider.resolve).toHaveBeenCalledWith('embedding-key'); + expect(credentialProvider.resolve).toHaveBeenCalledWith('extractor-key'); + expect(credentialProvider.resolve).toHaveBeenCalledWith('episodic-reflector-key'); + }); + it('can disable observational memory while keeping message memory', async () => { const config = makeConfig({ memory: { enabled: true, storage: 'n8n', observationalMemory: { enabled: false } }, diff --git a/packages/cli/src/modules/agents/agents.service.ts b/packages/cli/src/modules/agents/agents.service.ts index d61faa32fb2..4288b4ddac8 100644 --- a/packages/cli/src/modules/agents/agents.service.ts +++ b/packages/cli/src/modules/agents/agents.service.ts @@ -72,6 +72,7 @@ import { AgentExecutionService } from './agent-execution.service'; import { AgentSkillsService } from './agent-skills.service'; import { AgentsToolsService } from './agents-tools.service'; import { AGENT_THREAD_PREFIX } from './builder/builder-tool-names'; +import { LLM_PROVIDER_DEFAULTS } from './builder/interactive/llm-provider-defaults'; import { Agent } from './entities/agent.entity'; import { ExecutionRecorder } from './execution-recorder'; import { ChatIntegrationRegistry } from './integrations/agent-chat-integration'; @@ -997,6 +998,8 @@ export class AgentsService { * a real credential in the project * - "episodicMemory.credential": configured Episodic Memory credential * does not resolve to a real credential in the project + * - "memory.*.*Model.credential": configured memory worker model + * credential does not resolve or does not match the model provider * - "skill:": config references a skill id with no stored body */ async validateAgentIsRunnable( @@ -1025,9 +1028,12 @@ export class AgentsService { } let credentialList: Awaited> | undefined; - const credentialExists = async (credentialId: string) => { + const findCredential = async (credentialId: string) => { credentialList ??= await credentialProvider.list(); - return credentialList.some((credential) => credential.id === credentialId); + return credentialList.find((credential) => credential.id === credentialId); + }; + const credentialExists = async (credentialId: string) => { + return (await findCredential(credentialId)) !== undefined; }; if (!config.credential?.trim()) { @@ -1043,10 +1049,36 @@ export class AgentsService { } const episodicMemory = config.memory?.episodicMemory; - if (config.memory?.enabled && episodicMemory?.enabled === true) { + if (config.memory?.enabled) { try { - if (!(await credentialExists(episodicMemory.credential.trim()))) { - missing.push('episodicMemory.credential'); + await this.validateMemoryWorkerModel( + config.memory.observationalMemory?.observerModel, + 'memory.observationalMemory.observerModel', + findCredential, + missing, + ); + await this.validateMemoryWorkerModel( + config.memory.observationalMemory?.reflectorModel, + 'memory.observationalMemory.reflectorModel', + findCredential, + missing, + ); + if (episodicMemory?.enabled === true) { + if (!(await credentialExists(episodicMemory.credential.trim()))) { + missing.push('episodicMemory.credential'); + } + await this.validateMemoryWorkerModel( + episodicMemory.extractorModel, + 'memory.episodicMemory.extractorModel', + findCredential, + missing, + ); + await this.validateMemoryWorkerModel( + episodicMemory.reflectorModel, + 'memory.episodicMemory.reflectorModel', + findCredential, + missing, + ); } } catch { // Same behavior as the main model credential: runtime reconstruction @@ -1054,6 +1086,26 @@ export class AgentsService { } } + const webSearch = config.config?.webSearch; + if ( + webSearch?.enabled && + (webSearch.provider === 'brave' || webSearch.provider === 'searxng') + ) { + const webSearchCredentialId = webSearch.credential?.trim(); + if (!webSearchCredentialId) { + missing.push('webSearch.credential'); + } else { + try { + if (!(await credentialExists(webSearchCredentialId))) { + missing.push('webSearch.credential'); + } + } catch { + // Keep the same behavior as other credential checks: runtime execution + // surfaces list/permission failures with the concrete error. + } + } + } + missing.push( ...this.agentSkillsService .getMissingSkillIds(config, agentEntity.skills ?? {}) @@ -1063,6 +1115,44 @@ export class AgentsService { return { missing }; } + private async validateMemoryWorkerModel( + modelConfig: { model?: string | null; credential?: string | null } | string | null | undefined, + path: string, + findCredential: ( + credentialId: string, + ) => Promise>[number] | undefined>, + missing: string[], + ) { + if (modelConfig === undefined || modelConfig === null) return; + + if (typeof modelConfig === 'string') { + missing.push(`${path}.credential`); + return; + } + + if (!modelConfig.model?.trim() || !AgentModelSchema.safeParse(modelConfig.model).success) { + missing.push(`${path}.model`); + } + + const credentialId = modelConfig.credential?.trim(); + if (!credentialId) { + missing.push(`${path}.credential`); + return; + } + + const credential = await findCredential(credentialId); + if ( + !credential || + !this.workerCredentialSupportsModel(credential.type, modelConfig.model ?? '') + ) { + missing.push(`${path}.credential`); + } + } + + private workerCredentialSupportsModel(credentialType: string, model: string) { + return LLM_PROVIDER_DEFAULTS[credentialType]?.provider === getProviderPrefix(model); + } + /** * Execute an agent for the in-app test chat and yield stream chunks. * @@ -1932,3 +2022,8 @@ export class AgentsService { return { agent: reconstructed, toolRegistry }; } } + +function getProviderPrefix(modelId: string): string { + const slashIdx = modelId.indexOf('/'); + return slashIdx === -1 ? '' : modelId.slice(0, slashIdx); +} diff --git a/packages/cli/src/modules/agents/builder/__tests__/agents-builder-model-recommendations.test.ts b/packages/cli/src/modules/agents/builder/__tests__/agents-builder-model-recommendations.test.ts index f577866585a..dabba395a7e 100644 --- a/packages/cli/src/modules/agents/builder/__tests__/agents-builder-model-recommendations.test.ts +++ b/packages/cli/src/modules/agents/builder/__tests__/agents-builder-model-recommendations.test.ts @@ -124,6 +124,24 @@ describe('builder model recommendations', () => { expect(prompt).not.toContain('agent-builder-tools'); }); + it('tells the builder to preserve fallback web search on model switches', () => { + const prompt = buildPrompt(null); + + expect(prompt).toContain( + 'When changing models, preserve existing Brave or SearXNG\n `config.webSearch` unchanged', + ); + expect(prompt).toContain( + 'Only OpenAI and Anthropic models support native web search. Use native web\n search by default for those providers only', + ); + expect(prompt).toContain('For every provider other than OpenAI or Anthropic'); + expect(prompt).toContain( + 'Model-only changes must preserve existing Brave or SearXNG `config.webSearch`.', + ); + expect(prompt).toContain( + 'Preserve existing Brave/SearXNG `config.webSearch` on model switches unless', + ); + }); + it('injects custom tool builder guidance into the base builder prompt', () => { const prompt = buildPrompt(null); diff --git a/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts b/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts index 504591730ff..7fb189cf3c5 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts @@ -66,6 +66,12 @@ Before these specialized tasks, call \`load_skill\` with - \`agent-builder-integrations\`: schedule and chat integrations. - \`agent-builder-target-skills\`: creating skills for the target agent. +Requests for "web search", "Brave web search", or "SearXNG web search" are +agent config changes, not node-tool tasks. Follow the Config schema reference: +web search lives under \`config.webSearch\`. Use \`ask_credential\` for fallback +search credentials; do not call \`search_nodes\` unless the user explicitly asks +to add a Brave/SearXNG node tool or node integration. + Do not use \`create_skill\` for your own builder guidance. \`create_skill\` creates a skill for the target agent only.`; @@ -122,7 +128,8 @@ export const IMPORTANT_SECTION = `\ \`list_credentials\` into config. - Use \`ask_question\` instead of prose when the answer is a known small set. - Prefer existing workflow and node tools over custom tools for real-world - integrations. + integrations. Exception: generic web search is configured via + \`config.webSearch\`, including Brave and SearXNG fallback search. - \`build_custom_tool\` stores code only; register the returned id in config. - \`create_skill\` stores a target-agent skill body only. It is active only after \`read_config\` plus \`patch_config\` or \`write_config\` adds diff --git a/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts b/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts index 728e7de29af..3adf9102991 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts @@ -15,9 +15,14 @@ import type { Operation } from 'fast-json-patch'; import { createHash } from 'node:crypto'; import { z } from 'zod'; +import { CredentialTypes } from '@/credential-types'; import { AgentsToolsService } from '../agents-tools.service'; import { AgentsService } from '../agents.service'; import { composeJsonConfig } from '../json-config/agent-config-composition'; +import { + getNativeWebSearchProviderTools, + hasNativeWebSearchProvider, +} from '../json-config/native-web-search-provider-tools'; import { AgentSecureRuntime } from '../runtime/agent-secure-runtime'; import { BuilderModelLookupService } from './builder-model-lookup.service'; import { @@ -57,6 +62,27 @@ function rejectIfEmptyInstructions( return null; } +function rejectIfUnsupportedNativeWebSearch( + config: AgentJsonConfig, +): { errors: ConfigValidationError[] } | null { + const webSearch = config.config?.webSearch; + const requestsNativeWebSearch = + webSearch?.enabled === true && + (webSearch.provider === undefined || + webSearch.provider === 'auto' || + webSearch.provider === 'native'); + if (!requestsNativeWebSearch || hasNativeWebSearchProvider(config.model)) return null; + return { + errors: [ + { + path: '/config/webSearch/provider', + message: + 'Native web search is only supported for Anthropic and OpenAI models. Use Brave or SearXNG fallback web search for this model.', + }, + ], + }; +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -95,6 +121,48 @@ function snapshotFromConfig( }; } +/** + * The builder expresses web-search intent through `config.webSearch`; this + * write-path normalizer persists provider-specific native tool details so + * builder-saved configs are deterministic. Runtime reconstruction uses the + * same policy defensively for configs saved through other entry points. + */ +function applyNativeWebSearchBuilderDefaults(config: AgentJsonConfig): AgentJsonConfig { + const providerTools = getNativeWebSearchProviderTools(config, { + includeDefaultArgs: true, + defaultEnabled: true, + }); + const webSearch = config.config?.webSearch; + const fallbackWebSearch = + webSearch?.enabled === true && + (webSearch.provider === 'brave' || webSearch.provider === 'searxng'); + const hasNativeWebSearch = + !fallbackWebSearch && webSearch?.enabled !== false && hasNativeWebSearchProvider(config.model); + + if (!hasNativeWebSearch) { + const { webSearch, ...restConfig } = config.config ?? {}; + const { config: _config, providerTools: _providerTools, ...restAgentConfig } = config; + const normalizedConfig = { + ...restConfig, + ...(fallbackWebSearch ? { webSearch } : {}), + }; + return { + ...restAgentConfig, + ...(Object.keys(normalizedConfig).length > 0 ? { config: normalizedConfig } : {}), + ...(Object.keys(providerTools).length > 0 ? { providerTools } : {}), + }; + } + + return { + ...config, + config: { + ...(config.config ?? {}), + webSearch: { enabled: true }, + }, + providerTools, + }; +} + export interface BuilderTools { json: BuiltTool[]; shared: BuiltTool[]; @@ -108,6 +176,7 @@ export class AgentsBuilderToolsService { private readonly workflowRepository: WorkflowRepository, private readonly agentsToolsService: AgentsToolsService, private readonly builderModelLookupService: BuilderModelLookupService, + private readonly credentialTypes: CredentialTypes, ) {} getTools( @@ -193,11 +262,16 @@ export class AgentsBuilderToolsService { if (emptyInstructions) { return { ok: false, errors: emptyInstructions.errors }; } + const unsupportedNativeWebSearch = rejectIfUnsupportedNativeWebSearch(zodResult.data); + if (unsupportedNativeWebSearch) { + return { ok: false, errors: unsupportedNativeWebSearch.errors }; + } + const normalizedConfig = applyNativeWebSearchBuilderDefaults(zodResult.data); try { const result = await this.agentsService.updateConfig( agentId, projectId, - zodResult.data, + normalizedConfig, ); return { ok: true, @@ -294,12 +368,17 @@ export class AgentsBuilderToolsService { if (emptyInstructions) { return { ok: false, stage: 'schema', errors: emptyInstructions.errors }; } + const unsupportedNativeWebSearch = rejectIfUnsupportedNativeWebSearch(zodResult.data); + if (unsupportedNativeWebSearch) { + return { ok: false, stage: 'schema', errors: unsupportedNativeWebSearch.errors }; + } + const normalizedConfig = applyNativeWebSearchBuilderDefaults(zodResult.data); try { const result = await this.agentsService.updateConfig( agentId, projectId, - zodResult.data, + normalizedConfig, ); return { ok: true, @@ -346,7 +425,10 @@ export class AgentsBuilderToolsService { patchConfigTool, listIntegrationTypesTool, buildResolveLlmTool({ credentialProvider, modelLookup }), - buildAskCredentialTool({ credentialProvider }), + buildAskCredentialTool({ + credentialProvider, + isCredentialTypeKnown: (credentialType) => this.credentialTypes.recognizes(credentialType), + }), buildAskLlmTool(), buildAskQuestionTool(), ]; diff --git a/packages/cli/src/modules/agents/builder/interactive/__tests__/ask-credential.tool.test.ts b/packages/cli/src/modules/agents/builder/interactive/__tests__/ask-credential.tool.test.ts index 1aee20b5a20..d9c9ad47020 100644 --- a/packages/cli/src/modules/agents/builder/interactive/__tests__/ask-credential.tool.test.ts +++ b/packages/cli/src/modules/agents/builder/interactive/__tests__/ask-credential.tool.test.ts @@ -52,6 +52,35 @@ describe('ask_credential tool', () => { expect(ctx.suspend).toHaveBeenCalledTimes(1); }); + it('fails fast when the requested credential type is unknown', async () => { + const credentialProvider = makeProvider([{ id: 'c2', name: 'OpenAI', type: 'openAiApi' }]); + const tool = buildAskCredentialTool({ + credentialProvider, + isCredentialTypeKnown: (credentialType) => credentialType === 'openAiApi', + }); + const ctx = makeCtx(); + + await expect( + tool.handler!({ purpose: 'Brave search', credentialType: 'braveSearch' }, ctx as never), + ).rejects.toThrow('Unknown credential type "braveSearch"'); + expect(ctx.suspend).not.toHaveBeenCalled(); + }); + + it('still suspends when the requested credential type is known but has no credentials', async () => { + const credentialProvider = makeProvider([{ id: 'c2', name: 'OpenAI', type: 'openAiApi' }]); + const tool = buildAskCredentialTool({ + credentialProvider, + isCredentialTypeKnown: (credentialType) => credentialType === 'braveSearchApi', + }); + const ctx = makeCtx(); + + await tool.handler!( + { purpose: 'Brave search', credentialType: 'braveSearchApi' }, + ctx as never, + ); + expect(ctx.suspend).toHaveBeenCalledTimes(1); + }); + it('returns resumeData verbatim after resume without consulting the provider', async () => { const credentialProvider = makeProvider([]); const tool = buildAskCredentialTool({ credentialProvider }); diff --git a/packages/cli/src/modules/agents/builder/interactive/ask-credential.tool.ts b/packages/cli/src/modules/agents/builder/interactive/ask-credential.tool.ts index 501ab1de778..4fed3f2c46b 100644 --- a/packages/cli/src/modules/agents/builder/interactive/ask-credential.tool.ts +++ b/packages/cli/src/modules/agents/builder/interactive/ask-credential.tool.ts @@ -10,6 +10,7 @@ import { export interface AskCredentialToolDeps { credentialProvider: CredentialProvider; + isCredentialTypeKnown?: (credentialType: string) => boolean; } export function buildAskCredentialTool(deps: AskCredentialToolDeps): BuiltTool { @@ -34,6 +35,11 @@ export function buildAskCredentialTool(deps: AskCredentialToolDeps): BuiltTool { ctx: InterruptibleToolContext, ) => { if (ctx.resumeData !== undefined) return ctx.resumeData; + if (deps.isCredentialTypeKnown && !deps.isCredentialTypeKnown(input.credentialType)) { + throw new Error( + `Unknown credential type "${input.credentialType}". Use an exact n8n credential type name.`, + ); + } // If the user has exactly one credential of the requested type the // picker has nothing to ask — auto-resolve so the LLM doesn't render // a card the user can only confirm. diff --git a/packages/cli/src/modules/agents/builder/prompts/config-mutation.prompt.ts b/packages/cli/src/modules/agents/builder/prompts/config-mutation.prompt.ts index d24aa1945bb..5f646f25777 100644 --- a/packages/cli/src/modules/agents/builder/prompts/config-mutation.prompt.ts +++ b/packages/cli/src/modules/agents/builder/prompts/config-mutation.prompt.ts @@ -69,13 +69,26 @@ Use \`patch_config\` with: #### Configure Native Provider Features - Thinking lives under \`config.thinking\`. -- The write path fills native provider tool defaults. Do not invent provider tool keys. +- Web search lives under \`config.webSearch\`. +- Only OpenAI and Anthropic models support native web search. For those models, set + \`config.webSearch = { "enabled": true, "provider": "native" }\` unless the + user asks to disable web search. Omitting \`provider\` also means native. +- For every other provider, never use \`provider: "native"\` or omit + \`provider\` for enabled web search. +- For Brave or SearXNG search, call \`ask_credential\`, then set + \`config.webSearch = { "enabled": true, "provider": "brave" | "searxng", "credential": "" }\`. +- Brave and SearXNG remain fallback tools even when the model provider also supports native search. +- When patching only \`/model\` and \`/credential\`, do not patch + \`/config/webSearch\` if the existing provider is \`"brave"\` or \`"searxng"\` + unless the user explicitly asked to change the web-search method. +- Never write \`{ "enabled": true }\` alone for fallback search. +- The write path fills native provider tool defaults only for native search. Do not invent provider tool keys. #### Configure Fallback Services -- Services that require credentials must call \`ask_credential\` first. -- Persist only the credential id returned by \`ask_credential\`. +- Services that require credentials must call \`ask_credential\` first and persist only its returned credential id. - If credential selection is skipped, do not enable the feature unless it supports missing credentials. +- For fallback web search, use exact credential type names: \`braveSearchApi\` for \`provider: "brave"\`, and \`searXngApi\` for \`provider: "searxng"\`. #### Add Node Or Workflow Tools @@ -87,7 +100,7 @@ Use \`patch_config\` with: Bad: inventing top-level fields \`\`\`json -{ "temperature": 0.7 } +{ "webSearch": { "enabled": true } } \`\`\` Bad: provider namespace as provider tool @@ -102,7 +115,7 @@ Bad: copying credential IDs from \`list_credentials\` Bad: replacing \`config\` while dropping unrelated settings \`\`\`json -{ "config": { "thinking": { "provider": "anthropic", "budgetTokens": 1024 } } } +{ "config": { "webSearch": { "enabled": true } } } \`\`\` ### Gotchas @@ -110,12 +123,14 @@ Bad: replacing \`config\` while dropping unrelated settings - \`write_config\` replaces the full config; include every field that should survive. - \`patch_config\` cannot create a config when none exists; use \`write_config\` first. - \`/array/-\` appends to an array; \`/array/0\` inserts before the current first item. +- Model-only changes must preserve existing Brave or SearXNG \`config.webSearch\`. - Empty, placeholder, or guessed \`instructions\` are rejected; ask for details instead. ### Verify - The final payload validates against the Config schema reference. - Existing unrelated config, tools, skills, integrations, and memory remain present unless intentionally changed. +- Existing Brave or SearXNG web search remains present on model-only changes. - Credential fields use ids returned by the correct interactive credential tools. - Provider tool keys are valid and match the selected model provider. diff --git a/packages/cli/src/modules/agents/builder/prompts/config-rules.prompt.ts b/packages/cli/src/modules/agents/builder/prompts/config-rules.prompt.ts index f0481c0971f..a5c97f6ccf8 100644 --- a/packages/cli/src/modules/agents/builder/prompts/config-rules.prompt.ts +++ b/packages/cli/src/modules/agents/builder/prompts/config-rules.prompt.ts @@ -2,10 +2,15 @@ import type { JSONSchema7 } from 'json-schema'; import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; -import { RunnableAgentJsonConfigSchema } from '@n8n/api-types'; +import { AgentModelSchema, RunnableAgentJsonConfigSchema } from '@n8n/api-types'; import { jsonSchemaToCompactText } from '../../json-config/schema-text-serializer'; +const BuilderPromptMemoryWorkerModelSchema = z.object({ + model: AgentModelSchema, + credential: z.string().trim().min(1), +}); + const BuilderPromptMemoryConfigSchema = z.object({ enabled: z.boolean(), storage: z.literal('n8n'), @@ -13,6 +18,8 @@ const BuilderPromptMemoryConfigSchema = z.object({ observationalMemory: z .object({ enabled: z.boolean().optional(), + observerModel: BuilderPromptMemoryWorkerModelSchema.optional(), + reflectorModel: BuilderPromptMemoryWorkerModelSchema.optional(), observerThresholdTokens: z.number().int().min(1).optional(), reflectorThresholdTokens: z.number().int().min(1).optional(), renderTokenBudget: z.number().int().min(1).optional(), @@ -26,6 +33,8 @@ const BuilderPromptMemoryConfigSchema = z.object({ z.object({ enabled: z.literal(true), credential: z.string().min(1), + extractorModel: BuilderPromptMemoryWorkerModelSchema.optional(), + reflectorModel: BuilderPromptMemoryWorkerModelSchema.optional(), topK: z.number().int().min(1).max(100).optional(), maxEntriesPerRun: z.number().int().min(1).max(50).optional(), }), @@ -49,6 +58,17 @@ export function getConfigRulesSection(): string { - \`memory.storage\` must be "n8n"; \`memory.lastMessages\` defaults to 50. - \`memory.episodicMemory\` requires \`ask_credential\` with \`credentialType: "openAiApi"\`. +- Memory worker model fields use \`{ "model": "provider/model-name", "credential": "" }\`; + use only credential IDs returned by \`resolve_llm\`, \`ask_llm\`, or \`ask_credential\`. +- Web search lives under \`config.webSearch\`. Only OpenAI and Anthropic models + support native web search; for those providers, use + \`{ "enabled": true, "provider": "native" }\` or omit \`provider\`. Every + other provider requires fallback search with \`provider: "brave"\` or + \`provider: "searxng"\` and a credential. Never write \`{ "enabled": true }\` + alone for fallback search. Use exact \`ask_credential\` types: + \`braveSearchApi\` for Brave and \`searXngApi\` for SearXNG. +- Preserve existing Brave/SearXNG \`config.webSearch\` on model switches unless + the user explicitly asks to change web-search method. - \`config.maxIterations\` caps the number of agent loop iterations per run. Do not set or change this unless the user explicitly asks. - Fresh agents need a real model, credential, and instructions before config is written.`; diff --git a/packages/cli/src/modules/agents/builder/prompts/llm-selection.prompt.ts b/packages/cli/src/modules/agents/builder/prompts/llm-selection.prompt.ts index 741243b2485..d327480aa4c 100644 --- a/packages/cli/src/modules/agents/builder/prompts/llm-selection.prompt.ts +++ b/packages/cli/src/modules/agents/builder/prompts/llm-selection.prompt.ts @@ -24,6 +24,21 @@ Use this to resolve the target agent's main \`model\` and \`credential\`. - Explicit provider/model requests go to \`resolve_llm\` first. - If the user asks to pick, change, confirm, or configure a model or main credential, call \`ask_llm\`; do not ask in prose. - If \`resolve_llm\` succeeds, persist \`model = "{provider}/{model}"\` and \`credential = credentialId\`. +- Only OpenAI and Anthropic models support native web search. Use native web + search by default for those providers only, and only for + fresh agents or agents with no existing \`config.webSearch\`. Persist + \`config.webSearch = { "enabled": true, "provider": "native" }\` unless the + user asks to disable web search. Do not write native \`providerTools\`; the + write path derives them. +- When changing models, preserve existing Brave or SearXNG + \`config.webSearch\` unchanged, including its credential, even if the new + model supports native search. Switch fallback search to native only when the + user explicitly asks for native/provider web search. +- For every provider other than OpenAI or Anthropic, web search requires + fallback search: call \`ask_credential\`, then use \`provider: "brave"\` or + \`provider: "searxng"\`. +- If the user explicitly asks for Brave or SearXNG, keep that provider even + when the selected model also supports native search. - If \`resolve_llm\` reports missing or ambiguous credentials/provider, call \`ask_llm\`. - If it reports \`unknown_model\`, retry with a plausible returned model value or call \`ask_llm\`. - For "Anthropic via OpenRouter", pass \`provider: "openrouter"\`; if the user names a routed model, pass the routed id without adding another provider prefix. @@ -35,10 +50,12 @@ Use this to resolve the target agent's main \`model\` and \`credential\`. - Use \`resolve_llm\` or \`ask_llm\` only for the target agent's main model credential. - Use \`ask_credential\` for node tools, integrations, and Episodic Memory. - For OpenRouter, \`provider\` is \`"openrouter"\`; the model can be a routed id such as \`anthropic/...\`. +- Model changes must not silently replace existing Brave or SearXNG web search with native search. - Do not recommend current, best, latest, or fallback model IDs from memory when the recommendation catalog is unavailable. ### Verify - The persisted \`model\` is in \`provider/model\` form. -- The persisted \`credential\` came from \`resolve_llm\` or \`ask_llm\`.${recommendationGuidance}`; +- The persisted \`credential\` came from \`resolve_llm\` or \`ask_llm\`. +- Existing Brave or SearXNG \`config.webSearch\` is preserved on model changes unless the user explicitly requested a web-search method change.${recommendationGuidance}`; } diff --git a/packages/cli/src/modules/agents/builder/prompts/memory.prompt.ts b/packages/cli/src/modules/agents/builder/prompts/memory.prompt.ts index bb1972ff69a..122a7472c54 100644 --- a/packages/cli/src/modules/agents/builder/prompts/memory.prompt.ts +++ b/packages/cli/src/modules/agents/builder/prompts/memory.prompt.ts @@ -30,13 +30,17 @@ separate user-facing memory product. - \`lastMessages\` defaults to 50. - Set \`observationalMemory.enabled\` to \`true\` for new agents unless the user explicitly asks to disable observational memory. - Keep the rest of \`observationalMemory\` optional; add tuning fields only for explicit tuning or disabling. -- Supported observational memory tuning fields: \`enabled\`, \`observerThresholdTokens\`, \`reflectorThresholdTokens\`, \`renderTokenBudget\`, \`observationLogTailLimit\`, and \`lockTtlMs\`. +- Supported observational memory tuning fields: \`enabled\`, \`observerModel\`, \`reflectorModel\`, \`observerThresholdTokens\`, \`reflectorThresholdTokens\`, \`renderTokenBudget\`, \`observationLogTailLimit\`, and \`lockTtlMs\`. +- Memory worker model fields must use object shape: \`{ "model": "provider/model-name", "credential": "" }\`. +- Only set \`observerModel\`, \`reflectorModel\`, \`extractorModel\`, or \`episodicMemory.reflectorModel\` when the user explicitly asks to use a specific model for memory work. +- Use only credential IDs returned by \`resolve_llm\`, \`ask_llm\`, or \`ask_credential\` for memory worker model fields. Do not invent IDs or copy a main-model credential unless one of those tools returned it for that worker model provider. ### Episodic Memory - Enable \`memory.episodicMemory\` only when the user asks for Episodic Memory, long-term memory, prior conversations, remembered decisions, exact artifacts, or cross-session memory. - Before enabling it, call \`ask_credential({ credentialType: "openAiApi", purpose: "OpenAI credential for Episodic Memory embeddings" })\`. - On success, set \`memory.episodicMemory = { "enabled": true, "credential": "" }\` and preserve existing \`topK\` or \`maxEntriesPerRun\`. +- \`memory.episodicMemory.credential\` is only for OpenAI embeddings. It is separate from optional \`extractorModel\` and \`reflectorModel\` worker credentials. - If credential selection is skipped, do not enable Episodic Memory; explain that it needs an OpenAI credential for embeddings. - Do not add instructions saying the agent should remember, store, save, or decide what context matters. The runtime handles memory extraction and indexing. - If instructions mention Episodic Memory, phrase it as retrieval/use only, e.g. "Use recalled prior context when relevant to the user's request." diff --git a/packages/cli/src/modules/agents/builder/prompts/tools.prompt.ts b/packages/cli/src/modules/agents/builder/prompts/tools.prompt.ts index 2a18ac0a592..caa5411965a 100644 --- a/packages/cli/src/modules/agents/builder/prompts/tools.prompt.ts +++ b/packages/cli/src/modules/agents/builder/prompts/tools.prompt.ts @@ -62,6 +62,7 @@ export default new Tool('tool_name') ### Gotchas +- Web-search fallback services are config, not node tools, unless the user explicitly asks for a node integration. - Live crawling, fetching, and API integrations need workflow or node tools, not custom tools. - Do not include \`inputSchema\` or \`toolDescription\` for node tools. - \`$fromAI(...)\` placeholders define the node tool input schema; do not add it manually. diff --git a/packages/cli/src/modules/agents/json-config/from-json-config.ts b/packages/cli/src/modules/agents/json-config/from-json-config.ts index e482214b099..71ab6ed6a2c 100644 --- a/packages/cli/src/modules/agents/json-config/from-json-config.ts +++ b/packages/cli/src/modules/agents/json-config/from-json-config.ts @@ -17,10 +17,24 @@ import type { AgentJsonToolConfig, AgentJsonSkillConfig, } from '@n8n/api-types'; +import { z } from 'zod'; import { mapCredentialForProvider } from './credential-field-mapping'; +import { + getNativeWebSearchProviderTools, + hasNativeWebSearchProvider, + isNativeWebSearchRequested, +} from './native-web-search-provider-tools'; import { resolveProviderToolName } from './provider-tool-aliases'; +const WEB_SEARCH_TOOL_NAME = 'web_search'; +const WEB_SEARCH_INPUT_SCHEMA = z.object({ + query: z.string().min(1).describe('Search query'), + maxResults: z.number().int().min(1).max(10).optional().describe('Maximum number of results'), + includeDomains: z.array(z.string()).optional().describe('Only return results from these domains'), + excludeDomains: z.array(z.string()).optional().describe('Exclude results from these domains'), +}); + export type ToolResolver = ( toolSchema: AgentJsonToolConfig, ) => Promise; @@ -33,6 +47,11 @@ export interface ToolExecutor { /** Factory function that reconstructs a BuiltMemory backend from serialized params. */ export type MemoryFactory = (params: AgentJsonMemoryConfig) => BuiltMemory | Promise; +type MemoryWorkerModelConfig = { + model: string; + credential: string; +}; + export interface BuildFromJsonOptions { /** Executes custom tool handlers inside isolates. */ toolExecutor: ToolExecutor; @@ -78,12 +97,17 @@ export async function buildFromJson( agent.skills(configuredSkills); // Provider tools - if (config.providerTools) { - for (const [name, args] of Object.entries(config.providerTools)) { + const providerTools = getNativeWebSearchProviderTools(config, { includeDefaultArgs: false }); + if (providerTools) { + for (const [name, args] of Object.entries(providerTools)) { const resolved = resolveProviderToolName(name); agent.providerTool({ name: resolved as `${string}.${string}`, args }); } } + const fallbackWebSearchTool = buildFallbackWebSearchTool(config, options.credentialProvider); + if (fallbackWebSearchTool) { + agent.tool(fallbackWebSearchTool); + } // Memory if (config.memory?.enabled) { @@ -112,6 +136,56 @@ export async function buildFromJson( return agent; } +function buildFallbackWebSearchTool( + config: AgentJsonConfig, + credentialProvider: CredentialProvider, +): BuiltTool | null { + const webSearchConfig = config.config?.webSearch; + + if (!webSearchConfig?.enabled) return null; + if (isNativeWebSearchRequested(config) && hasNativeWebSearchProvider(config.model)) return null; + if (webSearchConfig.provider !== 'brave' && webSearchConfig.provider !== 'searxng') { + throw new Error('Web search is enabled but no fallback search provider is configured.'); + } + if (!webSearchConfig.credential) { + throw new Error('Web search is enabled but no search credential is configured.'); + } + const credentialId = webSearchConfig.credential; + + return { + name: WEB_SEARCH_TOOL_NAME, + description: 'Search the web for current information.', + systemInstruction: + 'Before using web_search, choose the smallest search plan that can answer the user. Default to one broad, high-signal query. After each search, stop if the results already contain enough credible sources to answer. Use a second search only when the first result set is insufficient or the user asked for comparison across independent source categories. Do not fan out variations of the same query, and do not search for confirmation only. Use more than two searches only when the user explicitly asks for deep research, exhaustive coverage, or multiple independent topics.', + inputSchema: WEB_SEARCH_INPUT_SCHEMA, + handler: async (input) => { + const args = WEB_SEARCH_INPUT_SCHEMA.parse(input); + const credential = await credentialProvider.resolve(credentialId); + const { braveSearch, searxngSearch } = await import('@n8n/ai-utilities'); + + if (webSearchConfig.provider === 'brave') { + if (typeof credential.apiKey !== 'string') { + throw new Error('Brave Search credential is missing an API key.'); + } + return await braveSearch(credential.apiKey, args.query, { + maxResults: args.maxResults, + includeDomains: args.includeDomains, + excludeDomains: args.excludeDomains, + }); + } + + if (typeof credential.apiUrl !== 'string') { + throw new Error('SearXNG credential is missing an API URL.'); + } + return await searxngSearch(credential.apiUrl, args.query, { + maxResults: args.maxResults, + includeDomains: args.includeDomains, + excludeDomains: args.excludeDomains, + }); + }, + }; +} + function getConfiguredSkills( refs: AgentJsonSkillConfig[], skills: Record, @@ -230,7 +304,27 @@ async function applyMemoryFromConfig( if (memoryConfig.observationalMemory?.enabled !== false) { const observationalMemory = memoryConfig.observationalMemory; + const { createObservationLogObserveFn, createObservationLogReflectFn } = await import( + '@n8n/agents' + ); + memory.observationalMemory({ + ...(observationalMemory?.observerModel !== undefined && { + observe: createObservationLogObserveFn( + await resolveMemoryWorkerModelConfig( + observationalMemory.observerModel, + credentialProvider, + ), + ), + }), + ...(observationalMemory?.reflectorModel !== undefined && { + reflect: createObservationLogReflectFn( + await resolveMemoryWorkerModelConfig( + observationalMemory.reflectorModel, + credentialProvider, + ), + ), + }), ...(observationalMemory?.observerThresholdTokens !== undefined && { observerThresholdTokens: observationalMemory.observerThresholdTokens, }), @@ -258,7 +352,11 @@ async function resolveEpisodicMemoryJsonConfig( config: Extract, { enabled: true }>, credentialProvider: CredentialProvider, ) { - const { DEFAULT_EPISODIC_MEMORY_EMBEDDING_MODEL } = await import('@n8n/agents'); + const { + DEFAULT_EPISODIC_MEMORY_EMBEDDING_MODEL, + createEpisodicMemoryExtractFn, + createEpisodicMemoryReflectFn, + } = await import('@n8n/agents'); const embeddingModel = DEFAULT_EPISODIC_MEMORY_EMBEDDING_MODEL; const raw = await credentialProvider.resolve(config.credential); const mapped = mapCredentialForProvider(getProviderPrefix(embeddingModel), raw); @@ -269,6 +367,16 @@ async function resolveEpisodicMemoryJsonConfig( return { enabled: true, + ...(config.extractorModel !== undefined && { + extract: createEpisodicMemoryExtractFn( + await resolveMemoryWorkerModelConfig(config.extractorModel, credentialProvider), + ), + }), + ...(config.reflectorModel !== undefined && { + reflect: createEpisodicMemoryReflectFn( + await resolveMemoryWorkerModelConfig(config.reflectorModel, credentialProvider), + ), + }), ...(config.topK !== undefined && { topK: config.topK }), ...(config.maxEntriesPerRun !== undefined && { maxEntriesPerRun: config.maxEntriesPerRun }), embeddingProviderOptions, @@ -281,11 +389,32 @@ async function resolveModelConfig( ): Promise { if (!config.credential) return config.model; - const slashIdx = config.model.indexOf('/'); - const providerPrefix = slashIdx !== -1 ? config.model.slice(0, slashIdx) : ''; - const raw = await credentialProvider.resolve(config.credential); - const mapped = mapCredentialForProvider(providerPrefix, raw); - return { id: config.model, ...mapped } as ModelConfig; + return await resolveCredentialAwareModelConfig( + config.model, + config.credential, + credentialProvider, + ); +} + +async function resolveMemoryWorkerModelConfig( + config: MemoryWorkerModelConfig, + credentialProvider: CredentialProvider, +): Promise { + return await resolveCredentialAwareModelConfig( + config.model, + config.credential, + credentialProvider, + ); +} + +async function resolveCredentialAwareModelConfig( + model: string, + credential: string, + credentialProvider: CredentialProvider, +): Promise { + const raw = await credentialProvider.resolve(credential); + const mapped = mapCredentialForProvider(getProviderPrefix(model), raw); + return { id: model, ...mapped } as ModelConfig; } function getProviderPrefix(modelId: string): string { diff --git a/packages/cli/src/modules/agents/json-config/native-web-search-provider-tools.ts b/packages/cli/src/modules/agents/json-config/native-web-search-provider-tools.ts new file mode 100644 index 00000000000..dab283b1247 --- /dev/null +++ b/packages/cli/src/modules/agents/json-config/native-web-search-provider-tools.ts @@ -0,0 +1,81 @@ +import { + NATIVE_WEB_SEARCH_DEFAULTS_BY_PROVIDER, + NATIVE_WEB_SEARCH_PROVIDER_BY_TOOL, + NATIVE_WEB_SEARCH_PROVIDER_TOOLS, + NATIVE_WEB_SEARCH_TOOL_BY_PROVIDER, + type AgentJsonConfig, + type NativeWebSearchProvider, +} from '@n8n/api-types'; + +export function getProviderPrefix(modelId: string): string { + const slashIdx = modelId.indexOf('/'); + return slashIdx !== -1 ? modelId.slice(0, slashIdx) : ''; +} + +function isNativeWebSearchProvider(provider: string): provider is NativeWebSearchProvider { + return provider in NATIVE_WEB_SEARCH_TOOL_BY_PROVIDER; +} + +export function hasNativeWebSearchProvider(modelId: string): boolean { + return isNativeWebSearchProvider(getProviderPrefix(modelId)); +} + +export function isNativeWebSearchRequested(config: AgentJsonConfig): boolean { + const webSearch = config.config?.webSearch; + return ( + webSearch?.provider === undefined || + webSearch.provider === 'auto' || + webSearch.provider === 'native' + ); +} + +/** + * Centralizes the policy for native web-search provider tools. `config.webSearch` + * is the source of truth; provider-tool entries are derived execution details + * that must be kept in sync with the selected model provider. + */ +export function getNativeWebSearchProviderTools( + config: AgentJsonConfig, + options: { includeDefaultArgs: boolean; defaultEnabled?: boolean }, +): Record> { + const providerTools = { ...(config.providerTools ?? {}) }; + const providerPrefix = getProviderPrefix(config.model); + const nativeWebSearch = isNativeWebSearchProvider(providerPrefix) + ? NATIVE_WEB_SEARCH_DEFAULTS_BY_PROVIDER[providerPrefix] + : undefined; + const explicitDisabled = config.config?.webSearch?.enabled === false; + const isEnabled = + !!nativeWebSearch && + isNativeWebSearchRequested(config) && + !explicitDisabled && + (options.defaultEnabled === true || config.config?.webSearch?.enabled === true); + + for (const key of NATIVE_WEB_SEARCH_PROVIDER_TOOLS) { + const toolProvider = NATIVE_WEB_SEARCH_PROVIDER_BY_TOOL[key]; + if (!isEnabled || toolProvider !== providerPrefix) { + delete providerTools[key]; + } + } + + if (isEnabled) { + const hasProviderWebSearchTool = Object.entries(NATIVE_WEB_SEARCH_PROVIDER_BY_TOOL).some( + ([toolName, toolProvider]) => toolProvider === providerPrefix && toolName in providerTools, + ); + if (!hasProviderWebSearchTool) { + providerTools[nativeWebSearch.toolName] = {}; + } + + if (options.includeDefaultArgs) { + for (const [toolName, toolProvider] of Object.entries(NATIVE_WEB_SEARCH_PROVIDER_BY_TOOL)) { + if (toolProvider === providerPrefix && toolName in providerTools) { + providerTools[toolName] = { + ...nativeWebSearch.args, + ...providerTools[toolName], + }; + } + } + } + } + + return providerTools; +} diff --git a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts index aa72c6d8ef3..c65d972b8da 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts @@ -10,7 +10,6 @@ import type { InstanceAiDataTableService, InstanceAiWebResearchService, FetchedPage, - WebSearchResponse, DataTableSummary, DataTableColumnInfo, WorkflowSummary, @@ -35,6 +34,7 @@ import type { ServiceProxyConfig, CredentialTypeSearchResult, } from '@n8n/instance-ai'; +import { braveSearch, searxngSearch, type WebSearchResponse } from '@n8n/ai-utilities'; import { BuilderTemplatesService, builderTemplatesOptionsFromEnv, @@ -51,13 +51,7 @@ import { resolveBuiltinNodeDefinitionDirs, listNodeDiscriminators, } from './node-definition-resolver'; -import { - fetchAndExtract, - maybeSummarize, - braveSearch, - searxngSearch, - LRUCache, -} from './web-research'; +import { fetchAndExtract, maybeSummarize, LRUCache } from './web-research'; import { AiBuilderTemporaryWorkflowRepository, ExecutionRepository, diff --git a/packages/cli/src/modules/instance-ai/web-research/index.ts b/packages/cli/src/modules/instance-ai/web-research/index.ts index 0cde7c14777..b85b111daa6 100644 --- a/packages/cli/src/modules/instance-ai/web-research/index.ts +++ b/packages/cli/src/modules/instance-ai/web-research/index.ts @@ -2,5 +2,3 @@ export { fetchAndExtract } from './fetch-and-extract'; export type { FetchAndExtractOptions } from './fetch-and-extract'; export { maybeSummarize } from './summarize-content'; export { LRUCache } from './cache'; -export { braveSearch } from './brave-search'; -export { searxngSearch } from './searxng-search'; diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 07a7c3b775f..d6a59a5f72c 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -6018,11 +6018,13 @@ "agents.chat.misconfigured.missing.model": "Model", "agents.chat.misconfigured.missing.credential": "Credential", "agents.chat.misconfigured.missing.episodicMemory.credential": "Episodic Memory credential", + "agents.chat.misconfigured.missing.webSearch.credential": "Web search credential", "agents.chat.misconfigured.missing.agent": "Agent", "agents.chat.misconfigured.missing.skill": "Skill ({id})", "agents.chat.misconfigured.openBuild": "Finish setup in Build", "agents.chat.misconfigured.dismiss": "Dismiss", "agents.chat.askCredential.skip": "Skip", + "agents.chat.toolNames.webSearch": "Web search", "agents.chat.askQuestion.otherLabel": "Other", "agents.chat.askQuestion.otherPlaceholder": "Type another answer", "agents.chat.askQuestion.submit": "Submit", @@ -6138,11 +6140,26 @@ "agents.builder.agent.instructions.characterCount": "{count} characters", "agents.builder.advanced.title": "Advanced", "agents.builder.advanced.description": "Execution tuning — reasoning depth, tool parallelism, and approval gating.", + "agents.builder.advanced.webSearch.label": "Web search", + "agents.builder.advanced.webSearch.hint": "Let the model search the web when it needs up-to-date information.", + "agents.builder.advanced.webSearch.maxUses.label": "Maximum searches", + "agents.builder.advanced.webSearch.maxUses.hint": "How many web searches the model can run in one response", + "agents.builder.advanced.webSearch.externalAccess.label": "Live page access", + "agents.builder.advanced.webSearch.externalAccess.hint": "Allow OpenAI to fetch live page content", + "agents.builder.advanced.webSearch.contextSize.label": "Search context", + "agents.builder.advanced.webSearch.method.label": "Search method", + "agents.builder.advanced.webSearch.method.off": "Off", + "agents.builder.advanced.webSearch.method.native": "Native", + "agents.builder.advanced.webSearch.fallbackProvider.brave": "Brave Search", + "agents.builder.advanced.webSearch.fallbackProvider.searxng": "SearXNG", + "agents.builder.advanced.webSearch.credential.label": "Credential", + "agents.builder.advanced.webSearch.credential.hint": "Used by the selected search provider", "agents.builder.advanced.thinking.label": "Thinking", "agents.builder.advanced.thinking.hint": "Let the model reason before responding.", "agents.builder.advanced.thinking.unsupportedTooltip": "{provider} does not support thinking", "agents.builder.advanced.thinking.unsupportedProviderFallback": "This provider", "agents.builder.advanced.budgetTokens.label": "Budget tokens", + "agents.builder.advanced.budgetTokens.hint": "How many tokens the model can use for reasoning before answering", "agents.builder.advanced.reasoningEffort.label": "Reasoning effort", "agents.builder.advanced.concurrency.label": "Tool call concurrency", "agents.builder.advanced.concurrency.hint": "How many tool calls the agent can run in parallel.", @@ -6156,11 +6173,13 @@ "agents.builder.memory.title": "Session Memory", "agents.builder.memory.description": "Keeps recent messages from this session available as context.", "agents.builder.memory.episodicMemory.label": "Episodic Memory", - "agents.builder.memory.episodicMemory.hint": "Stores source-backed memories from previous conversations.", + "agents.builder.memory.episodicMemory.hint": "Stores source-backed memories from previous conversations. Requires OpenAI credential.", "agents.builder.memory.episodicMemory.changeCredential": "Change credential", - "agents.builder.episodicMemoryCredentialModal.title": "Connect Episodic Memory", - "agents.builder.episodicMemoryCredentialModal.description": "Select the OpenAI credential used to create embeddings for Episodic Memory.", - "agents.builder.episodicMemoryCredentialModal.confirm": "Enable Episodic Memory", + "agents.builder.memory.recallModel.label": "Memory model", + "agents.builder.memory.recallModel.hint": "Choose the model that creates, reviews, and retrieves memories. Uses the agent model by default.", + "agents.builder.episodicMemoryCredentialModal.title": "Episodic Memory", + "agents.builder.episodicMemoryCredentialModal.description": "An OpenAI credential is used to create embeddings for Episodic Memory.", + "agents.builder.memory.semanticRecall.topK": "Top K", "agents.builder.memory.semanticRecall.rangeBefore": "Range before", "agents.builder.memory.semanticRecall.rangeAfter": "Range after", @@ -6242,6 +6261,8 @@ "agents.builder.evaluations.emptyPrefix": "No evaluations configured.", "agents.builder.quickActions.addTool": "Add tool", "agents.builder.quickActions.addTrigger": "Add trigger", + "agents.builder.quickActions.memoriesUsed.count": "{count} memory | {count} memories", + "agents.builder.quickActions.memoriesUsed.keyMemory": "Memory", "agents.builder.capabilities.title": "Capabilities", "agents.builder.triggers.title": "Triggers", "agents.builder.triggers.description": "Channels that can invoke this agent", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentAdvancedPanel.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentAdvancedPanel.test.ts index 5171c0e3ea4..2fa628fd3cc 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentAdvancedPanel.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentAdvancedPanel.test.ts @@ -11,7 +11,16 @@ vi.mock('@n8n/i18n', () => ({ useI18n: () => ({ baseText: (k: string) => k }), })); -// Sub-control flips depend on debouncing — execute synchronously in the test. +vi.mock('@/features/credentials/credentials.store', () => ({ + useCredentialsStore: () => ({ + allCredentials: [ + { id: 'brave-1', name: 'Brave Key', type: 'braveSearchApi' }, + { id: 'searxng-1', name: 'SearXNG', type: 'searXngApi' }, + ], + }), +})); + +// Numeric/thinking sub-controls debounce — execute synchronously in the test. vi.mock('@vueuse/core', async (importOriginal) => { const actual = await importOriginal(); return { @@ -44,9 +53,10 @@ const globalStubs = { N8nSelect: { props: ['modelValue', 'disabled'], emits: ['update:modelValue'], - template: '', + template: + '', }, - N8nOption: { template: '' }, + N8nOption: { props: ['value', 'label'], template: '' }, N8nSwitch2: { props: ['modelValue', 'disabled'], emits: ['update:modelValue'], @@ -65,7 +75,210 @@ function makeConfig(overrides: Partial = {}): AgentJsonConfig { } as AgentJsonConfig; } +function emitSelectValue(wrapper: ReturnType, testId: string, value: string) { + const select = wrapper.findComponent(`[data-testid="${testId}"]`) as unknown as { + vm: { $emit: (event: 'update:modelValue', value: string) => void }; + }; + select.vm.$emit('update:modelValue', value); +} + +function findStubComponent(wrapper: ReturnType, testId: string) { + return wrapper.findComponent(`[data-testid="${testId}"]`) as unknown as { + exists: () => boolean; + props: (name: string) => unknown; + }; +} + +type WebSearchConfig = { + enabled: boolean; + provider?: string; + credential?: string; +}; + +function getWebSearchConfig(changes: Partial): WebSearchConfig | undefined { + return ( + changes.config as + | (NonNullable & { webSearch?: WebSearchConfig }) + | undefined + )?.webSearch; +} + describe('AgentAdvancedPanel', () => { + it('treats sparse native web search config as disabled', async () => { + const wrapper = mount(AgentAdvancedPanel, { + props: { config: makeConfig() }, + global: { stubs: globalStubs }, + }); + + const method = findStubComponent(wrapper, 'agent-web-search-method'); + expect(method.exists()).toBe(true); + expect(method.props('modelValue')).toBe('off'); + + emitSelectValue(wrapper, 'agent-web-search-method', 'native'); + await nextTick(); + const events = wrapper.emitted('update:config') ?? []; + const last = events[events.length - 1][0] as Partial; + expect(getWebSearchConfig(last)).toEqual({ enabled: true, provider: 'native' }); + expect(last.providerTools).toEqual({ 'anthropic.web_search': { maxUses: 5 } }); + }); + + it('emits provider-specific web search options', async () => { + const config = makeConfig({ + model: 'openai/gpt-5', + config: { webSearch: { enabled: true } }, + providerTools: { 'openai.web_search': {} }, + } as Partial); + const wrapper = mount(AgentAdvancedPanel, { + props: { config }, + global: { stubs: globalStubs }, + }); + + await wrapper.find('[data-testid="agent-web-search-external-access"]').trigger('click'); + + const events = wrapper.emitted('update:config') ?? []; + const last = events[events.length - 1][0] as Partial; + expect(last.providerTools).toEqual({ + 'openai.web_search': { + externalWebAccess: false, + searchContextSize: 'medium', + }, + }); + }); + + it('strips native web search provider tools when native web search is disabled', async () => { + const config = makeConfig({ + config: { webSearch: { enabled: true } }, + providerTools: { + 'anthropic.web_search': { maxUses: 5 }, + 'openai.image_generation': {}, + }, + } as Partial); + const wrapper = mount(AgentAdvancedPanel, { + props: { config }, + global: { stubs: globalStubs }, + }); + + emitSelectValue(wrapper, 'agent-web-search-method', 'off'); + await nextTick(); + + const events = wrapper.emitted('update:config') ?? []; + const last = events[events.length - 1][0] as Partial; + expect(getWebSearchConfig(last)).toEqual({ enabled: false }); + expect(last.providerTools).toEqual({ 'openai.image_generation': {} }); + }); + + it('enables fallback web search for providers without native web search', async () => { + const config = makeConfig({ model: 'deepseek/deepseek-chat' }); + const wrapper = mount(AgentAdvancedPanel, { + props: { config }, + global: { stubs: globalStubs }, + }); + + emitSelectValue(wrapper, 'agent-web-search-method', 'brave'); + await nextTick(); + + const events = wrapper.emitted('update:config') ?? []; + const last = events[events.length - 1][0] as Partial; + expect(getWebSearchConfig(last)).toEqual({ enabled: true, provider: 'brave' }); + }); + + it('keeps fallback controls visible on native-capable models', async () => { + const config = makeConfig({ + config: { webSearch: { enabled: true, provider: 'brave', credential: 'brave-1' } }, + providerTools: { 'anthropic.web_search': { maxUses: 5 } }, + } as Partial); + const wrapper = mount(AgentAdvancedPanel, { + props: { config }, + global: { stubs: globalStubs }, + }); + + expect(wrapper.find('[data-testid="agent-web-search-method"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="agent-web-search-fallback-credential"]').exists()).toBe( + true, + ); + expect(wrapper.find('[data-testid="agent-web-search-max-uses"]').exists()).toBe(false); + }); + + it('switches fallback web search to native and emits native provider tools', async () => { + const config = makeConfig({ + config: { webSearch: { enabled: true, provider: 'brave', credential: 'brave-1' } }, + } as Partial); + const wrapper = mount(AgentAdvancedPanel, { + props: { config }, + global: { stubs: globalStubs }, + }); + + emitSelectValue(wrapper, 'agent-web-search-method', 'native'); + await nextTick(); + + const events = wrapper.emitted('update:config') ?? []; + const last = events[events.length - 1][0] as Partial; + expect(getWebSearchConfig(last)).toEqual({ enabled: true, provider: 'native' }); + expect(last.providerTools).toEqual({ 'anthropic.web_search': { maxUses: 5 } }); + }); + + it('preserves fallback web search credential when switching away and back to the same fallback provider', async () => { + const config = makeConfig({ + config: { webSearch: { enabled: true, provider: 'brave', credential: 'brave-1' } }, + } as Partial); + const wrapper = mount(AgentAdvancedPanel, { + props: { config }, + global: { stubs: globalStubs }, + }); + + emitSelectValue(wrapper, 'agent-web-search-method', 'native'); + await nextTick(); + emitSelectValue(wrapper, 'agent-web-search-method', 'brave'); + await nextTick(); + + const events = wrapper.emitted('update:config') ?? []; + const last = events[events.length - 1][0] as Partial; + expect(getWebSearchConfig(last)).toEqual({ + enabled: true, + provider: 'brave', + credential: 'brave-1', + }); + }); + + it('clears fallback web search credential when switching fallback providers', async () => { + const config = makeConfig({ + config: { webSearch: { enabled: true, provider: 'brave', credential: 'brave-1' } }, + } as Partial); + const wrapper = mount(AgentAdvancedPanel, { + props: { config }, + global: { stubs: globalStubs }, + }); + + emitSelectValue(wrapper, 'agent-web-search-method', 'searxng'); + await nextTick(); + + const events = wrapper.emitted('update:config') ?? []; + const last = events[events.length - 1][0] as Partial; + expect(getWebSearchConfig(last)).toEqual({ enabled: true, provider: 'searxng' }); + }); + + it('switches native web search to fallback and strips native provider tools', async () => { + const config = makeConfig({ + config: { webSearch: { enabled: true, provider: 'native' } }, + providerTools: { + 'anthropic.web_search': { maxUses: 5 }, + 'openai.image_generation': {}, + }, + } as Partial); + const wrapper = mount(AgentAdvancedPanel, { + props: { config }, + global: { stubs: globalStubs }, + }); + + emitSelectValue(wrapper, 'agent-web-search-method', 'brave'); + await nextTick(); + + const events = wrapper.emitted('update:config') ?? []; + const last = events[events.length - 1][0] as Partial; + expect(getWebSearchConfig(last)).toEqual({ enabled: true, provider: 'brave' }); + expect(last.providerTools).toEqual({ 'openai.image_generation': {} }); + }); + it('shows the budget-tokens sub-control for Anthropic when thinking is on', async () => { const config = makeConfig({ config: { thinking: { provider: 'anthropic', budgetTokens: 1024 } }, @@ -123,6 +336,8 @@ describe('AgentAdvancedPanel', () => { props: { config, disabled: true }, global: { stubs: globalStubs }, }); + const webSearchMethod = findStubComponent(wrapper, 'agent-web-search-method'); + expect(webSearchMethod.props('disabled')).toBe(true); expect( wrapper.find('[data-testid="agent-thinking-toggle"]').attributes('disabled'), ).toBeDefined(); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderEditorColumn.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderEditorColumn.spec.ts index db4d9026c01..417a5a950ef 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderEditorColumn.spec.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderEditorColumn.spec.ts @@ -7,9 +7,10 @@ vi.mock('@n8n/i18n', () => ({ useI18n: () => ({ baseText: (key: string) => ({ - 'agents.builder.memory.title': 'Session Memory', - 'agents.builder.memory.description': - 'Keeps recent messages from this session available as context.', + 'agents.builder.memory.episodicMemory.label': 'Episodic Memory', + 'agents.builder.memory.episodicMemory.hint': + 'Stores source-backed memories from previous conversations. Requires OpenAI credential.', + 'agents.builder.memory.episodicMemory.changeCredential': 'Change credential', 'agents.builder.editorColumn.ariaLabel': 'Agent editor', })[key] ?? key, }), @@ -18,9 +19,11 @@ vi.mock('@n8n/i18n', () => ({ vi.mock('@n8n/design-system', () => ({ N8nCard: { template: '
', props: ['variant'] }, N8nHeading: { template: '

', props: ['size'] }, + N8nIconButton: { template: '' }, N8nRadioButtons: { template: '
', props: ['modelValue', 'options'] }, N8nSwitch: { template: '' }, N8nText: { template: '', props: ['tag', 'bold', 'size', 'color'] }, + N8nTooltip: { template: '
' }, })); async function mountColumn() { @@ -61,14 +64,14 @@ async function mountColumn() { } describe('AgentBuilderEditorColumn', () => { - it('renders only the session memory row in the builder memory card', async () => { + it('renders only the episodic memory row in the builder memory card', async () => { const wrapper = await mountColumn(); - expect(wrapper.text()).toContain('Session Memory'); + expect(wrapper.text()).toContain('Episodic Memory'); expect(wrapper.text()).toContain( - 'Keeps recent messages from this session available as context.', + 'Stores source-backed memories from previous conversations. Requires OpenAI credential.', ); - expect(wrapper.text()).not.toContain('Automatic memory'); - expect(wrapper.find('[data-test-id="agent-observational-memory-toggle"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="agent-episodic-memory-toggle"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="agent-observational-memory-toggle"]').exists()).toBe(false); }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/nativeWebSearch.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/nativeWebSearch.test.ts new file mode 100644 index 00000000000..8a8db7496cf --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/nativeWebSearch.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { + normalizeWebSearchForModelChange, + stripNativeWebSearchProviderTools, +} from '../utils/nativeWebSearch'; +import type { AgentJsonConfig } from '../types'; + +function makeConfig(overrides: Partial = {}): AgentJsonConfig { + return { + name: 'A', + instructions: 'i', + model: 'anthropic/claude-sonnet-4-6', + credential: 'c', + ...overrides, + } as AgentJsonConfig; +} + +describe('nativeWebSearch utils', () => { + it('strips only native web search provider tools', () => { + expect( + stripNativeWebSearchProviderTools({ + 'anthropic.web_search': { maxUses: 5 }, + 'openai.image_generation': {}, + }), + ).toEqual({ 'openai.image_generation': {} }); + }); + + it('disables native web search when switching to a non-native provider', () => { + const result = normalizeWebSearchForModelChange( + makeConfig({ + config: { webSearch: { enabled: true, provider: 'native' } }, + providerTools: { + 'anthropic.web_search': { maxUses: 5 }, + 'openai.image_generation': {}, + }, + } as Partial), + false, + ); + + expect(result.config?.webSearch).toEqual({ enabled: false }); + expect(result.providerTools).toEqual({ 'openai.image_generation': {} }); + }); + + it('preserves fallback web search when switching to a native-capable provider', () => { + const result = normalizeWebSearchForModelChange( + makeConfig({ + model: 'google/gemini-2.5-flash', + config: { webSearch: { enabled: true, provider: 'brave', credential: 'brave-1' } }, + } as Partial), + 'anthropic.web_search', + ); + + expect(result.config?.webSearch).toEqual({ + enabled: true, + provider: 'brave', + credential: 'brave-1', + }); + expect(result.providerTools).toBeUndefined(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/provider-capabilities.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/provider-capabilities.test.ts index 1e1b2abc0b1..2c8c9988a78 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/provider-capabilities.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/provider-capabilities.test.ts @@ -14,6 +14,27 @@ describe('provider-capabilities', () => { expect(PROVIDER_CAPABILITIES.openai.thinking).toBe('reasoningEffort'); }); + it('enables native web search for Anthropic and OpenAI', () => { + expect(PROVIDER_CAPABILITIES.anthropic.webSearch).toBe('anthropic.web_search'); + expect(PROVIDER_CAPABILITIES.openai.webSearch).toBe('openai.web_search'); + }); + + it('marks providers without native web search support as `false`', () => { + const noWebSearch = [ + 'google', + 'xai', + 'groq', + 'deepseek', + 'mistral', + 'openrouter', + 'cohere', + 'ollama', + ]; + for (const provider of noWebSearch) { + expect(PROVIDER_CAPABILITIES[provider]?.webSearch).toBe(false); + } + }); + it('marks providers without thinking support as `false`', () => { const noThinking = [ 'google', diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/toolDisplayName.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/toolDisplayName.test.ts index 8b8afd57c1e..b070c80b228 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/toolDisplayName.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/toolDisplayName.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { formatToolNameForDisplay } from '../utils/toolDisplayName'; +import { + WEB_SEARCH_TOOL_NAME_KEY, + formatToolNameForDisplay, + getToolNameTranslationKey, +} from '../utils/toolDisplayName'; describe('formatToolNameForDisplay', () => { it('formats snake_case builder tool names as readable labels', () => { @@ -15,6 +19,20 @@ describe('formatToolNameForDisplay', () => { expect(formatToolNameForDisplay('ask__credential')).toBe('Ask credential'); }); + it('returns an i18n key for native and fallback web search tool names', () => { + expect(getToolNameTranslationKey('web_search')).toBe(WEB_SEARCH_TOOL_NAME_KEY); + expect(getToolNameTranslationKey('anthropic.web_search')).toBe(WEB_SEARCH_TOOL_NAME_KEY); + expect(getToolNameTranslationKey('anthropic.web_search_20250305')).toBe( + WEB_SEARCH_TOOL_NAME_KEY, + ); + expect(getToolNameTranslationKey('anthropic.web_search_20260209')).toBe( + WEB_SEARCH_TOOL_NAME_KEY, + ); + expect(getToolNameTranslationKey('openai.web_search')).toBe(WEB_SEARCH_TOOL_NAME_KEY); + expect(getToolNameTranslationKey('openai.web_search_20270101')).toBe(WEB_SEARCH_TOOL_NAME_KEY); + expect(getToolNameTranslationKey('custom_web_search')).toBeUndefined(); + }); + it('returns an empty string for missing or blank names', () => { expect(formatToolNameForDisplay(undefined)).toBe(''); expect(formatToolNameForDisplay(' ')).toBe(''); diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentAdvancedPanel.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentAdvancedPanel.vue index ad5e02705c9..4c4731467db 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentAdvancedPanel.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentAdvancedPanel.vue @@ -1,4 +1,14 @@ + + + + diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageActions.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageActions.vue index 2c923bdbd32..9bbab6bb287 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageActions.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageActions.vue @@ -48,7 +48,7 @@ const i18n = useI18n(); & g, & path { - color: var(--color--text--tint-1); + color: var(--icon-color); stroke-width: 2.5; } } diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageList.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageList.vue index e0b79c7023c..5b0af9463f7 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageList.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageList.vue @@ -6,10 +6,11 @@ import ChatMarkdownChunk from '@/features/ai/chatHub/components/ChatMarkdownChun import ChatTypingIndicator from '@/features/ai/chatHub/components/ChatTypingIndicator.vue'; import { buildDisplayGroups, - type DisplayGroup, type ChatMessage, + type DisplayGroup, type InteractivePayload, } from '../composables/agentChatMessages'; +import AgentChatMemoryUsed from './AgentChatMemoryUsed.vue'; import AgentChatToolSteps from './AgentChatToolSteps.vue'; import InteractiveCard from './interactive/InteractiveCard.vue'; import { CHAT_MESSAGE_STATUS } from '../constants'; @@ -46,8 +47,7 @@ function getAssistantGroupContent(group: DisplayGroup): string { } function isAssistantGroup(group: DisplayGroup): boolean { - if (group.kind === 'toolRun') return true; - return group.message.role === 'assistant'; + return group.kind === 'toolRun' || group.message.role === 'assistant'; } function getAssistantRunContent(groupId: string): string { @@ -66,18 +66,114 @@ function getAssistantRunContent(groupId: string): string { return lines.join('\n\n'); } -function shouldShowAssistantActions(groupId: string): boolean { +interface RecallMemoryOutputEntry { + id: string; + content: string; +} + +function getRecallMemoryEntries(output: unknown): RecallMemoryOutputEntry[] { + if (!output || typeof output !== 'object') return []; + if (!('entries' in output) || !Array.isArray(output.entries)) return []; + + const entries: RecallMemoryOutputEntry[] = []; + + for (const [index, entry] of output.entries.entries()) { + if (!entry || typeof entry !== 'object') continue; + if (!('content' in entry) || typeof entry.content !== 'string') continue; + + const id = + 'id' in entry && typeof entry.id === 'string' + ? entry.id + : 'createdAt' in entry && typeof entry.createdAt === 'string' + ? entry.createdAt + : `${entry.content}:${index}`; + entries.push({ id, content: entry.content }); + } + + return entries; +} + +interface MemoryUsed { + id: string; + keyMemory: string; + evidence: string[]; +} + +function parseMemoryOutput(output: unknown): MemoryUsed[] { + return getRecallMemoryEntries(output) + .map((entry) => ({ + id: entry.id, + keyMemory: entry.content.trim(), + evidence: [], + })) + .filter((memory) => memory.keyMemory.length > 0); +} + +function isCompletedAssistantGroup(group: DisplayGroup): boolean { + if (group.kind === 'toolRun') { + return ( + group.finalMessage !== undefined && + group.finalMessage.status !== CHAT_MESSAGE_STATUS.STREAMING && + group.finalMessage.status !== CHAT_MESSAGE_STATUS.AWAITING_USER + ); + } + + return ( + group.message.role === 'assistant' && + group.message.status !== CHAT_MESSAGE_STATUS.STREAMING && + group.message.status !== CHAT_MESSAGE_STATUS.AWAITING_USER + ); +} + +function shouldShowAssistantFooter(groupId: string): boolean { const index = displayGroups.value.findIndex((group) => group.id === groupId); if (index === -1) return false; const group = displayGroups.value[index]; - if (!isAssistantGroup(group)) return false; - if (!getAssistantRunContent(groupId)) return false; + if (!isAssistantGroup(group) || !isCompletedAssistantGroup(group)) return false; const nextGroup = displayGroups.value[index + 1]; return !nextGroup || !isAssistantGroup(nextGroup); } +function getMemoriesUsedInAssistantRun(groupId: string): MemoryUsed[] { + const index = displayGroups.value.findIndex((group) => group.id === groupId); + if (index === -1) return []; + + const memories: MemoryUsed[] = []; + const memoryIds = new Set(); + + for (let i = index; i >= 0; i--) { + const group = displayGroups.value[i]; + if (!isAssistantGroup(group)) break; + + const toolCalls = group.kind === 'toolRun' ? group.toolCalls : (group.message.toolCalls ?? []); + for (let j = toolCalls.length - 1; j >= 0; j--) { + const toolCall = toolCalls[j]; + if (toolCall.tool !== 'recall_memory') continue; + + const uniqueMemories = parseMemoryOutput(toolCall.output).filter((memory) => { + if (memoryIds.has(memory.id)) return false; + memoryIds.add(memory.id); + return true; + }); + memories.unshift(...uniqueMemories); + } + } + + return memories; +} + +const openMemoryFooterGroupId = ref(null); + +function setMemoryFooterOpen(groupId: string, open: boolean): void { + openMemoryFooterGroupId.value = open + ? groupId + : openMemoryFooterGroupId.value === groupId + ? null + : openMemoryFooterGroupId.value; +} + const spokenMessageId = ref(null); const spokenText = computed(() => { if (!spokenMessageId.value) return ''; @@ -252,8 +348,19 @@ onBeforeUnmount(() => { />
-
+
+ { />
-
+
+
{ align-items: stretch; } -.messageActions { +.messageFooter { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing--2xs); + margin-top: var(--spacing--4xs); opacity: 0; - pointer-events: none; - transition: opacity 0.15s ease; } -.message.assistant:hover .messageActions, -.message.assistant:focus-within .messageActions { +.message.assistant:hover .messageFooter, +.message.assistant:focus-within .messageFooter, +.messageFooter:hover, +.messageFooter:focus-within, +.messageFooterVisible { opacity: 1; - pointer-events: auto; } .message.user .content { diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChatToolSteps.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentChatToolSteps.vue index db99205c93d..f37f8e24ef9 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentChatToolSteps.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentChatToolSteps.vue @@ -1,11 +1,19 @@