Add Keenable web search tool to the agent (#16233)

Adds Keenable as a web search tool in the agent, alongside the existing
Tavily/DuckDuckGo/SearXNG/Google tools.

The main difference from the other search tools is that it doesn't
require an
API key. By default it uses Keenable's keyless public endpoint, so it
works out
of the box. Providing a key (in the tool config) switches to the
authenticated
endpoint and lifts the rate limits.

### Changes

- Backend: `agent/tools/keenable.py` — `KeenableSearch`, follows the
  Tavily/DuckDuckGo tool shape (results go through `_retrieve_chunks`).
  Auto-registered by `agent/tools/__init__.py`.
- Frontend: wired into the agent builder — operator + icon, config form
(optional API key, search mode, site filter, top N), the search tool
menu,
  and the existing api_key export sanitizer.

### Config

- API key: optional. Blank = keyless free tier; set it to lift limits /
enable
  `realtime` mode.
- `site`: restrict to a single domain.
- `mode`: `pro` (default) or `realtime`.

### Notes

`KEENABLE_API_URL` can override the API base (HTTPS enforced; defaults
to
`https://api.keenable.ai`). The tool only sends the query (no URL
fetch), so
there's no SSRF surface. Verified the frontend with `vite build` and the
backend search path against the public endpoint.
This commit is contained in:
Ilya Bogin
2026-06-25 07:12:28 +03:00
committed by GitHub
parent 06d45c50cb
commit 10d02e54a8
16 changed files with 392 additions and 3 deletions

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" rx="5" fill="#3B5BFE"/>
<circle cx="10.5" cy="10.5" r="4.25" stroke="#fff" stroke-width="1.8"/>
<line x1="13.6" y1="13.6" x2="18" y2="18" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 334 B

View File

@@ -108,6 +108,7 @@ export enum Operator {
UserFillUp = 'UserFillUp',
StringTransform = 'StringTransform',
SearXNG = 'SearXNG',
KeenableSearch = 'KeenableSearch',
DocGenerator = 'DocGenerator',
Browser = 'Browser',
Placeholder = 'Placeholder',

View File

@@ -2133,6 +2133,12 @@ Best for: Documents with flowing, contextually connected content — such as boo
searXNG: 'SearXNG',
searXNGDescription:
'A component that searches via your provided SearXNG instance URL. Specify TopN and the instance URL.',
keenableSearch: 'Keenable',
keenableSearchDescription:
'A web search component powered by Keenable, a search API built for AI agents. Works without an API key by default (keyless free tier); add a key to lift rate limits.',
keenableMode: 'Search mode',
keenableSite: 'Site',
keenableApiKeyTip: 'Optional. Leave blank to use the keyless free tier.',
docGenerator: 'Doc Generator',
docGeneratorDescription: `Generate a file from Markdown content.`,
browser: 'Browser',

View File

@@ -122,6 +122,7 @@ export function AccordionOperators({
Operator.Invoke,
Operator.WenCai,
Operator.SearXNG,
Operator.KeenableSearch,
Operator.DocGenerator,
Operator.Browser,
]}

View File

@@ -192,6 +192,29 @@ export const initialSearXNGValues = {
},
};
export enum KeenableMode {
Pro = 'pro',
Realtime = 'realtime',
}
export const initialKeenableValues = {
api_key: '',
query: AgentGlobals.SysQuery,
mode: KeenableMode.Pro,
site: '',
top_n: 10,
outputs: {
formalized_content: {
value: '',
type: 'string',
},
json: {
value: [],
type: 'Array<Object>',
},
},
};
export const initialWikipediaValues = {
top_n: 10,
language: 'en',
@@ -665,6 +688,7 @@ export const RestrictedUpstreamMap = {
[Operator.GoogleScholar]: [Operator.Begin, Operator.Retrieval],
[Operator.GitHub]: [Operator.Begin, Operator.Retrieval],
[Operator.SearXNG]: [Operator.Begin, Operator.Retrieval],
[Operator.KeenableSearch]: [Operator.Begin, Operator.Retrieval],
[Operator.ExeSQL]: [Operator.Begin],
[Operator.Switch]: [Operator.Begin],
[Operator.WenCai]: [Operator.Begin],
@@ -716,6 +740,7 @@ export const NodeMap = {
[Operator.GoogleScholar]: 'ragNode',
[Operator.GitHub]: 'ragNode',
[Operator.SearXNG]: 'ragNode',
[Operator.KeenableSearch]: 'ragNode',
[Operator.ExeSQL]: 'ragNode',
[Operator.Switch]: 'switchNode',
[Operator.WenCai]: 'ragNode',

View File

@@ -19,6 +19,7 @@ import GoogleScholarForm from '../form/google-scholar-form';
import InvokeForm from '../form/invoke-form';
import IterationForm from '../form/iteration-form';
import IterationStartForm from '../form/iteration-start-from';
import KeenableForm from '../form/keenable-form';
import ListOperationsForm from '../form/list-operations-form';
import LoopForm from '../form/loop-form';
import MessageForm from '../form/message-form';
@@ -70,6 +71,9 @@ export const FormConfigMap = {
[Operator.DuckDuckGo]: {
component: DuckDuckGoForm,
},
[Operator.KeenableSearch]: {
component: KeenableForm,
},
[Operator.Wikipedia]: {
component: WikipediaForm,
},

View File

@@ -28,6 +28,7 @@ const Menus = [
Operator.DuckDuckGo,
Operator.Wikipedia,
Operator.SearXNG,
Operator.KeenableSearch,
Operator.YahooFinance,
Operator.PubMed,
Operator.GoogleScholar,

View File

@@ -0,0 +1,114 @@
import { FormContainer } from '@/components/form-container';
import { TopNFormField } from '@/components/top-n-item';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { RAGFlowSelect } from '@/components/ui/select';
import { useTranslate } from '@/hooks/common-hooks';
import { zodResolver } from '@hookform/resolvers/zod';
import { memo, useMemo } from 'react';
import { useForm, useFormContext } from 'react-hook-form';
import { z } from 'zod';
import { KeenableMode, initialKeenableValues } from '../../constant';
import { useFormValues } from '../../hooks/use-form-values';
import { useWatchFormChange } from '../../hooks/use-watch-form-change';
import { INextOperatorForm } from '../../interface';
import { buildOutputList } from '../../utils/build-output-list';
import { ApiKeyField } from '../components/api-key-field';
import { FormWrapper } from '../components/form-wrapper';
import { Output } from '../components/output';
import { QueryVariable } from '../components/query-variable';
export const KeenableFormPartialSchema = {
api_key: z.string().optional(),
mode: z.string(),
site: z.string().optional(),
top_n: z.coerce.number(),
};
const FormSchema = z.object({
query: z.string(),
...KeenableFormPartialSchema,
});
export function KeenableWidgets() {
const { t } = useTranslate('flow');
const form = useFormContext();
const modeOptions = useMemo(
() =>
Object.values(KeenableMode).map((x) => ({
value: x,
label: x.charAt(0).toUpperCase() + x.slice(1),
})),
[],
);
return (
<>
<ApiKeyField placeholder={t('keenableApiKeyTip')}></ApiKeyField>
<FormField
control={form.control}
name={'mode'}
render={({ field }) => (
<FormItem>
<FormLabel>{t('keenableMode')}</FormLabel>
<FormControl>
<RAGFlowSelect {...field} options={modeOptions} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={'site'}
render={({ field }) => (
<FormItem>
<FormLabel>{t('keenableSite')}</FormLabel>
<FormControl>
<Input {...field} placeholder="example.com"></Input>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<TopNFormField></TopNFormField>
</>
);
}
const outputList = buildOutputList(initialKeenableValues.outputs);
function KeenableForm({ node }: INextOperatorForm) {
const defaultValues = useFormValues(initialKeenableValues, node);
const form = useForm<z.infer<typeof FormSchema>>({
defaultValues,
resolver: zodResolver(FormSchema),
});
useWatchFormChange(node?.id, form);
return (
<Form {...form}>
<FormWrapper>
<FormContainer>
<QueryVariable></QueryVariable>
<KeenableWidgets></KeenableWidgets>
</FormContainer>
</FormWrapper>
<div className="p-5">
<Output list={outputList}></Output>
</div>
</Form>
);
}
export default memo(KeenableForm);

View File

@@ -8,6 +8,7 @@ import ExeSQLForm from './exesql-form';
import GithubForm from './github-form';
import GoogleForm from './google-form';
import GoogleScholarForm from './google-scholar-form';
import KeenableForm from './keenable-form';
import PubMedForm from './pubmed-form';
import RetrievalForm from './retrieval-form';
import SearXNGForm from './searxng-form';
@@ -35,4 +36,5 @@ export const ToolFormConfigMap = {
[Operator.TavilyExtract]: TavilyForm,
[Operator.WenCai]: WenCaiForm,
[Operator.SearXNG]: SearXNGForm,
[Operator.KeenableSearch]: KeenableForm,
};

View File

@@ -0,0 +1,38 @@
import { FormContainer } from '@/components/form-container';
import { Form } from '@/components/ui/form';
import { zodResolver } from '@hookform/resolvers/zod';
import { memo } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { FormWrapper } from '../../components/form-wrapper';
import {
KeenableFormPartialSchema,
KeenableWidgets,
} from '../../keenable-form';
import { useValues } from '../use-values';
import { useWatchFormChange } from '../use-watch-change';
function KeenableForm() {
const values = useValues();
const FormSchema = z.object(KeenableFormPartialSchema);
const form = useForm<z.infer<typeof FormSchema>>({
defaultValues: values,
resolver: zodResolver(FormSchema),
});
useWatchFormChange(form);
return (
<Form {...form}>
<FormWrapper>
<FormContainer>
<KeenableWidgets></KeenableWidgets>
</FormContainer>
</FormWrapper>
</Form>
);
}
export default memo(KeenableForm);

View File

@@ -29,6 +29,7 @@ import {
initialInvokeValues,
initialIterationStartValues,
initialIterationValues,
initialKeenableValues,
initialListOperationsValues,
initialLoopValues,
initialMessageValues,
@@ -163,6 +164,7 @@ export const useInitializeOperatorParams = () => {
[Operator.Agent]: { ...initialAgentValues, llm_id: llmId },
[Operator.Tool]: {},
[Operator.TavilySearch]: initialTavilyValues,
[Operator.KeenableSearch]: initialKeenableValues,
[Operator.UserFillUp]: initialUserFillUpValues,
[Operator.StringTransform]: initialStringTransformValues,
[Operator.TavilyExtract]: initialTavilyExtractValues,

View File

@@ -59,6 +59,8 @@ export function useAgentToolInitialValues() {
return {};
case Operator.SearXNG:
return pick(initialValues, 'searxng_url', 'top_n');
case Operator.KeenableSearch:
return pick(initialValues, 'api_key', 'mode', 'site', 'top_n');
default:
return initialValues;

View File

@@ -13,9 +13,12 @@ const clearSensitiveFields = <T>(obj: T): T =>
cloneDeepWith(obj, (value) => {
if (
isPlainObject(value) &&
[Operator.TavilySearch, Operator.TavilyExtract, Operator.Google].includes(
value.component_name,
) &&
[
Operator.TavilySearch,
Operator.TavilyExtract,
Operator.Google,
Operator.KeenableSearch,
].includes(value.component_name) &&
get(value, 'params.api_key')
) {
return { ...value, params: { ...value.params, api_key: '' } };

View File

@@ -27,6 +27,7 @@ type IToolIcon =
| Operator.PubMed
| Operator.TavilyExtract
| Operator.TavilySearch
| Operator.KeenableSearch
| Operator.Wikipedia
| Operator.YahooFinance
| Operator.WenCai

View File

@@ -46,6 +46,7 @@ export const SVGIconMap = {
[Operator.GoogleScholar]: 'google-scholar',
[Operator.PubMed]: 'pubmed',
[Operator.SearXNG]: 'searxng',
[Operator.KeenableSearch]: 'keenable',
[Operator.TavilyExtract]: 'tavily',
[Operator.TavilySearch]: 'tavily',
[Operator.Wikipedia]: 'wikipedia',