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
+
+
+
+ } onClick={openHITL}>
+ Edit
+
+
)}
>
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) => {