From 369e008a7fcb58b2fce9864bd03ecef428e911dc Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Wed, 30 Sep 2026 12:41:13 -0400 Subject: [PATCH 1/3] add backend components, strip out translation --- backend/open_webui/config.py | 11 ++ backend/open_webui/routers/chats.py | 8 + backend/open_webui/tools/builtin.py | 39 +++++ backend/open_webui/utils/middleware.py | 48 ++++++ backend/open_webui/utils/tool_search.py | 162 ++++++++++++++++++ backend/open_webui/utils/tools.py | 5 + .../admin/Settings/Interface.svelte | 56 +++++- src/lib/i18n/locales/en-US/translation.json | 7 + 8 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 backend/open_webui/utils/tool_search.py diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index cc38281c4b..76590caa65 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -2208,6 +2208,14 @@ CONTEXT_COMPACTION_RETENTION_PERCENTAGE = min( CONTEXT_COMPACTION_PROMPT_TEMPLATE = os.getenv('CONTEXT_COMPACTION_PROMPT_TEMPLATE', '') +ENABLE_TOOL_SEARCH = os.getenv('ENABLE_TOOL_SEARCH', 'False').lower() == 'true' + +TOOL_SEARCH_DEFER_THRESHOLD = int(os.getenv('TOOL_SEARCH_DEFER_THRESHOLD', '400')) + +TOOL_SEARCH_ALWAYS_LOADED = [ + item.strip() for item in os.getenv('TOOL_SEARCH_ALWAYS_LOADED', '').split(',') if item.strip() +] + TITLE_GENERATION_PROMPT_TEMPLATE = os.getenv('TITLE_GENERATION_PROMPT_TEMPLATE', '') DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE = """### Task: @@ -3139,6 +3147,9 @@ DEFAULT_CONFIG = { 'chat.context_compaction.retention_percentage': CONTEXT_COMPACTION_RETENTION_PERCENTAGE, 'chat.context_compaction.prompt_template': CONTEXT_COMPACTION_PROMPT_TEMPLATE, 'chat.tool_permissions.enable': ENABLE_TOOL_PERMISSIONS, + 'chat.tool_search.enable': ENABLE_TOOL_SEARCH, + 'chat.tool_search.defer_threshold': TOOL_SEARCH_DEFER_THRESHOLD, + 'chat.tool_search.always_loaded': TOOL_SEARCH_ALWAYS_LOADED, 'task.title.prompt_template': TITLE_GENERATION_PROMPT_TEMPLATE, 'task.tags.prompt_template': TAGS_GENERATION_PROMPT_TEMPLATE, 'task.image.prompt_template': IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE, diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index e6194d0f64..7f176a6ec7 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -56,6 +56,9 @@ CHAT_CONFIG_KEYS = { 'CONTEXT_COMPACTION_RETENTION_PERCENTAGE': 'chat.context_compaction.retention_percentage', 'CONTEXT_COMPACTION_PROMPT_TEMPLATE': 'chat.context_compaction.prompt_template', 'ENABLE_TOOL_PERMISSIONS': 'chat.tool_permissions.enable', + 'ENABLE_TOOL_SEARCH': 'chat.tool_search.enable', + 'TOOL_SEARCH_DEFER_THRESHOLD': 'chat.tool_search.defer_threshold', + 'TOOL_SEARCH_ALWAYS_LOADED': 'chat.tool_search.always_loaded', } @@ -165,6 +168,9 @@ class ChatConfigForm(BaseModel): CONTEXT_COMPACTION_RETENTION_PERCENTAGE: int = 40 CONTEXT_COMPACTION_PROMPT_TEMPLATE: str ENABLE_TOOL_PERMISSIONS: bool = False + ENABLE_TOOL_SEARCH: bool = False + TOOL_SEARCH_DEFER_THRESHOLD: int = 400 + TOOL_SEARCH_ALWAYS_LOADED: list[str] = [] class CompactChatForm(BaseModel): @@ -845,6 +851,7 @@ async def set_chat_config(form_data: ChatConfigForm, user=Depends(get_admin_user threshold = max(1, int(form_data.CONTEXT_COMPACTION_TOKEN_THRESHOLD)) token_cap = max(1, int(form_data.CONTEXT_COMPACTION_TOKEN_CAP or threshold)) retention_percentage = min(50, max(10, int(form_data.CONTEXT_COMPACTION_RETENTION_PERCENTAGE))) + tool_search_defer_threshold = max(0, int(form_data.TOOL_SEARCH_DEFER_THRESHOLD)) await Config.upsert( chat_config_updates( { @@ -853,6 +860,7 @@ async def set_chat_config(form_data: ChatConfigForm, user=Depends(get_admin_user 'CONTEXT_COMPACTION_TOKEN_THRESHOLD': threshold, 'CONTEXT_COMPACTION_TOKEN_CAP': token_cap, 'CONTEXT_COMPACTION_RETENTION_PERCENTAGE': retention_percentage, + 'TOOL_SEARCH_DEFER_THRESHOLD': tool_search_defer_threshold, } ) ) diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index c974c68b46..3c7be973ca 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -3548,6 +3548,45 @@ async def view_skill( return JSONCodec.dumps({'error': str(e)}) +# ============================================================================= +# TOOL SEARCH +# ============================================================================= + + +async def search_tools( + query: str, + limit: int = 5, + __metadata__: dict = None, +) -> str: + """ + Search the tools listed in and load the matches so you can call them in your next step. + Pass the exact tool name when you already know it. + + :param query: Keywords describing the capability you need (e.g. "jira create issue"), or an exact tool name + :param limit: Maximum number of tools to load (default 5, max 20) + :return: JSON with the names of the loaded tools + """ + from open_webui.utils.tool_search import mark_tools_loaded, search_deferred_tools + + metadata = __metadata__ or {} + state = metadata.get('tool_search') + if not state: + return JSONCodec.dumps({'error': 'Tool search is not active for this request'}) + + try: + tools = metadata['tools'] + matches = search_deferred_tools(query, {name: tools[name]['spec'] for name in state['deferred']}, limit) + mark_tools_loaded(metadata, matches) + if not matches: + return JSONCodec.dumps( + {'loaded': [], 'message': 'No matching tools found. Try different keywords or the exact tool name.'} + ) + return JSONCodec.dumps({'loaded': matches}) + except Exception as e: + log.exception(f'search_tools error: {e}') + return JSONCodec.dumps({'error': str(e)}) + + # ============================================================================= # TASK MANAGEMENT TOOLS # ============================================================================= diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 50d8617298..83f853be78 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -142,6 +142,15 @@ from open_webui.utils.task import ( rag_template, tools_function_calling_generation_template, ) +from open_webui.utils.tool_search import ( + SEARCH_TOOL_NAME, + build_deferred_tools_manifest, + collect_called_tool_names, + get_tool_search_config, + mark_tools_loaded, + rebuild_tools_payload, + select_deferred_tools, +) from open_webui.utils.tools import ( build_tool_server_headers, get_attached_knowledge, @@ -2921,6 +2930,8 @@ async def process_chat_payload(request, form_data, user, metadata, model): log.debug('tool_ids=%r', tool_ids) log.debug('direct_tool_servers=%r', direct_tool_servers) + tool_search_config = await get_tool_search_config() + tools_dict = {} mcp_clients = {} @@ -3093,6 +3104,8 @@ async def process_chat_payload(request, form_data, user, metadata, model): **extra_params, '__event_emitter__': event_emitter, '__skill_ids__': view_skill_ids, + '__tool_search__': tool_search_config['enable'] + and metadata.get('params', {}).get('function_calling') != 'legacy', }, features, model, @@ -3152,12 +3165,33 @@ async def process_chat_payload(request, form_data, user, metadata, model): metadata['tools'] = tools_dict if metadata.get('params', {}).get('function_calling') != 'legacy': + # Tool search: withhold large schemas from the provider and let the + # model load them on demand via the search_tools builtin. + if tools_dict.get(SEARCH_TOOL_NAME, {}).get('type') == 'builtin': + deferred = select_deferred_tools(tools_dict, tool_search_config) + if deferred: + metadata['tool_search'] = { + 'deferred': deferred, + # Tools called earlier in this conversation stay loaded. + 'loaded': sorted(set(deferred) & collect_called_tool_names(form_data['messages'])), + 'extra_tools': inlet_filter_tools or [], + } + form_data['messages'] = add_or_update_system_message( + build_deferred_tools_manifest(tools_dict, deferred), + form_data['messages'], + append=True, + ) + else: + # Nothing to defer, so behave as if tool search were off. + tools_dict.pop(SEARCH_TOOL_NAME) + # If the function calling is native, then call the tools function calling handler form_data['tools'] = [ {'type': 'function', 'function': tool.get('spec', {})} for tool in tools_dict.values() ] if inlet_filter_tools: form_data['tools'].extend(inlet_filter_tools) + rebuild_tools_payload(form_data, metadata) else: # If the function calling is not native, then call the tools function calling handler try: @@ -3468,6 +3502,11 @@ async def drain_approved_tool_calls(request, form_data, user, model, metadata) - ) changed = True + if changed and metadata.get('tool_search'): + # Approved deferred tools must reach the provider on the next request. + mark_tools_loaded(metadata, [item.get('name', '') for item in approved_calls]) + rebuild_tools_payload(form_data, metadata) + if changed: result_call_ids = { item.get('call_id') for item in output if item.get('type') == 'function_call_output' and item.get('call_id') @@ -6034,6 +6073,13 @@ async def streaming_chat_response_handler(response, ctx): ) ) + # A deferred tool called straight from the manifest still runs (it is in + # metadata['tools']); mark it loaded so its schema is sent next iteration. + mark_tools_loaded( + metadata, + [tool_call.get('function', {}).get('name', '') for tool_call in response_tool_calls], + ) + for tool_call in response_tool_calls: tool_call_id = tool_call.get('id', '') tool_function_name = tool_call.get('function', {}).get('name', '') @@ -6222,6 +6268,8 @@ async def streaming_chat_response_handler(response, ctx): 'stream': True, 'metadata': metadata, } + # Tool search: include tools loaded during this iteration. + rebuild_tools_payload(new_form_data, metadata) if ENABLE_RESPONSES_API_STATEFUL and last_response_id: system_message = get_system_message(form_data['messages']) diff --git a/backend/open_webui/utils/tool_search.py b/backend/open_webui/utils/tool_search.py new file mode 100644 index 0000000000..ddb60bad01 --- /dev/null +++ b/backend/open_webui/utils/tool_search.py @@ -0,0 +1,162 @@ +""" +Tool search / lazy tool loading. + +Large tool schemas are withheld from the provider `tools` array and listed in a +compact `` manifest instead. The model loads them on demand +through the `search_tools` builtin. Per-request state lives on `metadata`: + + metadata['tool_search'] = { + 'deferred': [tool names withheld from the provider], + 'loaded': [deferred names that have since been loaded], + 'extra_tools': [tools added by filter inlets, always sent], + } +""" + +import fnmatch +import re + +from rank_bm25 import BM25Okapi + +from open_webui.models.config import Config +from open_webui.utils.json_codec import JSONCodec + +SEARCH_TOOL_NAME = 'search_tools' +MANIFEST_DESCRIPTION_MAX_CHARS = 100 +MAX_SEARCH_LIMIT = 20 +DEFAULT_SEARCH_LIMIT = 5 + + +async def get_tool_search_config() -> dict: + values = await Config.get_many( + 'chat.tool_search.enable', + 'chat.tool_search.defer_threshold', + 'chat.tool_search.always_loaded', + ) + return { + 'enable': bool(values.get('chat.tool_search.enable', False)), + 'defer_threshold': _to_int(values.get('chat.tool_search.defer_threshold'), 400), + 'always_loaded': values.get('chat.tool_search.always_loaded') or [], + } + + +def _to_int(value, default: int) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def select_deferred_tools(tools_dict: dict[str, dict], config: dict) -> list[str]: + """Sorted names of non-builtin tools whose schema exceeds the threshold and is not always loaded.""" + patterns = config['always_loaded'] + return sorted( + name + for name, tool in tools_dict.items() + if tool.get('type') != 'builtin' + and not any(fnmatch.fnmatchcase(name, pattern) for pattern in patterns) + and len(JSONCodec.dumps(tool.get('spec') or {})) > config['defer_threshold'] + ) + + +def _truncate_description(description: str | None) -> str: + text = ' '.join(str(description or '').split()) + if len(text) > MANIFEST_DESCRIPTION_MAX_CHARS: + text = text[: MANIFEST_DESCRIPTION_MAX_CHARS - 1].rstrip() + '…' + return text + + +def build_deferred_tools_manifest(tools_dict: dict[str, dict], deferred: list[str]) -> str: + """System prompt block listing every deferred tool (mirrors ). + + Loaded tools stay listed so the block is identical across turns and provider prompt caches hold. + """ + entries = '' + for name in deferred: + description = _truncate_description(tools_dict[name].get('spec', {}).get('description')) + entries += f'\n{name}\n{description}\n\n' + + return ( + '\n' + 'The following tools are available but their definitions are not loaded. ' + f'To use one, call `{SEARCH_TOOL_NAME}` with a short keyword query or the exact tool name; ' + 'matching tools become callable immediately afterwards. ' + 'Never tell the user a capability is unavailable without searching first.\n' + f'{entries}' + ) + + +def build_tools_payload(tools_dict: dict[str, dict], deferred, loaded, extra: list | None = None) -> list[dict]: + """OpenAI `tools` array: every tool that is not deferred, plus deferred tools that are loaded, plus extras.""" + deferred_set = set(deferred) - set(loaded) + tools = [ + {'type': 'function', 'function': tool.get('spec', {})} + for name, tool in tools_dict.items() + if name not in deferred_set + ] + if extra: + tools.extend(extra) + return tools + + +_SPLIT_RE = re.compile(r'[^0-9a-zA-Z]+') +_CAMEL_RE = re.compile(r'(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])') + + +def tokenize(text: str | None) -> list[str]: + """Lowercase tokens split on non-alphanumerics and camelCase boundaries.""" + return [part.lower() for chunk in _SPLIT_RE.split(text or '') for part in _CAMEL_RE.split(chunk) if part] + + +def _document_text(name: str, spec: dict) -> str: + properties = (spec.get('parameters') or {}).get('properties') or {} + return ' '.join([name, str(spec.get('description') or ''), *properties.keys()]) + + +def search_deferred_tools(query: str, candidates: dict[str, dict], limit: int = DEFAULT_SEARCH_LIMIT) -> list[str]: + """Rank candidate tools (name -> spec) against the query; an exact tool name wins outright.""" + query = (query or '').strip() + if query in candidates: + return [query] + + query_tokens = tokenize(query) + if not candidates or not query_tokens: + return [] + + names = list(candidates) + documents = [tokenize(_document_text(name, candidates[name])) or ['_'] for name in names] + # BM25 IDF is zero for a term found in half the corpus, common with small tool sets, + # so raw token overlap keeps every matching term rankable. + bm25 = BM25Okapi(documents).get_scores(query_tokens) + scores = { + name: max(0.0, float(bm25[index])) + len(set(query_tokens) & set(documents[index])) + for index, name in enumerate(names) + } + + ranked = sorted((name for name in names if scores[name] > 0), key=lambda name: (-scores[name], name)) + return ranked[: max(1, min(_to_int(limit, DEFAULT_SEARCH_LIMIT), MAX_SEARCH_LIMIT))] + + +def collect_called_tool_names(messages: list[dict]) -> set[str]: + """Tools the conversation already called, so they stay loaded on later turns.""" + return { + (tool_call.get('function') or {}).get('name') + for message in messages + if message.get('role') == 'assistant' + for tool_call in message.get('tool_calls') or [] + } + + +def mark_tools_loaded(metadata: dict, names: list[str]) -> None: + """Record deferred tools as loaded on metadata['tool_search']; no-op when tool search is inactive.""" + state = metadata.get('tool_search') + if state: + state['loaded'] = sorted(set(state['loaded']) | (set(names) & set(state['deferred']))) + + +def rebuild_tools_payload(form_data: dict, metadata: dict) -> None: + """Refresh form_data['tools'] from metadata when tool search is active; no-op otherwise.""" + state = metadata.get('tool_search') + if state: + form_data['tools'] = build_tools_payload( + metadata['tools'], state['deferred'], state['loaded'], state['extra_tools'] + ) diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 120bbb39b4..69061101fe 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -84,6 +84,7 @@ from open_webui.tools.builtin import ( search_knowledge_files, search_memories, search_notes, + search_tools, search_web, timer, toggle_automation, @@ -736,6 +737,10 @@ async def get_builtin_tools( if extra_params.get('__skill_ids__'): builtin_functions.append(view_skill) + # Tool search - lets the model load deferred (large-schema) tools on demand + if extra_params.get('__tool_search__'): + builtin_functions.append(search_tools) + # Task management - break down complex work into trackable steps # Task state is stored on the chats row; local/channel IDs do not have one. if is_builtin_tool_enabled('tasks') and is_saved_chat_id(metadata.get('chat_id')): diff --git a/src/lib/components/admin/Settings/Interface.svelte b/src/lib/components/admin/Settings/Interface.svelte index cd68640c6e..3666a45b86 100644 --- a/src/lib/components/admin/Settings/Interface.svelte +++ b/src/lib/components/admin/Settings/Interface.svelte @@ -50,7 +50,10 @@ CONTEXT_COMPACTION_TOKEN_CAP: 80000, CONTEXT_COMPACTION_RETENTION_PERCENTAGE: 40, CONTEXT_COMPACTION_PROMPT_TEMPLATE: '', - ENABLE_TOOL_PERMISSIONS: false + ENABLE_TOOL_PERMISSIONS: false, + ENABLE_TOOL_SEARCH: false, + TOOL_SEARCH_DEFER_THRESHOLD: 400, + TOOL_SEARCH_ALWAYS_LOADED: '' }; let showTaskParameters = false; @@ -69,8 +72,14 @@ [taskConfig, chatConfig] = await Promise.all([ updateTaskConfig(localStorage.token, taskConfigPayload), - updateChatConfig(localStorage.token, chatConfig) + updateChatConfig(localStorage.token, { + ...chatConfig, + TOOL_SEARCH_ALWAYS_LOADED: chatConfig.TOOL_SEARCH_ALWAYS_LOADED.split(',') + .map((item) => item.trim()) + .filter((item) => item !== '') + }) ]); + chatConfig.TOOL_SEARCH_ALWAYS_LOADED = (chatConfig.TOOL_SEARCH_ALWAYS_LOADED ?? []).join(', '); appConfig.update((current) => current ? { @@ -123,6 +132,9 @@ getChatConfig(localStorage.token) ]); taskConfig.TASK_MODEL_PARAMS = taskConfig.TASK_MODEL_PARAMS ?? {}; + chatConfig.TOOL_SEARCH_ALWAYS_LOADED = (chatConfig.TOOL_SEARCH_ALWAYS_LOADED ?? []).join( + ', ' + ); workspaceModels = await getBaseModels(localStorage.token); baseModels = await getModels(localStorage.token, null, false); @@ -362,6 +374,46 @@ {/if} + + +
+ {$i18n.t('settings.admin.interface.toolSearch.label')} + +
+ +
+ + {#if chatConfig.ENABLE_TOOL_SEARCH} + + + + + + + + {/if} diff --git a/src/lib/i18n/locales/en-US/translation.json b/src/lib/i18n/locales/en-US/translation.json index ee7dba23b5..0c60817d1b 100644 --- a/src/lib/i18n/locales/en-US/translation.json +++ b/src/lib/i18n/locales/en-US/translation.json @@ -2534,6 +2534,13 @@ "settings.admin.interface.tokenThreshold.label": "Token Threshold", "settings.admin.interface.toolPermissions.description": "Show Full access and Ask for approval in the chat input menu.", "settings.admin.interface.toolPermissions.label": "Tool Permissions", + "settings.admin.interface.toolSearch.description": "Withhold large tool definitions from the request and let the model load them on demand. Reduces prompt size when many tools are enabled.", + "settings.admin.interface.toolSearch.label": "Tool Search", + "settings.admin.interface.toolSearchAlwaysLoaded.description": "Comma-separated tool names or glob patterns that are never deferred.", + "settings.admin.interface.toolSearchAlwaysLoaded.label": "Always Loaded Tools", + "settings.admin.interface.toolSearchAlwaysLoaded.placeholder": "e.g. jira_create_issue, github_*", + "settings.admin.interface.toolSearchThreshold.description": "Tools whose definition is longer than this many characters are deferred.", + "settings.admin.interface.toolSearchThreshold.label": "Deferral Threshold (characters)", "settings.admin.interface.toolsFunctionCallingPrompt.description": "Guides how the assistant formats tool and function calls.", "settings.admin.interface.toolsFunctionCallingPrompt.label": "Tools Function Calling Prompt", "settings.admin.interface.voiceModePrompt.description": "Apply voice-specific instructions while voice mode is active.", From 28d34c39c5ececf4fa9d06c7624e0e54a49d4f02 Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Wed, 30 Sep 2026 14:01:17 -0400 Subject: [PATCH 2/3] address all pr feedback --- backend/open_webui/config.py | 3 + backend/open_webui/routers/chats.py | 2 + backend/open_webui/tools/builtin.py | 23 ++-- backend/open_webui/utils/middleware.py | 53 +------- backend/open_webui/utils/subagents.py | 3 +- backend/open_webui/utils/timers.py | 3 +- backend/open_webui/utils/tool_search.py | 126 ++++++++---------- backend/open_webui/utils/tools.py | 29 ++-- .../admin/Settings/Interface.svelte | 26 +++- src/lib/i18n/locales/en-US/translation.json | 4 +- 10 files changed, 118 insertions(+), 154 deletions(-) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 76590caa65..6e73bb3e60 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -2216,6 +2216,8 @@ TOOL_SEARCH_ALWAYS_LOADED = [ item.strip() for item in os.getenv('TOOL_SEARCH_ALWAYS_LOADED', '').split(',') if item.strip() ] +TOOL_SEARCH_DEFER_BUILTIN_TOOLS = os.getenv('TOOL_SEARCH_DEFER_BUILTIN_TOOLS', 'True').lower() == 'true' + TITLE_GENERATION_PROMPT_TEMPLATE = os.getenv('TITLE_GENERATION_PROMPT_TEMPLATE', '') DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE = """### Task: @@ -3150,6 +3152,7 @@ DEFAULT_CONFIG = { 'chat.tool_search.enable': ENABLE_TOOL_SEARCH, 'chat.tool_search.defer_threshold': TOOL_SEARCH_DEFER_THRESHOLD, 'chat.tool_search.always_loaded': TOOL_SEARCH_ALWAYS_LOADED, + 'chat.tool_search.defer_builtin_tools': TOOL_SEARCH_DEFER_BUILTIN_TOOLS, 'task.title.prompt_template': TITLE_GENERATION_PROMPT_TEMPLATE, 'task.tags.prompt_template': TAGS_GENERATION_PROMPT_TEMPLATE, 'task.image.prompt_template': IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE, diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 7f176a6ec7..7a5a97cd4e 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -59,6 +59,7 @@ CHAT_CONFIG_KEYS = { 'ENABLE_TOOL_SEARCH': 'chat.tool_search.enable', 'TOOL_SEARCH_DEFER_THRESHOLD': 'chat.tool_search.defer_threshold', 'TOOL_SEARCH_ALWAYS_LOADED': 'chat.tool_search.always_loaded', + 'TOOL_SEARCH_DEFER_BUILTIN_TOOLS': 'chat.tool_search.defer_builtin_tools', } @@ -171,6 +172,7 @@ class ChatConfigForm(BaseModel): ENABLE_TOOL_SEARCH: bool = False TOOL_SEARCH_DEFER_THRESHOLD: int = 400 TOOL_SEARCH_ALWAYS_LOADED: list[str] = [] + TOOL_SEARCH_DEFER_BUILTIN_TOOLS: bool = True class CompactChatForm(BaseModel): diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 3c7be973ca..8f63bf722f 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -3555,33 +3555,32 @@ async def view_skill( async def search_tools( query: str, - limit: int = 5, + count: int = 5, __metadata__: dict = None, ) -> str: """ - Search the tools listed in and load the matches so you can call them in your next step. + Search the tools listed in and return their full definitions. Pass the exact tool name when you already know it. :param query: Keywords describing the capability you need (e.g. "jira create issue"), or an exact tool name - :param limit: Maximum number of tools to load (default 5, max 20) - :return: JSON with the names of the loaded tools + :param count: Maximum number of results to return (default: 5, max: 20) + :return: JSON with the definitions of the matching tools, which can then be called by name """ - from open_webui.utils.tool_search import mark_tools_loaded, search_deferred_tools + from open_webui.utils.tool_search import search_deferred_tools metadata = __metadata__ or {} - state = metadata.get('tool_search') - if not state: + tools = metadata.get('tools') or {} + deferred = [name for name in metadata.get('deferred_tools') or [] if name in tools] + if not deferred: return JSONCodec.dumps({'error': 'Tool search is not active for this request'}) try: - tools = metadata['tools'] - matches = search_deferred_tools(query, {name: tools[name]['spec'] for name in state['deferred']}, limit) - mark_tools_loaded(metadata, matches) + matches = search_deferred_tools(query, {name: tools[name]['spec'] for name in deferred}, count) if not matches: return JSONCodec.dumps( - {'loaded': [], 'message': 'No matching tools found. Try different keywords or the exact tool name.'} + {'tools': [], 'message': 'No matching tools found. Try different keywords or the exact tool name.'} ) - return JSONCodec.dumps({'loaded': matches}) + return JSONCodec.dumps({'tools': [tools[name]['spec'] for name in matches]}) except Exception as e: log.exception(f'search_tools error: {e}') return JSONCodec.dumps({'error': str(e)}) diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 83f853be78..db3ea10b7b 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -142,15 +142,7 @@ from open_webui.utils.task import ( rag_template, tools_function_calling_generation_template, ) -from open_webui.utils.tool_search import ( - SEARCH_TOOL_NAME, - build_deferred_tools_manifest, - collect_called_tool_names, - get_tool_search_config, - mark_tools_loaded, - rebuild_tools_payload, - select_deferred_tools, -) +from open_webui.utils.tool_search import apply_tool_search from open_webui.utils.tools import ( build_tool_server_headers, get_attached_knowledge, @@ -2930,8 +2922,6 @@ async def process_chat_payload(request, form_data, user, metadata, model): log.debug('tool_ids=%r', tool_ids) log.debug('direct_tool_servers=%r', direct_tool_servers) - tool_search_config = await get_tool_search_config() - tools_dict = {} mcp_clients = {} @@ -3104,8 +3094,6 @@ async def process_chat_payload(request, form_data, user, metadata, model): **extra_params, '__event_emitter__': event_emitter, '__skill_ids__': view_skill_ids, - '__tool_search__': tool_search_config['enable'] - and metadata.get('params', {}).get('function_calling') != 'legacy', }, features, model, @@ -3165,33 +3153,16 @@ async def process_chat_payload(request, form_data, user, metadata, model): metadata['tools'] = tools_dict if metadata.get('params', {}).get('function_calling') != 'legacy': - # Tool search: withhold large schemas from the provider and let the - # model load them on demand via the search_tools builtin. - if tools_dict.get(SEARCH_TOOL_NAME, {}).get('type') == 'builtin': - deferred = select_deferred_tools(tools_dict, tool_search_config) - if deferred: - metadata['tool_search'] = { - 'deferred': deferred, - # Tools called earlier in this conversation stay loaded. - 'loaded': sorted(set(deferred) & collect_called_tool_names(form_data['messages'])), - 'extra_tools': inlet_filter_tools or [], - } - form_data['messages'] = add_or_update_system_message( - build_deferred_tools_manifest(tools_dict, deferred), - form_data['messages'], - append=True, - ) - else: - # Nothing to defer, so behave as if tool search were off. - tools_dict.pop(SEARCH_TOOL_NAME) + deferred = await apply_tool_search(form_data, metadata, tools_dict) # If the function calling is native, then call the tools function calling handler form_data['tools'] = [ - {'type': 'function', 'function': tool.get('spec', {})} for tool in tools_dict.values() + {'type': 'function', 'function': tool.get('spec', {})} + for name, tool in tools_dict.items() + if name not in deferred ] if inlet_filter_tools: form_data['tools'].extend(inlet_filter_tools) - rebuild_tools_payload(form_data, metadata) else: # If the function calling is not native, then call the tools function calling handler try: @@ -3502,11 +3473,6 @@ async def drain_approved_tool_calls(request, form_data, user, model, metadata) - ) changed = True - if changed and metadata.get('tool_search'): - # Approved deferred tools must reach the provider on the next request. - mark_tools_loaded(metadata, [item.get('name', '') for item in approved_calls]) - rebuild_tools_payload(form_data, metadata) - if changed: result_call_ids = { item.get('call_id') for item in output if item.get('type') == 'function_call_output' and item.get('call_id') @@ -6073,13 +6039,6 @@ async def streaming_chat_response_handler(response, ctx): ) ) - # A deferred tool called straight from the manifest still runs (it is in - # metadata['tools']); mark it loaded so its schema is sent next iteration. - mark_tools_loaded( - metadata, - [tool_call.get('function', {}).get('name', '') for tool_call in response_tool_calls], - ) - for tool_call in response_tool_calls: tool_call_id = tool_call.get('id', '') tool_function_name = tool_call.get('function', {}).get('name', '') @@ -6268,8 +6227,6 @@ async def streaming_chat_response_handler(response, ctx): 'stream': True, 'metadata': metadata, } - # Tool search: include tools loaded during this iteration. - rebuild_tools_payload(new_form_data, metadata) if ENABLE_RESPONSES_API_STATEFUL and last_response_id: system_message = get_system_message(form_data['messages']) diff --git a/backend/open_webui/utils/subagents.py b/backend/open_webui/utils/subagents.py index b2f9693af8..cd96af1adf 100644 --- a/backend/open_webui/utils/subagents.py +++ b/backend/open_webui/utils/subagents.py @@ -17,6 +17,7 @@ from open_webui.tasks import create_task, has_active_tasks from open_webui.utils.auth import VERIFIED_USER_ROLES, create_token from open_webui.utils.json_codec import JSONCodec from open_webui.utils.misc import get_message_list +from open_webui.utils.tool_search import strip_deferred_tools_manifest from sqlalchemy import select from starlette.datastructures import Headers @@ -320,7 +321,7 @@ async def delegate( 'session_id': metadata.get('session_id'), 'tool_ids': copy.deepcopy(metadata.get('tool_ids') or []), 'skill_ids': copy.deepcopy(metadata.get('skill_ids') or []), - 'system_prompt': metadata.get('system_prompt'), + 'system_prompt': strip_deferred_tools_manifest(metadata.get('system_prompt')), 'tool_servers': [] if background else copy.deepcopy(metadata.get('tool_servers') or []), 'filter_ids': copy.deepcopy(metadata.get('filter_ids') or []), 'terminal_id': metadata.get('terminal_id'), diff --git a/backend/open_webui/utils/timers.py b/backend/open_webui/utils/timers.py index c2a91139ef..663fe1bfd9 100644 --- a/backend/open_webui/utils/timers.py +++ b/backend/open_webui/utils/timers.py @@ -20,6 +20,7 @@ from open_webui.tasks import has_active_tasks from open_webui.utils.auth import VERIFIED_USER_ROLES from open_webui.utils.json_codec import JSONCodec from open_webui.utils.misc import get_message_list +from open_webui.utils.tool_search import strip_deferred_tools_manifest from sqlalchemy import select from starlette.datastructures import Headers @@ -108,7 +109,7 @@ async def create_timer( 'session_id': metadata.get('session_id'), 'tool_ids': copy.deepcopy(metadata.get('tool_ids') or []), 'skill_ids': copy.deepcopy(metadata.get('skill_ids') or []), - 'system_prompt': metadata.get('system_prompt'), + 'system_prompt': strip_deferred_tools_manifest(metadata.get('system_prompt')), 'filter_ids': copy.deepcopy(metadata.get('filter_ids') or []), 'terminal_id': metadata.get('terminal_id'), 'features': copy.deepcopy(metadata.get('features') or {}), diff --git a/backend/open_webui/utils/tool_search.py b/backend/open_webui/utils/tool_search.py index ddb60bad01..84923e2f81 100644 --- a/backend/open_webui/utils/tool_search.py +++ b/backend/open_webui/utils/tool_search.py @@ -1,41 +1,39 @@ """ -Tool search / lazy tool loading. - -Large tool schemas are withheld from the provider `tools` array and listed in a -compact `` manifest instead. The model loads them on demand -through the `search_tools` builtin. Per-request state lives on `metadata`: - - metadata['tool_search'] = { - 'deferred': [tool names withheld from the provider], - 'loaded': [deferred names that have since been loaded], - 'extra_tools': [tools added by filter inlets, always sent], - } +Tool search: large tool schemas are left out of the provider `tools` array and listed by name in an +`` system prompt block. `search_tools` returns the full definitions of matching tools, +which the model then calls by name. The `tools` array never changes during a chat, so prompt caches hold. """ import fnmatch import re - -from rank_bm25 import BM25Okapi +import unicodedata from open_webui.models.config import Config from open_webui.utils.json_codec import JSONCodec +from open_webui.utils.misc import add_or_update_system_message +from rank_bm25 import BM25Okapi SEARCH_TOOL_NAME = 'search_tools' MANIFEST_DESCRIPTION_MAX_CHARS = 100 -MAX_SEARCH_LIMIT = 20 -DEFAULT_SEARCH_LIMIT = 5 +MAX_SEARCH_COUNT = 20 +DEFAULT_SEARCH_COUNT = 5 + +_MANIFEST_RE = re.compile(r'\n?\n.*?', re.DOTALL) -async def get_tool_search_config() -> dict: +async def get_tool_search_config() -> dict | None: + """Tool search settings, or None when the feature is off.""" + if not await Config.get('chat.tool_search.enable'): + return None values = await Config.get_many( - 'chat.tool_search.enable', 'chat.tool_search.defer_threshold', 'chat.tool_search.always_loaded', + 'chat.tool_search.defer_builtin_tools', ) return { - 'enable': bool(values.get('chat.tool_search.enable', False)), 'defer_threshold': _to_int(values.get('chat.tool_search.defer_threshold'), 400), 'always_loaded': values.get('chat.tool_search.always_loaded') or [], + 'defer_builtin_tools': values.get('chat.tool_search.defer_builtin_tools', True) is not False, } @@ -47,12 +45,12 @@ def _to_int(value, default: int) -> int: def select_deferred_tools(tools_dict: dict[str, dict], config: dict) -> list[str]: - """Sorted names of non-builtin tools whose schema exceeds the threshold and is not always loaded.""" + """Sorted names of tools whose schema exceeds the threshold and that are not always loaded.""" patterns = config['always_loaded'] return sorted( name for name, tool in tools_dict.items() - if tool.get('type') != 'builtin' + if (config['defer_builtin_tools'] or tool.get('type') != 'builtin') and not any(fnmatch.fnmatchcase(name, pattern) for pattern in patterns) and len(JSONCodec.dumps(tool.get('spec') or {})) > config['defer_threshold'] ) @@ -66,10 +64,6 @@ def _truncate_description(description: str | None) -> str: def build_deferred_tools_manifest(tools_dict: dict[str, dict], deferred: list[str]) -> str: - """System prompt block listing every deferred tool (mirrors ). - - Loaded tools stay listed so the block is identical across turns and provider prompt caches hold. - """ entries = '' for name in deferred: description = _truncate_description(tools_dict[name].get('spec', {}).get('description')) @@ -78,33 +72,55 @@ def build_deferred_tools_manifest(tools_dict: dict[str, dict], deferred: list[st return ( '\n' 'The following tools are available but their definitions are not loaded. ' - f'To use one, call `{SEARCH_TOOL_NAME}` with a short keyword query or the exact tool name; ' - 'matching tools become callable immediately afterwards. ' + f'To use one, call `{SEARCH_TOOL_NAME}` with a short keyword query or the exact tool name to get its ' + 'definition, then call the tool by name with the parameters it defines. ' 'Never tell the user a capability is unavailable without searching first.\n' f'{entries}' ) -def build_tools_payload(tools_dict: dict[str, dict], deferred, loaded, extra: list | None = None) -> list[dict]: - """OpenAI `tools` array: every tool that is not deferred, plus deferred tools that are loaded, plus extras.""" - deferred_set = set(deferred) - set(loaded) - tools = [ - {'type': 'function', 'function': tool.get('spec', {})} - for name, tool in tools_dict.items() - if name not in deferred_set - ] - if extra: - tools.extend(extra) - return tools +async def apply_tool_search(form_data: dict, metadata: dict, tools_dict: dict[str, dict]) -> set[str]: + """Add search_tools and the manifest when tools can be deferred; returns the names to leave out of `tools`.""" + config = SEARCH_TOOL_NAME not in tools_dict and await get_tool_search_config() + deferred = select_deferred_tools(tools_dict, config) if config else [] + if not deferred: + return set() + + from open_webui.tools.builtin import search_tools + from open_webui.utils.tools import get_builtin_tool + + metadata['deferred_tools'] = deferred + form_data['messages'] = add_or_update_system_message( + build_deferred_tools_manifest(tools_dict, deferred), + form_data['messages'], + append=True, + ) + tools_dict[SEARCH_TOOL_NAME] = await get_builtin_tool(search_tools, {'__metadata__': metadata}) + return set(deferred) -_SPLIT_RE = re.compile(r'[^0-9a-zA-Z]+') +def strip_deferred_tools_manifest(system_prompt: str | None) -> str | None: + """Remove the manifest from a system prompt that is reused by a request which builds its own.""" + return _MANIFEST_RE.sub('', system_prompt) if system_prompt else system_prompt + + +_WORD_RE = re.compile(r'[^\W_]+') _CAMEL_RE = re.compile(r'(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])') +# Scripts written without spaces between words: Han, Hiragana, Katakana and Hangul. +_CJK_RE = re.compile(r'([\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]+)') def tokenize(text: str | None) -> list[str]: - """Lowercase tokens split on non-alphanumerics and camelCase boundaries.""" - return [part.lower() for chunk in _SPLIT_RE.split(text or '') for part in _CAMEL_RE.split(chunk) if part] + """Lowercase word tokens split on camelCase boundaries, with CJK runs split into character bigrams.""" + tokens = [] + for word in _WORD_RE.findall(unicodedata.normalize('NFKC', text or '')): + for part in _CAMEL_RE.split(word): + for chunk in _CJK_RE.split(part.casefold()): + if _CJK_RE.fullmatch(chunk): + tokens.extend([chunk[i : i + 2] for i in range(len(chunk) - 1)] or [chunk]) + elif chunk: + tokens.append(chunk) + return tokens def _document_text(name: str, spec: dict) -> str: @@ -112,7 +128,7 @@ def _document_text(name: str, spec: dict) -> str: return ' '.join([name, str(spec.get('description') or ''), *properties.keys()]) -def search_deferred_tools(query: str, candidates: dict[str, dict], limit: int = DEFAULT_SEARCH_LIMIT) -> list[str]: +def search_deferred_tools(query: str, candidates: dict[str, dict], count: int = DEFAULT_SEARCH_COUNT) -> list[str]: """Rank candidate tools (name -> spec) against the query; an exact tool name wins outright.""" query = (query or '').strip() if query in candidates: @@ -133,30 +149,4 @@ def search_deferred_tools(query: str, candidates: dict[str, dict], limit: int = } ranked = sorted((name for name in names if scores[name] > 0), key=lambda name: (-scores[name], name)) - return ranked[: max(1, min(_to_int(limit, DEFAULT_SEARCH_LIMIT), MAX_SEARCH_LIMIT))] - - -def collect_called_tool_names(messages: list[dict]) -> set[str]: - """Tools the conversation already called, so they stay loaded on later turns.""" - return { - (tool_call.get('function') or {}).get('name') - for message in messages - if message.get('role') == 'assistant' - for tool_call in message.get('tool_calls') or [] - } - - -def mark_tools_loaded(metadata: dict, names: list[str]) -> None: - """Record deferred tools as loaded on metadata['tool_search']; no-op when tool search is inactive.""" - state = metadata.get('tool_search') - if state: - state['loaded'] = sorted(set(state['loaded']) | (set(names) & set(state['deferred']))) - - -def rebuild_tools_payload(form_data: dict, metadata: dict) -> None: - """Refresh form_data['tools'] from metadata when tool search is active; no-op otherwise.""" - state = metadata.get('tool_search') - if state: - form_data['tools'] = build_tools_payload( - metadata['tools'], state['deferred'], state['loaded'], state['extra_tools'] - ) + return ranked[: max(1, min(_to_int(count, DEFAULT_SEARCH_COUNT), MAX_SEARCH_COUNT))] diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 69061101fe..ee34132db8 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -84,7 +84,6 @@ from open_webui.tools.builtin import ( search_knowledge_files, search_memories, search_notes, - search_tools, search_web, timer, toggle_automation, @@ -737,10 +736,6 @@ async def get_builtin_tools( if extra_params.get('__skill_ids__'): builtin_functions.append(view_skill) - # Tool search - lets the model load deferred (large-schema) tools on demand - if extra_params.get('__tool_search__'): - builtin_functions.append(search_tools) - # Task management - break down complex work into trackable steps # Task state is stored on the chats row; local/channel IDs do not have one. if is_builtin_tool_enabled('tasks') and is_saved_chat_id(metadata.get('chat_id')): @@ -775,7 +770,7 @@ async def get_builtin_tools( builtin_functions = [func for func in builtin_functions if func.__name__ not in MUTATING_MEMORY_TOOLS] for func in builtin_functions: - callable = await get_async_tool_function_and_apply_extra_params( + tools_dict[func.__name__] = await get_builtin_tool( func, { '__request__': request, @@ -788,26 +783,28 @@ async def get_builtin_tools( '__message_id__': extra_params.get('__message_id__'), '__model_knowledge__': model_knowledge, }, - get_builtin_function_introspection(func), ) - spec = get_builtin_tool_spec(func) if func.__name__ == 'delegate_task' and not config.get('subagents.background_enabled'): - parameters = spec.get('parameters', {}) + parameters = tools_dict[func.__name__]['spec'].get('parameters', {}) parameters.get('properties', {}).pop('background', None) if isinstance(parameters.get('required'), list): parameters['required'] = [name for name in parameters['required'] if name != 'background'] - tools_dict[func.__name__] = { - 'tool_id': f'builtin:{func.__name__}', - 'callable': callable, - 'spec': spec, - 'type': 'builtin', - } - return tools_dict +async def get_builtin_tool(func, extra_params: dict) -> dict: + return { + 'tool_id': f'builtin:{func.__name__}', + 'callable': await get_async_tool_function_and_apply_extra_params( + func, extra_params, get_builtin_function_introspection(func) + ), + 'spec': get_builtin_tool_spec(func), + 'type': 'builtin', + } + + def parse_description(docstring: str | None) -> str: """ Parse a function's docstring to extract the description. diff --git a/src/lib/components/admin/Settings/Interface.svelte b/src/lib/components/admin/Settings/Interface.svelte index 3666a45b86..7a2d02b9f9 100644 --- a/src/lib/components/admin/Settings/Interface.svelte +++ b/src/lib/components/admin/Settings/Interface.svelte @@ -53,8 +53,10 @@ ENABLE_TOOL_PERMISSIONS: false, ENABLE_TOOL_SEARCH: false, TOOL_SEARCH_DEFER_THRESHOLD: 400, - TOOL_SEARCH_ALWAYS_LOADED: '' + TOOL_SEARCH_ALWAYS_LOADED: [] as string[], + TOOL_SEARCH_DEFER_BUILTIN_TOOLS: true }; + let toolSearchAlwaysLoaded = ''; let showTaskParameters = false; const configuredParams = (params: Record = {}) => @@ -74,12 +76,13 @@ updateTaskConfig(localStorage.token, taskConfigPayload), updateChatConfig(localStorage.token, { ...chatConfig, - TOOL_SEARCH_ALWAYS_LOADED: chatConfig.TOOL_SEARCH_ALWAYS_LOADED.split(',') + TOOL_SEARCH_ALWAYS_LOADED: toolSearchAlwaysLoaded + .split(',') .map((item) => item.trim()) .filter((item) => item !== '') }) ]); - chatConfig.TOOL_SEARCH_ALWAYS_LOADED = (chatConfig.TOOL_SEARCH_ALWAYS_LOADED ?? []).join(', '); + toolSearchAlwaysLoaded = (chatConfig.TOOL_SEARCH_ALWAYS_LOADED ?? []).join(', '); appConfig.update((current) => current ? { @@ -132,9 +135,7 @@ getChatConfig(localStorage.token) ]); taskConfig.TASK_MODEL_PARAMS = taskConfig.TASK_MODEL_PARAMS ?? {}; - chatConfig.TOOL_SEARCH_ALWAYS_LOADED = (chatConfig.TOOL_SEARCH_ALWAYS_LOADED ?? []).join( - ', ' - ); + toolSearchAlwaysLoaded = (chatConfig.TOOL_SEARCH_ALWAYS_LOADED ?? []).join(', '); workspaceModels = await getBaseModels(localStorage.token); baseModels = await getModels(localStorage.token, null, false); @@ -388,6 +389,17 @@ {#if chatConfig.ENABLE_TOOL_SEARCH} + + + + diff --git a/src/lib/i18n/locales/en-US/translation.json b/src/lib/i18n/locales/en-US/translation.json index 0c60817d1b..88dfb09fb6 100644 --- a/src/lib/i18n/locales/en-US/translation.json +++ b/src/lib/i18n/locales/en-US/translation.json @@ -2534,11 +2534,13 @@ "settings.admin.interface.tokenThreshold.label": "Token Threshold", "settings.admin.interface.toolPermissions.description": "Show Full access and Ask for approval in the chat input menu.", "settings.admin.interface.toolPermissions.label": "Tool Permissions", - "settings.admin.interface.toolSearch.description": "Withhold large tool definitions from the request and let the model load them on demand. Reduces prompt size when many tools are enabled.", + "settings.admin.interface.toolSearch.description": "Leave large tool definitions out of the request and let the model look them up on demand. Reduces prompt size when many tools are enabled.", "settings.admin.interface.toolSearch.label": "Tool Search", "settings.admin.interface.toolSearchAlwaysLoaded.description": "Comma-separated tool names or glob patterns that are never deferred.", "settings.admin.interface.toolSearchAlwaysLoaded.label": "Always Loaded Tools", "settings.admin.interface.toolSearchAlwaysLoaded.placeholder": "e.g. jira_create_issue, github_*", + "settings.admin.interface.toolSearchDeferBuiltinTools.description": "Defer built-in tools like other tools. When off, built-in tools are always sent in full.", + "settings.admin.interface.toolSearchDeferBuiltinTools.label": "Defer Built-in Tools", "settings.admin.interface.toolSearchThreshold.description": "Tools whose definition is longer than this many characters are deferred.", "settings.admin.interface.toolSearchThreshold.label": "Deferral Threshold (characters)", "settings.admin.interface.toolsFunctionCallingPrompt.description": "Guides how the assistant formats tool and function calls.", From d79280cf48749323f13795b306c5962d0a765784 Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Wed, 30 Sep 2026 14:46:14 -0400 Subject: [PATCH 3/3] fix: tool search follow-up manifest, padded always-loaded patterns, Hindi tokens - Strip the block when a pending sub-agent or timer report resumes the parent chat, so the follow-up request carries it once. - Trim always-loaded patterns saved through the chat config API, matching the settings page and the env var. - Tokenize on letters, digits and combining marks so Hindi words keep their vowel signs instead of splitting into single consonants. - Build the search_tools entry directly and leave the builtin tools loop untouched; drop the unreachable "not active" branch, config fallbacks and ranking try/except; import rank_bm25 where it is used. --- backend/open_webui/routers/chats.py | 2 ++ backend/open_webui/tools/builtin.py | 24 ++++++---------- backend/open_webui/utils/subagents.py | 2 +- backend/open_webui/utils/tool_search.py | 37 +++++++++++++++---------- backend/open_webui/utils/tools.py | 24 ++++++++-------- 5 files changed, 44 insertions(+), 45 deletions(-) diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 7a5a97cd4e..0f44e6abb7 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -854,6 +854,7 @@ async def set_chat_config(form_data: ChatConfigForm, user=Depends(get_admin_user token_cap = max(1, int(form_data.CONTEXT_COMPACTION_TOKEN_CAP or threshold)) retention_percentage = min(50, max(10, int(form_data.CONTEXT_COMPACTION_RETENTION_PERCENTAGE))) tool_search_defer_threshold = max(0, int(form_data.TOOL_SEARCH_DEFER_THRESHOLD)) + tool_search_always_loaded = [item.strip() for item in form_data.TOOL_SEARCH_ALWAYS_LOADED if item.strip()] await Config.upsert( chat_config_updates( { @@ -863,6 +864,7 @@ async def set_chat_config(form_data: ChatConfigForm, user=Depends(get_admin_user 'CONTEXT_COMPACTION_TOKEN_CAP': token_cap, 'CONTEXT_COMPACTION_RETENTION_PERCENTAGE': retention_percentage, 'TOOL_SEARCH_DEFER_THRESHOLD': tool_search_defer_threshold, + 'TOOL_SEARCH_ALWAYS_LOADED': tool_search_always_loaded, } ) ) diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 8f63bf722f..d9096bb50f 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -3568,22 +3568,14 @@ async def search_tools( """ from open_webui.utils.tool_search import search_deferred_tools - metadata = __metadata__ or {} - tools = metadata.get('tools') or {} - deferred = [name for name in metadata.get('deferred_tools') or [] if name in tools] - if not deferred: - return JSONCodec.dumps({'error': 'Tool search is not active for this request'}) - - try: - matches = search_deferred_tools(query, {name: tools[name]['spec'] for name in deferred}, count) - if not matches: - return JSONCodec.dumps( - {'tools': [], 'message': 'No matching tools found. Try different keywords or the exact tool name.'} - ) - return JSONCodec.dumps({'tools': [tools[name]['spec'] for name in matches]}) - except Exception as e: - log.exception(f'search_tools error: {e}') - return JSONCodec.dumps({'error': str(e)}) + tools = __metadata__['tools'] + candidates = {name: tools[name]['spec'] for name in __metadata__['deferred_tools']} + matches = search_deferred_tools(query, candidates, count) + if not matches: + return JSONCodec.dumps( + {'tools': [], 'message': 'No matching tools found. Try different keywords or the exact tool name.'} + ) + return JSONCodec.dumps({'tools': [candidates[name] for name in matches]}) # ============================================================================= diff --git a/backend/open_webui/utils/subagents.py b/backend/open_webui/utils/subagents.py index cd96af1adf..15b977980a 100644 --- a/backend/open_webui/utils/subagents.py +++ b/backend/open_webui/utils/subagents.py @@ -187,7 +187,7 @@ async def process_pending_internal_messages( assistant_message_id = str(uuid4()) message_list = get_message_list(messages, parent_id) - system_prompt = run.get('system_prompt') + system_prompt = strip_deferred_tools_manifest(run.get('system_prompt')) user_message = { 'id': user_message_id, 'parentId': parent_id, diff --git a/backend/open_webui/utils/tool_search.py b/backend/open_webui/utils/tool_search.py index 84923e2f81..8bff23e62c 100644 --- a/backend/open_webui/utils/tool_search.py +++ b/backend/open_webui/utils/tool_search.py @@ -8,10 +8,10 @@ import fnmatch import re import unicodedata +import regex from open_webui.models.config import Config from open_webui.utils.json_codec import JSONCodec from open_webui.utils.misc import add_or_update_system_message -from rank_bm25 import BM25Okapi SEARCH_TOOL_NAME = 'search_tools' MANIFEST_DESCRIPTION_MAX_CHARS = 100 @@ -31,19 +31,12 @@ async def get_tool_search_config() -> dict | None: 'chat.tool_search.defer_builtin_tools', ) return { - 'defer_threshold': _to_int(values.get('chat.tool_search.defer_threshold'), 400), - 'always_loaded': values.get('chat.tool_search.always_loaded') or [], - 'defer_builtin_tools': values.get('chat.tool_search.defer_builtin_tools', True) is not False, + 'defer_threshold': values['chat.tool_search.defer_threshold'], + 'always_loaded': values['chat.tool_search.always_loaded'], + 'defer_builtin_tools': values['chat.tool_search.defer_builtin_tools'], } -def _to_int(value, default: int) -> int: - try: - return int(value) - except (TypeError, ValueError): - return default - - def select_deferred_tools(tools_dict: dict[str, dict], config: dict) -> list[str]: """Sorted names of tools whose schema exceeds the threshold and that are not always loaded.""" patterns = config['always_loaded'] @@ -87,7 +80,11 @@ async def apply_tool_search(form_data: dict, metadata: dict, tools_dict: dict[st return set() from open_webui.tools.builtin import search_tools - from open_webui.utils.tools import get_builtin_tool + from open_webui.utils.tools import ( + get_async_tool_function_and_apply_extra_params, + get_builtin_function_introspection, + get_builtin_tool_spec, + ) metadata['deferred_tools'] = deferred form_data['messages'] = add_or_update_system_message( @@ -95,7 +92,14 @@ async def apply_tool_search(form_data: dict, metadata: dict, tools_dict: dict[st form_data['messages'], append=True, ) - tools_dict[SEARCH_TOOL_NAME] = await get_builtin_tool(search_tools, {'__metadata__': metadata}) + tools_dict[SEARCH_TOOL_NAME] = { + 'tool_id': f'builtin:{SEARCH_TOOL_NAME}', + 'callable': await get_async_tool_function_and_apply_extra_params( + search_tools, {'__metadata__': metadata}, get_builtin_function_introspection(search_tools) + ), + 'spec': get_builtin_tool_spec(search_tools), + 'type': 'builtin', + } return set(deferred) @@ -104,7 +108,8 @@ def strip_deferred_tools_manifest(system_prompt: str | None) -> str | None: return _MANIFEST_RE.sub('', system_prompt) if system_prompt else system_prompt -_WORD_RE = re.compile(r'[^\W_]+') +# Letters, digits and combining marks, so vowel signs in scripts like Devanagari stay attached to their word. +_WORD_RE = regex.compile(r'[\p{L}\p{N}\p{M}]+') _CAMEL_RE = re.compile(r'(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])') # Scripts written without spaces between words: Han, Hiragana, Katakana and Hangul. _CJK_RE = re.compile(r'([\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]+)') @@ -130,6 +135,8 @@ def _document_text(name: str, spec: dict) -> str: def search_deferred_tools(query: str, candidates: dict[str, dict], count: int = DEFAULT_SEARCH_COUNT) -> list[str]: """Rank candidate tools (name -> spec) against the query; an exact tool name wins outright.""" + from rank_bm25 import BM25Okapi + query = (query or '').strip() if query in candidates: return [query] @@ -149,4 +156,4 @@ def search_deferred_tools(query: str, candidates: dict[str, dict], count: int = } ranked = sorted((name for name in names if scores[name] > 0), key=lambda name: (-scores[name], name)) - return ranked[: max(1, min(_to_int(count, DEFAULT_SEARCH_COUNT), MAX_SEARCH_COUNT))] + return ranked[: max(1, min(count, MAX_SEARCH_COUNT))] diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index ee34132db8..120bbb39b4 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -770,7 +770,7 @@ async def get_builtin_tools( builtin_functions = [func for func in builtin_functions if func.__name__ not in MUTATING_MEMORY_TOOLS] for func in builtin_functions: - tools_dict[func.__name__] = await get_builtin_tool( + callable = await get_async_tool_function_and_apply_extra_params( func, { '__request__': request, @@ -783,28 +783,26 @@ async def get_builtin_tools( '__message_id__': extra_params.get('__message_id__'), '__model_knowledge__': model_knowledge, }, + get_builtin_function_introspection(func), ) + spec = get_builtin_tool_spec(func) if func.__name__ == 'delegate_task' and not config.get('subagents.background_enabled'): - parameters = tools_dict[func.__name__]['spec'].get('parameters', {}) + parameters = spec.get('parameters', {}) parameters.get('properties', {}).pop('background', None) if isinstance(parameters.get('required'), list): parameters['required'] = [name for name in parameters['required'] if name != 'background'] + tools_dict[func.__name__] = { + 'tool_id': f'builtin:{func.__name__}', + 'callable': callable, + 'spec': spec, + 'type': 'builtin', + } + return tools_dict -async def get_builtin_tool(func, extra_params: dict) -> dict: - return { - 'tool_id': f'builtin:{func.__name__}', - 'callable': await get_async_tool_function_and_apply_extra_params( - func, extra_params, get_builtin_function_introspection(func) - ), - 'spec': get_builtin_tool_spec(func), - 'type': 'builtin', - } - - def parse_description(docstring: str | None) -> str: """ Parse a function's docstring to extract the description.