feat: add permission modes (ask/skip) for agent tool execution
This commit is contained in:
parent
25e970f1ae
commit
bf354642ba
|
|
@ -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 });
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
Loading…
Reference in New Issue