diff --git a/src/common/HITLSettings/CheckpointItem.tsx b/src/common/HITLSettings/CheckpointItem.tsx new file mode 100644 index 0000000..b641f96 --- /dev/null +++ b/src/common/HITLSettings/CheckpointItem.tsx @@ -0,0 +1,43 @@ +import { DeleteIcon, EditIcon } from "@chakra-ui/icons"; +import { Box, Text, Flex, IconButton, Tooltip } from "@chakra-ui/react"; +import type { CheckpointRule } from "../../helpers/hitl"; + +type CheckpointItemProps = { + rule: CheckpointRule; + onEdit: (rule: CheckpointRule) => void; + onDelete: (id: string) => void; +}; + +const CheckpointItem = ({ rule, onEdit, onDelete }: CheckpointItemProps) => { + return ( + + + + {rule.description} + + + + } + size="sm" + variant="ghost" + onClick={() => onEdit(rule)} + /> + + + } + size="sm" + variant="ghost" + onClick={() => onDelete(rule.id)} + /> + + + + + ); +}; + +export default CheckpointItem; diff --git a/src/common/HITLSettings/NewHITLForm.tsx b/src/common/HITLSettings/NewHITLForm.tsx new file mode 100644 index 0000000..ac540ed --- /dev/null +++ b/src/common/HITLSettings/NewHITLForm.tsx @@ -0,0 +1,70 @@ +import React, { useState } from "react"; +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalBody, + ModalCloseButton, + FormControl, + FormLabel, + Input, + Button, + VStack, +} from "@chakra-ui/react"; + +type NewHITLFormProps = { + isOpen: boolean; + isEditMode: boolean; + editRule?: { + id: string; + description: string; + }; + closeForm: () => void; + onSave: (rule: { description: string }) => void; +}; + +const NewHITLForm: React.FC = ({ + isOpen, + isEditMode, + editRule, + closeForm, + onSave, +}) => { + const [description, setDescription] = useState(editRule?.description || ""); + + const handleSubmit = () => { + if (!description) return; + onSave({ description }); + setDescription(""); + }; + + return ( + + + + + {isEditMode ? "Edit Safety Checkpoint" : "Add Safety Checkpoint"} + + + + + + Description + setDescription(e.target.value)} + placeholder="e.g., Confirm before posting a Tweet" + /> + + + + + + + ); +}; + +export default NewHITLForm; diff --git a/src/common/HITLSettings/index.tsx b/src/common/HITLSettings/index.tsx new file mode 100644 index 0000000..3be0b2d --- /dev/null +++ b/src/common/HITLSettings/index.tsx @@ -0,0 +1,88 @@ +import React, { useState } from "react"; +import { Button, Text, VStack, Alert, AlertIcon } from "@chakra-ui/react"; +import { useAppState } from "../../state/store"; +import NewHITLForm from "./NewHITLForm"; +import CheckpointItem from "./CheckpointItem"; +import type { CheckpointRule } from "../../helpers/hitl"; + +const HITLSettings = () => { + const [isFormOpen, setIsFormOpen] = useState(false); + const [editRule, setEditRule] = useState( + undefined, + ); + + const { hitlRules, updateSettings } = useAppState((state) => ({ + hitlRules: state.settings.hitlRules, + updateSettings: state.settings.actions.update, + })); + + const closeForm = () => { + setEditRule(undefined); + setIsFormOpen(false); + }; + + const handleSaveRule = (rule: Omit) => { + if (editRule) { + // Update existing rule + const updatedRules = hitlRules.map((r) => + r.id === editRule.id ? { ...rule, id: editRule.id } : r, + ) as CheckpointRule[]; + updateSettings({ hitlRules: updatedRules }); + } else { + // Add new rule + const newRule: CheckpointRule = { + ...rule, + id: crypto.randomUUID(), + }; + updateSettings({ hitlRules: [...hitlRules, newRule] }); + } + closeForm(); + }; + + const handleDeleteRule = (id: string) => { + const updatedRules = hitlRules.filter((rule) => rule.id !== id); + updateSettings({ hitlRules: updatedRules }); + }; + + const openEditForm = (rule: CheckpointRule) => { + setEditRule(rule); + setIsFormOpen(true); + }; + + return ( + + + + + {" "} + Add checkpoints to make Fuji ask for your permission before performing + certain actions. + + + {hitlRules.length > 0 ? ( + hitlRules.map((rule) => ( + + )) + ) : ( + No safety checkpoints configured + )} + + {isFormOpen && ( + + )} + + ); +}; + +export default HITLSettings; diff --git a/src/common/Settings.tsx b/src/common/Settings.tsx index fbc0aa7..01f7898 100644 --- a/src/common/Settings.tsx +++ b/src/common/Settings.tsx @@ -26,6 +26,7 @@ import ModelDropdown from "./settings/ModelDropdown"; import AgentModeDropdown from "./settings/AgentModeDropdown"; import { callRPC } from "../helpers/rpc/pageRPC"; import CustomKnowledgeBase from "./CustomKnowledgeBase"; +import HITLSettings from "./HITLSettings"; import SetAPIKey from "./settings/SetAPIKey"; import { debugMode } from "../constants"; import { isValidModelSettings } from "../helpers/aiSdkUtils"; @@ -35,7 +36,7 @@ type SettingsProps = { }; const Settings = ({ setInSettingsView }: SettingsProps) => { - const [view, setView] = useState<"settings" | "knowledge" | "api">( + const [view, setView] = useState<"settings" | "knowledge" | "api" | "hitl">( "settings", ); const state = useAppState((state) => ({ @@ -53,6 +54,7 @@ const Settings = ({ setInSettingsView }: SettingsProps) => { const closeSetting = () => setInSettingsView(false); const openCKB = () => setView("knowledge"); + const openHITL = () => setView("hitl"); const backToSettings = () => setView("settings"); async function checkMicrophonePermission(): Promise { @@ -118,6 +120,11 @@ const Settings = ({ setInSettingsView }: SettingsProps) => { API )} + {view === "hitl" && ( + + Checkpoints + + )} {view === "knowledge" && } @@ -129,6 +136,7 @@ const Settings = ({ setInSettingsView }: SettingsProps) => { onClose={backToSettings} /> )} + {view === "hitl" && } {view === "settings" && ( { Edit + + + Safety Checkpoints + + Define actions that require your approval + + + + + )} diff --git a/src/common/TaskHistory.tsx b/src/common/TaskHistory.tsx index 1894143..e989173 100644 --- a/src/common/TaskHistory.tsx +++ b/src/common/TaskHistory.tsx @@ -16,6 +16,8 @@ import { Spacer, ColorProps, BackgroundProps, + Text, + Button, } from "@chakra-ui/react"; import { TaskHistoryEntry } from "../state/currentTask"; import { BsSortNumericDown, BsSortNumericUp } from "react-icons/bs"; @@ -75,7 +77,8 @@ const CollapsibleComponent = (props: { {props.title} - + + {props.subtitle && ( {props.subtitle} @@ -153,6 +156,50 @@ const TaskHistoryItem = ({ index, entry }: TaskHistoryItemProps) => { ); }; +const PendingApprovalItem = () => { + const { isPendingApproval, proposedAction, setUserDecision } = useAppState( + (state) => state.hitl, + ); + + if (!isPendingApproval || !proposedAction) return null; + + return ( + + + + + ⚠️ + + Action requires approval + + {proposedAction.thought} + + + + + + + ); +}; + export default function TaskHistory() { const { taskHistory, taskStatus } = useAppState((state) => ({ taskStatus: state.currentTask.status, @@ -164,16 +211,25 @@ export default function TaskHistory() { }; if (taskHistory.length === 0 && taskStatus !== "running") return null; + + // Build the basic history items const historyItems = taskHistory.map((entry, index) => ( )); + + // Insert matched notes at the top historyItems.unshift(); + + // Reverse if needed if (!sortNumericDown) { historyItems.reverse(); } return ( - + + {/* Pending approval item goes above the heading */} + + Action History diff --git a/src/helpers/hitl/index.ts b/src/helpers/hitl/index.ts new file mode 100644 index 0000000..2bb30d3 --- /dev/null +++ b/src/helpers/hitl/index.ts @@ -0,0 +1,78 @@ +import { useAppState } from "../../state/store"; +import { fetchResponseFromModel } from "../aiSdkUtils"; +import { QueryResult } from "../vision-agent/determineNextAction"; +import { Action } from "../vision-agent/parseResponse"; + +const systemMessage = ` +You are an oversight system for a browser automation assistant. + +You will receive a JSON with two fields: + hitl_rule: a rule that describes what kind of actions require human approval before execution. These actions trigger a safety checkpoint. + action: a JSON describing an action that the assistant intends to execute. + previous_actions: an array of previous actions that have been performed, so that you understand what's happening in the current one. + +You will be asked to determine if the assistant's intended action falls within the scope of actions described by the rule. +For the action to trigger the rule, the action must fall precisely within the rule's scope. +If the action is just leading up to an action requiring approval, it does not require approval itself. + +If the action falls within the scope of the rule, then they require human approval and you should respond with "true". +If it does not, then they do not require human approval and you should respond with "false". + +You response will be strictly "true" or "false". It will not contain any further text, including any additional context or explanation. +`; + +export type CheckpointRule = { + id: string; + description: string; +}; + +export function hasCheckpointRules(): boolean { + const store = useAppState.getState(); + return store.settings.hitlRules.length > 0; +} + +// TODO: remove console logs +export async function matchesCheckpointRule( + query: QueryResult, + previousActions: Action[], +): Promise { + // If no rules defined, don't require approval + if (!hasCheckpointRules()) { + return false; + } + console.log("Checking checkpoint rules..."); + const store = useAppState.getState(); + const rules = store.settings.hitlRules; + console.log("Rules: ", rules); + + const rawResponse = query?.rawResponse; + + for (const rule of rules) { + const prompt = JSON.stringify( + { + hitl_rule: rule.description, + action: rawResponse, + previous_actions: previousActions.map((action) => action.thought), + }, + null, + 2, + ); + console.log("Prompt: ", prompt); + const model = useAppState.getState().settings.selectedModel; + + const completion = await fetchResponseFromModel(model, { + systemMessage: systemMessage, + prompt, + jsonMode: false, + }); + console.log("Completion: ", completion); + + const needsApproval = completion.rawResponse.toLowerCase() === "true"; + if (needsApproval) { + return true; + } + } + + // If no rules matched, don't require approval + return false; +} diff --git a/src/state/currentTask.ts b/src/state/currentTask.ts index 1e852d3..3ddc099 100644 --- a/src/state/currentTask.ts +++ b/src/state/currentTask.ts @@ -28,6 +28,7 @@ import buildAnnotatedScreenshots from "../helpers/buildAnnotatedScreenshots"; import { voiceControl } from "../helpers/voiceControl"; import { fetchKnowledge, type Knowledge } from "../helpers/knowledge"; import { isValidModelSettings, AgentMode } from "../helpers/aiSdkUtils"; +import { matchesCheckpointRule } from "../helpers/hitl"; export type TaskHistoryEntry = { prompt: string; @@ -268,6 +269,35 @@ export const createCurrentTaskSlice: MyStateCreator = ( if (wasStopped()) break; + const needsApproval = await matchesCheckpointRule( + query, + previousActions, + ); + + if (needsApproval && query) { + set((state) => { + state.hitl.isPendingApproval = true; + state.hitl.proposedAction = query.action; + state.hitl.userDecision = null; + }); + + const decision = await get().hitl.waitForApproval(); + + // Reset HITL state + set((state) => { + state.hitl.isPendingApproval = false; + state.hitl.proposedAction = null; + state.hitl.userDecision = null; + }); + + if (decision === "reject") { + set((state) => { + state.currentTask.status = "interrupted"; + }); + break; + } + } + const shouldContinue = await performAction(query); if (wasStopped() || !shouldContinue) break; diff --git a/src/state/hitl.ts b/src/state/hitl.ts new file mode 100644 index 0000000..2e12b46 --- /dev/null +++ b/src/state/hitl.ts @@ -0,0 +1,43 @@ +import { MyStateCreator } from "./store"; +import { Action } from "../helpers/vision-agent/parseResponse"; + +export type HitlSlice = { + proposedAction: Action | null; + setProposedAction: (action: Action | null) => void; + userDecision: "approve" | "reject" | null; + setUserDecision: (decision: "approve" | "reject" | null) => void; + isPendingApproval: boolean; + setIsPendingApproval: (isPending: boolean) => void; + waitForApproval: () => Promise<"approve" | "reject">; +}; + +export const createHitlSlice: MyStateCreator = (set, get) => ({ + proposedAction: null, + setProposedAction: (action) => + set((state) => { + state.hitl.proposedAction = action; + }), + userDecision: null, + setUserDecision: (decision) => + set((state) => { + state.hitl.userDecision = decision; + }), + isPendingApproval: false, + setIsPendingApproval: (isPending) => + set((state) => { + state.hitl.isPendingApproval = isPending; + }), + waitForApproval: async () => { + return new Promise((resolve) => { + const checkDecision = () => { + const decision = get().hitl.userDecision; + if (decision) { + resolve(decision); + } else { + setTimeout(checkDecision, 100); + } + }; + checkDecision(); + }); + }, +}); diff --git a/src/state/settings.ts b/src/state/settings.ts index 4083dbe..a01d3ec 100644 --- a/src/state/settings.ts +++ b/src/state/settings.ts @@ -1,4 +1,5 @@ -import { type Data } from "../helpers/knowledge/index"; +import { type Data } from "../helpers/knowledge"; +import { type CheckpointRule } from "../helpers/hitl"; import { MyStateCreator } from "./store"; import { SupportedModels, @@ -16,6 +17,7 @@ export type SettingsSlice = { agentMode: AgentMode; voiceMode: boolean; customKnowledgeBase: Data; + hitlRules: CheckpointRule[]; actions: { update: (values: Partial) => void; }; @@ -30,6 +32,7 @@ export const createSettingsSlice: MyStateCreator = (set) => ({ selectedModel: SupportedModels.Gpt4Turbo, voiceMode: false, customKnowledgeBase: {}, + hitlRules: [], actions: { update: (values) => { set((state) => { diff --git a/src/state/store.ts b/src/state/store.ts index e317987..0b0e639 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -5,12 +5,14 @@ import { createJSONStorage, devtools, persist } from "zustand/middleware"; import { createCurrentTaskSlice, CurrentTaskSlice } from "./currentTask"; import { createUiSlice, UiSlice } from "./ui"; import { createSettingsSlice, SettingsSlice } from "./settings"; +import { createHitlSlice, HitlSlice } from "./hitl"; import { findBestMatchingModel } from "../helpers/aiSdkUtils"; export type StoreType = { currentTask: CurrentTaskSlice; ui: UiSlice; settings: SettingsSlice; + hitl: HitlSlice; }; export type MyStateCreator = StateCreator< @@ -27,13 +29,13 @@ export const useAppState = create()( currentTask: createCurrentTaskSlice(...a), ui: createUiSlice(...a), settings: createSettingsSlice(...a), + hitl: createHitlSlice(...a), })), ), { name: "app-state", storage: createJSONStorage(() => localStorage), partialize: (state) => ({ - // Stuff we want to persist ui: { instructions: state.ui.instructions, }, @@ -47,6 +49,7 @@ export const useAppState = create()( selectedModel: state.settings.selectedModel, voiceMode: state.settings.voiceMode, customKnowledgeBase: state.settings.customKnowledgeBase, + hitlRules: state.settings.hitlRules, }, }), merge: (persistedState, currentState) => {