Add tool call confirmation with inline approve/reject buttons

This commit is contained in:
oobabooga
2026-04-19 13:19:21 -07:00
parent 1e1d2d3728
commit a253d38953
7 changed files with 134 additions and 19 deletions

View File

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

View File

@@ -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_call>{tc_headers[j]}\nCancelled\n</tool_call>'
_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'<tool_call>{tc_headers[j]}\nCancelled\n</tool_call>'
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'<tool_call>{tc_headers[i]}\n{TOOL_APPROVAL_PENDING}\n</tool_call>'
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'<tool_call>{tc_headers[i]}\nRejected\n</tool_call>'
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']})

View File

@@ -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):
</details>
'''
if body == TOOL_APPROVAL_PENDING:
return f'''
<details class="thinking-block" open data-block-id="{block_id}">
<summary class="thinking-header">
{tool_svg_small}
<span class="thinking-title">{html.escape(header)}</span>
</summary>
<div class="thinking-content tool-approval-buttons">
<button class="tool-approval-btn" onclick="document.getElementById('tool-approve-btn').click()">Approve</button>
<button class="tool-approval-btn" onclick="document.getElementById('tool-always-approve-btn').click()">Always approve</button>
<button class="tool-approval-btn" onclick="document.getElementById('tool-reject-btn').click()">Reject</button>
</div>
</details>
'''
# Build a plain <pre> directly to avoid highlight.js auto-detection
escaped_body = html.escape(body)
return f'''

View File

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

View File

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

View File

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

View File

@@ -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("<div class='sidebar-vertical-separator'></div>")
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(