feat(editor): Move web search to agent capabilities (#38646)

This commit is contained in:
Rob Hough 2026-09-16 09:51:44 +00:00 committed by GitHub
parent 62ff356566
commit a6ca37cbbf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 1098 additions and 751 deletions

View File

@ -7975,9 +7975,11 @@
"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": "Provider",
"agents.builder.advanced.webSearch.contextSize.hint": "How much space web search results use in the model's context window",
"agents.builder.advanced.webSearch.contextSize.low": "Low",
"agents.builder.advanced.webSearch.contextSize.medium": "Medium",
"agents.builder.advanced.webSearch.contextSize.high": "High",
"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",

View File

@ -5,7 +5,7 @@ import { nextTick, ref } from 'vue';
import type * as VueUse from '@vueuse/core';
import AgentAdvancedPanel from '../components/AgentAdvancedPanel.vue';
import AgentCredentialSelect from '../components/AgentCredentialSelect.vue';
import AgentWebSearchSection from '../components/AgentWebSearchSection.vue';
import type { ProviderCatalog } from '../composables/useAgentApi';
import type { AgentJsonConfig } from '../types';
@ -192,50 +192,6 @@ function findStubComponent(wrapper: ReturnType<typeof mount>, testId: string) {
};
}
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;
}
/** Mounts the panel with the Brave fallback picker visible for `projectId`. */
function mountWithFallbackPicker({
projectId = 'project-1',
disabled = false,
}: { projectId?: string; disabled?: boolean } = {}) {
return mount(AgentAdvancedPanel, {
props: {
config: makeConfig({
model: 'deepseek/deepseek-chat',
config: { webSearch: { enabled: true, provider: 'brave' } },
} as Partial<AgentJsonConfig>),
projectId,
disabled,
},
global: { stubs: globalStubs },
});
}
function findCreateCredentialButton(wrapper: ReturnType<typeof mount>) {
return wrapper.find('[data-test-id="node-credentials-select-item-new"]');
}
function getCreateCredentialTooltip(wrapper: ReturnType<typeof mount>) {
const tooltip = findCreateCredentialButton(wrapper).element.closest('[data-tooltip-disabled]');
return {
disabled: tooltip?.getAttribute('data-tooltip-disabled'),
content: tooltip?.getAttribute('data-tooltip-content'),
};
}
describe('AgentAdvancedPanel', () => {
beforeEach(() => {
ensureLoadedMock.mockReset();
@ -246,346 +202,27 @@ describe('AgentAdvancedPanel', () => {
projectsStoreState.myProjects = [];
});
it('renders both advanced sections without a collapsible heading', () => {
it('renders the advanced section without a collapsible heading', () => {
const wrapper = mount(AgentAdvancedPanel, {
props: { config: makeConfig() },
global: { stubs: globalStubs },
});
expect(wrapper.text()).toContain('agents.builder.advanced.webSearch.label');
expect(wrapper.text()).toContain('agents.builder.advanced.title');
expect(wrapper.find('[data-testid="agent-advanced-trigger"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="agent-advanced-chevron"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="agent-advanced-content"]').isVisible()).toBe(true);
expect(wrapper.find('[data-testid="agent-behavior-panel"]').isVisible()).toBe(true);
expect(wrapper.find('[data-testid="agent-reasoning-toggle"]').isVisible()).toBe(true);
});
it('treats sparse native web search config as disabled', async () => {
it('does not render web search controls', function doesNotRenderWebSearchControls() {
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-test-id="agent-web-search-fallback-credential"]').exists()).toBe(
true,
);
expect(wrapper.find('[data-testid="agent-web-search-max-uses"]').exists()).toBe(false);
});
it.each([
['brave', 'braveSearchApi'],
['searxng', 'searXngApi'],
] as const)(
"creating a credential from the fallback picker opens the credential modal for the provider's type in the panel's project and selects the created credential",
async (provider, credentialType) => {
const wrapper = mount(AgentAdvancedPanel, {
props: {
config: makeConfig({
model: 'deepseek/deepseek-chat',
config: { webSearch: { enabled: true, provider } },
} as Partial<AgentJsonConfig>),
projectId: 'project-1',
},
global: { stubs: globalStubs },
});
wrapper.findComponent(AgentCredentialSelect).vm.$emit('create');
expect(openNewCredentialMock).toHaveBeenCalledWith(
credentialType,
false,
false,
'project-1',
undefined,
undefined,
undefined,
expect.objectContaining({ hideAskAssistant: true }),
);
const onCredentialCreated = openNewCredentialMock.mock.calls.at(-1)?.[7]?.onCredentialCreated;
expect(onCredentialCreated).toBeTypeOf('function');
onCredentialCreated?.({ id: 'new-cred' });
const last = wrapper.emitted('update:config')?.at(-1)?.[0] as Partial<AgentJsonConfig>;
expect(getWebSearchConfig(last)).toEqual({
enabled: true,
provider,
credential: 'new-cred',
});
},
);
it('offers an enabled "Create new credential" action without the permission tooltip when the user can create credentials in the project', () => {
const wrapper = mountWithFallbackPicker();
const createButton = findCreateCredentialButton(wrapper);
expect(createButton.text()).toBe('Create new credential');
expect(createButton.attributes()).not.toHaveProperty('disabled');
expect(getCreateCredentialTooltip(wrapper).disabled).toBe('true');
});
it('disables "Create new credential" with the permission tooltip and does not open the credential modal when the user cannot create credentials in the project', () => {
projectsStoreState.currentProject = { id: 'project-1', scopes: ['credential:read'] };
const wrapper = mountWithFallbackPicker();
expect(
wrapper.findComponent(AgentCredentialSelect).props('credentialPermissions'),
).toMatchObject({ create: false });
expect(findCreateCredentialButton(wrapper).attributes()).toHaveProperty('disabled');
expect(getCreateCredentialTooltip(wrapper)).toEqual({
disabled: 'false',
content: 'Your current role does not allow you to create credentials',
});
// Even if the dropdown emits `create` anyway, the panel must not open the modal.
wrapper.findComponent(AgentCredentialSelect).vm.$emit('create');
expect(openNewCredentialMock).not.toHaveBeenCalled();
expect(wrapper.emitted('update:config')).toBeUndefined();
});
it('does not open the credential modal from the fallback picker while the panel is disabled', () => {
const wrapper = mountWithFallbackPicker({ disabled: true });
wrapper.findComponent(AgentCredentialSelect).vm.$emit('create');
expect(openNewCredentialMock).not.toHaveBeenCalled();
expect(wrapper.emitted('update:config')).toBeUndefined();
});
describe('resolving the project whose scopes decide whether a credential can be created', () => {
const canCreate = CREDENTIAL_CREATE_SCOPES;
const cannotCreate = ['credential:read'];
it.each<{
name: string;
projectId: string;
currentProject: MockProject;
personalProject: MockProject;
myProjects: MockProject[];
expectedCreate: boolean;
}>([
{
name: 'uses the current project when it matches the panel project',
projectId: 'project-1',
currentProject: { id: 'project-1', scopes: canCreate },
personalProject: { id: 'project-2', scopes: cannotCreate },
myProjects: [{ id: 'project-3', scopes: cannotCreate }],
expectedCreate: true,
},
{
name: 'falls back to the personal project when the current project does not match',
projectId: 'project-2',
currentProject: { id: 'project-1', scopes: cannotCreate },
personalProject: { id: 'project-2', scopes: canCreate },
myProjects: [{ id: 'project-3', scopes: cannotCreate }],
expectedCreate: true,
},
{
name: 'falls back to the matching entry in myProjects when neither the current nor the personal project match',
projectId: 'project-3',
currentProject: { id: 'project-1', scopes: cannotCreate },
personalProject: { id: 'project-2', scopes: cannotCreate },
myProjects: [{ id: 'project-3', scopes: canCreate }],
expectedCreate: true,
},
{
name: 'denies creating credentials when no known project matches the panel project',
projectId: 'project-unknown',
currentProject: { id: 'project-1', scopes: canCreate },
personalProject: { id: 'project-2', scopes: canCreate },
myProjects: [{ id: 'project-3', scopes: canCreate }],
expectedCreate: false,
},
])('$name', ({ projectId, currentProject, personalProject, myProjects, expectedCreate }) => {
projectsStoreState.currentProject = currentProject;
projectsStoreState.personalProject = personalProject;
projectsStoreState.myProjects = myProjects;
const wrapper = mountWithFallbackPicker({ projectId });
expect(
wrapper.findComponent(AgentCredentialSelect).props('credentialPermissions'),
).toMatchObject({ create: expectedCreate });
wrapper.findComponent(AgentCredentialSelect).vm.$emit('create');
if (expectedCreate) {
expect(openNewCredentialMock).toHaveBeenCalledWith(
'braveSearchApi',
false,
false,
projectId,
undefined,
undefined,
undefined,
expect.objectContaining({ hideAskAssistant: true }),
);
} else {
expect(openNewCredentialMock).not.toHaveBeenCalled();
}
});
});
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': {} });
expect(wrapper.findComponent(AgentWebSearchSection).exists()).toBe(false);
expect(wrapper.find('[data-testid="agent-web-search-method"]').exists()).toBe(false);
});
it('loads the model catalog for the current project', () => {
@ -880,8 +517,6 @@ 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-reasoning-toggle"]').attributes('disabled'),
).toBeDefined();

View File

@ -69,6 +69,14 @@ vi.mock('@n8n/i18n', () => ({
}),
}));
vi.mock('../components/AgentWebSearchSection.vue', () => ({
default: {
name: 'AgentWebSearchSection',
emits: ['update:config'],
template: '<div data-testid="agent-web-search-section" />',
},
}));
function mountSection(
tools: AgentJsonToolRef[],
customTools: Record<string, CustomToolEntry> = {},
@ -158,6 +166,16 @@ describe('AgentCapabilitiesSection', () => {
integrationsCatalogRef.value = [];
});
it('renders web search and forwards its config updates', () => {
const wrapper = mountSection([]);
const webSearchSection = wrapper.getComponent({ name: 'AgentWebSearchSection' });
const update = { config: { webSearch: { enabled: false } } };
webSearchSection.vm.$emit('update:config', update);
expect(wrapper.emitted('update:config')?.[0]).toEqual([update]);
});
it('formats node and custom tool chip labels for display', () => {
getNodeType.mockReturnValue(null);
@ -997,42 +1015,17 @@ describe('AgentCapabilitiesSection', () => {
});
it('renders only the allowlisted sections and skips sub-agents', async () => {
const wrapper = mount(AgentCapabilitiesSection, {
props: {
config: null,
tools: [],
customTools: {},
skills: [],
projectId: 'project-id',
agentId: 'agent-id',
isPublished: false,
sections: ['tools', 'skills'],
},
global: {
stubs: {
NodeIcon: { template: '<span />' },
N8nButton: {
props: ['disabled'],
emits: ['click'],
template:
'<button v-bind="$attrs" :disabled="disabled" @click="$emit(\'click\')"><slot name="icon" /><slot /></button>',
},
N8nIcon: { template: '<span />' },
N8nText: { template: '<span><slot /></span>' },
N8nTooltip: { template: '<span><slot /></span>' },
},
},
});
const wrapper = mountSection([], {}, null, [], [], { sections: ['tools', 'skills'] });
await flushPromises();
// Allowlisted rows present.
/** Allowlisted rows are present. */
expect(wrapper.find('[data-testid="agent-capabilities-add-tool"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="agent-capabilities-add-skill"]').exists()).toBe(true);
// Suppressed rows absent.
/** Suppressed rows are absent. */
expect(wrapper.find('[data-testid="agent-capabilities-add-sub-agent"]').exists()).toBe(false);
// The project-agents list (only needed for sub-agents) is not fetched.
/** The project-agent list is not needed for hidden sub-agents. */
expect(ensureProjectAgentsLoadedSpy).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,559 @@
/* eslint-disable import-x/no-extraneous-dependencies -- test-only pattern */
import { beforeEach, describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import AgentWebSearchSection from '../components/AgentWebSearchSection.vue';
import AgentCredentialSelect from '../components/AgentCredentialSelect.vue';
import type { AgentJsonConfig } from '../types';
const openNewCredentialMock = vi.hoisted(() => vi.fn());
type MockProject = { id: string; scopes: string[] };
const CREDENTIAL_CREATE_SCOPES = ['credential:create'];
// Mutable per test (reset in beforeEach) so the project → permission resolution can be exercised.
const projectsStoreState = vi.hoisted(() => ({
currentProject: null as MockProject | null,
personalProject: null as MockProject | null,
myProjects: [] as MockProject[],
}));
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({
baseText: (key: string) =>
({
'agents.builder.advanced.reasoning.hint': 'Let the model reason before responding.',
'agents.builder.advanced.reasoning.unsupportedHint':
'This model does not support reasoning',
'agents.builder.advanced.reasoning.noModelHint': 'No model selected',
'nodeCredentials.createNew': 'Create new credential',
'nodeCredentials.createNew.permissionDenied':
'Your current role does not allow you to create credentials',
})[key] ?? key,
}),
}));
vi.mock('@/features/credentials/credentials.store', () => ({
useCredentialsStore: () => ({
allCredentials: [
{ id: 'brave-1', name: 'Brave Key', type: 'braveSearchApi' },
{ id: 'searxng-1', name: 'SearXNG', type: 'searXngApi' },
],
getCredentialTypeByName: (type: string) => ({ displayName: type }),
}),
}));
vi.mock('@/features/collaboration/projects/projects.store', () => ({
useProjectsStore: () => projectsStoreState,
}));
vi.mock('@/app/stores/ui.store', () => ({
useUIStore: () => ({ openNewCredential: openNewCredentialMock }),
}));
vi.mock('@n8n/design-system', async (importOriginal) => {
const actual = await importOriginal<typeof import('@n8n/design-system')>();
return {
...actual,
N8nDropdownMenu: {
name: 'N8nDropdownMenu',
props: ['items', 'disabled', 'placement'],
emits: ['select'],
template: '<div v-bind="$attrs"><slot name="trigger" /></div>',
},
};
});
const globalStubs = {
N8nIcon: { template: '<span v-bind="$attrs" />', props: ['icon', 'size'] },
N8nText: { template: '<span><slot /></span>' },
N8nInputNumber: {
props: ['modelValue', 'disabled', 'min', 'max', 'precision', 'placeholder'],
emits: ['update:modelValue'],
template:
'<input :value="modelValue" :disabled="disabled" @input="$emit(\'update:modelValue\', Number($event.target.value))" />',
},
/** Keep the select as one root and render its credential creation footer. */
N8nSelect: {
props: ['modelValue', 'disabled'],
emits: ['update:modelValue'],
template:
'<select v-bind="$attrs" :value="modelValue" :disabled="disabled" @change="$emit(\'update:modelValue\', $event.target.value)"><slot /><slot name="footer" /></select>',
},
N8nTooltip: {
props: ['disabled', 'content'],
template:
'<span :data-tooltip-disabled="String(disabled)" :data-tooltip-content="content"><slot /></span>',
},
N8nOption: {
name: 'N8nOption',
props: ['value', 'label'],
template: '<option :value="value">{{ label }}</option>',
},
Option: {
props: ['value', 'label'],
template: '<option :value="value">{{ label }}</option>',
},
N8nSwitch2: {
props: ['modelValue', 'disabled'],
emits: ['update:modelValue'],
template:
'<button :disabled="disabled" :data-checked="modelValue" @click="$emit(\'update:modelValue\', !modelValue)" />',
},
N8nSegmentControl: {
props: ['modelValue', 'options', 'disabled', 'size'],
emits: ['update:modelValue'],
template: '<div v-bind="$attrs" />',
},
};
function makeConfig(overrides: Partial<AgentJsonConfig> = {}): AgentJsonConfig {
return {
name: 'A',
instructions: 'i',
model: 'anthropic/claude-sonnet-4-6',
credential: 'c',
...overrides,
} 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: 'select', value: string) => void };
};
select.vm.$emit('select', value);
}
function findStubComponent(wrapper: ReturnType<typeof mount>, testId: string) {
return wrapper.findComponent(`[data-testid="${testId}"]`) as unknown as {
exists: () => boolean;
props: (name: string) => unknown;
vm: { $emit: (event: 'update:modelValue', value: string) => void };
};
}
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;
}
/** Mounts the panel with the Brave fallback picker visible for `projectId`. */
function mountWithFallbackPicker({
projectId = 'project-1',
disabled = false,
}: { projectId?: string; disabled?: boolean } = {}) {
return mount(AgentWebSearchSection, {
props: {
config: makeConfig({
model: 'deepseek/deepseek-chat',
config: { webSearch: { enabled: true, provider: 'brave' } },
} as Partial<AgentJsonConfig>),
projectId,
disabled,
},
global: { stubs: globalStubs },
});
}
function findCreateCredentialButton(wrapper: ReturnType<typeof mount>) {
return wrapper.find('[data-test-id="node-credentials-select-item-new"]');
}
function getCreateCredentialTooltip(wrapper: ReturnType<typeof mount>) {
const tooltip = findCreateCredentialButton(wrapper).element.closest('[data-tooltip-disabled]');
return {
disabled: tooltip?.getAttribute('data-tooltip-disabled'),
content: tooltip?.getAttribute('data-tooltip-content'),
};
}
describe('AgentWebSearchSection', () => {
beforeEach(() => {
openNewCredentialMock.mockReset();
projectsStoreState.currentProject = { id: 'project-1', scopes: CREDENTIAL_CREATE_SCOPES };
projectsStoreState.personalProject = null;
projectsStoreState.myProjects = [];
});
it('treats sparse native web search config as disabled', async () => {
const wrapper = mount(AgentWebSearchSection, {
props: { config: makeConfig() },
global: { stubs: globalStubs },
});
const method = findStubComponent(wrapper, 'agent-web-search-method');
expect(method.exists()).toBe(true);
expect(method.props('items')).toEqual(
expect.arrayContaining([expect.objectContaining({ id: 'off', checked: true })]),
);
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(AgentWebSearchSection, {
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.each(['low', 'medium', 'high'] as const)(
'emits the %s search context size and offers all context size options',
async function emitsSearchContextSize(contextSize) {
const config = makeConfig({
model: 'openai/gpt-5',
config: { webSearch: { enabled: true } },
providerTools: { 'openai.web_search': {} },
} as Partial<AgentJsonConfig>);
const wrapper = mount(AgentWebSearchSection, {
props: { config },
global: { stubs: globalStubs },
});
const contextSizeControl = wrapper.findComponent({ name: 'SegmentControl' });
expect(contextSizeControl.props('options')).toEqual([
{
label: 'agents.builder.advanced.webSearch.contextSize.low',
value: 'low',
},
{
label: 'agents.builder.advanced.webSearch.contextSize.medium',
value: 'medium',
},
{
label: 'agents.builder.advanced.webSearch.contextSize.high',
value: 'high',
},
]);
contextSizeControl.vm.$emit('update:modelValue', contextSize);
await nextTick();
const last = wrapper.emitted('update:config')?.at(-1)?.[0] as Partial<AgentJsonConfig>;
expect(last.providerTools).toEqual({
'openai.web_search': {
externalWebAccess: true,
searchContextSize: contextSize,
},
});
},
);
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(AgentWebSearchSection, {
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(AgentWebSearchSection, {
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(AgentWebSearchSection, {
props: { config },
global: { stubs: globalStubs },
});
expect(wrapper.find('[data-testid="agent-web-search-method"]').exists()).toBe(true);
expect(wrapper.find('[data-test-id="agent-web-search-fallback-credential"]').exists()).toBe(
true,
);
expect(wrapper.find('[data-testid="agent-web-search-max-uses"]').exists()).toBe(false);
});
it.each([
['brave', 'braveSearchApi'],
['searxng', 'searXngApi'],
] as const)(
"creating a credential from the fallback picker opens the credential modal for the provider's type in the panel's project and selects the created credential",
async (provider, credentialType) => {
const wrapper = mount(AgentWebSearchSection, {
props: {
config: makeConfig({
model: 'deepseek/deepseek-chat',
config: { webSearch: { enabled: true, provider } },
} as Partial<AgentJsonConfig>),
projectId: 'project-1',
},
global: { stubs: globalStubs },
});
wrapper.findComponent(AgentCredentialSelect).vm.$emit('create');
expect(openNewCredentialMock).toHaveBeenCalledWith(
credentialType,
false,
false,
'project-1',
undefined,
undefined,
undefined,
expect.objectContaining({ hideAskAssistant: true }),
);
const onCredentialCreated = openNewCredentialMock.mock.calls.at(-1)?.[7]?.onCredentialCreated;
expect(onCredentialCreated).toBeTypeOf('function');
onCredentialCreated?.({ id: 'new-cred' });
const last = wrapper.emitted('update:config')?.at(-1)?.[0] as Partial<AgentJsonConfig>;
expect(getWebSearchConfig(last)).toEqual({
enabled: true,
provider,
credential: 'new-cred',
});
},
);
it('offers an enabled "Create new credential" action without the permission tooltip when the user can create credentials in the project', () => {
const wrapper = mountWithFallbackPicker();
const createButton = findCreateCredentialButton(wrapper);
expect(createButton.text()).toBe('Create new credential');
expect(createButton.attributes()).not.toHaveProperty('disabled');
expect(getCreateCredentialTooltip(wrapper).disabled).toBe('true');
});
it('disables "Create new credential" with the permission tooltip and does not open the credential modal when the user cannot create credentials in the project', () => {
projectsStoreState.currentProject = { id: 'project-1', scopes: ['credential:read'] };
const wrapper = mountWithFallbackPicker();
expect(
wrapper.findComponent(AgentCredentialSelect).props('credentialPermissions'),
).toMatchObject({ create: false });
expect(findCreateCredentialButton(wrapper).attributes()).toHaveProperty('disabled');
expect(getCreateCredentialTooltip(wrapper)).toEqual({
disabled: 'false',
content: 'Your current role does not allow you to create credentials',
});
// Even if the dropdown emits `create` anyway, the panel must not open the modal.
wrapper.findComponent(AgentCredentialSelect).vm.$emit('create');
expect(openNewCredentialMock).not.toHaveBeenCalled();
expect(wrapper.emitted('update:config')).toBeUndefined();
});
it('does not open the credential modal from the fallback picker while the panel is disabled', () => {
const wrapper = mountWithFallbackPicker({ disabled: true });
wrapper.findComponent(AgentCredentialSelect).vm.$emit('create');
expect(openNewCredentialMock).not.toHaveBeenCalled();
expect(wrapper.emitted('update:config')).toBeUndefined();
});
describe('resolving the project whose scopes decide whether a credential can be created', () => {
const canCreate = CREDENTIAL_CREATE_SCOPES;
const cannotCreate = ['credential:read'];
it.each<{
name: string;
projectId: string;
currentProject: MockProject;
personalProject: MockProject;
myProjects: MockProject[];
expectedCreate: boolean;
}>([
{
name: 'uses the current project when it matches the panel project',
projectId: 'project-1',
currentProject: { id: 'project-1', scopes: canCreate },
personalProject: { id: 'project-2', scopes: cannotCreate },
myProjects: [{ id: 'project-3', scopes: cannotCreate }],
expectedCreate: true,
},
{
name: 'falls back to the personal project when the current project does not match',
projectId: 'project-2',
currentProject: { id: 'project-1', scopes: cannotCreate },
personalProject: { id: 'project-2', scopes: canCreate },
myProjects: [{ id: 'project-3', scopes: cannotCreate }],
expectedCreate: true,
},
{
name: 'falls back to the matching entry in myProjects when neither the current nor the personal project match',
projectId: 'project-3',
currentProject: { id: 'project-1', scopes: cannotCreate },
personalProject: { id: 'project-2', scopes: cannotCreate },
myProjects: [{ id: 'project-3', scopes: canCreate }],
expectedCreate: true,
},
{
name: 'denies creating credentials when no known project matches the panel project',
projectId: 'project-unknown',
currentProject: { id: 'project-1', scopes: canCreate },
personalProject: { id: 'project-2', scopes: canCreate },
myProjects: [{ id: 'project-3', scopes: canCreate }],
expectedCreate: false,
},
])('$name', ({ projectId, currentProject, personalProject, myProjects, expectedCreate }) => {
projectsStoreState.currentProject = currentProject;
projectsStoreState.personalProject = personalProject;
projectsStoreState.myProjects = myProjects;
const wrapper = mountWithFallbackPicker({ projectId });
expect(
wrapper.findComponent(AgentCredentialSelect).props('credentialPermissions'),
).toMatchObject({ create: expectedCreate });
wrapper.findComponent(AgentCredentialSelect).vm.$emit('create');
if (expectedCreate) {
expect(openNewCredentialMock).toHaveBeenCalledWith(
'braveSearchApi',
false,
false,
projectId,
undefined,
undefined,
undefined,
expect.objectContaining({ hideAskAssistant: true }),
);
} else {
expect(openNewCredentialMock).not.toHaveBeenCalled();
}
});
});
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(AgentWebSearchSection, {
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(AgentWebSearchSection, {
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(AgentWebSearchSection, {
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(AgentWebSearchSection, {
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': {} });
});
});

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
/**
* Behavior panel — execution-behavior knobs that used to live in the old
* AgentOverviewPanel: native web search, reasoning depth, and tool-call
* AgentOverviewPanel: reasoning depth and tool-call
* concurrency.
*/
import { ref, computed, watch } from 'vue';
@ -9,14 +9,8 @@ import { useDebounceFn } from '@vueuse/core';
import { AGENT_REASONING_LEVELS, type AgentReasoningLevel } from '@n8n/api-types';
import { N8nInputNumber, N8nOption, N8nSelect, N8nSwitch2, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { getResourcePermissions } from '@n8n/permissions';
import AgentCredentialSelect, { type AgentCredentialOption } from './AgentCredentialSelect.vue';
import AgentPanel from './AgentPanel.vue';
import { useUIStore } from '@/app/stores/ui.store';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useModelCatalog } from '../composables/useModelCatalog';
import type { AgentJsonConfig } from '../types';
import {
@ -25,30 +19,15 @@ import {
type AnthropicCacheTtl,
} from '../provider-capabilities';
import { modelToString, parseModelString, parseProvider } from '../utils/model-string';
import {
getNativeWebSearchArgs,
getWebSearchMethod,
type FallbackWebSearchProvider,
type NativeWebSearchArgs,
type WebSearchMethod,
withWebSearchConfig,
} from '../utils/nativeWebSearch';
import shared from '../styles/agent-panel.module.scss';
const i18n = useI18n();
const credentialsStore = useCredentialsStore();
const projectsStore = useProjectsStore();
const uiStore = useUIStore();
const { catalog, ensureLoaded } = useModelCatalog();
const DEFAULT_CAPABILITIES = {
promptCaching: 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;
function normalizeReasoningLevel(value: unknown): AgentReasoningLevel {
return AGENT_REASONING_LEVELS.find((level) => level === value) ?? 'medium';
@ -83,7 +62,6 @@ const reasoningHintKey = computed(() => {
return 'agents.builder.advanced.reasoning.hint';
});
const capabilities = computed(() => PROVIDER_CAPABILITIES[provider.value] ?? DEFAULT_CAPABILITIES);
const hasNativeWebSearch = computed(() => Boolean(capabilities.value.webSearch));
watch(
() => props.projectId,
@ -199,20 +177,6 @@ const {
// Reasoning
// ---------------------------------------------------------------------------
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 reasoningEnabled = ref(props.config?.config?.reasoning !== undefined);
const reasoningLevel = ref<AgentReasoningLevel>(
normalizeReasoningLevel(props.config?.config?.reasoning),
@ -224,166 +188,19 @@ function anthropicTtlFrom(cfg: AgentJsonConfig | null): AnthropicCacheTtl {
const anthropicTtl = ref<AnthropicCacheTtl>(anthropicTtlFrom(props.config));
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) => {
if (!cfg) return;
reasoningEnabled.value = cfg.config?.reasoning !== undefined;
reasoningLevel.value = normalizeReasoningLevel(cfg.config?.reasoning);
anthropicTtl.value = anthropicTtlFrom(cfg);
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);
(config) => {
if (!config) return;
reasoningEnabled.value = config.config?.reasoning !== undefined;
reasoningLevel.value = normalizeReasoningLevel(config.config?.reasoning);
anthropicTtl.value = anthropicTtlFrom(config);
syncConcurrency(config);
syncMaxIterations(config);
},
{ deep: true },
);
const fallbackCredentialType = computed(() =>
webSearchMethod.value === 'searxng' ? 'searXngApi' : 'braveSearchApi',
);
const fallbackCredentials = computed<AgentCredentialOption[]>(() =>
credentialsStore.allCredentials
.filter((credential) => credential.type === fallbackCredentialType.value)
.map((credential) => ({
id: credential.id,
name: credential.name,
typeDisplayName: credentialsStore.getCredentialTypeByName(credential.type)?.displayName,
homeProject: credential.homeProject,
})),
);
const projectForPermissions = computed(() => {
if (projectsStore.currentProject?.id === props.projectId) return projectsStore.currentProject;
if (projectsStore.personalProject?.id === props.projectId) return projectsStore.personalProject;
return projectsStore.myProjects.find((project) => project.id === props.projectId) ?? null;
});
const credentialPermissions = computed(() => {
const permissions = getResourcePermissions(projectForPermissions.value?.scopes).credential;
return { ...permissions, create: !!permissions.create };
});
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 onCreateFallbackCredential() {
if (props.disabled || !credentialPermissions.value.create) return;
uiStore.openNewCredential(
fallbackCredentialType.value,
false,
false,
props.projectId,
undefined,
undefined,
undefined,
{
hideAskAssistant: true,
onCredentialCreated: (credential) => onFallbackCredentialChange(credential.id),
},
);
}
function emitReasoning() {
emit('update:config', {
config: { ...props.config?.config, reasoning: reasoningLevel.value },
@ -419,163 +236,6 @@ function onAnthropicTtlChange(value: AnthropicCacheTtl) {
<template>
<div :class="$style.panels" data-testid="agent-behavior-panel">
<AgentPanel
:header="i18n.baseText('agents.builder.advanced.webSearch.label')"
:description="i18n.baseText('agents.builder.advanced.webSearch.hint')"
>
<div :class="$style.content" data-testid="agent-advanced-content">
<div :class="$style.settingGroup">
<div :class="$style.row">
<N8nText step="sm" bold :class="shared.dataEntryLabel">{{
i18n.baseText('agents.builder.advanced.webSearch.method.label')
}}</N8nText>
<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>
<div
v-if="webSearchEnabled"
:class="$style.subSettings"
data-testid="agent-web-search-settings"
>
<div
v-if="
webSearchMethod === 'native' && capabilities.webSearch === 'anthropic.web_search'
"
:class="$style.row"
>
<div :class="$style.rowLabel">
<N8nText step="sm" bold :class="shared.dataEntryLabel">{{
i18n.baseText('agents.builder.advanced.webSearch.maxUses.label')
}}</N8nText>
<N8nText size="small" :class="shared.dataEntrySubLabel">
{{ i18n.baseText('agents.builder.advanced.webSearch.maxUses.hint') }}
</N8nText>
</div>
<N8nInputNumber
:model-value="Number(webSearchMaxUses)"
:min="1"
:precision="0"
:controls="false"
: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 step="sm" bold :class="shared.dataEntryLabel">{{
i18n.baseText('agents.builder.advanced.webSearch.externalAccess.label')
}}</N8nText>
<N8nText size="small" :class="shared.dataEntrySubLabel">
{{ 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 step="sm" bold :class="shared.dataEntryLabel">{{
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 step="sm" bold :class="shared.dataEntryLabel">{{
i18n.baseText('agents.builder.advanced.webSearch.credential.label')
}}</N8nText>
<N8nText size="small" :class="shared.dataEntrySubLabel">
{{ i18n.baseText('agents.builder.advanced.webSearch.credential.hint') }}
</N8nText>
</div>
<AgentCredentialSelect
:model-value="fallbackWebSearchCredential"
:credentials="fallbackCredentials"
:placeholder="
i18n.baseText('agents.builder.advanced.webSearch.credential.placeholder')
"
:credential-permissions="credentialPermissions"
:disabled="props.disabled"
:class="$style.credentialSelect"
data-test-id="agent-web-search-fallback-credential"
@update:model-value="onFallbackCredentialChange"
@create="onCreateFallbackCredential"
/>
</div>
</div>
</div>
</div>
</AgentPanel>
<AgentPanel
:header="i18n.baseText('agents.builder.advanced.title')"
:description="i18n.baseText('agents.builder.advanced.description')"

View File

@ -27,6 +27,7 @@ import type {
import { buildToolRows } from './AgentCapabilitiesSection.utils';
import AgentChipButton from './AgentChipButton.vue';
import AgentChipRow from './AgentChipRow.vue';
import AgentWebSearchSection from './AgentWebSearchSection.vue';
export type AgentCapabilitySection = 'tools' | 'tasks' | 'skills' | 'subAgents';
type CapabilityRow = Exclude<AgentCapabilitySection, 'tasks'> | 'workflows';
@ -706,6 +707,13 @@ function openExistingSubAgentModal(subAgent: {
</div>
</AgentChipRow>
</template>
<div :class="$style.divider" aria-hidden="true" />
<AgentWebSearchSection
:config="props.config"
:disabled="props.disabled"
:project-id="props.projectId"
@update:config="emit('update:config', $event)"
/>
</div>
</div>
</template>
@ -737,4 +745,11 @@ function openExistingSubAgentModal(subAgent: {
align-items: center;
gap: var(--spacing--4xs);
}
.divider {
flex: initial;
height: 1px;
background-color: var(--border-color--subtle);
margin-inline: calc(var(--spacing--sm) * -1);
}
</style>

View File

@ -0,0 +1,483 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import {
N8nButton,
N8nDropdownMenu,
N8nInputNumber,
N8nSegmentControl,
N8nSwitch2,
N8nText,
N8nIcon,
type DropdownMenuItemProps,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { getResourcePermissions } from '@n8n/permissions';
import { useUIStore } from '@/app/stores/ui.store';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import type { AgentJsonConfig } from '../types';
import { AGENT_MODEL_PROVIDER_DEFINITIONS, isAgentModelProvider } from '../model-providers';
import { PROVIDER_CAPABILITIES } from '../provider-capabilities';
import { parseProvider } from '../utils/model-string';
import {
getNativeWebSearchArgs,
getWebSearchMethod,
type FallbackWebSearchProvider,
type NativeWebSearchArgs,
type WebSearchMethod,
withWebSearchConfig,
} from '../utils/nativeWebSearch';
import AgentCredentialSelect, { type AgentCredentialOption } from './AgentCredentialSelect.vue';
const props = withDefaults(
defineProps<{
config: AgentJsonConfig | null;
disabled?: boolean;
projectId?: string;
}>(),
{ disabled: false },
);
const emit = defineEmits<{ 'update:config': [changes: Partial<AgentJsonConfig>] }>();
const i18n = useI18n();
const credentialsStore = useCredentialsStore();
const projectsStore = useProjectsStore();
const uiStore = useUIStore();
const DEFAULT_CAPABILITIES = { webSearch: false } 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 searchContextSizeSegments = computed<Array<{ label: string; value: SearchContextSize }>>(
function getSearchContextSizeSegments() {
return [
{
label: i18n.baseText('agents.builder.advanced.webSearch.contextSize.low'),
value: 'low',
},
{
label: i18n.baseText('agents.builder.advanced.webSearch.contextSize.medium'),
value: 'medium',
},
{
label: i18n.baseText('agents.builder.advanced.webSearch.contextSize.high'),
value: 'high',
},
];
},
);
const provider = computed(() => parseProvider(props.config?.model));
const capabilities = computed(() => PROVIDER_CAPABILITIES[provider.value] ?? DEFAULT_CAPABILITIES);
const hasNativeWebSearch = computed(() => Boolean(capabilities.value.webSearch));
const nativeWebSearchLabel = computed(() =>
isAgentModelProvider(provider.value)
? AGENT_MODEL_PROVIDER_DEFINITIONS[provider.value].displayName
: provider.value,
);
const webSearchMenuItems = computed<Array<DropdownMenuItemProps<WebSearchSelectValue>>>(() => [
{
id: 'off',
label: i18n.baseText('agents.builder.advanced.webSearch.method.off'),
checked: webSearchMethod.value === 'off',
},
...(capabilities.value.webSearch
? [
{
id: 'native' as const,
label: nativeWebSearchLabel.value,
checked: webSearchMethod.value === 'native',
},
]
: []),
{
id: 'brave',
label: i18n.baseText('agents.builder.advanced.webSearch.fallbackProvider.brave'),
checked: webSearchMethod.value === 'brave',
divided: true,
},
{
id: 'searxng',
label: i18n.baseText('agents.builder.advanced.webSearch.fallbackProvider.searxng'),
checked: webSearchMethod.value === 'searxng',
},
]);
const selectedWebSearchLabel = computed(
() => webSearchMenuItems.value.find((item) => item.checked)?.label ?? '',
);
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 ?? '');
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) => {
if (!cfg) return;
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<AgentCredentialOption[]>(() =>
credentialsStore.allCredentials
.filter((credential) => credential.type === fallbackCredentialType.value)
.map((credential) => ({
id: credential.id,
name: credential.name,
typeDisplayName: credentialsStore.getCredentialTypeByName(credential.type)?.displayName,
homeProject: credential.homeProject,
})),
);
const projectForPermissions = computed(() => {
if (projectsStore.currentProject?.id === props.projectId) return projectsStore.currentProject;
if (projectsStore.personalProject?.id === props.projectId) return projectsStore.personalProject;
return projectsStore.myProjects.find((project) => project.id === props.projectId) ?? null;
});
const credentialPermissions = computed(() => {
const permissions = getResourcePermissions(projectForPermissions.value?.scopes).credential;
return { ...permissions, create: !!permissions.create };
});
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 onWebSearchContextSizeChange(value: SearchContextSize) {
webSearchContextSize.value = value;
onWebSearchOptionInput();
}
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 onCreateFallbackCredential() {
if (props.disabled || !credentialPermissions.value.create) return;
uiStore.openNewCredential(
fallbackCredentialType.value,
false,
false,
props.projectId,
undefined,
undefined,
undefined,
{
hideAskAssistant: true,
onCredentialCreated: (credential) => onFallbackCredentialChange(credential.id),
},
);
}
</script>
<template>
<div :class="$style.content" data-testid="agent-web-search-content">
<div :class="$style.settingGroup">
<div :class="$style.row">
<div :class="$style.rowLabel">
<N8nText step="sm" bold>{{
i18n.baseText('agents.builder.advanced.webSearch.label')
}}</N8nText>
<N8nText step="sm" color="text-light">{{
i18n.baseText('agents.builder.advanced.webSearch.hint')
}}</N8nText>
</div>
<N8nDropdownMenu
:items="webSearchMenuItems"
:disabled="props.disabled"
placement="bottom-end"
data-testid="agent-web-search-method"
@select="onWebSearchMethodChange"
>
<template #trigger>
<N8nButton variant="outline" :disabled="props.disabled" :class="$style.shortInput">
<span :class="$style.dropdownTriggerLabel">{{ selectedWebSearchLabel }}</span>
<N8nIcon icon="chevron-down" size="small" color="text-light" />
</N8nButton>
</template>
</N8nDropdownMenu>
</div>
<div
v-if="webSearchEnabled"
:class="$style.subSettings"
data-testid="agent-web-search-settings"
>
<div
v-if="webSearchMethod === 'native' && capabilities.webSearch === 'anthropic.web_search'"
:class="$style.row"
>
<div :class="$style.rowLabel">
<N8nText step="sm" bold>{{
i18n.baseText('agents.builder.advanced.webSearch.maxUses.label')
}}</N8nText>
<N8nText step="sm" color="text-light">
{{ i18n.baseText('agents.builder.advanced.webSearch.maxUses.hint') }}
</N8nText>
</div>
<N8nInputNumber
:model-value="Number(webSearchMaxUses)"
:min="1"
:precision="0"
:controls="false"
: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 step="sm" bold>{{
i18n.baseText('agents.builder.advanced.webSearch.externalAccess.label')
}}</N8nText>
<N8nText step="sm" 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"
>
<div :class="$style.rowLabel">
<N8nText step="sm" bold>{{
i18n.baseText('agents.builder.advanced.webSearch.contextSize.label')
}}</N8nText>
<N8nText step="sm" color="text-light">
{{ i18n.baseText('agents.builder.advanced.webSearch.contextSize.hint') }}
</N8nText>
</div>
<N8nSegmentControl
:model-value="webSearchContextSize"
:options="searchContextSizeSegments"
size="default"
:disabled="props.disabled"
data-testid="agent-web-search-context-size"
@update:model-value="onWebSearchContextSizeChange"
/>
</div>
<div v-if="webSearchMethod !== 'native'" :class="$style.row">
<div :class="$style.rowLabel">
<N8nText step="sm" bold>{{
i18n.baseText('agents.builder.advanced.webSearch.credential.label')
}}</N8nText>
<N8nText step="sm" color="text-light">
{{ i18n.baseText('agents.builder.advanced.webSearch.credential.hint') }}
</N8nText>
</div>
<AgentCredentialSelect
:model-value="fallbackWebSearchCredential"
:credentials="fallbackCredentials"
:placeholder="i18n.baseText('agents.builder.advanced.webSearch.credential.placeholder')"
:credential-permissions="credentialPermissions"
:disabled="props.disabled"
:class="$style.credentialSelect"
data-test-id="agent-web-search-fallback-credential"
@update:model-value="onFallbackCredentialChange"
@create="onCreateFallbackCredential"
/>
</div>
</div>
</div>
</div>
</template>
<style module>
.content {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
width: 100%;
}
.settingGroup {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing--sm);
min-height: var(--spacing--xl);
}
.rowLabel {
display: flex;
flex-direction: column;
gap: var(--spacing--5xs);
flex: 1;
min-width: 0;
}
.subSettings {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
padding: var(--spacing--xs);
border: var(--border);
border-radius: var(--radius);
}
.shortInput {
max-width: 140px;
width: fit-content;
flex-shrink: 0;
}
.dropdownTriggerLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.credentialSelect {
max-width: 220px;
flex-shrink: 0;
}
.switchControl:not([data-disabled]) {
cursor: pointer;
}
</style>