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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user