forked from enderofwings/NexusOS
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:
@@ -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}…
|
||||
|
||||
@@ -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; 0–100 = 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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user