feat: add permission modes (ask/skip) for agent tool execution

This commit is contained in:
Luis Esteban Acevedo Ladino 2026-04-27 12:28:21 -05:00
parent 25e970f1ae
commit bf354642ba
5 changed files with 178 additions and 5 deletions

View File

@ -12,6 +12,27 @@ import { useAgentStore } from "@/stores/agent-store";
const MAX_ITERATIONS = 20;
const WRITE_TOOLS = new Set([
"split",
"delete_timeline_elements",
"move_timeline_elements",
"duplicate_elements",
"add_media_to_timeline",
"update_timeline_element_timing",
"add_text",
"update_text",
"apply_effect",
"update_effect",
"update_clip",
"undo",
"redo",
"toggle_track_mute",
"toggle_track_visibility",
"upsert_keyframe",
"remove_keyframe",
"update_keyframe_curve",
]);
interface APIResponse {
content: string;
toolCalls?: ToolCall[];
@ -188,12 +209,25 @@ async function resolveToolCalls(
const results: ToolResult[] = [];
for (const tc of toolCalls) {
agentStore.setActiveTool(tc.name);
useAgentStore.getState().setActiveTool(tc.name);
const currentMode = useAgentStore.getState().permissionMode;
if (currentMode === "ask" && WRITE_TOOLS.has(tc.name)) {
const approved = await requestApproval(tc);
if (!approved) {
results.push({
toolCallId: tc.id,
name: tc.name,
result: null,
error: "User denied permission",
});
continue;
}
}
try {
const tool = toolRegistry.get(tc.name);
// Validate args BEFORE execution
const validationError = validateToolArgs(tool, tc.args);
if (validationError) {
results.push({
@ -220,3 +254,10 @@ async function resolveToolCalls(
agentStore.setActiveTool(null);
return results;
}
function requestApproval(toolCall: ToolCall): Promise<boolean> {
return new Promise((resolve) => {
const agentStore = useAgentStore.getState();
agentStore.setPendingApproval({ toolCall, resolve });
});
}

View File

@ -2,9 +2,15 @@
import { useState, useCallback, type KeyboardEvent } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import { Sent02Icon } from "@hugeicons/core-free-icons";
import {
Sent02Icon,
FlashIcon,
Shield01Icon,
} from "@hugeicons/core-free-icons";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { useAgentStore, type PermissionMode } from "@/stores/agent-store";
import { cn } from "@/utils/ui";
interface ChatInputProps {
onSend: (content: string) => void;
@ -13,6 +19,9 @@ interface ChatInputProps {
export function ChatInput({ onSend, disabled }: ChatInputProps) {
const [value, setValue] = useState("");
const permissionMode = useAgentStore((s) => s.permissionMode);
const setPermissionMode = useAgentStore((s) => s.setPermissionMode);
const pendingApproval = useAgentStore((s) => s.pendingApproval);
const handleSend = useCallback(() => {
const trimmed = value.trim();
@ -31,6 +40,14 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
[handleSend],
);
const toggleMode = useCallback(() => {
const next: PermissionMode =
permissionMode === "skip" ? "ask" : "skip";
setPermissionMode(next);
}, [permissionMode, setPermissionMode]);
const isAsk = permissionMode === "ask";
return (
<div className="border-t p-3">
<div className="flex items-end gap-2">
@ -43,6 +60,20 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
rows={1}
className="border-border bg-input focus-visible:border-primary/50 focus-visible:ring-primary/20 flex-1 resize-none rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
<Button
variant={isAsk ? "default" : "secondary"}
size="icon"
onClick={toggleMode}
disabled={!!pendingApproval}
aria-label={isAsk ? "Ask permissions" : "Skip permissions"}
title={isAsk ? "Ask permissions" : "Skip permissions"}
>
<HugeiconsIcon
icon={isAsk ? Shield01Icon : FlashIcon}
className={cn("size-4", isAsk && "text-amber-300")}
strokeWidth={1.5}
/>
</Button>
<Button
variant="secondary"
size="icon"

View File

@ -7,8 +7,10 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { useChatStore } from "@/stores/chat-store";
import { useAgentStore } from "@/stores/agent-store";
import { MessageBubble } from "./message-bubble";
import { ChatInput } from "./chat-input";
import { ToolPermissionRequest } from "./tool-permission-request";
import { run as orchestratorRun } from "@/agent/orchestrator";
import { EditorContextAdapter } from "@/agent/context";
@ -18,6 +20,7 @@ export function ChatPanel() {
const error = useChatStore((s) => s.error);
const sendMessage = useChatStore((s) => s.sendMessage);
const setError = useChatStore((s) => s.setError);
const pendingApproval = useAgentStore((s) => s.pendingApproval);
const scrollRef = useRef<HTMLDivElement>(null);
@ -77,6 +80,9 @@ export function ChatPanel() {
</span>
</div>
)}
{pendingApproval && (
<ToolPermissionRequest pending={pendingApproval} />
)}
</div>
</ScrollArea>
)}

View File

@ -0,0 +1,75 @@
"use client";
import { useCallback } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import { Shield01Icon, Cancel01Icon } from "@hugeicons/core-free-icons";
import { Button } from "@/components/ui/button";
import { formatToolCall } from "./tool-formatters";
import { useAgentStore } from "@/stores/agent-store";
import type { PendingToolApproval } from "@/stores/agent-store";
interface ToolPermissionRequestProps {
pending: PendingToolApproval;
}
export function ToolPermissionRequest({ pending }: ToolPermissionRequestProps) {
const { toolCall, resolve } = pending;
const summary = formatToolCall(toolCall.name, toolCall.args);
const handleAllow = useCallback(() => {
useAgentStore.getState().setPendingApproval(null);
resolve(true);
}, [resolve]);
const handleDeny = useCallback(() => {
useAgentStore.getState().setPendingApproval(null);
resolve(false);
}, [resolve]);
return (
<div className="flex flex-col gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2">
<div className="flex items-center gap-2">
<HugeiconsIcon
icon={Shield01Icon}
className="size-4 shrink-0 text-amber-500"
strokeWidth={1.5}
/>
<span className="text-xs font-medium text-amber-600">
Permission Request
</span>
</div>
<div className="text-xs">
<span className="font-medium text-foreground/80">{summary.label}</span>
<span className="text-muted-foreground"> {summary.description}</span>
</div>
{Object.keys(toolCall.args).length > 0 && (
<pre className="max-h-32 overflow-auto rounded bg-muted/50 px-2 py-1 font-mono text-[11px] text-muted-foreground">
{JSON.stringify(toolCall.args, null, 2)}
</pre>
)}
<div className="flex items-center gap-2">
<Button
variant="default"
size="sm"
onClick={handleAllow}
className="text-xs"
>
Allow
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDeny}
className="text-xs"
>
<HugeiconsIcon
icon={Cancel01Icon}
className="mr-1 size-3"
strokeWidth={2}
/>
Deny
</Button>
</div>
</div>
);
}

View File

@ -1,5 +1,12 @@
import { create } from "zustand";
import type { AgentContext, ExecutionState } from "@/agent/types";
import type { AgentContext, ExecutionState, ToolCall } from "@/agent/types";
export type PermissionMode = "ask" | "skip";
export interface PendingToolApproval {
toolCall: ToolCall;
resolve: (approved: boolean) => void;
}
const DEFAULT_CONTEXT: AgentContext = {
projectId: null,
@ -12,9 +19,13 @@ interface AgentState {
status: ExecutionState;
activeTool: string | null;
context: AgentContext;
permissionMode: PermissionMode;
pendingApproval: PendingToolApproval | null;
setStatus: (status: ExecutionState) => void;
setActiveTool: (tool: string | null) => void;
setContext: (context: AgentContext) => void;
setPermissionMode: (mode: PermissionMode) => void;
setPendingApproval: (pending: PendingToolApproval | null) => void;
reset: () => void;
}
@ -22,9 +33,18 @@ export const useAgentStore = create<AgentState>()((set) => ({
status: "idle",
activeTool: null,
context: DEFAULT_CONTEXT,
permissionMode: "skip",
pendingApproval: null,
setStatus: (status) => set({ status }),
setActiveTool: (tool) => set({ activeTool: tool }),
setContext: (context) => set({ context }),
setPermissionMode: (mode) => set({ permissionMode: mode }),
setPendingApproval: (pending) => set({ pendingApproval: pending }),
reset: () =>
set({ status: "idle", activeTool: null, context: DEFAULT_CONTEXT }),
set({
status: "idle",
activeTool: null,
context: DEFAULT_CONTEXT,
pendingApproval: null,
}),
}));