feat(tools): per-call approval for action tools

3-way action_tool_policy (off/ask/allow). In "ask", the chat stream stays
open and the tool loop awaits approval: emits event:tool_request, the UI
shows Approve/Deny, POST /chat/approve resumes the same stream. Declined
actions return a denied result; a timeout denies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jon
2026-07-23 14:39:00 -05:00
co-authored by Claude Opus 4.8
parent 492020b547
commit 52b3c5c3f0
6 changed files with 190 additions and 30 deletions
+46
View File
@@ -16,6 +16,7 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
const [memoryToast, setMemoryToast] = useState(null);
const [images, setImages] = useState([]); // {name, b64} for vision models
const [activeTool, setActiveTool] = useState(null); // playbook tool currently running
const [pendingApproval, setPendingApproval] = useState(null); // [{name, arguments}] awaiting yes/no
const [editingIdx, setEditingIdx] = useState(null); // user message being edited
const [editText, setEditText] = useState("");
const [listening, setListening] = useState(false); // mic dictation active
@@ -267,6 +268,11 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
pendingEventType = null;
continue;
}
if (pendingEventType === "tool_request") {
try { setPendingApproval(JSON.parse(payload)); } catch { /* ignore */ }
pendingEventType = null;
continue;
}
if (pendingEventType === "sources") {
try {
const src = JSON.parse(payload).sources;
@@ -284,6 +290,7 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
// slow post-processing (title, memory) on the still-open stream.
setLoading(false);
setActiveTool(null);
setPendingApproval(null);
pendingEventType = null;
continue;
}
@@ -309,6 +316,7 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
try { token = JSON.parse(payload); } catch { /* plain text fallback */ }
if (activeTool) setActiveTool(null); // tokens started -> tools done
if (pendingApproval) setPendingApproval(null);
setMessages(prev => {
const updated = [...prev];
updated[assistantIndex] = {
@@ -373,10 +381,25 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
await streamAssistant({ message: newText.trim(), history, images: [], assistantIndex: base.length });
};
// Approve or deny the pending action tool(s); the open chat stream resumes.
const resolveApproval = async (approve) => {
const req = pendingApproval || [];
setPendingApproval(null);
const decisions = {};
req.forEach(a => { decisions[a.name] = approve; });
try {
await fetch(`${API_BASE}/chat/approve`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ conversation_id: conversationId, decisions }),
});
} catch { /* ignore */ }
};
const stopGeneration = () => {
if (abortRef.current) abortRef.current.abort();
setLoading(false);
setActiveTool(null);
if (pendingApproval) resolveApproval(false); // stopping = deny pending actions
};
const updateAssistant = (index, text) => {
@@ -647,6 +670,29 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang
<div ref={messagesEndRef} />
</div>
{pendingApproval && (
<div style={{ marginBottom: "0.5rem", padding: "0.7rem 0.9rem", background: "#2a2418", border: "1px solid #6a5a2a", borderRadius: "10px" }}>
<div style={{ color: "#e8c65a", fontSize: "0.9rem", marginBottom: "0.5rem" }}>
The assistant wants to run:
{" "}
{pendingApproval.map((a, i) => (
<code key={i} style={{ color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px", marginRight: "0.35rem" }}>
{a.name}({a.arguments ? Object.values(a.arguments).join(", ") : ""})
</code>
))}
</div>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button onClick={() => resolveApproval(true)}
style={{ padding: "0.4rem 1rem", background: "#2a5a2a", color: "#8aff8a", border: "1px solid #3a7a3a", borderRadius: "8px", cursor: "pointer" }}>
Approve
</button>
<button onClick={() => resolveApproval(false)}
style={{ padding: "0.4rem 1rem", background: "#3a1a1a", color: "#ff8a80", border: "1px solid #5a2a2a", borderRadius: "8px", cursor: "pointer" }}>
Deny
</button>
</div>
</div>
)}
{activeTool && (
<div style={{ marginBottom: "0.5rem", color: "#8ab4ff", fontSize: "0.9rem" }}>
🔧 running tool: {activeTool}