Add tool confirmation UI with Accept/Decline for write operations

Before executing any write tool (create/update/delete), the backend now
pauses with an asyncio.Future and emits a tool_pending SSE event. The
frontend displays a ToolConfirmCard with Accept and Decline buttons.
Clicking Accept resolves the Future and proceeds; Decline records a
declined tool_call chip and falls through to regular streaming. Typing
single-word yes/no responses (e.g. "yes", "cancel") also works as
confirmation. 120s timeout auto-declines if no response.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 20:43:47 -05:00
parent 1b63371bb3
commit 815eed2574
8 changed files with 304 additions and 43 deletions
+22
View File
@@ -187,6 +187,28 @@ async def generation_stream_route(conv_id: int):
)
@chat_bp.route("/conversations/<int:conv_id>/generation/confirm", methods=["POST"])
@login_required
async def confirm_generation_route(conv_id: int):
"""Resolve a pending tool confirmation (accept or decline)."""
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
buf = get_buffer(conv_id)
if buf is None or buf.state != GenerationState.RUNNING:
return jsonify({"error": "No active generation"}), 404
if buf.confirmation_future is None or buf.confirmation_future.done():
return jsonify({"error": "No pending tool confirmation"}), 409
data = await request.get_json(force=True, silent=True) or {}
decision = bool(data.get("confirmed", False))
buf.confirmation_future.set_result(decision)
return jsonify({"status": "ok", "confirmed": decision})
@chat_bp.route("/conversations/<int:conv_id>/generation/cancel", methods=["POST"])
@login_required
async def cancel_generation_route(conv_id: int):
@@ -37,6 +37,9 @@ class GenerationBuffer:
finished_at: float | None = None
cancel_event: asyncio.Event = field(default_factory=asyncio.Event)
_notify: asyncio.Event = field(default_factory=asyncio.Event)
# Tool confirmation — set when a write tool is awaiting user approval
confirmation_future: asyncio.Future | None = None
pending_tool: dict | None = None
def append_event(self, event_type: str, data: dict) -> SSEEvent:
event = SSEEvent(index=len(self.events), event_type=event_type, data=data)
+104 -40
View File
@@ -50,6 +50,13 @@ _TOOL_LABELS: dict[str, str] = {
"delete_todo": "Removing todo",
}
# Tools that write data and require explicit user confirmation before executing.
_WRITE_TOOLS: frozenset[str] = frozenset({
"create_task", "create_note", "update_note",
"create_event", "update_event", "delete_event",
"create_todo", "update_todo", "complete_todo", "delete_todo",
})
# Action phrases used in the acknowledgment prompt — "You are about to: {action}"
_TOOL_ACTIONS: dict[str, str] = {
"create_task": "create a task",
@@ -238,50 +245,107 @@ async def run_generation(
intent.confidence, intent.tool_name,
)
if intent.should_execute:
buf.append_event("status", {"status": f"{_TOOL_LABELS.get(intent.tool_name, 'Working')}..."})
tool_name = intent.tool_name
confirmed = True # Non-write tools auto-confirm
# Run tool execution and acknowledgment generation in parallel.
# The acknowledgment uses the fast intent model (already in VRAM),
# so the user sees text within ~200-400ms instead of waiting for
# the full main-model TTFT (~22s).
t_tool = time.monotonic()
result, ack_text = await asyncio.gather(
execute_tool(user_id, intent.tool_name, intent.arguments),
_generate_acknowledgment(user_content, intent.tool_name, intent_model),
)
timing["tools"].append({"name": intent.tool_name, "ms": int((time.monotonic() - t_tool) * 1000)})
logger.info("Intent-routed tool %s result: success=%s", intent.tool_name, result.get("success"))
if tool_name in _WRITE_TOOLS:
# Pause and ask the user to accept or decline before executing.
loop = asyncio.get_running_loop()
confirm_future: asyncio.Future = loop.create_future()
buf.confirmation_future = confirm_future
buf.pending_tool = {
"function": tool_name,
"arguments": intent.arguments,
"label": _TOOL_LABELS.get(tool_name, "Action"),
}
buf.append_event("status", {"status": "Waiting for confirmation..."})
buf.append_event("tool_pending", {"tool_pending": buf.pending_tool})
# Stream acknowledgment immediately — user sees text before main LLM starts
if ack_text:
buf.append_event("chunk", {"chunk": ack_text})
buf.content_so_far += ack_text
if timing["ttft_ms"] is None:
timing["ttft_ms"] = int((time.monotonic() - t_start) * 1000)
cancel_task = asyncio.create_task(buf.cancel_event.wait())
confirmed = False
try:
done_set, _ = await asyncio.wait(
{confirm_future, cancel_task},
timeout=120.0,
return_when=asyncio.FIRST_COMPLETED,
)
if cancel_task in done_set:
cancelled = True
elif confirm_future in done_set:
try:
confirmed = bool(confirm_future.result())
except Exception:
confirmed = False
# else: timeout → confirmed stays False
except Exception:
confirmed = False
finally:
cancel_task.cancel()
buf.confirmation_future = None
buf.pending_tool = None
tool_record = {
"function": intent.tool_name,
"arguments": intent.arguments,
"result": result,
"status": "success" if result.get("success") else "error",
}
all_tool_calls.append(tool_record)
buf.append_event("tool_call", {"tool_call": tool_record})
if not confirmed:
if not cancelled:
# Record the declined action so the UI can show it
declined_record = {
"function": tool_name,
"arguments": intent.arguments,
"result": {"success": False, "error": "Declined"},
"status": "declined",
}
all_tool_calls.append(declined_record)
buf.append_event("tool_call", {"tool_call": declined_record})
# Fall through to streaming without tool context
# Include ack as the assistant's partial response so round 1
# continues coherently from where the acknowledgment left off
messages.append({
"role": "assistant",
"content": ack_text,
"tool_calls": [
{"function": {"name": intent.tool_name, "arguments": intent.arguments}}
],
})
messages.append({
"role": "tool",
"content": json.dumps(result),
})
continue # Next round: LLM streams response incorporating result
if confirmed:
buf.append_event("status", {"status": f"{_TOOL_LABELS.get(tool_name, 'Working')}..."})
# Run tool execution and acknowledgment generation in parallel.
# The acknowledgment uses the fast intent model (already in VRAM),
# so the user sees text within ~200-400ms instead of waiting for
# the full main-model TTFT (~22s).
t_tool = time.monotonic()
result, ack_text = await asyncio.gather(
execute_tool(user_id, tool_name, intent.arguments),
_generate_acknowledgment(user_content, tool_name, intent_model),
)
timing["tools"].append({"name": tool_name, "ms": int((time.monotonic() - t_tool) * 1000)})
logger.info("Intent-routed tool %s result: success=%s", tool_name, result.get("success"))
# Stream acknowledgment immediately — user sees text before main LLM starts
if ack_text:
buf.append_event("chunk", {"chunk": ack_text})
buf.content_so_far += ack_text
if timing["ttft_ms"] is None:
timing["ttft_ms"] = int((time.monotonic() - t_start) * 1000)
tool_record = {
"function": tool_name,
"arguments": intent.arguments,
"result": result,
"status": "success" if result.get("success") else "error",
}
all_tool_calls.append(tool_record)
buf.append_event("tool_call", {"tool_call": tool_record})
# Include ack as the assistant's partial response so round 1
# continues coherently from where the acknowledgment left off
messages.append({
"role": "assistant",
"content": ack_text,
"tool_calls": [
{"function": {"name": tool_name, "arguments": intent.arguments}}
],
})
messages.append({
"role": "tool",
"content": json.dumps(result),
})
continue # Next round: LLM streams response incorporating result
# Bail out here if cancelled during confirmation wait
if cancelled:
break
buf.append_event("status", {"status": "Generating response..." if _round == 0 else "Composing response..."})
t_stream = time.monotonic()