diff --git a/css/main.css b/css/main.css
index 652d1cdb..7e5e4f29 100644
--- a/css/main.css
+++ b/css/main.css
@@ -1580,6 +1580,28 @@ audio {
100% { opacity: 0.6; }
}
+.tool-approval-buttons {
+ display: flex;
+ gap: 8px;
+ max-height: none;
+ overflow-y: visible;
+}
+
+.tool-approval-btn {
+ padding: 6px 12px;
+ border: 1px solid var(--border-color-primary);
+ border-radius: 0.75rem;
+ background: var(--button-secondary-background-fill);
+ color: var(--button-secondary-text-color);
+ cursor: pointer;
+ font-size: 12px;
+ margin-bottom: 0 !important;
+}
+
+.tool-approval-btn:hover {
+ background: var(--button-secondary-background-fill-hover);
+}
+
strong {
font-weight: bold;
}
@@ -1678,18 +1700,12 @@ strong {
border: 1px solid var(--border-color-primary);
border-radius: 0.75rem;
cursor: pointer;
- background-color: #f8f9fa;
- color: #212529;
+ background: var(--button-secondary-background-fill);
+ color: var(--button-secondary-text-color);
font-size: 12px;
margin: 0;
}
-.dark .edit-control-button {
- border: 1px solid var(--border-color-dark);
- background-color: var(--light-gray);
- color: #efefef;
-}
-
/* --- Simple Version Navigation --- */
.version-navigation {
position: absolute;
diff --git a/modules/chat.py b/modules/chat.py
index a4e4100e..2957b17d 100644
--- a/modules/chat.py
+++ b/modules/chat.py
@@ -22,6 +22,7 @@ import modules.shared as shared
from modules import utils
from modules.extensions import apply_extensions
from modules.html_generator import (
+ TOOL_APPROVAL_PENDING,
chat_html_wrapper,
convert_to_markdown,
extract_thinking_block,
@@ -46,6 +47,42 @@ from modules.web_search import add_web_search_attachments
_history_file_lock = threading.Lock()
+_tool_approvals = {}
+_tool_approvals_lock = threading.Lock()
+
+
+def request_tool_approval(session_key, tool_name):
+ """Block until the user approves/rejects a tool call. Returns 'approve'|'always'|'reject'."""
+ with _tool_approvals_lock:
+ if session_key not in _tool_approvals:
+ _tool_approvals[session_key] = {
+ "event": threading.Event(),
+ "result": None,
+ "tool_name": None,
+ "approved": set(),
+ }
+ session = _tool_approvals[session_key]
+ session["event"].clear()
+ session["result"] = None
+ session["tool_name"] = tool_name
+ while not session["event"].wait(timeout=0.5):
+ if shared.stop_everything:
+ session["tool_name"] = None
+ return 'reject'
+ session["tool_name"] = None
+ return session["result"]
+
+
+def resolve_tool_approval(session_key, result):
+ """Called by button handlers to resolve a pending approval."""
+ session = _tool_approvals.get(session_key)
+ if not session:
+ return
+ if result == 'always' and session["tool_name"]:
+ session["approved"].add(session["tool_name"])
+ session["result"] = result
+ session["event"].set()
+
def strftime_now(format):
return datetime.now().strftime(format)
@@ -1470,19 +1507,43 @@ def generate_chat_reply_wrapper(text, state, regenerate=False, _continue=False):
yield _render(), history
# Execute tools, store results, and replace placeholders with real results
- for i, tc in enumerate(parsed_calls):
- # Check for stop request before each tool execution
- if shared.stop_everything:
- for j in range(i, len(parsed_calls)):
- seq.append({'role': 'tool', 'content': 'Tool execution was cancelled by the user.', 'tool_call_id': parsed_calls[j]['id']})
- pending_placeholders[j] = f'
+ {tool_svg_small}
+ {html.escape(header)}
+
+
+
directly to avoid highlight.js auto-detection
escaped_body = html.escape(body)
return f'''
diff --git a/modules/shared.py b/modules/shared.py
index 8d79eb07..4217f612 100644
--- a/modules/shared.py
+++ b/modules/shared.py
@@ -260,6 +260,7 @@ settings = {
'web_search_pages': 3,
'selected_tools': [],
'mcp_servers': '',
+ 'confirm_tool_calls': False,
'prompt-notebook': '',
'preset': 'Top-P' if (user_data_dir / 'presets/Top-P.yaml').exists() else None,
'max_new_tokens': 512,
diff --git a/modules/tool_use.py b/modules/tool_use.py
index 9e883f89..05690e69 100644
--- a/modules/tool_use.py
+++ b/modules/tool_use.py
@@ -220,10 +220,13 @@ def load_mcp_tools(servers_str):
uncached = [s for s in servers if _mcp_server_id(s) not in _mcp_server_cache]
if uncached:
- results = asyncio.run(asyncio.gather(
- *(_connect_mcp_server(s) for s in uncached),
- return_exceptions=True
- ))
+ async def _discover_uncached():
+ return await asyncio.gather(
+ *(_connect_mcp_server(s) for s in uncached),
+ return_exceptions=True
+ )
+
+ results = asyncio.run(_discover_uncached())
for server, result in zip(uncached, results):
sid = _mcp_server_id(server)
if isinstance(result, Exception):
diff --git a/modules/ui.py b/modules/ui.py
index ddfe164b..7b81f0b4 100644
--- a/modules/ui.py
+++ b/modules/ui.py
@@ -210,6 +210,7 @@ def list_interface_input_elements():
'start_with',
'selected_tools',
'mcp_servers',
+ 'confirm_tool_calls',
'mode',
'chat_style',
'chat-instruct_command',
@@ -436,6 +437,7 @@ def setup_auto_save():
'chat_template_str',
'selected_tools',
'mcp_servers',
+ 'confirm_tool_calls',
# Parameters tab (ui_parameters.py) - Generation parameters
'preset_menu',
diff --git a/modules/ui_chat.py b/modules/ui_chat.py
index f0a2c6d6..a0d52379 100644
--- a/modules/ui_chat.py
+++ b/modules/ui_chat.py
@@ -63,6 +63,11 @@ def create_ui():
shared.gradio['Stop'] = gr.Button('Stop', elem_id='stop', visible=False)
shared.gradio['Generate'] = gr.Button('Send', elem_id='Generate', variant='primary')
+ # Hidden buttons for tool approval (triggered via JS from inline HTML buttons)
+ shared.gradio['tool_approve'] = gr.Button(visible=False, elem_id='tool-approve-btn')
+ shared.gradio['tool_always_approve'] = gr.Button(visible=False, elem_id='tool-always-approve-btn')
+ shared.gradio['tool_reject'] = gr.Button(visible=False, elem_id='tool-reject-btn')
+
# Hover menu buttons
with gr.Column(elem_id='chat-buttons'):
shared.gradio['Regenerate'] = gr.Button('Regenerate (Ctrl + Enter)', elem_id='Regenerate')
@@ -110,6 +115,8 @@ def create_ui():
with gr.Accordion('MCP servers', open=False):
shared.gradio['mcp_servers'] = gr.Textbox(value=shared.settings.get('mcp_servers', ''), lines=3, max_lines=3, label='', info='One URL per line for HTTP servers. For headers: url,Header: value. For stdio servers, use user_data/mcp.json.', elem_classes=['add_scrollbar'])
+ shared.gradio['confirm_tool_calls'] = gr.Checkbox(value=shared.settings.get('confirm_tool_calls', False), label='Confirm tool calls', info='Ask for approval before executing each tool call.')
+
gr.HTML("")
with gr.Row():
@@ -287,6 +294,13 @@ def create_event_handlers():
stop_everything_event, None, None, queue=False).then(
chat.redraw_html, gradio(reload_arr), gradio('display'), show_progress=False)
+ shared.gradio['tool_approve'].click(
+ lambda uid: chat.resolve_tool_approval(uid or '', 'approve'), gradio('unique_id'), None, queue=False)
+ shared.gradio['tool_always_approve'].click(
+ lambda uid: chat.resolve_tool_approval(uid or '', 'always'), gradio('unique_id'), None, queue=False)
+ shared.gradio['tool_reject'].click(
+ lambda uid: chat.resolve_tool_approval(uid or '', 'reject'), gradio('unique_id'), None, queue=False)
+
if not shared.args.multi_user:
shared.gradio['unique_id'].select(
ui.gather_interface_values, gradio(shared.input_elements), gradio('interface_state')).then(