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}
+15 -13
View File
@@ -8,7 +8,7 @@ const DEFAULTS = {
num_ctx: 0, // context window in tokens; 0 = model default
rag_top_k: 3, // document chunks injected into chat
rag_min_score: 0.6, // min cosine similarity for a chunk to count
allow_action_tools: false, // consent gate for web/fetch/memory-write tools
action_tool_policy: "off", // off | ask (per-call approval) | allow
system_prompt: "",
timeout: 120,
gpu_offload: -1, // -1 = Auto; 0100 = percent of layers forced onto the GPU
@@ -243,18 +243,20 @@ export function Settings() {
</div>
<div style={{ marginTop: "1.25rem" }}>
<label style={{ display: "flex", alignItems: "center", gap: "0.6rem", cursor: "pointer" }}>
<input
type="checkbox"
checked={form.allow_action_tools}
onChange={e => update("allow_action_tools", e.target.checked)}
/>
<span style={labelStyle}>Allow action tools</span>
</label>
<div style={{ fontSize: "0.72rem", color: form.allow_action_tools ? "#c9a227" : "#555", marginTop: "0.2rem" }}>
Lets playbooks run tools that act: web search, fetching a URL, and writing
to memory. Off by default — a playbook can list them, but they only fire
when this is on.
<label style={labelStyle}>Action tools (web search, fetch URL, write memory)</label>
<select
value={form.action_tool_policy}
onChange={e => update("action_tool_policy", e.target.value)}
style={{ width: "100%", padding: "0.6rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "8px", marginTop: "0.3rem" }}
>
<option value="off">Off — never run action tools</option>
<option value="ask">Ask — approve each action before it runs</option>
<option value="allow">Allow — run action tools freely</option>
</select>
<div style={{ fontSize: "0.72rem", color: form.action_tool_policy === "allow" ? "#c9a227" : "#555", marginTop: "0.2rem" }}>
Controls tools that act (vs. read-only). A playbook must also list the tool.
{form.action_tool_policy === "ask" && " You'll get an Approve/Deny prompt in chat."}
{form.action_tool_policy === "allow" && " Actions run without confirmation."}
</div>
</div>