address all pr feedback

This commit is contained in:
Taylor Wilsdon 2026-09-30 14:01:17 -04:00
parent 369e008a7f
commit 28d34c39c5
10 changed files with 118 additions and 154 deletions

View file

@ -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,

View file

@ -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):

View file

@ -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 <available_tools> and load the matches so you can call them in your next step.
Search the tools listed in <available_tools> 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)})

View file

@ -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'])

View file

@ -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'),

View file

@ -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 {}),

View file

@ -1,41 +1,39 @@
"""
Tool search / lazy tool loading.
Large tool schemas are withheld from the provider `tools` array and listed in a
compact `<available_tools>` 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
`<available_tools>` 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?<available_tools>\n.*?</available_tools>', 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 <available_skills>).
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 (
'<available_tools>\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}</available_tools>'
)
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))]

View file

@ -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.

View file

@ -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<string, any> = {}) =>
@ -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 @@
</AdminSettingRow>
{#if chatConfig.ENABLE_TOOL_SEARCH}
<AdminSettingRow
label={$i18n.t('settings.admin.interface.toolSearchDeferBuiltinTools.label')}
description={$i18n.t('settings.admin.interface.toolSearchDeferBuiltinTools.description')}
let:labelId
>
<Switch
bind:state={chatConfig.TOOL_SEARCH_DEFER_BUILTIN_TOOLS}
ariaLabelledbyId={labelId}
/>
</AdminSettingRow>
<AdminSettingField
label={$i18n.t('settings.admin.interface.toolSearchThreshold.label')}
description={$i18n.t('settings.admin.interface.toolSearchThreshold.description')}
@ -409,7 +421,7 @@
class={inputClass}
type="text"
placeholder={$i18n.t('settings.admin.interface.toolSearchAlwaysLoaded.placeholder')}
bind:value={chatConfig.TOOL_SEARCH_ALWAYS_LOADED}
bind:value={toolSearchAlwaysLoaded}
autocomplete="off"
/>
</AdminSettingField>

View file

@ -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.",