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'{tc_headers[j]}\nCancelled\n' + _session_key = state.get('unique_id', '') + def _cancel_remaining(from_idx): + for j in range(from_idx, 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'{tc_headers[j]}\nCancelled\n' - history['visible'][-1][1] = '\n\n'.join(visible_prefix + pending_placeholders) + history['visible'][-1][1] = '\n\n'.join(visible_prefix + pending_placeholders) + + for i, tc in enumerate(parsed_calls): + if shared.stop_everything: + _cancel_remaining(i) yield _render(), history break fn_name = tc['function']['name'] fn_args = tc['function'].get('arguments', {}) + + _approved = _tool_approvals[_session_key]["approved"] if _session_key in _tool_approvals else set() + if state.get('confirm_tool_calls', False) and fn_name not in _approved: + pending_placeholders[i] = f'{tc_headers[i]}\n{TOOL_APPROVAL_PENDING}\n' + history['visible'][-1][1] = '\n\n'.join(visible_prefix + pending_placeholders) + yield _render(), history + + approval = request_tool_approval(_session_key, fn_name) + + if approval == 'reject' and shared.stop_everything: + _cancel_remaining(i) + yield _render(), history + break + + if approval == 'reject': + seq.append({'role': 'tool', 'content': 'Tool call was rejected by the user.', 'tool_call_id': tc['id']}) + pending_placeholders[i] = f'{tc_headers[i]}\nRejected\n' + history['visible'][-1][1] = '\n\n'.join(visible_prefix + pending_placeholders) + yield _render(), history + continue + result = execute_tool(fn_name, fn_args, tool_executors) seq.append({'role': 'tool', 'content': result, 'tool_call_id': tc['id']}) diff --git a/modules/html_generator.py b/modules/html_generator.py index 44e80e99..e060604d 100644 --- a/modules/html_generator.py +++ b/modules/html_generator.py @@ -122,6 +122,9 @@ def extract_thinking_block(string): +TOOL_APPROVAL_PENDING = '\x00approval_pending' + + def build_tool_call_block(header, body, message_id, index): """Build HTML for a tool call accordion block.""" block_id = f"tool-call-{message_id}-{index}" @@ -137,6 +140,21 @@ def build_tool_call_block(header, body, message_id, index): ''' + if body == TOOL_APPROVAL_PENDING: + return f''' +
+ + {tool_svg_small} + {html.escape(header)} + +
+ + + +
+
+ ''' + # Build a plain
 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(