mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-04 02:37:46 +02:00
feat(editor): Add fallback web search for agents (#31010)
Co-authored-by: heymynameisrob <robhough180@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
3f191bcf7c
commit
b415544683
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -72,6 +72,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return isRecord(value) ? value : undefined;
|
||||
}
|
||||
|
||||
type ContentToolResultOutput = Extract<ToolResultPart['output'], { type: 'content' }>;
|
||||
|
||||
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] });
|
||||
|
|
|
|||
|
|
@ -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<Record<string, unknown>> }).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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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<typeof skillLoadOutputSchema>;
|
||||
|
||||
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<RuntimeSkillSource['loadFile']>;
|
||||
},
|
||||
): Promise<SkillLoadOutput> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
@ -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({
|
||||
|
|
@ -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<Record<string, string>>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Record<string, string>>;
|
||||
};
|
||||
},
|
||||
options: BraveSearchOptions,
|
||||
): Promise<WebSearchResponse> {
|
||||
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<string, string> = {
|
||||
Accept: 'application/json',
|
||||
'Accept-Encoding': 'gzip',
|
||||
3
packages/@n8n/ai-utilities/src/web-search/index.ts
Normal file
3
packages/@n8n/ai-utilities/src/web-search/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { braveSearch, type BraveSearchOptions } from './brave-search';
|
||||
export { searxngSearch } from './searxng-search';
|
||||
export type { WebSearchOptions, WebSearchResponse, WebSearchResult } from './types';
|
||||
|
|
@ -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<WebSearchResponse> {
|
||||
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({
|
||||
17
packages/@n8n/ai-utilities/src/web-search/types.ts
Normal file
17
packages/@n8n/ai-utilities/src/web-search/types.ts
Normal file
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
86
packages/@n8n/api-types/src/agents/provider-capabilities.ts
Normal file
86
packages/@n8n/api-types/src/agents/provider-capabilities.ts
Normal file
|
|
@ -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<string, NativeWebSearchProviderTool>;
|
||||
|
||||
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<NativeWebSearchProviderTool, NativeWebSearchProvider>;
|
||||
|
||||
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<string, unknown> }
|
||||
>;
|
||||
|
||||
export interface ProviderCapabilities {
|
||||
thinking: false | 'budgetTokens' | 'reasoningEffort';
|
||||
webSearch: false | NativeWebSearchCanonicalTool;
|
||||
providerTools: ReadonlyArray<NativeWebSearchCanonicalTool | 'openai.image_generation'>;
|
||||
}
|
||||
|
||||
export const PROVIDER_CAPABILITIES: Record<string, ProviderCapabilities> = {
|
||||
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),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<WorkflowRepository>();
|
||||
const agentsToolsService = mock<AgentsToolsService>();
|
||||
const builderModelLookupService = mock<BuilderModelLookupService>();
|
||||
const credentialTypes = mock<CredentialTypes>();
|
||||
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: [] };
|
||||
|
|
|
|||
|
|
@ -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<typeof service.validateAgentIsRunnable>[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<typeof service.validateAgentIsRunnable>[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<typeof service.validateAgentIsRunnable>[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<typeof service.validateAgentIsRunnable>[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<typeof service.validateAgentIsRunnable>[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: {
|
||||
|
|
|
|||
|
|
@ -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> = {}): 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 } },
|
||||
|
|
|
|||
|
|
@ -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:<id>": config references a skill id with no stored body
|
||||
*/
|
||||
async validateAgentIsRunnable(
|
||||
|
|
@ -1025,9 +1028,12 @@ export class AgentsService {
|
|||
}
|
||||
|
||||
let credentialList: Awaited<ReturnType<CredentialProvider['list']>> | 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<Awaited<ReturnType<CredentialProvider['list']>>[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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> {
|
||||
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(),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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<AskCredentialInput, AskCredentialResume>,
|
||||
) => {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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": "<credentialId>" }\`.
|
||||
- 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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": "<credentialId>" }\`;
|
||||
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.`;
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "<credentialId>" }\`.
|
||||
- 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": "<credentialId>" }\` 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."
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<BuiltTool | null | undefined>;
|
||||
|
|
@ -33,6 +47,11 @@ export interface ToolExecutor {
|
|||
/** Factory function that reconstructs a BuiltMemory backend from serialized params. */
|
||||
export type MemoryFactory = (params: AgentJsonMemoryConfig) => BuiltMemory | Promise<BuiltMemory>;
|
||||
|
||||
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<string, AgentSkill>,
|
||||
|
|
@ -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<NonNullable<AgentJsonMemoryConfig['episodicMemory']>, { 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<ModelConfig> {
|
||||
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<ModelConfig> {
|
||||
return await resolveCredentialAwareModelConfig(
|
||||
config.model,
|
||||
config.credential,
|
||||
credentialProvider,
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveCredentialAwareModelConfig(
|
||||
model: string,
|
||||
credential: string,
|
||||
credentialProvider: CredentialProvider,
|
||||
): Promise<ModelConfig> {
|
||||
const raw = await credentialProvider.resolve(credential);
|
||||
const mapped = mapCredentialForProvider(getProviderPrefix(model), raw);
|
||||
return { id: model, ...mapped } as ModelConfig;
|
||||
}
|
||||
|
||||
function getProviderPrefix(modelId: string): string {
|
||||
|
|
|
|||
|
|
@ -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<string, Record<string, unknown>> {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<typeof VueUse>();
|
||||
return {
|
||||
|
|
@ -44,9 +53,10 @@ const globalStubs = {
|
|||
N8nSelect: {
|
||||
props: ['modelValue', 'disabled'],
|
||||
emits: ['update:modelValue'],
|
||||
template: '<select :disabled="disabled"><slot /></select>',
|
||||
template:
|
||||
'<select v-bind="$attrs" :value="modelValue" :disabled="disabled" @change="$emit(\'update:modelValue\', $event.target.value)"><slot /></select>',
|
||||
},
|
||||
N8nOption: { template: '<option><slot /></option>' },
|
||||
N8nOption: { props: ['value', 'label'], template: '<option :value="value">{{ label }}</option>' },
|
||||
N8nSwitch2: {
|
||||
props: ['modelValue', 'disabled'],
|
||||
emits: ['update:modelValue'],
|
||||
|
|
@ -65,7 +75,210 @@ function makeConfig(overrides: Partial<AgentJsonConfig> = {}): AgentJsonConfig {
|
|||
} as AgentJsonConfig;
|
||||
}
|
||||
|
||||
function emitSelectValue(wrapper: ReturnType<typeof mount>, 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<typeof mount>, 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<AgentJsonConfig>): WebSearchConfig | undefined {
|
||||
return (
|
||||
changes.config as
|
||||
| (NonNullable<AgentJsonConfig['config']> & { 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<AgentJsonConfig>;
|
||||
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<AgentJsonConfig>);
|
||||
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<AgentJsonConfig>;
|
||||
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<AgentJsonConfig>);
|
||||
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<AgentJsonConfig>;
|
||||
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<AgentJsonConfig>;
|
||||
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<AgentJsonConfig>);
|
||||
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<AgentJsonConfig>);
|
||||
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<AgentJsonConfig>;
|
||||
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<AgentJsonConfig>);
|
||||
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<AgentJsonConfig>;
|
||||
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<AgentJsonConfig>);
|
||||
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<AgentJsonConfig>;
|
||||
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<AgentJsonConfig>);
|
||||
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<AgentJsonConfig>;
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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: '<div><slot /></div>', props: ['variant'] },
|
||||
N8nHeading: { template: '<h2><slot /></h2>', props: ['size'] },
|
||||
N8nIconButton: { template: '<button><slot /></button>' },
|
||||
N8nRadioButtons: { template: '<div />', props: ['modelValue', 'options'] },
|
||||
N8nSwitch: { template: '<button data-test-id="agent-memory-toggle"></button>' },
|
||||
N8nText: { template: '<span><slot /></span>', props: ['tag', 'bold', 'size', 'color'] },
|
||||
N8nTooltip: { template: '<div><slot /><slot name="content" /></div>' },
|
||||
}));
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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> = {}): 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<AgentJsonConfig>),
|
||||
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<AgentJsonConfig>),
|
||||
'anthropic.web_search',
|
||||
);
|
||||
|
||||
expect(result.config?.webSearch).toEqual({
|
||||
enabled: true,
|
||||
provider: 'brave',
|
||||
credential: 'brave-1',
|
||||
});
|
||||
expect(result.providerTools).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
|
|
|
|||
|
|
@ -1,4 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Behavior panel — execution-behavior knobs that used to live in the old
|
||||
* AgentOverviewPanel: native web search, reasoning depth (provider-gated),
|
||||
* and tool-call concurrency.
|
||||
*
|
||||
* Thinking is always visible as a toggle but disabled (with a tooltip) when
|
||||
* the selected provider doesn't support it. The sub-control differs by
|
||||
* provider: Anthropic takes a `budgetTokens` number, OpenAI takes a
|
||||
* `reasoningEffort` low/medium/high select.
|
||||
*/
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import {
|
||||
|
|
@ -12,6 +22,7 @@ import {
|
|||
import N8nOption from '@n8n/design-system/components/N8nOption';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
||||
import { useCredentialsStore } from '@/features/credentials/credentials.store';
|
||||
import type { AgentJsonConfig } from '../types';
|
||||
import {
|
||||
PROVIDER_CAPABILITIES,
|
||||
|
|
@ -19,8 +30,22 @@ import {
|
|||
type ReasoningEffort,
|
||||
} from '../provider-capabilities';
|
||||
import { parseProvider } from '../utils/model-string';
|
||||
import {
|
||||
getNativeWebSearchArgs,
|
||||
getWebSearchMethod,
|
||||
type FallbackWebSearchProvider,
|
||||
type NativeWebSearchArgs,
|
||||
type WebSearchMethod,
|
||||
withWebSearchConfig,
|
||||
} from '../utils/nativeWebSearch';
|
||||
|
||||
const i18n = useI18n();
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const DEFAULT_CAPABILITIES = { thinking: false, webSearch: false, providerTools: [] } as const;
|
||||
const ANTHROPIC_WEB_SEARCH_DEFAULT_MAX_USES = 5;
|
||||
const SEARCH_CONTEXT_SIZE_OPTIONS = ['low', 'medium', 'high'] as const;
|
||||
type SearchContextSize = (typeof SEARCH_CONTEXT_SIZE_OPTIONS)[number];
|
||||
type WebSearchSelectValue = 'off' | WebSearchMethod;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ config: AgentJsonConfig | null; disabled?: boolean; collapsible?: boolean }>(),
|
||||
|
|
@ -34,9 +59,8 @@ const emit = defineEmits<{ 'update:config': [changes: Partial<AgentJsonConfig>]
|
|||
const isExpanded = ref(!props.collapsible);
|
||||
|
||||
const provider = computed(() => parseProvider(props.config?.model));
|
||||
const capabilities = computed(
|
||||
() => PROVIDER_CAPABILITIES[provider.value] ?? { thinking: false as const },
|
||||
);
|
||||
const capabilities = computed(() => PROVIDER_CAPABILITIES[provider.value] ?? DEFAULT_CAPABILITIES);
|
||||
const hasNativeWebSearch = computed(() => Boolean(capabilities.value.webSearch));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic helper for numeric config fields
|
||||
|
|
@ -111,6 +135,20 @@ const {
|
|||
// Thinking — provider-gated, handled separately
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const webSearchEnabled = ref(props.config?.config?.webSearch?.enabled === true);
|
||||
const webSearchMethod = ref<WebSearchSelectValue>(
|
||||
webSearchEnabled.value ? getWebSearchMethod(props.config, hasNativeWebSearch.value) : 'off',
|
||||
);
|
||||
const webSearchArgs = ref<NativeWebSearchArgs>(
|
||||
getNativeWebSearchArgs(props.config, capabilities.value.webSearch),
|
||||
);
|
||||
const webSearchMaxUses = ref('');
|
||||
const webSearchExternalAccess = ref(true);
|
||||
const webSearchContextSize = ref<SearchContextSize>('medium');
|
||||
const fallbackWebSearchProvider = ref<FallbackWebSearchProvider>(
|
||||
props.config?.config?.webSearch?.provider === 'searxng' ? 'searxng' : 'brave',
|
||||
);
|
||||
const fallbackWebSearchCredential = ref(props.config?.config?.webSearch?.credential ?? '');
|
||||
const thinkingCfg = computed(() => props.config?.config?.thinking ?? null);
|
||||
const thinkingEnabled = ref(thinkingCfg.value !== null);
|
||||
const budgetTokens = ref(thinkingCfg.value?.budgetTokens ?? BUDGET_TOKENS_DEFAULT);
|
||||
|
|
@ -118,6 +156,23 @@ const reasoningEffort = ref<ReasoningEffort>(
|
|||
(thinkingCfg.value?.reasoningEffort as ReasoningEffort) ?? 'medium',
|
||||
);
|
||||
|
||||
function syncWebSearchOptions(args: NativeWebSearchArgs) {
|
||||
webSearchMaxUses.value =
|
||||
typeof args.maxUses === 'number'
|
||||
? String(args.maxUses)
|
||||
: String(ANTHROPIC_WEB_SEARCH_DEFAULT_MAX_USES);
|
||||
webSearchExternalAccess.value =
|
||||
typeof args.externalWebAccess === 'boolean' ? args.externalWebAccess : true;
|
||||
webSearchContextSize.value =
|
||||
args.searchContextSize === 'low' ||
|
||||
args.searchContextSize === 'medium' ||
|
||||
args.searchContextSize === 'high'
|
||||
? args.searchContextSize
|
||||
: 'medium';
|
||||
}
|
||||
|
||||
syncWebSearchOptions(webSearchArgs.value);
|
||||
|
||||
watch(
|
||||
() => props.config,
|
||||
(cfg) => {
|
||||
|
|
@ -128,10 +183,107 @@ watch(
|
|||
reasoningEffort.value = (t?.reasoningEffort as ReasoningEffort) ?? 'medium';
|
||||
syncConcurrency(cfg);
|
||||
syncMaxIterations(cfg);
|
||||
webSearchEnabled.value = cfg.config?.webSearch?.enabled === true;
|
||||
webSearchMethod.value = webSearchEnabled.value
|
||||
? getWebSearchMethod(cfg, hasNativeWebSearch.value)
|
||||
: 'off';
|
||||
webSearchArgs.value = getNativeWebSearchArgs(cfg, capabilities.value.webSearch);
|
||||
fallbackWebSearchProvider.value = webSearchMethod.value === 'searxng' ? 'searxng' : 'brave';
|
||||
fallbackWebSearchCredential.value = cfg.config?.webSearch?.credential ?? '';
|
||||
syncWebSearchOptions(webSearchArgs.value);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const fallbackCredentialType = computed(() =>
|
||||
webSearchMethod.value === 'searxng' ? 'searXngApi' : 'braveSearchApi',
|
||||
);
|
||||
const fallbackCredentials = computed(() =>
|
||||
credentialsStore.allCredentials.filter(
|
||||
(credential) => credential.type === fallbackCredentialType.value,
|
||||
),
|
||||
);
|
||||
|
||||
function buildWebSearchArgs(): NativeWebSearchArgs {
|
||||
const tool = capabilities.value.webSearch;
|
||||
if (!tool || webSearchMethod.value !== 'native') return {};
|
||||
|
||||
if (tool === 'anthropic.web_search') {
|
||||
const maxUses = Number(webSearchMaxUses.value);
|
||||
return {
|
||||
...(Number.isFinite(maxUses) && maxUses > 0 && { maxUses }),
|
||||
};
|
||||
}
|
||||
|
||||
if (tool === 'openai.web_search') {
|
||||
return {
|
||||
externalWebAccess: webSearchExternalAccess.value,
|
||||
searchContextSize: webSearchContextSize.value,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function emitWebSearchConfig() {
|
||||
if (!webSearchEnabled.value) return;
|
||||
const method = webSearchMethod.value === 'off' ? 'native' : webSearchMethod.value;
|
||||
emit(
|
||||
'update:config',
|
||||
withWebSearchConfig(
|
||||
props.config,
|
||||
true,
|
||||
method,
|
||||
capabilities.value.webSearch,
|
||||
buildWebSearchArgs(),
|
||||
fallbackWebSearchCredential.value,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function onWebSearchOptionInput() {
|
||||
emitWebSearchConfig();
|
||||
}
|
||||
|
||||
function onWebSearchMethodChange(value: WebSearchSelectValue) {
|
||||
webSearchMethod.value = value;
|
||||
webSearchEnabled.value = value !== 'off';
|
||||
const method = value === 'off' ? 'native' : value;
|
||||
const nextFallbackProvider = value === 'brave' || value === 'searxng' ? value : null;
|
||||
if (nextFallbackProvider && nextFallbackProvider !== fallbackWebSearchProvider.value) {
|
||||
fallbackWebSearchCredential.value = '';
|
||||
}
|
||||
if (nextFallbackProvider) {
|
||||
fallbackWebSearchProvider.value = nextFallbackProvider;
|
||||
}
|
||||
emit(
|
||||
'update:config',
|
||||
withWebSearchConfig(
|
||||
props.config,
|
||||
webSearchEnabled.value,
|
||||
method,
|
||||
capabilities.value.webSearch,
|
||||
buildWebSearchArgs(),
|
||||
fallbackWebSearchCredential.value,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function onFallbackCredentialChange(value: string) {
|
||||
fallbackWebSearchCredential.value = value;
|
||||
emit(
|
||||
'update:config',
|
||||
withWebSearchConfig(
|
||||
props.config,
|
||||
webSearchEnabled.value,
|
||||
webSearchMethod.value === 'off' ? 'native' : webSearchMethod.value,
|
||||
capabilities.value.webSearch,
|
||||
buildWebSearchArgs(),
|
||||
value,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function emitThinking() {
|
||||
const cap = capabilities.value.thinking;
|
||||
if (!cap) return;
|
||||
|
|
@ -190,61 +342,231 @@ const thinkingDisabledReason = computed(() =>
|
|||
<N8nText tag="h3" :bold="true">{{ i18n.baseText('agents.builder.advanced.title') }}</N8nText>
|
||||
</template>
|
||||
<div :class="$style.content">
|
||||
<div :class="$style.row">
|
||||
<div :class="$style.rowLabel">
|
||||
<N8nText size="small" :bold="true">{{
|
||||
i18n.baseText('agents.builder.advanced.thinking.label')
|
||||
}}</N8nText>
|
||||
<N8nText size="xsmall" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.advanced.thinking.hint') }}
|
||||
</N8nText>
|
||||
<div :class="$style.settingGroup">
|
||||
<div :class="$style.row">
|
||||
<div :class="$style.rowLabel">
|
||||
<N8nText size="small" :bold="true">{{
|
||||
i18n.baseText('agents.builder.advanced.webSearch.label')
|
||||
}}</N8nText>
|
||||
<N8nText size="xsmall" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.advanced.webSearch.hint') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nSelect
|
||||
:model-value="webSearchMethod"
|
||||
size="small"
|
||||
:disabled="props.disabled"
|
||||
:class="$style.shortInput"
|
||||
data-testid="agent-web-search-method"
|
||||
@update:model-value="(v) => onWebSearchMethodChange(v as WebSearchSelectValue)"
|
||||
>
|
||||
<N8nOption
|
||||
value="off"
|
||||
:label="i18n.baseText('agents.builder.advanced.webSearch.method.off')"
|
||||
/>
|
||||
<N8nOption
|
||||
v-if="capabilities.webSearch"
|
||||
value="native"
|
||||
:label="i18n.baseText('agents.builder.advanced.webSearch.method.native')"
|
||||
/>
|
||||
<N8nOption
|
||||
value="brave"
|
||||
:label="i18n.baseText('agents.builder.advanced.webSearch.fallbackProvider.brave')"
|
||||
/>
|
||||
<N8nOption
|
||||
value="searxng"
|
||||
:label="i18n.baseText('agents.builder.advanced.webSearch.fallbackProvider.searxng')"
|
||||
/>
|
||||
</N8nSelect>
|
||||
</div>
|
||||
<N8nTooltip
|
||||
:content="thinkingDisabledReason"
|
||||
:disabled="!!capabilities.thinking"
|
||||
placement="top"
|
||||
|
||||
<div
|
||||
v-if="webSearchEnabled"
|
||||
:class="$style.subSettings"
|
||||
data-testid="agent-web-search-settings"
|
||||
>
|
||||
<N8nSwitch2
|
||||
:model-value="thinkingEnabled"
|
||||
:disabled="!capabilities.thinking || props.disabled"
|
||||
data-testid="agent-thinking-toggle"
|
||||
@update:model-value="(v) => onThinkingToggle(Boolean(v))"
|
||||
/>
|
||||
</N8nTooltip>
|
||||
<div
|
||||
v-if="webSearchMethod === 'native' && capabilities.webSearch === 'anthropic.web_search'"
|
||||
:class="$style.row"
|
||||
>
|
||||
<div :class="$style.rowLabel">
|
||||
<N8nText size="small" :bold="true">{{
|
||||
i18n.baseText('agents.builder.advanced.webSearch.maxUses.label')
|
||||
}}</N8nText>
|
||||
<N8nText size="xsmall" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.advanced.webSearch.maxUses.hint') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nInputNumber2
|
||||
:model-value="Number(webSearchMaxUses)"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
:disabled="props.disabled"
|
||||
:class="$style.shortInput"
|
||||
data-testid="agent-web-search-max-uses"
|
||||
@update:model-value="
|
||||
(v) => {
|
||||
webSearchMaxUses = String(v);
|
||||
onWebSearchOptionInput();
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="webSearchMethod === 'native' && capabilities.webSearch === 'openai.web_search'"
|
||||
:class="$style.row"
|
||||
>
|
||||
<div :class="$style.rowLabel">
|
||||
<N8nText size="small" :bold="true">{{
|
||||
i18n.baseText('agents.builder.advanced.webSearch.externalAccess.label')
|
||||
}}</N8nText>
|
||||
<N8nText size="xsmall" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.advanced.webSearch.externalAccess.hint') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nSwitch2
|
||||
:model-value="webSearchExternalAccess"
|
||||
:disabled="props.disabled"
|
||||
:class="$style.switchControl"
|
||||
data-testid="agent-web-search-external-access"
|
||||
@update:model-value="
|
||||
(v) => {
|
||||
webSearchExternalAccess = Boolean(v);
|
||||
onWebSearchOptionInput();
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="webSearchMethod === 'native' && capabilities.webSearch === 'openai.web_search'"
|
||||
:class="$style.row"
|
||||
>
|
||||
<N8nText size="small" :bold="true">{{
|
||||
i18n.baseText('agents.builder.advanced.webSearch.contextSize.label')
|
||||
}}</N8nText>
|
||||
<N8nSelect
|
||||
:model-value="webSearchContextSize"
|
||||
size="small"
|
||||
:disabled="props.disabled"
|
||||
:class="$style.shortInput"
|
||||
data-testid="agent-web-search-context-size"
|
||||
@update:model-value="
|
||||
(v) => {
|
||||
webSearchContextSize = v as SearchContextSize;
|
||||
onWebSearchOptionInput();
|
||||
}
|
||||
"
|
||||
>
|
||||
<N8nOption
|
||||
v-for="opt in SEARCH_CONTEXT_SIZE_OPTIONS"
|
||||
:key="opt"
|
||||
:value="opt"
|
||||
:label="opt"
|
||||
/>
|
||||
</N8nSelect>
|
||||
</div>
|
||||
|
||||
<div v-if="webSearchMethod !== 'native'" :class="$style.row">
|
||||
<div :class="$style.rowLabel">
|
||||
<N8nText size="small" :bold="true">{{
|
||||
i18n.baseText('agents.builder.advanced.webSearch.credential.label')
|
||||
}}</N8nText>
|
||||
<N8nText size="xsmall" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.advanced.webSearch.credential.hint') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nSelect
|
||||
:model-value="fallbackWebSearchCredential"
|
||||
size="small"
|
||||
:disabled="props.disabled"
|
||||
:class="$style.credentialSelect"
|
||||
data-testid="agent-web-search-fallback-credential"
|
||||
@update:model-value="(v) => onFallbackCredentialChange(String(v))"
|
||||
>
|
||||
<N8nOption
|
||||
v-for="credential in fallbackCredentials"
|
||||
:key="credential.id"
|
||||
:value="credential.id"
|
||||
:label="credential.name"
|
||||
/>
|
||||
</N8nSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="thinkingEnabled && capabilities.thinking === 'budgetTokens'" :class="$style.row">
|
||||
<N8nText size="small" :bold="true">{{
|
||||
i18n.baseText('agents.builder.advanced.budgetTokens.label')
|
||||
}}</N8nText>
|
||||
<N8nInputNumber2
|
||||
:model-value="budgetTokens"
|
||||
:min="BUDGET_TOKENS_MIN"
|
||||
:precision="0"
|
||||
:disabled="props.disabled"
|
||||
:class="$style.shortInput"
|
||||
data-testid="agent-budget-tokens-input"
|
||||
@update:model-value="onBudgetChange"
|
||||
/>
|
||||
</div>
|
||||
<div :class="$style.settingGroup">
|
||||
<div :class="$style.row">
|
||||
<div :class="$style.rowLabel">
|
||||
<N8nText size="small" :bold="true">{{
|
||||
i18n.baseText('agents.builder.advanced.thinking.label')
|
||||
}}</N8nText>
|
||||
<N8nText size="xsmall" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.advanced.thinking.hint') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nTooltip
|
||||
:content="thinkingDisabledReason"
|
||||
:disabled="!!capabilities.thinking"
|
||||
placement="top"
|
||||
>
|
||||
<N8nSwitch2
|
||||
:model-value="thinkingEnabled"
|
||||
:disabled="!capabilities.thinking || props.disabled"
|
||||
:class="$style.switchControl"
|
||||
data-testid="agent-thinking-toggle"
|
||||
@update:model-value="(v) => onThinkingToggle(Boolean(v))"
|
||||
/>
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="thinkingEnabled && capabilities.thinking === 'reasoningEffort'"
|
||||
:class="$style.row"
|
||||
>
|
||||
<N8nText size="small" :bold="true">{{
|
||||
i18n.baseText('agents.builder.advanced.reasoningEffort.label')
|
||||
}}</N8nText>
|
||||
<N8nSelect
|
||||
:model-value="reasoningEffort"
|
||||
size="small"
|
||||
:disabled="props.disabled"
|
||||
:class="$style.shortInput"
|
||||
data-testid="agent-reasoning-effort-select"
|
||||
@update:model-value="onReasoningEffortChange"
|
||||
<div
|
||||
v-if="thinkingEnabled && capabilities.thinking"
|
||||
:class="$style.subSettings"
|
||||
data-testid="agent-thinking-settings"
|
||||
>
|
||||
<N8nOption v-for="opt in REASONING_EFFORT_OPTIONS" :key="opt" :value="opt" :label="opt" />
|
||||
</N8nSelect>
|
||||
<div v-if="capabilities.thinking === 'budgetTokens'" :class="$style.row">
|
||||
<div :class="$style.rowLabel">
|
||||
<N8nText size="small" :bold="true">{{
|
||||
i18n.baseText('agents.builder.advanced.budgetTokens.label')
|
||||
}}</N8nText>
|
||||
<N8nText size="xsmall" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.advanced.budgetTokens.hint') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nInputNumber2
|
||||
:model-value="budgetTokens"
|
||||
:min="BUDGET_TOKENS_MIN"
|
||||
:precision="0"
|
||||
:disabled="props.disabled"
|
||||
:class="$style.shortInput"
|
||||
data-testid="agent-budget-tokens-input"
|
||||
@update:model-value="onBudgetChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="capabilities.thinking === 'reasoningEffort'" :class="$style.row">
|
||||
<N8nText size="small" :bold="true">{{
|
||||
i18n.baseText('agents.builder.advanced.reasoningEffort.label')
|
||||
}}</N8nText>
|
||||
<N8nSelect
|
||||
:model-value="reasoningEffort"
|
||||
size="small"
|
||||
:disabled="props.disabled"
|
||||
:class="$style.shortInput"
|
||||
data-testid="agent-reasoning-effort-select"
|
||||
@update:model-value="onReasoningEffortChange"
|
||||
>
|
||||
<N8nOption
|
||||
v-for="opt in REASONING_EFFORT_OPTIONS"
|
||||
:key="opt"
|
||||
:value="opt"
|
||||
:label="opt"
|
||||
/>
|
||||
</N8nSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="$style.row">
|
||||
|
|
@ -324,6 +646,12 @@ const thinkingDisabledReason = computed(() =>
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.settingGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -340,8 +668,25 @@ const thinkingDisabledReason = computed(() =>
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
.subSettings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--xs);
|
||||
padding-left: var(--spacing--sm);
|
||||
border-left: var(--border);
|
||||
}
|
||||
|
||||
.shortInput {
|
||||
width: 140px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.credentialSelect {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.switchControl:not([data-disabled]) {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,212 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { type BaseTextKey, useI18n } from '@n8n/i18n';
|
||||
import { HoverCardContent, HoverCardPortal, HoverCardRoot, HoverCardTrigger } from 'reka-ui';
|
||||
import { N8nIcon, N8nText } from '@n8n/design-system';
|
||||
|
||||
export interface DisplayMemory {
|
||||
id: string;
|
||||
keyMemory: string;
|
||||
evidence: string[];
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
memories: DisplayMemory[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [open: boolean];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const memoriesCountLabelKey = 'agents.builder.quickActions.memoriesUsed.count' as BaseTextKey;
|
||||
const keyMemoryLabelKey = 'agents.builder.quickActions.memoriesUsed.keyMemory' as BaseTextKey;
|
||||
|
||||
const memories = computed(() => props.memories);
|
||||
const isOpen = ref(false);
|
||||
|
||||
function onOpenChange(open: boolean) {
|
||||
isOpen.value = open;
|
||||
emit('update:open', open);
|
||||
}
|
||||
|
||||
function splitKeyMemory(text: string): string[] {
|
||||
return text.split(/(?<=[.!?])\s+/).filter((part) => part.length > 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HoverCardRoot
|
||||
v-if="memories.length > 0"
|
||||
v-model:open="isOpen"
|
||||
:open-delay="400"
|
||||
:close-delay="0"
|
||||
@update:open="onOpenChange"
|
||||
>
|
||||
<HoverCardTrigger as-child>
|
||||
<div :class="$style.trigger">
|
||||
<N8nIcon icon="brain" size="small" />
|
||||
<span>
|
||||
{{
|
||||
i18n.baseText(memoriesCountLabelKey, {
|
||||
adjustToNumber: memories.length,
|
||||
interpolate: { count: String(memories.length) },
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
:side-offset="8"
|
||||
:class="[$style.popoverContent, $style.panel]"
|
||||
>
|
||||
<div v-for="memory in memories" :key="memory.id" :class="$style.memorySection">
|
||||
<N8nText step="sm" bold :class="$style.label">
|
||||
{{ i18n.baseText(keyMemoryLabelKey) }}
|
||||
</N8nText>
|
||||
<ul :class="$style.keyMemoryList">
|
||||
<li
|
||||
v-for="(sentence, sentenceIndex) in splitKeyMemory(memory.keyMemory)"
|
||||
:key="`${memory.id}-${sentenceIndex}`"
|
||||
>
|
||||
<N8nText step="sm" tag="p" :class="$style.keyMemory">
|
||||
{{ sentence }}
|
||||
</N8nText>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</HoverCardRoot>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
@use '../../../../../@n8n/design-system/src/css/mixins/motion';
|
||||
|
||||
/** When https://github.com/n8n-io/n8n/pull/30611 is merged we can replace with proper N8nHoverCard component **/
|
||||
.popoverContent {
|
||||
--popover--offset--slide-x: 0;
|
||||
--popover--offset--slide-y: 0;
|
||||
--popover--offset--origin-x: center;
|
||||
--popover--offset--origin-y: center;
|
||||
--animation--popover-in--translate-x: var(--popover--offset--slide-x);
|
||||
--animation--popover-in--translate-y: var(--popover--offset--slide-y);
|
||||
|
||||
border-radius: var(--radius--xs);
|
||||
background-color: var(--background--surface);
|
||||
box-shadow:
|
||||
var(--shadow--md),
|
||||
inset var(--shadow--outline);
|
||||
will-change: transform, opacity;
|
||||
transform-origin: var(--popover--offset--origin-x) var(--popover--offset--origin-y);
|
||||
|
||||
&[data-state='open'] {
|
||||
@include motion.popover-in;
|
||||
}
|
||||
|
||||
&[data-state='closed'] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.popoverContent[data-state='open'][data-side='top'] {
|
||||
--popover--offset--slide-y: -2px;
|
||||
--popover--offset--origin-y: bottom;
|
||||
}
|
||||
|
||||
.popoverContent[data-state='open'][data-side='right'] {
|
||||
--popover--offset--slide-x: 2px;
|
||||
--popover--offset--origin-x: left;
|
||||
}
|
||||
|
||||
.popoverContent[data-state='open'][data-side='bottom'] {
|
||||
--popover--offset--slide-y: 2px;
|
||||
--popover--offset--origin-y: top;
|
||||
}
|
||||
|
||||
.popoverContent[data-state='open'][data-side='left'] {
|
||||
--popover--offset--slide-x: -2px;
|
||||
--popover--offset--origin-x: right;
|
||||
}
|
||||
|
||||
.popoverContent[data-state='open'][data-side='top'][data-align='start'],
|
||||
.popoverContent[data-state='open'][data-side='bottom'][data-align='start'] {
|
||||
--popover--offset--slide-x: -2px;
|
||||
--popover--offset--origin-x: left;
|
||||
}
|
||||
|
||||
.popoverContent[data-state='open'][data-side='top'][data-align='end'],
|
||||
.popoverContent[data-state='open'][data-side='bottom'][data-align='end'] {
|
||||
--popover--offset--slide-x: 2px;
|
||||
--popover--offset--origin-x: right;
|
||||
}
|
||||
|
||||
.popoverContent[data-state='open'][data-side='left'][data-align='start'],
|
||||
.popoverContent[data-state='open'][data-side='right'][data-align='start'] {
|
||||
--popover--offset--slide-y: -2px;
|
||||
--popover--offset--origin-y: top;
|
||||
}
|
||||
|
||||
.popoverContent[data-state='open'][data-side='left'][data-align='end'],
|
||||
.popoverContent[data-state='open'][data-side='right'][data-align='end'] {
|
||||
--popover--offset--slide-y: 2px;
|
||||
--popover--offset--origin-y: bottom;
|
||||
}
|
||||
|
||||
.panel {
|
||||
max-width: 24rem;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.panel::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
padding: 0 var(--spacing--xs);
|
||||
font-size: var(--font-size--2xs);
|
||||
text-align: right;
|
||||
font-weight: var(--font-weight--medium);
|
||||
color: var(--text-color--subtler);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.keyMemoryList {
|
||||
margin: 0;
|
||||
padding-left: var(--spacing--sm);
|
||||
|
||||
li {
|
||||
display: list-item;
|
||||
list-style-type: disc;
|
||||
margin-bottom: var(--spacing--2xs);
|
||||
}
|
||||
li::marker {
|
||||
color: var(--border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.memorySection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--xs);
|
||||
padding: var(--spacing--sm);
|
||||
border-bottom: var(--border);
|
||||
}
|
||||
|
||||
.keyMemory {
|
||||
margin: 0;
|
||||
line-height: var(--line-height--xl);
|
||||
color: var(--text-color--subtle);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -48,7 +48,7 @@ const i18n = useI18n();
|
|||
|
||||
& g,
|
||||
& path {
|
||||
color: var(--color--text--tint-1);
|
||||
color: var(--icon-color);
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
|
||||
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<string | null>(null);
|
||||
|
||||
function setMemoryFooterOpen(groupId: string, open: boolean): void {
|
||||
openMemoryFooterGroupId.value = open
|
||||
? groupId
|
||||
: openMemoryFooterGroupId.value === groupId
|
||||
? null
|
||||
: openMemoryFooterGroupId.value;
|
||||
}
|
||||
|
||||
const spokenMessageId = ref<string | null>(null);
|
||||
const spokenText = computed(() => {
|
||||
if (!spokenMessageId.value) return '';
|
||||
|
|
@ -252,8 +348,19 @@ onBeforeUnmount(() => {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="shouldShowAssistantActions(group.id)" :class="$style.messageActions">
|
||||
<div
|
||||
v-if="shouldShowAssistantFooter(group.id)"
|
||||
:class="[
|
||||
$style.messageFooter,
|
||||
{ [$style.messageFooterVisible]: openMemoryFooterGroupId === group.id },
|
||||
]"
|
||||
>
|
||||
<AgentChatMemoryUsed
|
||||
:memories="getMemoriesUsedInAssistantRun(group.id)"
|
||||
@update:open="setMemoryFooterOpen(group.id, $event)"
|
||||
/>
|
||||
<AgentChatMessageActions
|
||||
v-if="getAssistantRunContent(group.id)"
|
||||
:content="getAssistantRunContent(group.id)"
|
||||
:is-speech-synthesis-available="isSpeechSynthesisAvailable"
|
||||
:is-speaking="isSpeakingMessage(group.id)"
|
||||
|
|
@ -307,13 +414,24 @@ onBeforeUnmount(() => {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="shouldShowAssistantActions(group.id)" :class="$style.messageActions">
|
||||
<div
|
||||
v-if="shouldShowAssistantFooter(group.id)"
|
||||
:class="[
|
||||
$style.messageFooter,
|
||||
{ [$style.messageFooterVisible]: openMemoryFooterGroupId === group.id },
|
||||
]"
|
||||
>
|
||||
<AgentChatMessageActions
|
||||
v-if="getAssistantRunContent(group.id)"
|
||||
:content="getAssistantRunContent(group.id)"
|
||||
:is-speech-synthesis-available="isSpeechSynthesisAvailable"
|
||||
:is-speaking="isSpeakingMessage(group.id)"
|
||||
@read-aloud="toggleReadAloud(group.id)"
|
||||
/>
|
||||
<AgentChatMemoryUsed
|
||||
:memories="getMemoriesUsedInAssistantRun(group.id)"
|
||||
@update:open="setMemoryFooterOpen(group.id, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
@ -377,16 +495,21 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
<script setup lang="ts">
|
||||
import { N8nIcon, N8nTooltip } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import type { ToolCall } from '../composables/agentChatMessages';
|
||||
import { formatToolNameForDisplay } from '../utils/toolDisplayName';
|
||||
import { formatToolNameForDisplay, getToolNameTranslationKey } from '../utils/toolDisplayName';
|
||||
|
||||
defineProps<{
|
||||
toolCalls: ToolCall[];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
function getToolDisplayName(toolName: string): string {
|
||||
const translationKey = getToolNameTranslationKey(toolName);
|
||||
return translationKey ? i18n.baseText(translationKey) : formatToolNameForDisplay(toolName);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -34,7 +42,7 @@ defineProps<{
|
|||
<N8nIcon v-else icon="spinner" size="large" :spin="true" :class="$style.toolStepLoading" />
|
||||
</div>
|
||||
<span :class="[$style.toolStepLabel, { [$style.shimmer]: tc.state === 'running' }]">
|
||||
{{ formatToolNameForDisplay(tc.tool) }}
|
||||
{{ getToolDisplayName(tc.tool) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="tc.displaySummary"
|
||||
|
|
|
|||
|
|
@ -4,34 +4,23 @@
|
|||
* canonical ChatHub ModelSelector), and instructions. Credential selection is
|
||||
* handled inside the model picker — no separate credential field.
|
||||
*/
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { ref, watch } from 'vue';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import type {
|
||||
ChatHubConversationModel,
|
||||
ChatHubProvider,
|
||||
ChatModelDto,
|
||||
ChatModelsResponse,
|
||||
} from '@n8n/api-types';
|
||||
import type { ChatHubProvider } from '@n8n/api-types';
|
||||
|
||||
import { DEBOUNCE_TIME, getDebounceTime } from '@/app/constants/durations';
|
||||
import { useUsersStore } from '@/features/settings/users/users.store';
|
||||
import shared from '../styles/agent-panel.module.scss';
|
||||
import { useChatStore } from '@/features/ai/chatHub/chat.store';
|
||||
import { useChatCredentials } from '@/features/ai/chatHub/composables/useChatCredentials';
|
||||
import { isLlmProviderModel } from '@/features/ai/chatHub/chat.utils';
|
||||
import ModelSelector from '@/features/ai/chatHub/components/ModelSelector.vue';
|
||||
import AgentPanelHeader from './AgentPanelHeader.vue';
|
||||
|
||||
import type { AgentJsonConfig } from '../types';
|
||||
import {
|
||||
CHATHUB_TO_CATALOG,
|
||||
CATALOG_TO_CHATHUB,
|
||||
AGENT_UNSUPPORTED_PROVIDERS,
|
||||
} from '../provider-mapping';
|
||||
import { parseModelString, modelToString, sanitizeModelId } from '../utils/model-string';
|
||||
import { CATALOG_TO_CHATHUB } from '../provider-mapping';
|
||||
import { PROVIDER_CAPABILITIES } from '../provider-capabilities';
|
||||
import { parseModelString, modelToString } from '../utils/model-string';
|
||||
import { normalizeWebSearchForModelChange } from '../utils/nativeWebSearch';
|
||||
import AgentMiniEditor from './AgentMiniEditor.vue';
|
||||
import AgentModelSelector from './AgentModelSelector.vue';
|
||||
import { useToast } from '@/app/composables/useToast';
|
||||
|
||||
const props = withDefaults(
|
||||
|
|
@ -44,76 +33,27 @@ const props = withDefaults(
|
|||
const emit = defineEmits<{ 'update:config': [changes: Partial<AgentJsonConfig>] }>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const usersStore = useUsersStore();
|
||||
const chatStore = useChatStore();
|
||||
const { showError } = useToast();
|
||||
|
||||
const { credentialsByProvider, selectCredential } = useChatCredentials(
|
||||
usersStore.currentUserId ?? 'anonymous',
|
||||
);
|
||||
|
||||
watch(
|
||||
credentialsByProvider,
|
||||
(credentials) => {
|
||||
if (credentials) void chatStore.fetchAgents(credentials);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const filteredAgents = computed<ChatModelsResponse>(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(chatStore.agents).filter(
|
||||
([provider]) => !AGENT_UNSUPPORTED_PROVIDERS.has(provider),
|
||||
),
|
||||
) as ChatModelsResponse,
|
||||
);
|
||||
|
||||
const selectedAgent = computed<ChatModelDto | null>(() => {
|
||||
const modelStr = modelToString(props.config?.model);
|
||||
if (!modelStr) return null;
|
||||
const parsed = parseModelString(modelStr);
|
||||
if (!parsed) return null;
|
||||
const chatHubProvider = CATALOG_TO_CHATHUB[parsed.provider];
|
||||
if (!chatHubProvider) return null;
|
||||
|
||||
const registryEntry = filteredAgents.value[chatHubProvider]?.models.find(
|
||||
(m) => isLlmProviderModel(m.model) && m.model.model === parsed.name,
|
||||
);
|
||||
if (registryEntry) return registryEntry;
|
||||
|
||||
return {
|
||||
model: { provider: chatHubProvider, model: parsed.name } as ChatHubConversationModel,
|
||||
name: parsed.name,
|
||||
description: null,
|
||||
icon: null,
|
||||
updatedAt: null,
|
||||
createdAt: null,
|
||||
metadata: {} as ChatModelDto['metadata'],
|
||||
groupName: null,
|
||||
groupIcon: null,
|
||||
};
|
||||
});
|
||||
|
||||
function onModelChange(selection: ChatHubConversationModel) {
|
||||
if (!isLlmProviderModel(selection)) return;
|
||||
const catalogProvider = CHATHUB_TO_CATALOG[selection.provider] ?? selection.provider;
|
||||
const credentialId = credentialsByProvider.value?.[selection.provider];
|
||||
if (!credentialId) {
|
||||
function onModelChange(selection: { model: string; credentialId: string | null }) {
|
||||
if (!selection.credentialId) {
|
||||
showError(new Error(i18n.baseText('credentials.noResults')), i18n.baseText('error'));
|
||||
return;
|
||||
}
|
||||
const parsed = parseModelString(selection.model);
|
||||
const nextProviderTool = parsed
|
||||
? (PROVIDER_CAPABILITIES[parsed.provider]?.webSearch ?? false)
|
||||
: false;
|
||||
emit('update:config', {
|
||||
model: `${catalogProvider}/${sanitizeModelId(catalogProvider, selection.model)}`,
|
||||
credential: credentialId,
|
||||
model: selection.model,
|
||||
credential: selection.credentialId,
|
||||
...normalizeWebSearchForModelChange(props.config, nextProviderTool),
|
||||
});
|
||||
}
|
||||
|
||||
function onSelectCredential(provider: ChatHubProvider, credentialId: string | null) {
|
||||
selectCredential(provider, credentialId);
|
||||
const parsed = parseModelString(modelToString(props.config?.model));
|
||||
const currentChatHubProvider = parsed ? CATALOG_TO_CHATHUB[parsed.provider] : undefined;
|
||||
if (currentChatHubProvider === provider && credentialId) {
|
||||
if (parsed && CATALOG_TO_CHATHUB[parsed.provider] === provider && credentialId) {
|
||||
emit('update:config', { credential: credentialId });
|
||||
}
|
||||
}
|
||||
|
|
@ -152,14 +92,8 @@ function onInstructionsInput(value: string) {
|
|||
i18n.baseText('agents.builder.agent.model.label')
|
||||
}}</N8nText></label
|
||||
>
|
||||
<ModelSelector
|
||||
:selected-agent="selectedAgent"
|
||||
:include-custom-agents="false"
|
||||
:credentials="credentialsByProvider"
|
||||
:agents="filteredAgents"
|
||||
:is-loading="false"
|
||||
:warn-missing-credentials="true"
|
||||
horizontal
|
||||
<AgentModelSelector
|
||||
:model="modelToString(props.config?.model)"
|
||||
data-testid="agent-model-selector"
|
||||
@change="onModelChange"
|
||||
@select-credential="onSelectCredential"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { N8nButton, N8nText, N8nSwitch } from '@n8n/design-system';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { N8nTooltip, N8nIconButton, N8nText, N8nSwitch } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import {
|
||||
|
|
@ -8,6 +8,8 @@ import {
|
|||
AGENT_EPISODIC_MEMORY_CREDENTIAL_TYPE,
|
||||
DEFAULT_AGENT_MEMORY_LAST_MESSAGES,
|
||||
} from '../constants';
|
||||
import AgentModelSelector from './AgentModelSelector.vue';
|
||||
import { modelToString } from '../utils/model-string';
|
||||
import type { AgentJsonConfig } from '../types';
|
||||
|
||||
const props = withDefaults(
|
||||
|
|
@ -21,43 +23,37 @@ const emit = defineEmits<{ 'update:config': [changes: Partial<AgentJsonConfig>]
|
|||
|
||||
const i18n = useI18n();
|
||||
const uiStore = useUIStore();
|
||||
const memory = computed(() => (props.config?.memory?.enabled ? props.config.memory : null));
|
||||
const episodicMemory = computed(() => props.config?.memory?.episodicMemory ?? null);
|
||||
const episodicMemoryEnabled = computed(
|
||||
() => memory.value !== null && episodicMemory.value?.enabled === true,
|
||||
);
|
||||
const episodicMemoryEnabled = computed(() => episodicMemory.value?.enabled === true);
|
||||
const episodicMemoryCredential = computed(() =>
|
||||
episodicMemory.value?.enabled === true ? episodicMemory.value.credential : null,
|
||||
);
|
||||
const configuredMemoryModel = computed(() => {
|
||||
if (episodicMemory.value?.enabled !== true) return null;
|
||||
|
||||
function onEnableMemory() {
|
||||
return (
|
||||
episodicMemory.value.reflectorModel?.model ??
|
||||
episodicMemory.value.extractorModel?.model ??
|
||||
props.config?.memory?.observationalMemory?.reflectorModel?.model ??
|
||||
props.config?.memory?.observationalMemory?.observerModel?.model ??
|
||||
null
|
||||
);
|
||||
});
|
||||
const selectedMemoryModel = ref<string | null>(configuredMemoryModel.value);
|
||||
|
||||
watch(configuredMemoryModel, (model) => {
|
||||
selectedMemoryModel.value = model;
|
||||
});
|
||||
|
||||
function buildEnabledMemoryConfig() {
|
||||
const existingMemory = props.config?.memory;
|
||||
emit('update:config', {
|
||||
memory: {
|
||||
...existingMemory,
|
||||
enabled: true,
|
||||
storage: 'n8n',
|
||||
lastMessages: existingMemory?.lastMessages ?? DEFAULT_AGENT_MEMORY_LAST_MESSAGES,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onDisableMemory() {
|
||||
emit('update:config', {
|
||||
memory: {
|
||||
...(props.config?.memory ?? { storage: 'n8n' as const }),
|
||||
enabled: false,
|
||||
episodicMemory: { enabled: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onMemoryToggle(enabled: boolean) {
|
||||
if (enabled) {
|
||||
onEnableMemory();
|
||||
} else {
|
||||
onDisableMemory();
|
||||
}
|
||||
return {
|
||||
...existingMemory,
|
||||
enabled: true,
|
||||
storage: 'n8n' as const,
|
||||
lastMessages: existingMemory?.lastMessages ?? DEFAULT_AGENT_MEMORY_LAST_MESSAGES,
|
||||
};
|
||||
}
|
||||
|
||||
function enableEpisodicMemory(credentialId: string) {
|
||||
|
|
@ -65,10 +61,7 @@ function enableEpisodicMemory(credentialId: string) {
|
|||
const existingEpisodicMemory = existingMemory?.episodicMemory;
|
||||
emit('update:config', {
|
||||
memory: {
|
||||
...existingMemory,
|
||||
enabled: true,
|
||||
storage: 'n8n',
|
||||
lastMessages: existingMemory?.lastMessages ?? DEFAULT_AGENT_MEMORY_LAST_MESSAGES,
|
||||
...buildEnabledMemoryConfig(),
|
||||
episodicMemory: {
|
||||
...(existingEpisodicMemory?.enabled === true ? existingEpisodicMemory : {}),
|
||||
enabled: true,
|
||||
|
|
@ -81,13 +74,40 @@ function enableEpisodicMemory(credentialId: string) {
|
|||
function disableEpisodicMemory() {
|
||||
emit('update:config', {
|
||||
memory: {
|
||||
...(props.config?.memory ?? { storage: 'n8n' as const }),
|
||||
enabled: props.config?.memory?.enabled ?? false,
|
||||
...buildEnabledMemoryConfig(),
|
||||
episodicMemory: { enabled: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onMemoryRecallModelChange(selection: { model: string; credentialId: string | null }) {
|
||||
if (!selection.credentialId) return;
|
||||
|
||||
selectedMemoryModel.value = selection.model;
|
||||
const workerModel = { model: selection.model, credential: selection.credentialId };
|
||||
|
||||
const existingMemory = props.config?.memory;
|
||||
const existingEpisodicMemory = existingMemory?.episodicMemory;
|
||||
|
||||
if (existingEpisodicMemory?.enabled !== true) return;
|
||||
|
||||
emit('update:config', {
|
||||
memory: {
|
||||
...buildEnabledMemoryConfig(),
|
||||
observationalMemory: {
|
||||
...existingMemory?.observationalMemory,
|
||||
observerModel: workerModel,
|
||||
reflectorModel: workerModel,
|
||||
},
|
||||
episodicMemory: {
|
||||
...existingEpisodicMemory,
|
||||
extractorModel: workerModel,
|
||||
reflectorModel: workerModel,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function openEpisodicMemoryCredentialModal() {
|
||||
uiStore.openModalWithData({
|
||||
name: AGENT_EPISODIC_MEMORY_CREDENTIAL_MODAL_KEY,
|
||||
|
|
@ -98,9 +118,9 @@ function openEpisodicMemoryCredentialModal() {
|
|||
title: i18n.baseText('agents.builder.episodicMemoryCredentialModal.title'),
|
||||
description: i18n.baseText('agents.builder.episodicMemoryCredentialModal.description'),
|
||||
cancelLabel: i18n.baseText('generic.cancel'),
|
||||
confirmLabel: i18n.baseText('agents.builder.episodicMemoryCredentialModal.confirm'),
|
||||
confirmLabel: i18n.baseText('generic.connect'),
|
||||
showDelete: false,
|
||||
hideCreateNew: false,
|
||||
hideCreateNew: true,
|
||||
source: 'agent_episodic_memory',
|
||||
pickerDataTestId: 'agent-episodic-memory-credential-picker',
|
||||
onSelect: (credentialId: string | null) => {
|
||||
|
|
@ -122,20 +142,25 @@ function onEpisodicMemoryToggle(enabled: boolean) {
|
|||
|
||||
<template>
|
||||
<div :class="[$style.container, props.disabled && $style.disabled]">
|
||||
<div :class="$style.titleGroup">
|
||||
<div :class="$style.header">
|
||||
<N8nText tag="h3" :bold="true">{{ i18n.baseText('agents.builder.memory.title') }}</N8nText>
|
||||
<N8nSwitch
|
||||
:model-value="memory !== null"
|
||||
:disabled="props.disabled"
|
||||
data-testid="agent-memory-toggle"
|
||||
@update:model-value="onMemoryToggle"
|
||||
<div v-if="episodicMemoryEnabled" :class="$style.row">
|
||||
<div :class="$style.titleGroup">
|
||||
<N8nText :bold="true">
|
||||
{{ i18n.baseText('agents.builder.memory.recallModel.label') }}
|
||||
</N8nText>
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.memory.recallModel.hint') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<div :class="$style.modelSelector">
|
||||
<AgentModelSelector
|
||||
:model="selectedMemoryModel"
|
||||
:default-model="modelToString(props.config?.model)"
|
||||
data-testid="agent-memory-recall-model-selector"
|
||||
@change="onMemoryRecallModelChange"
|
||||
/>
|
||||
</div>
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('agents.builder.memory.description') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
|
||||
<div :class="$style.row">
|
||||
<div :class="$style.titleGroup">
|
||||
<N8nText :bold="true">
|
||||
|
|
@ -146,16 +171,21 @@ function onEpisodicMemoryToggle(enabled: boolean) {
|
|||
</N8nText>
|
||||
</div>
|
||||
<div :class="$style.actions">
|
||||
<N8nButton
|
||||
v-if="episodicMemoryEnabled"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
:disabled="props.disabled"
|
||||
data-testid="agent-episodic-memory-change-credential"
|
||||
@click="openEpisodicMemoryCredentialModal"
|
||||
>
|
||||
{{ i18n.baseText('agents.builder.memory.episodicMemory.changeCredential') }}
|
||||
</N8nButton>
|
||||
<N8nTooltip>
|
||||
<template #content>
|
||||
{{ i18n.baseText('agents.builder.memory.episodicMemory.changeCredential') }}
|
||||
</template>
|
||||
<N8nIconButton
|
||||
v-if="episodicMemoryEnabled"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
icon-size="medium"
|
||||
icon="cog"
|
||||
:disabled="props.disabled"
|
||||
data-testid="agent-episodic-memory-change-credential"
|
||||
@click="openEpisodicMemoryCredentialModal"
|
||||
/>
|
||||
</N8nTooltip>
|
||||
<N8nSwitch
|
||||
:model-value="episodicMemoryEnabled"
|
||||
:disabled="props.disabled"
|
||||
|
|
@ -203,6 +233,17 @@ function onEpisodicMemoryToggle(enabled: boolean) {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--xs);
|
||||
|
||||
button {
|
||||
color: var(--icon-color);
|
||||
}
|
||||
}
|
||||
|
||||
.modelSelector {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-left: auto;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.container.disabled {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue';
|
||||
import type {
|
||||
ChatHubConversationModel,
|
||||
ChatHubProvider,
|
||||
ChatModelDto,
|
||||
ChatModelsResponse,
|
||||
} from '@n8n/api-types';
|
||||
|
||||
import { useUsersStore } from '@/features/settings/users/users.store';
|
||||
import { useChatStore } from '@/features/ai/chatHub/chat.store';
|
||||
import { useChatCredentials } from '@/features/ai/chatHub/composables/useChatCredentials';
|
||||
import { isLlmProviderModel } from '@/features/ai/chatHub/chat.utils';
|
||||
import ModelSelector from '@/features/ai/chatHub/components/ModelSelector.vue';
|
||||
import {
|
||||
AGENT_UNSUPPORTED_PROVIDERS,
|
||||
CATALOG_TO_CHATHUB,
|
||||
CHATHUB_TO_CATALOG,
|
||||
} from '../provider-mapping';
|
||||
import { parseModelString, sanitizeModelId } from '../utils/model-string';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
model: string | null | undefined;
|
||||
defaultModel?: string | null | undefined;
|
||||
includeCustomAgents?: boolean;
|
||||
warnMissingCredentials?: boolean;
|
||||
horizontal?: boolean;
|
||||
}>(),
|
||||
{
|
||||
defaultModel: null,
|
||||
includeCustomAgents: false,
|
||||
warnMissingCredentials: true,
|
||||
horizontal: true,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
change: [selection: { model: string; credentialId: string | null; provider: ChatHubProvider }];
|
||||
selectCredential: [provider: ChatHubProvider, credentialId: string | null];
|
||||
}>();
|
||||
|
||||
const usersStore = useUsersStore();
|
||||
const chatStore = useChatStore();
|
||||
const { credentialsByProvider, selectCredential } = useChatCredentials(
|
||||
usersStore.currentUserId ?? 'anonymous',
|
||||
);
|
||||
|
||||
watch(
|
||||
credentialsByProvider,
|
||||
(credentials) => {
|
||||
if (credentials) void chatStore.fetchAgents(credentials);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const filteredAgents = computed<ChatModelsResponse>(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(chatStore.agents).filter(
|
||||
([provider]) => !AGENT_UNSUPPORTED_PROVIDERS.has(provider),
|
||||
),
|
||||
) as ChatModelsResponse,
|
||||
);
|
||||
|
||||
const selectedAgent = computed<ChatModelDto | null>(() => {
|
||||
const model = props.model || props.defaultModel;
|
||||
if (!model) return null;
|
||||
|
||||
const parsed = parseModelString(model);
|
||||
if (!parsed) return null;
|
||||
|
||||
const chatHubProvider = CATALOG_TO_CHATHUB[parsed.provider];
|
||||
if (!chatHubProvider) return null;
|
||||
|
||||
const registryEntry = filteredAgents.value[chatHubProvider]?.models.find(
|
||||
(entry) => isLlmProviderModel(entry.model) && entry.model.model === parsed.name,
|
||||
);
|
||||
if (registryEntry) return registryEntry;
|
||||
|
||||
return {
|
||||
model: { provider: chatHubProvider, model: parsed.name } as ChatHubConversationModel,
|
||||
name: parsed.name,
|
||||
description: null,
|
||||
icon: null,
|
||||
updatedAt: null,
|
||||
createdAt: null,
|
||||
metadata: {} as ChatModelDto['metadata'],
|
||||
groupName: null,
|
||||
groupIcon: null,
|
||||
};
|
||||
});
|
||||
|
||||
function onModelChange(selection: ChatHubConversationModel) {
|
||||
if (!isLlmProviderModel(selection)) return;
|
||||
|
||||
const catalogProvider = CHATHUB_TO_CATALOG[selection.provider] ?? selection.provider;
|
||||
const model = `${catalogProvider}/${sanitizeModelId(catalogProvider, selection.model)}`;
|
||||
emit('change', {
|
||||
model,
|
||||
credentialId: credentialsByProvider.value?.[selection.provider] ?? null,
|
||||
provider: selection.provider,
|
||||
});
|
||||
}
|
||||
|
||||
function onSelectCredential(provider: ChatHubProvider, credentialId: string | null) {
|
||||
selectCredential(provider, credentialId);
|
||||
emit('selectCredential', provider, credentialId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModelSelector
|
||||
:selected-agent="selectedAgent"
|
||||
:include-custom-agents="includeCustomAgents"
|
||||
:credentials="credentialsByProvider"
|
||||
:agents="filteredAgents"
|
||||
:is-loading="false"
|
||||
:warn-missing-credentials="warnMissingCredentials"
|
||||
:horizontal="horizontal"
|
||||
@change="onModelChange"
|
||||
@select-credential="onSelectCredential"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -1,25 +1,6 @@
|
|||
/**
|
||||
* Static capability map for LLM providers the agent runtime can target.
|
||||
* Used by the Behavior panel to decide whether the Thinking toggle is
|
||||
* available for the currently-selected provider and which sub-control to
|
||||
* show (Anthropic → budget tokens, OpenAI → reasoning effort).
|
||||
*/
|
||||
export interface ProviderCapabilities {
|
||||
thinking: false | 'budgetTokens' | 'reasoningEffort';
|
||||
}
|
||||
|
||||
export const PROVIDER_CAPABILITIES: Record<string, ProviderCapabilities> = {
|
||||
anthropic: { thinking: 'budgetTokens' },
|
||||
openai: { thinking: 'reasoningEffort' },
|
||||
google: { thinking: false },
|
||||
xai: { thinking: false },
|
||||
groq: { thinking: false },
|
||||
deepseek: { thinking: false },
|
||||
mistral: { thinking: false },
|
||||
openrouter: { thinking: false },
|
||||
cohere: { thinking: false },
|
||||
ollama: { thinking: false },
|
||||
};
|
||||
|
||||
export const REASONING_EFFORT_OPTIONS = ['low', 'medium', 'high'] as const;
|
||||
export type ReasoningEffort = (typeof REASONING_EFFORT_OPTIONS)[number];
|
||||
export {
|
||||
PROVIDER_CAPABILITIES,
|
||||
REASONING_EFFORT_OPTIONS,
|
||||
type ProviderCapabilities,
|
||||
type ReasoningEffort,
|
||||
} from '@n8n/api-types';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,168 @@
|
|||
import type { AgentJsonConfig } from '../types';
|
||||
import type { ProviderCapabilities } from '../provider-capabilities';
|
||||
import {
|
||||
ANTHROPIC_NATIVE_WEB_SEARCH_PROVIDER_TOOLS,
|
||||
NATIVE_WEB_SEARCH_DEFAULTS_BY_PROVIDER,
|
||||
NATIVE_WEB_SEARCH_PROVIDER_TOOLS,
|
||||
type NativeWebSearchCanonicalTool,
|
||||
} from '@n8n/api-types';
|
||||
|
||||
export type NativeWebSearchProviderTool = NativeWebSearchCanonicalTool;
|
||||
export type NativeWebSearchArgs = Record<string, unknown>;
|
||||
type WebSearchConfig = NonNullable<NonNullable<AgentJsonConfig['config']>['webSearch']>;
|
||||
type WebSearchProvider = WebSearchConfig['provider'];
|
||||
export type FallbackWebSearchProvider = 'brave' | 'searxng';
|
||||
export type WebSearchMethod = 'native' | FallbackWebSearchProvider;
|
||||
|
||||
export function isFallbackWebSearchProvider(
|
||||
provider: WebSearchProvider,
|
||||
): provider is FallbackWebSearchProvider {
|
||||
return provider === 'brave' || provider === 'searxng';
|
||||
}
|
||||
|
||||
export function stripNativeWebSearchProviderTools(
|
||||
providerTools: AgentJsonConfig['providerTools'],
|
||||
): AgentJsonConfig['providerTools'] {
|
||||
if (!providerTools) return undefined;
|
||||
const next = { ...providerTools };
|
||||
for (const key of NATIVE_WEB_SEARCH_PROVIDER_TOOLS) {
|
||||
delete next[key];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function getWebSearchMethod(
|
||||
config: AgentJsonConfig | null,
|
||||
hasNativeWebSearch: boolean,
|
||||
): WebSearchMethod {
|
||||
const configuredProvider = config?.config?.webSearch?.provider;
|
||||
if (isFallbackWebSearchProvider(configuredProvider)) return configuredProvider;
|
||||
return hasNativeWebSearch ? 'native' : 'brave';
|
||||
}
|
||||
|
||||
export function getNativeWebSearchArgs(
|
||||
config: AgentJsonConfig | null,
|
||||
providerTool: ProviderCapabilities['webSearch'],
|
||||
): NativeWebSearchArgs {
|
||||
if (!providerTool) return {};
|
||||
if (providerTool === 'anthropic.web_search') {
|
||||
const matchingTool = ANTHROPIC_NATIVE_WEB_SEARCH_PROVIDER_TOOLS.find(
|
||||
(tool) => config?.providerTools?.[tool],
|
||||
);
|
||||
return { ...(matchingTool ? config?.providerTools?.[matchingTool] : {}) };
|
||||
}
|
||||
|
||||
return { ...(config?.providerTools?.[providerTool] ?? {}) };
|
||||
}
|
||||
|
||||
function getDefaultNativeWebSearchArgs(providerTool: NativeWebSearchCanonicalTool) {
|
||||
const defaults = Object.values(NATIVE_WEB_SEARCH_DEFAULTS_BY_PROVIDER).find(
|
||||
(defaultsByProvider) => defaultsByProvider.toolName === providerTool,
|
||||
);
|
||||
return defaults ? { ...defaults.args } : {};
|
||||
}
|
||||
|
||||
export function withNativeWebSearchConfig(
|
||||
config: AgentJsonConfig | null,
|
||||
enabled: boolean,
|
||||
providerTool: ProviderCapabilities['webSearch'],
|
||||
args: NativeWebSearchArgs = {},
|
||||
): Partial<AgentJsonConfig> {
|
||||
const providerTools = { ...(stripNativeWebSearchProviderTools(config?.providerTools) ?? {}) };
|
||||
|
||||
const changes: Partial<AgentJsonConfig> = {
|
||||
config: {
|
||||
...(config?.config ?? {}),
|
||||
webSearch: enabled ? { enabled: true, provider: 'native' } : { enabled: false },
|
||||
},
|
||||
};
|
||||
|
||||
if (enabled && providerTool) {
|
||||
providerTools[providerTool] = {
|
||||
...getDefaultNativeWebSearchArgs(providerTool),
|
||||
...args,
|
||||
};
|
||||
}
|
||||
|
||||
if (config?.providerTools || (enabled && providerTool)) {
|
||||
changes.providerTools = providerTools;
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
export function withWebSearchConfig(
|
||||
config: AgentJsonConfig | null,
|
||||
enabled: boolean,
|
||||
method: WebSearchMethod,
|
||||
providerTool: ProviderCapabilities['webSearch'],
|
||||
args: NativeWebSearchArgs = {},
|
||||
credential = '',
|
||||
): Partial<AgentJsonConfig> {
|
||||
if (!enabled) {
|
||||
return {
|
||||
config: {
|
||||
...(config?.config ?? {}),
|
||||
webSearch: { enabled: false },
|
||||
},
|
||||
...(config?.providerTools && {
|
||||
providerTools: stripNativeWebSearchProviderTools(config.providerTools) ?? {},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (method === 'native' && providerTool) {
|
||||
return withNativeWebSearchConfig(config, true, providerTool, args);
|
||||
}
|
||||
|
||||
const webSearch =
|
||||
method === 'native'
|
||||
? { enabled: false as const }
|
||||
: {
|
||||
enabled: true as const,
|
||||
provider: method,
|
||||
...(credential && { credential }),
|
||||
};
|
||||
|
||||
return {
|
||||
config: {
|
||||
...(config?.config ?? {}),
|
||||
webSearch,
|
||||
},
|
||||
...(config?.providerTools && {
|
||||
providerTools: stripNativeWebSearchProviderTools(config.providerTools) ?? {},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeWebSearchForModelChange(
|
||||
config: AgentJsonConfig | null,
|
||||
nextProviderTool: ProviderCapabilities['webSearch'],
|
||||
): Partial<AgentJsonConfig> {
|
||||
const webSearch = config?.config?.webSearch;
|
||||
if (!webSearch) {
|
||||
return config?.providerTools
|
||||
? { providerTools: stripNativeWebSearchProviderTools(config.providerTools) ?? {} }
|
||||
: {};
|
||||
}
|
||||
|
||||
const method = getWebSearchMethod(config, Boolean(nextProviderTool));
|
||||
if (isFallbackWebSearchProvider(webSearch.provider)) {
|
||||
return withWebSearchConfig(
|
||||
config,
|
||||
webSearch.enabled,
|
||||
method,
|
||||
nextProviderTool,
|
||||
{},
|
||||
webSearch.credential,
|
||||
);
|
||||
}
|
||||
|
||||
if (!webSearch.enabled) {
|
||||
return withWebSearchConfig(config, false, 'native', nextProviderTool);
|
||||
}
|
||||
|
||||
return nextProviderTool
|
||||
? withNativeWebSearchConfig(config, true, nextProviderTool)
|
||||
: withWebSearchConfig(config, false, 'native', nextProviderTool);
|
||||
}
|
||||
|
|
@ -1,3 +1,16 @@
|
|||
import type { BaseTextKey } from '@n8n/i18n';
|
||||
|
||||
export const WEB_SEARCH_TOOL_NAME_KEY: BaseTextKey = 'agents.chat.toolNames.webSearch';
|
||||
|
||||
const WEB_SEARCH_TOOL_NAME_PATTERN = /^(?:web_search|(?:anthropic|openai)\.web_search(?:_\d{8})?)$/;
|
||||
|
||||
export function getToolNameTranslationKey(toolName: string | undefined): BaseTextKey | undefined {
|
||||
const trimmed = toolName?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
return WEB_SEARCH_TOOL_NAME_PATTERN.test(trimmed) ? WEB_SEARCH_TOOL_NAME_KEY : undefined;
|
||||
}
|
||||
|
||||
export function formatToolNameForDisplay(toolName: string | undefined): string {
|
||||
const trimmed = toolName?.trim();
|
||||
const normalized = trimmed?.replace(/[_-]+/g, ' ').replace(/\s+/g, ' ');
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import {
|
|||
AGENT_SKILL_MODAL_KEY,
|
||||
AGENT_ADD_TRIGGER_MODAL_KEY,
|
||||
CONTINUE_SESSION_ID_PARAM,
|
||||
DEFAULT_AGENT_MEMORY_LAST_MESSAGES,
|
||||
} from '../constants';
|
||||
import { agentsEventBus } from '../agents.eventBus';
|
||||
import AgentBuilderHeader from '../components/AgentBuilderHeader.vue';
|
||||
|
|
@ -411,6 +412,18 @@ async function flushAutosave() {
|
|||
await Promise.all([configAutosave.flushAutosave(), skillAutosave.flushAutosave()]);
|
||||
}
|
||||
|
||||
function normalizeAgentMemoryConfig(config: AgentJsonConfig): AgentJsonConfig {
|
||||
return {
|
||||
...config,
|
||||
memory: {
|
||||
...config.memory,
|
||||
enabled: true,
|
||||
storage: 'n8n',
|
||||
lastMessages: config.memory?.lastMessages ?? DEFAULT_AGENT_MEMORY_LAST_MESSAGES,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function onConfigFieldUpdate(updates: Partial<AgentJsonConfig>) {
|
||||
if (!localConfig.value) return;
|
||||
// Record BEFORE assigning so the composable can diff against the pre-update state.
|
||||
|
|
@ -429,7 +442,11 @@ function onConfigFieldUpdate(updates: Partial<AgentJsonConfig>) {
|
|||
projectId: projectId.value,
|
||||
agentId: agentId.value,
|
||||
type: 'config',
|
||||
config: deepCopy(localConfig.value),
|
||||
// The memory toggle is gone, but older agent configs may still have
|
||||
// session memory disabled. Normalize on save so legacy configs are
|
||||
// corrected the next time the user makes a real edit, without mutating
|
||||
// config during component mount.
|
||||
config: normalizeAgentMemoryConfig(deepCopy(localConfig.value)),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user