mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-20 21:18:13 +02:00
feat: updated agent harness
This commit is contained in:
parent
9ec9b64348
commit
31a372bb84
139 changed files with 12583 additions and 1111 deletions
|
|
@ -0,0 +1,451 @@
|
|||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertTriangle, Check, Plus, ShieldCheck, Trash2, X } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
type AgentPermissionAction,
|
||||
type AgentPermissionRule,
|
||||
type AgentPermissionRuleCreate,
|
||||
agentPermissionsApiService,
|
||||
} from "@/lib/apis/agent-permissions-api.service";
|
||||
import { AppError } from "@/lib/error";
|
||||
import { formatRelativeDate } from "@/lib/format-date";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ACTION_DESCRIPTIONS: Record<AgentPermissionAction, string> = {
|
||||
allow: "Always run without prompting",
|
||||
deny: "Block silently",
|
||||
ask: "Pause and ask for approval",
|
||||
};
|
||||
|
||||
const ACTION_BADGE: Record<AgentPermissionAction, { label: string; className: string }> = {
|
||||
allow: { label: "Allow", className: "bg-emerald-500/10 text-emerald-600 border-emerald-500/30" },
|
||||
deny: { label: "Deny", className: "bg-destructive/10 text-destructive border-destructive/30" },
|
||||
ask: { label: "Ask", className: "bg-amber-500/10 text-amber-600 border-amber-500/30" },
|
||||
};
|
||||
|
||||
const EMPTY_FORM: AgentPermissionRuleCreate = {
|
||||
permission: "",
|
||||
pattern: "*",
|
||||
action: "ask",
|
||||
user_id: null,
|
||||
thread_id: null,
|
||||
};
|
||||
|
||||
function permissionRulesQueryKey(searchSpaceId: number) {
|
||||
return ["agent-permission-rules", searchSpaceId] as const;
|
||||
}
|
||||
|
||||
function ScopeBadge({ rule }: { rule: AgentPermissionRule }) {
|
||||
if (rule.thread_id !== null) {
|
||||
return (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
Thread #{rule.thread_id}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (rule.user_id !== null) {
|
||||
return (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
User-specific
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
Search space
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentPermissionsContent() {
|
||||
const searchSpaceIdRaw = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const searchSpaceId = searchSpaceIdRaw ? Number(searchSpaceIdRaw) : null;
|
||||
|
||||
const { data: flags } = useAtomValue(agentFlagsAtom);
|
||||
const featureEnabled = !!flags?.enable_permission && !flags?.disable_new_agent_stack;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: rules,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: searchSpaceId
|
||||
? permissionRulesQueryKey(searchSpaceId)
|
||||
: ["agent-permission-rules", "none"],
|
||||
queryFn: () => agentPermissionsApiService.list(searchSpaceId as number),
|
||||
enabled: !!searchSpaceId && featureEnabled,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (payload: AgentPermissionRuleCreate) =>
|
||||
agentPermissionsApiService.create(searchSpaceId as number, payload),
|
||||
onSuccess: () => {
|
||||
toast.success("Rule created.");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: permissionRulesQueryKey(searchSpaceId as number),
|
||||
});
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to create rule.");
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (params: { ruleId: number; action: AgentPermissionAction; pattern?: string }) =>
|
||||
agentPermissionsApiService.update(searchSpaceId as number, params.ruleId, {
|
||||
action: params.action,
|
||||
pattern: params.pattern,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: permissionRulesQueryKey(searchSpaceId as number),
|
||||
});
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to update rule.");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (ruleId: number) =>
|
||||
agentPermissionsApiService.remove(searchSpaceId as number, ruleId),
|
||||
onSuccess: () => {
|
||||
toast.success("Rule deleted.");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: permissionRulesQueryKey(searchSpaceId as number),
|
||||
});
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to delete rule.");
|
||||
},
|
||||
});
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [formData, setFormData] = useState<AgentPermissionRuleCreate>(EMPTY_FORM);
|
||||
const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
|
||||
|
||||
const sortedRules = useMemo(() => rules ?? [], [rules]);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!formData.permission.trim()) {
|
||||
toast.error("Permission is required.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createMutation.mutateAsync({
|
||||
...formData,
|
||||
permission: formData.permission.trim(),
|
||||
pattern: formData.pattern.trim() || "*",
|
||||
});
|
||||
setShowForm(false);
|
||||
setFormData(EMPTY_FORM);
|
||||
} catch (err) {
|
||||
if (err instanceof AppError && err.message) {
|
||||
// already toasted by onError
|
||||
}
|
||||
}
|
||||
}, [createMutation, formData]);
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (deleteTarget === null) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(deleteTarget);
|
||||
} finally {
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}, [deleteMutation, deleteTarget]);
|
||||
|
||||
if (!featureEnabled) {
|
||||
return (
|
||||
<Alert className="border-dashed">
|
||||
<ShieldCheck className="size-4" />
|
||||
<AlertTitle>Permission middleware is disabled</AlertTitle>
|
||||
<AlertDescription>
|
||||
Flip{" "}
|
||||
<code className="rounded bg-muted px-1 text-[10px]">SURFSENSE_ENABLE_PERMISSION</code> on
|
||||
the backend to manage allow/deny/ask rules from this panel.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!searchSpaceId) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">Open a search space to manage agent rules.</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-destructive/40 p-8 text-center">
|
||||
<AlertTriangle className="mx-auto size-8 text-destructive/60" />
|
||||
<p className="mt-2 text-sm text-destructive">Failed to load rules</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{error instanceof Error ? error.message : "Unknown error."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-6 overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tell the agent which tools to allow, deny, or ask before running. Rules use wildcard
|
||||
patterns and are evaluated at the most specific scope first.
|
||||
</p>
|
||||
</div>
|
||||
{!showForm && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowForm(true);
|
||||
setFormData(EMPTY_FORM);
|
||||
}}
|
||||
className="shrink-0 gap-1.5"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
New rule
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="rounded-lg border border-border/60 bg-card p-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold tracking-tight">New permission rule</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="permission-name">Permission</Label>
|
||||
<Input
|
||||
id="permission-name"
|
||||
value={formData.permission}
|
||||
placeholder="e.g. tool:create_linear_issue or tool:*"
|
||||
onChange={(e) => setFormData((p) => ({ ...p, permission: e.target.value }))}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Match a tool capability. Use <code className="font-mono">*</code> for wildcards.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pattern">Argument pattern</Label>
|
||||
<Input
|
||||
id="pattern"
|
||||
value={formData.pattern}
|
||||
placeholder="*"
|
||||
onChange={(e) => setFormData((p) => ({ ...p, pattern: e.target.value }))}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Wildcard against the canonical argument (e.g. <code>prod-*</code>).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Action</Label>
|
||||
<Select
|
||||
value={formData.action}
|
||||
onValueChange={(value) =>
|
||||
setFormData((p) => ({ ...p, action: value as AgentPermissionAction }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="allow">Allow — run without asking</SelectItem>
|
||||
<SelectItem value="ask">Ask — pause for approval</SelectItem>
|
||||
<SelectItem value="deny">Deny — block silently</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{ACTION_DESCRIPTIONS[formData.action]}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowForm(false);
|
||||
setFormData(EMPTY_FORM);
|
||||
}}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleCreate}
|
||||
disabled={createMutation.isPending || !formData.permission.trim()}
|
||||
className="relative"
|
||||
>
|
||||
<span className={createMutation.isPending ? "opacity-0" : ""}>Create</span>
|
||||
{createMutation.isPending && <Spinner className="absolute size-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedRules.length === 0 && !showForm && (
|
||||
<div className="rounded-lg border border-dashed border-border/60 p-8 text-center">
|
||||
<ShieldCheck className="mx-auto size-8 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">No rules yet</p>
|
||||
<p className="text-xs text-muted-foreground/60">
|
||||
Without rules the agent uses the deployment default for every tool.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedRules.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{sortedRules.map((rule) => {
|
||||
const badge = ACTION_BADGE[rule.action];
|
||||
const isUpdating =
|
||||
updateMutation.isPending && updateMutation.variables?.ruleId === rule.id;
|
||||
const isDeleting = deleteMutation.isPending && deleteMutation.variables === rule.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={rule.id}
|
||||
className="group flex flex-col gap-3 rounded-lg border border-border/60 bg-card p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<code className="truncate rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
|
||||
{rule.permission}
|
||||
</code>
|
||||
{rule.pattern !== "*" && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
→ <code className="font-mono">{rule.pattern}</code>
|
||||
</span>
|
||||
)}
|
||||
<ScopeBadge rule={rule} />
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Created {formatRelativeDate(rule.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Select
|
||||
value={rule.action}
|
||||
onValueChange={(value) =>
|
||||
updateMutation.mutate({
|
||||
ruleId: rule.id,
|
||||
action: value as AgentPermissionAction,
|
||||
})
|
||||
}
|
||||
disabled={isUpdating || isDeleting}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn("h-8 gap-1 border px-2 text-[11px]", badge.className)}
|
||||
>
|
||||
<SelectValue>
|
||||
<span className="flex items-center gap-1">
|
||||
{rule.action === "allow" && <Check className="size-3" />}
|
||||
{rule.action === "deny" && <X className="size-3" />}
|
||||
{badge.label}
|
||||
</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="allow">Allow</SelectItem>
|
||||
<SelectItem value="ask">Ask</SelectItem>
|
||||
<SelectItem value="deny">Deny</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="size-8 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => setDeleteTarget(rule.id)}
|
||||
disabled={isUpdating || isDeleting}
|
||||
aria-label="Delete rule"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete this rule?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
The agent will fall back to deployment defaults for matching tool calls.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleConfirmDelete();
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? "Deleting…" : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,309 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { CircleCheck, CircleSlash, Cog, RotateCcw } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { AgentFeatureFlags } from "@/lib/apis/agent-flags-api.service";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type FlagKey = keyof AgentFeatureFlags;
|
||||
|
||||
interface FlagDef {
|
||||
key: FlagKey;
|
||||
label: string;
|
||||
description: string;
|
||||
envVar: string;
|
||||
}
|
||||
|
||||
interface FlagGroup {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
flags: FlagDef[];
|
||||
}
|
||||
|
||||
const FLAG_GROUPS: FlagGroup[] = [
|
||||
{
|
||||
id: "tier1",
|
||||
title: "Tier 1 — Agent quality",
|
||||
subtitle: "Context editing, retries, fallbacks, doom-loop, tool-call repair.",
|
||||
flags: [
|
||||
{
|
||||
key: "enable_context_editing",
|
||||
label: "Context editing",
|
||||
description: "Trim tool outputs and spill old text into backend storage.",
|
||||
envVar: "SURFSENSE_ENABLE_CONTEXT_EDITING",
|
||||
},
|
||||
{
|
||||
key: "enable_compaction_v2",
|
||||
label: "Compaction v2",
|
||||
description: "SurfSense-aware compaction replacing safe summarization.",
|
||||
envVar: "SURFSENSE_ENABLE_COMPACTION_V2",
|
||||
},
|
||||
{
|
||||
key: "enable_retry_after",
|
||||
label: "Retry-After",
|
||||
description: "Honour rate-limit retry-after headers automatically.",
|
||||
envVar: "SURFSENSE_ENABLE_RETRY_AFTER",
|
||||
},
|
||||
{
|
||||
key: "enable_model_fallback",
|
||||
label: "Model fallback",
|
||||
description: "Fail over to a backup model on persistent errors.",
|
||||
envVar: "SURFSENSE_ENABLE_MODEL_FALLBACK",
|
||||
},
|
||||
{
|
||||
key: "enable_model_call_limit",
|
||||
label: "Model call limit",
|
||||
description: "Cap total model calls per turn to prevent budget run-aways.",
|
||||
envVar: "SURFSENSE_ENABLE_MODEL_CALL_LIMIT",
|
||||
},
|
||||
{
|
||||
key: "enable_tool_call_limit",
|
||||
label: "Tool call limit",
|
||||
description: "Cap total tool calls per turn.",
|
||||
envVar: "SURFSENSE_ENABLE_TOOL_CALL_LIMIT",
|
||||
},
|
||||
{
|
||||
key: "enable_tool_call_repair",
|
||||
label: "Tool-call name repair",
|
||||
description: "Recover from lower-cased / fuzzy tool names emitted by smaller models.",
|
||||
envVar: "SURFSENSE_ENABLE_TOOL_CALL_REPAIR",
|
||||
},
|
||||
{
|
||||
key: "enable_doom_loop",
|
||||
label: "Doom-loop detection",
|
||||
description: "Detect repeated identical tool calls and ask the user to confirm.",
|
||||
envVar: "SURFSENSE_ENABLE_DOOM_LOOP",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tier2",
|
||||
title: "Tier 2 — Safety",
|
||||
subtitle: "Permission rules, busy-mutex, smarter tool selection.",
|
||||
flags: [
|
||||
{
|
||||
key: "enable_permission",
|
||||
label: "Permission middleware",
|
||||
description: "Apply allow/deny/ask rules from the Agent Permissions tab.",
|
||||
envVar: "SURFSENSE_ENABLE_PERMISSION",
|
||||
},
|
||||
{
|
||||
key: "enable_busy_mutex",
|
||||
label: "Busy mutex",
|
||||
description: "Prevent two concurrent runs from corrupting the same thread.",
|
||||
envVar: "SURFSENSE_ENABLE_BUSY_MUTEX",
|
||||
},
|
||||
{
|
||||
key: "enable_llm_tool_selector",
|
||||
label: "LLM tool selector",
|
||||
description: "Use a smaller model to pre-filter the tool list per turn.",
|
||||
envVar: "SURFSENSE_ENABLE_LLM_TOOL_SELECTOR",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tier4",
|
||||
title: "Tier 4 — Skills + subagents",
|
||||
subtitle: "Built-in skills, specialized subagents, KB planner runnable.",
|
||||
flags: [
|
||||
{
|
||||
key: "enable_skills",
|
||||
label: "Skills",
|
||||
description: "Load on-demand skill packs (kb-research, report-writing, …).",
|
||||
envVar: "SURFSENSE_ENABLE_SKILLS",
|
||||
},
|
||||
{
|
||||
key: "enable_specialized_subagents",
|
||||
label: "Specialized subagents",
|
||||
description: "Spin up explore / report_writer / connector_negotiator subagents.",
|
||||
envVar: "SURFSENSE_ENABLE_SPECIALIZED_SUBAGENTS",
|
||||
},
|
||||
{
|
||||
key: "enable_kb_planner_runnable",
|
||||
label: "KB planner runnable",
|
||||
description: "Compile a private planner sub-agent for KB search.",
|
||||
envVar: "SURFSENSE_ENABLE_KB_PLANNER_RUNNABLE",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tier5",
|
||||
title: "Tier 5 — Audit + revert",
|
||||
subtitle: "Action log + revert route used by the Agent Actions sheet.",
|
||||
flags: [
|
||||
{
|
||||
key: "enable_action_log",
|
||||
label: "Action log",
|
||||
description: "Persist every tool call to agent_action_log.",
|
||||
envVar: "SURFSENSE_ENABLE_ACTION_LOG",
|
||||
},
|
||||
{
|
||||
key: "enable_revert_route",
|
||||
label: "Revert route",
|
||||
description: "Allow reverting reversible actions from the action log.",
|
||||
envVar: "SURFSENSE_ENABLE_REVERT_ROUTE",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tier6",
|
||||
title: "Tier 6 — Plugins",
|
||||
subtitle: "Optional middleware loaded from entry points.",
|
||||
flags: [
|
||||
{
|
||||
key: "enable_plugin_loader",
|
||||
label: "Plugin loader",
|
||||
description: "Load surfsense.plugins entry-point middleware.",
|
||||
envVar: "SURFSENSE_ENABLE_PLUGIN_LOADER",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "obs",
|
||||
title: "Observability",
|
||||
subtitle: "Telemetry pipelines (orthogonal to feature gating).",
|
||||
flags: [
|
||||
{
|
||||
key: "enable_otel",
|
||||
label: "OpenTelemetry",
|
||||
description: "Emit OTel spans (also requires OTEL_EXPORTER_OTLP_ENDPOINT).",
|
||||
envVar: "SURFSENSE_ENABLE_OTEL",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function FlagRow({ def, value }: { def: FlagDef; value: boolean }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 py-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">{def.label}</span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
{def.envVar}
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{def.description}</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={value ? "default" : "secondary"}
|
||||
className={cn(
|
||||
"shrink-0 gap-1",
|
||||
value
|
||||
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{value ? <CircleCheck className="size-3" /> : <CircleSlash className="size-3" />}
|
||||
{value ? "On" : "Off"}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentStatusContent() {
|
||||
const { data: flags, isLoading, isError, error, refetch } = useAtomValue(agentFlagsAtom);
|
||||
|
||||
const enabledCount = useMemo(() => {
|
||||
if (!flags) return 0;
|
||||
return Object.entries(flags).filter(([k, v]) => k !== "disable_new_agent_stack" && v === true)
|
||||
.length;
|
||||
}, [flags]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-12 w-full rounded-md" />
|
||||
<Skeleton className="h-32 w-full rounded-md" />
|
||||
<Skeleton className="h-32 w-full rounded-md" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !flags) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to load agent status</AlertTitle>
|
||||
<AlertDescription className="flex items-center gap-2">
|
||||
{error instanceof Error ? error.message : "Unknown error."}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
className="ml-auto inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs hover:bg-background"
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
Retry
|
||||
</button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const masterOff = flags.disable_new_agent_stack;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{masterOff ? (
|
||||
<Alert variant="destructive">
|
||||
<Cog className="size-4" />
|
||||
<AlertTitle>Master kill-switch is on</AlertTitle>
|
||||
<AlertDescription>
|
||||
<code className="rounded bg-muted px-1 text-[10px]">
|
||||
SURFSENSE_DISABLE_NEW_AGENT_STACK=true
|
||||
</code>
|
||||
forces every new middleware off, regardless of the individual flags below. Restart the
|
||||
backend after changing it.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert>
|
||||
<Cog className="size-4" />
|
||||
<AlertTitle className="flex items-center gap-2">
|
||||
Agent stack
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{enabledCount} on
|
||||
</Badge>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
Read-only mirror of the backend's <code>AgentFeatureFlags</code>. Flip an env var and
|
||||
restart the backend to change a value.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{FLAG_GROUPS.map((group, groupIdx) => {
|
||||
const allOff = group.flags.every((f) => !flags[f.key]);
|
||||
return (
|
||||
<div key={group.id}>
|
||||
{groupIdx > 0 && <Separator className="my-4" />}
|
||||
<div className="rounded-lg border border-border/60 bg-card">
|
||||
<div className="flex items-start justify-between gap-3 border-b px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{group.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{group.subtitle}</p>
|
||||
</div>
|
||||
{allOff && (
|
||||
<Badge variant="outline" className="text-[10px] text-muted-foreground">
|
||||
all off
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="divide-y divide-border/50 px-4">
|
||||
{group.flags.map((def) => (
|
||||
<FlagRow key={def.key} def={def} value={flags[def.key]} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
surfsense_web/atoms/agent/action-log-sheet.atom.ts
Normal file
19
surfsense_web/atoms/agent/action-log-sheet.atom.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { atom } from "jotai";
|
||||
|
||||
interface ActionLogSheetState {
|
||||
open: boolean;
|
||||
threadId: number | null;
|
||||
}
|
||||
|
||||
export const actionLogSheetAtom = atom<ActionLogSheetState>({
|
||||
open: false,
|
||||
threadId: null,
|
||||
});
|
||||
|
||||
export const openActionLogSheetAtom = atom(null, (_get, set, threadId: number) => {
|
||||
set(actionLogSheetAtom, { open: true, threadId });
|
||||
});
|
||||
|
||||
export const closeActionLogSheetAtom = atom(null, (_get, set) => {
|
||||
set(actionLogSheetAtom, { open: false, threadId: null });
|
||||
});
|
||||
17
surfsense_web/atoms/agent/agent-flags-query.atom.ts
Normal file
17
surfsense_web/atoms/agent/agent-flags-query.atom.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { atomWithQuery } from "jotai-tanstack-query";
|
||||
import { agentFlagsApiService } from "@/lib/apis/agent-flags-api.service";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
|
||||
export const AGENT_FLAGS_QUERY_KEY = ["agent", "flags"] as const;
|
||||
|
||||
/**
|
||||
* Reads the backend agent feature flags. Cached for the lifetime of the
|
||||
* page (flags only change on backend restart) so we can drive UI gating
|
||||
* without re-hitting the API.
|
||||
*/
|
||||
export const agentFlagsAtom = atomWithQuery(() => ({
|
||||
queryKey: AGENT_FLAGS_QUERY_KEY,
|
||||
staleTime: 10 * 60 * 1000,
|
||||
enabled: !!getBearerToken(),
|
||||
queryFn: () => agentFlagsApiService.get(),
|
||||
}));
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { Activity } from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import { openActionLogSheetAtom } from "@/atoms/agent/action-log-sheet.atom";
|
||||
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
interface ActionLogButtonProps {
|
||||
threadId: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header button that opens the agent action log sheet for the current
|
||||
* thread. Renders nothing when:
|
||||
* - the action log feature flag is off (graceful no-op for older
|
||||
* deployments), OR
|
||||
* - there is no active thread (lazy-created chats haven't started).
|
||||
*/
|
||||
export function ActionLogButton({ threadId }: ActionLogButtonProps) {
|
||||
const { data: flags } = useAtomValue(agentFlagsAtom);
|
||||
const open = useSetAtom(openActionLogSheetAtom);
|
||||
|
||||
const enabled = !!flags?.enable_action_log && !flags?.disable_new_agent_stack;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (threadId !== null) open(threadId);
|
||||
}, [open, threadId]);
|
||||
|
||||
if (!enabled || threadId === null) return null;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="size-8 p-0"
|
||||
aria-label="Open agent action log"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Activity className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Agent actions</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
215
surfsense_web/components/agent-action-log/action-log-item.tsx
Normal file
215
surfsense_web/components/agent-action-log/action-log-item.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronRight, RotateCcw, ShieldOff, Undo2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getToolIcon } from "@/contracts/enums/toolIcons";
|
||||
import { type AgentAction, agentActionsApiService } from "@/lib/apis/agent-actions-api.service";
|
||||
import { AppError } from "@/lib/error";
|
||||
import { formatRelativeDate } from "@/lib/format-date";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function formatToolName(name: string): string {
|
||||
return name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
interface ActionLogItemProps {
|
||||
action: AgentAction;
|
||||
threadId: number;
|
||||
onRevertSuccess: () => void;
|
||||
}
|
||||
|
||||
export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogItemProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isReverting, setIsReverting] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
const isAlreadyReverted = action.reverted_by_action_id !== null;
|
||||
const isRevertAction = action.is_revert_action;
|
||||
const hasError = action.error !== null && action.error !== undefined;
|
||||
|
||||
const Icon = getToolIcon(action.tool_name);
|
||||
const displayName = formatToolName(action.tool_name);
|
||||
|
||||
const argsPreview = action.args ? JSON.stringify(action.args, null, 2) : null;
|
||||
const truncatedArgs =
|
||||
argsPreview && argsPreview.length > 600 ? `${argsPreview.slice(0, 600)}…` : argsPreview;
|
||||
|
||||
const canRevert = action.reversible && !isAlreadyReverted && !isRevertAction && !hasError;
|
||||
|
||||
const handleRevert = async () => {
|
||||
setIsReverting(true);
|
||||
try {
|
||||
const response = await agentActionsApiService.revert(threadId, action.id);
|
||||
toast.success(response.message || "Action reverted successfully.");
|
||||
onRevertSuccess();
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof AppError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Failed to revert action.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsReverting(false);
|
||||
setConfirmOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border bg-card transition-colors",
|
||||
isAlreadyReverted && "opacity-70"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded((v) => !v)}
|
||||
className="flex w-full items-start gap-3 p-3 text-left hover:bg-muted/40"
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted">
|
||||
{isRevertAction ? (
|
||||
<Undo2 className="size-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="truncate text-sm font-medium">{displayName}</span>
|
||||
{isRevertAction && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Revert
|
||||
</Badge>
|
||||
)}
|
||||
{hasError && (
|
||||
<Badge variant="destructive" className="text-[10px]">
|
||||
Error
|
||||
</Badge>
|
||||
)}
|
||||
{!isRevertAction && action.reversible && !isAlreadyReverted && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
Reversible
|
||||
</Badge>
|
||||
)}
|
||||
{isAlreadyReverted && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Reverted
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{formatRelativeDate(action.created_at)}</p>
|
||||
</div>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-4 shrink-0 text-muted-foreground transition-transform",
|
||||
isExpanded && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="flex flex-col gap-3 border-t bg-muted/20 p-3">
|
||||
{truncatedArgs && (
|
||||
<div>
|
||||
<p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Arguments
|
||||
</p>
|
||||
<pre className="max-h-48 overflow-auto rounded-md bg-background p-2 text-[11px] text-foreground/80">
|
||||
{truncatedArgs}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{action.error && (
|
||||
<div>
|
||||
<p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Error
|
||||
</p>
|
||||
<pre className="max-h-32 overflow-auto rounded-md bg-destructive/10 p-2 text-[11px] text-destructive">
|
||||
{JSON.stringify(action.error, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{action.reverse_descriptor && (
|
||||
<div>
|
||||
<p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Reverse plan
|
||||
</p>
|
||||
<pre className="max-h-32 overflow-auto rounded-md bg-background p-2 text-[11px] text-foreground/80">
|
||||
{JSON.stringify(action.reverse_descriptor, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Action ID: <span className="font-mono">{action.id}</span>
|
||||
</p>
|
||||
{canRevert ? (
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="outline" className="gap-1.5">
|
||||
<RotateCcw className="size-3.5" />
|
||||
Revert
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Revert this action?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will undo <span className="font-medium">{displayName}</span> and append a
|
||||
new audit entry. The agent's chat history is preserved — only the tool's
|
||||
effects on your knowledge base or connectors will be reversed where possible.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isReverting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleRevert();
|
||||
}}
|
||||
disabled={isReverting}
|
||||
>
|
||||
{isReverting ? "Reverting…" : "Revert"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<ShieldOff className="size-3.5" />
|
||||
{isAlreadyReverted
|
||||
? "Already reverted"
|
||||
: isRevertAction
|
||||
? "Revert entry"
|
||||
: hasError
|
||||
? "Cannot revert errored action"
|
||||
: "Not reversible"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
surfsense_web/components/agent-action-log/action-log-sheet.tsx
Normal file
185
surfsense_web/components/agent-action-log/action-log-sheet.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { Activity, RefreshCcw } from "lucide-react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { actionLogSheetAtom } from "@/atoms/agent/action-log-sheet.atom";
|
||||
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { agentActionsApiService } from "@/lib/apis/agent-actions-api.service";
|
||||
import { ActionLogItem } from "./action-log-item";
|
||||
|
||||
const ACTION_LOG_PAGE_SIZE = 50;
|
||||
|
||||
function actionLogQueryKey(threadId: number) {
|
||||
return ["agent-actions", threadId] as const;
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
|
||||
<Activity className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium">No actions logged yet</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Once the agent calls a tool in this thread, it will show up here. From the log you can
|
||||
inspect arguments and revert reversible actions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DisabledState() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
|
||||
<Activity className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium">Action log is disabled</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This deployment hasn't enabled the agent action log. An admin can flip
|
||||
<code className="ml-1 rounded bg-muted px-1 text-[10px]">
|
||||
SURFSENSE_ENABLE_ACTION_LOG
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SKELETON_KEYS = ["s1", "s2", "s3", "s4"] as const;
|
||||
|
||||
function LoadingState() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
{SKELETON_KEYS.map((key) => (
|
||||
<Skeleton key={key} className="h-16 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActionLogSheet() {
|
||||
const [state, setState] = useAtom(actionLogSheetAtom);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: flags } = useAtomValue(agentFlagsAtom);
|
||||
const actionLogEnabled = !!flags?.enable_action_log && !flags?.disable_new_agent_stack;
|
||||
const revertEnabled = !!flags?.enable_revert_route && !flags?.disable_new_agent_stack;
|
||||
|
||||
const threadId = state.threadId;
|
||||
|
||||
const { data, isLoading, isFetching, isError, error, refetch } = useQuery({
|
||||
queryKey: threadId !== null ? actionLogQueryKey(threadId) : ["agent-actions", "none"],
|
||||
queryFn: () =>
|
||||
agentActionsApiService.listForThread(threadId as number, {
|
||||
page: 0,
|
||||
pageSize: ACTION_LOG_PAGE_SIZE,
|
||||
}),
|
||||
enabled: state.open && threadId !== null && actionLogEnabled,
|
||||
staleTime: 15 * 1000,
|
||||
});
|
||||
|
||||
const handleRevertSuccess = useCallback(() => {
|
||||
if (threadId !== null) {
|
||||
queryClient.invalidateQueries({ queryKey: actionLogQueryKey(threadId) });
|
||||
}
|
||||
}, [queryClient, threadId]);
|
||||
|
||||
const items = useMemo(() => data?.items ?? [], [data]);
|
||||
|
||||
return (
|
||||
<Sheet open={state.open} onOpenChange={(open) => setState((s) => ({ ...s, open }))}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex h-full w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-md"
|
||||
>
|
||||
<SheetHeader className="shrink-0 border-b px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="size-4 text-muted-foreground" />
|
||||
<SheetTitle className="text-base font-semibold">Agent actions</SheetTitle>
|
||||
{data?.total !== undefined && data.total > 0 && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{data.total}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching || !actionLogEnabled}
|
||||
className="size-8 p-0"
|
||||
aria-label="Refresh action log"
|
||||
>
|
||||
<RefreshCcw className={isFetching ? "size-3.5 animate-spin" : "size-3.5"} />
|
||||
</Button>
|
||||
</div>
|
||||
<SheetDescription className="text-xs text-muted-foreground">
|
||||
Audit trail of every tool call the agent made in this thread.
|
||||
{revertEnabled
|
||||
? " Reversible actions can be undone in place."
|
||||
: " Reverts are read-only on this deployment."}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto scrollbar-thin">
|
||||
{!actionLogEnabled ? (
|
||||
<DisabledState />
|
||||
) : threadId === null ? (
|
||||
<EmptyState />
|
||||
) : isLoading ? (
|
||||
<LoadingState />
|
||||
) : isError ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-6 text-center">
|
||||
<p className="text-sm font-medium text-destructive">Failed to load actions</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{error instanceof Error ? error.message : "Unknown error"}
|
||||
</p>
|
||||
<Button size="sm" variant="outline" onClick={() => refetch()}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
{items.map((action) => (
|
||||
<ActionLogItem
|
||||
key={action.id}
|
||||
action={action}
|
||||
threadId={threadId}
|
||||
onRevertSuccess={handleRevertSuccess}
|
||||
/>
|
||||
))}
|
||||
{data?.has_more && (
|
||||
<p className="py-2 text-center text-[11px] text-muted-foreground">
|
||||
Showing {items.length} of {data.total}. Older actions are paginated.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
|
@ -85,10 +85,13 @@ function preprocessMarkdown(content: string): string {
|
|||
}
|
||||
);
|
||||
|
||||
// All math forms are normalised to $$...$$ so we can disable single-dollar
|
||||
// inline math in remark-math (otherwise currency like "$3,120.00 and $0.00"
|
||||
// gets parsed as a LaTeX expression).
|
||||
// 1. Block math: \[...\] → $$...$$
|
||||
content = content.replace(/\\\[([\s\S]*?)\\\]/g, (_, inner) => `$$${inner}$$`);
|
||||
// 2. Inline math: \(...\) → $...$
|
||||
content = content.replace(/\\\(([\s\S]*?)\\\)/g, (_, inner) => `$${inner}$`);
|
||||
// 2. Inline math: \(...\) → $$...$$
|
||||
content = content.replace(/\\\(([\s\S]*?)\\\)/g, (_, inner) => `$$${inner}$$`);
|
||||
// 3. Block: \begin{equation}...\end{equation} → $$...$$
|
||||
content = content.replace(
|
||||
/\\begin\{equation\}([\s\S]*?)\\end\{equation\}/g,
|
||||
|
|
@ -99,8 +102,11 @@ function preprocessMarkdown(content: string): string {
|
|||
/\\begin\{displaymath\}([\s\S]*?)\\end\{displaymath\}/g,
|
||||
(_, inner) => `$$${inner}$$`
|
||||
);
|
||||
// 5. Inline: \begin{math}...\end{math} → $...$
|
||||
content = content.replace(/\\begin\{math\}([\s\S]*?)\\end\{math\}/g, (_, inner) => `$${inner}$`);
|
||||
// 5. Inline: \begin{math}...\end{math} → $$...$$
|
||||
content = content.replace(
|
||||
/\\begin\{math\}([\s\S]*?)\\end\{math\}/g,
|
||||
(_, inner) => `$$${inner}$$`
|
||||
);
|
||||
// 6. Strip backtick wrapping around math: `$$...$$` → $$...$$ and `$...$` → $...$
|
||||
content = content.replace(/`(\${1,2})((?:(?!\1).)+)\1`/g, "$1$2$1");
|
||||
|
||||
|
|
@ -180,7 +186,7 @@ const MarkdownTextImpl = () => {
|
|||
return (
|
||||
<MarkdownTextPrimitive
|
||||
smooth={false}
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
remarkPlugins={[remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
className="aui-md"
|
||||
components={defaultComponents}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, XCircleIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
DoomLoopApprovalToolUI,
|
||||
isDoomLoopInterrupt,
|
||||
} from "@/components/tool-ui/doom-loop-approval";
|
||||
import { GenericHitlApprovalToolUI } from "@/components/tool-ui/generic-hitl-approval";
|
||||
import { getToolIcon } from "@/contracts/enums/toolIcons";
|
||||
import { isInterruptResult } from "@/lib/hitl";
|
||||
|
|
@ -150,6 +154,9 @@ const DefaultToolFallbackInner: ToolCallMessagePartComponent = ({
|
|||
|
||||
export const ToolFallback: ToolCallMessagePartComponent = (props) => {
|
||||
if (isInterruptResult(props.result)) {
|
||||
if (isDoomLoopInterrupt(props.result)) {
|
||||
return <DoomLoopApprovalToolUI {...props} />;
|
||||
}
|
||||
return <GenericHitlApprovalToolUI {...props} />;
|
||||
}
|
||||
return <DefaultToolFallbackInner {...props} />;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog";
|
||||
import { TeamDialog } from "@/components/settings/team-dialog";
|
||||
import { ActionLogSheet } from "@/components/agent-action-log/action-log-sheet";
|
||||
import { UserSettingsDialog } from "@/components/settings/user-settings-dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
|
|
@ -909,6 +910,9 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
<SearchSpaceSettingsDialog searchSpaceId={Number(searchSpaceId)} />
|
||||
<UserSettingsDialog />
|
||||
<TeamDialog searchSpaceId={Number(searchSpaceId)} />
|
||||
|
||||
{/* Agent action log + revert sheet */}
|
||||
<ActionLogSheet />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";
|
|||
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { activeTabAtom, tabsAtom } from "@/atoms/tabs/tabs.atom";
|
||||
import { ActionLogButton } from "@/components/agent-action-log/action-log-button";
|
||||
import { ChatHeader } from "@/components/new-chat/chat-header";
|
||||
import { ChatShareButton } from "@/components/new-chat/chat-share-button";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
|
|
@ -69,6 +70,7 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
|
|||
|
||||
{/* Right side - Actions */}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{hasThread && <ActionLogButton threadId={currentThreadState.id} />}
|
||||
{hasThread && (
|
||||
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ const code = createCodePlugin({
|
|||
});
|
||||
|
||||
const math = createMathPlugin({
|
||||
singleDollarTextMath: true,
|
||||
// Disabled so currency like "$3,120.00 and ... $0.00" isn't parsed as
|
||||
// inline LaTeX. convertLatexDelimiters() below normalises any genuine
|
||||
// inline math (\(...\), $...$ starting with a LaTeX command, etc.) to
|
||||
// $$...$$, so this flip doesn't lose any math rendering.
|
||||
singleDollarTextMath: false,
|
||||
});
|
||||
|
||||
interface MarkdownViewerProps {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
Activity,
|
||||
Brain,
|
||||
CircleUser,
|
||||
Globe,
|
||||
|
|
@ -9,6 +10,7 @@ import {
|
|||
KeyRound,
|
||||
Monitor,
|
||||
ReceiptText,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
|
|
@ -74,6 +76,20 @@ const MemoryContent = dynamic(
|
|||
),
|
||||
{ ssr: false }
|
||||
);
|
||||
const AgentPermissionsContent = dynamic(
|
||||
() =>
|
||||
import(
|
||||
"@/app/dashboard/[search_space_id]/user-settings/components/AgentPermissionsContent"
|
||||
).then((m) => ({ default: m.AgentPermissionsContent })),
|
||||
{ ssr: false }
|
||||
);
|
||||
const AgentStatusContent = dynamic(
|
||||
() =>
|
||||
import("@/app/dashboard/[search_space_id]/user-settings/components/AgentStatusContent").then(
|
||||
(m) => ({ default: m.AgentStatusContent })
|
||||
),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
export function UserSettingsDialog() {
|
||||
const t = useTranslations("userSettings");
|
||||
|
|
@ -103,6 +119,16 @@ export function UserSettingsDialog() {
|
|||
label: "Memory",
|
||||
icon: <Brain className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "agent-permissions",
|
||||
label: "Agent Permissions",
|
||||
icon: <ShieldCheck className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "agent-status",
|
||||
label: "Agent Status",
|
||||
icon: <Activity className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "purchases",
|
||||
label: "Purchase History",
|
||||
|
|
@ -141,6 +167,8 @@ export function UserSettingsDialog() {
|
|||
{state.initialTab === "prompts" && <PromptsContent />}
|
||||
{state.initialTab === "community-prompts" && <CommunityPromptsContent />}
|
||||
{state.initialTab === "memory" && <MemoryContent />}
|
||||
{state.initialTab === "agent-permissions" && <AgentPermissionsContent />}
|
||||
{state.initialTab === "agent-status" && <AgentStatusContent />}
|
||||
{state.initialTab === "purchases" && <PurchaseHistoryContent />}
|
||||
{state.initialTab === "desktop" && <DesktopContent />}
|
||||
{state.initialTab === "desktop-shortcuts" && <DesktopShortcutsContent />}
|
||||
|
|
|
|||
187
surfsense_web/components/tool-ui/doom-loop-approval.tsx
Normal file
187
surfsense_web/components/tool-ui/doom-loop-approval.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
"use client";
|
||||
|
||||
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
|
||||
import { CornerDownLeftIcon, OctagonAlert } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
|
||||
/**
|
||||
* Specialized HITL card for ``DoomLoopMiddleware`` interrupts. The
|
||||
* backend signals these by setting ``context.permission === "doom_loop"``
|
||||
* on the ``permission_ask`` interrupt.
|
||||
*
|
||||
* The card replaces the generic "approve/reject" framing with a
|
||||
* "continue/stop" affordance that better matches the user's mental
|
||||
* model: the agent is stuck repeating itself, not asking permission
|
||||
* for a destructive action.
|
||||
*/
|
||||
function DoomLoopCard({
|
||||
toolName,
|
||||
args,
|
||||
interruptData,
|
||||
onDecision,
|
||||
}: {
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
|
||||
const context = (interruptData.context ?? {}) as Record<string, unknown>;
|
||||
const threshold = typeof context.threshold === "number" ? context.threshold : 3;
|
||||
const stuckTool = (typeof context.tool === "string" && context.tool) || toolName;
|
||||
const recentSignatures = Array.isArray(context.recent_signatures)
|
||||
? (context.recent_signatures as string[])
|
||||
: [];
|
||||
const displayName = stuckTool.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
const argPreview = useMemo(() => {
|
||||
if (!args || Object.keys(args).length === 0) return null;
|
||||
try {
|
||||
const json = JSON.stringify(args, null, 2);
|
||||
return json.length > 600 ? `${json.slice(0, 600)}…` : json;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [args]);
|
||||
|
||||
const handleContinue = useCallback(() => {
|
||||
if (phase !== "pending") return;
|
||||
setProcessing();
|
||||
onDecision({ type: "approve" });
|
||||
}, [phase, setProcessing, onDecision]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (phase !== "pending") return;
|
||||
setRejected();
|
||||
onDecision({ type: "reject", message: "Doom loop: user requested stop." });
|
||||
}, [phase, setRejected, onDecision]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (phase !== "pending") return;
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
handleStop();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [phase, handleStop]);
|
||||
|
||||
const isResolved = phase === "complete" || phase === "rejected";
|
||||
|
||||
return (
|
||||
<Alert variant={phase === "rejected" ? "default" : "destructive"} className="my-4 max-w-lg">
|
||||
<OctagonAlert className="size-4" />
|
||||
<AlertTitle className="flex items-center gap-2">
|
||||
<span>
|
||||
{phase === "rejected"
|
||||
? "Stopped"
|
||||
: phase === "processing"
|
||||
? "Continuing…"
|
||||
: phase === "complete"
|
||||
? "Continued"
|
||||
: "I might be stuck"}
|
||||
</span>
|
||||
{!isResolved && (
|
||||
<Badge variant="outline" className="font-mono text-[10px]">
|
||||
doom-loop
|
||||
</Badge>
|
||||
)}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-3">
|
||||
{phase === "processing" ? (
|
||||
<TextShimmerLoader text="Resuming…" size="sm" />
|
||||
) : phase === "rejected" ? (
|
||||
<p className="text-xs">
|
||||
I stopped retrying <span className="font-medium">{displayName}</span> as you asked.
|
||||
</p>
|
||||
) : phase === "complete" ? (
|
||||
<p className="text-xs">
|
||||
Continuing to call <span className="font-medium">{displayName}</span> as you asked.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs">
|
||||
I called <span className="font-medium">{displayName}</span> {threshold} times in a row
|
||||
with similar arguments. Should I keep going or stop and rethink?
|
||||
</p>
|
||||
)}
|
||||
|
||||
{argPreview && phase === "pending" && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Last arguments
|
||||
</p>
|
||||
<pre className="max-h-32 overflow-auto rounded-md bg-muted/50 p-2 text-[11px] text-foreground/80">
|
||||
{argPreview}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{recentSignatures.length > 0 && phase === "pending" && (
|
||||
<details className="text-[11px] text-muted-foreground">
|
||||
<summary className="cursor-pointer select-none">
|
||||
Show repeated signatures ({recentSignatures.length})
|
||||
</summary>
|
||||
<ul className="mt-1 ml-4 list-disc">
|
||||
{recentSignatures.map((sig) => (
|
||||
<li key={sig} className="font-mono break-all">
|
||||
{sig}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{phase === "pending" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" className="rounded-lg gap-1.5" onClick={handleStop}>
|
||||
Stop and rethink
|
||||
<CornerDownLeftIcon className="size-3 opacity-60" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleContinue}>
|
||||
Continue anyway
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export const DoomLoopApprovalToolUI: ToolCallMessagePartComponent = ({
|
||||
toolName,
|
||||
args,
|
||||
result,
|
||||
}) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
|
||||
if (!result || !isInterruptResult(result)) return null;
|
||||
|
||||
return (
|
||||
<DoomLoopCard
|
||||
toolName={toolName}
|
||||
args={args as Record<string, unknown>}
|
||||
interruptData={result}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export function isDoomLoopInterrupt(result: unknown): boolean {
|
||||
if (!isInterruptResult(result)) return false;
|
||||
const ctx = (result.context ?? {}) as Record<string, unknown>;
|
||||
return ctx.permission === "doom_loop";
|
||||
}
|
||||
64
surfsense_web/lib/apis/agent-actions-api.service.ts
Normal file
64
surfsense_web/lib/apis/agent-actions-api.service.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { z } from "zod";
|
||||
import { baseApiService } from "./base-api.service";
|
||||
|
||||
const AgentActionReadSchema = z.object({
|
||||
id: z.number(),
|
||||
thread_id: z.number(),
|
||||
user_id: z.string().nullable(),
|
||||
search_space_id: z.number(),
|
||||
tool_name: z.string(),
|
||||
args: z.record(z.string(), z.unknown()).nullable(),
|
||||
result_id: z.string().nullable(),
|
||||
reversible: z.boolean(),
|
||||
reverse_descriptor: z.record(z.string(), z.unknown()).nullable(),
|
||||
error: z.record(z.string(), z.unknown()).nullable(),
|
||||
reverse_of: z.number().nullable(),
|
||||
reverted_by_action_id: z.number().nullable(),
|
||||
is_revert_action: z.boolean(),
|
||||
created_at: z.string(),
|
||||
});
|
||||
|
||||
export type AgentAction = z.infer<typeof AgentActionReadSchema>;
|
||||
|
||||
const AgentActionListResponseSchema = z.object({
|
||||
items: z.array(AgentActionReadSchema),
|
||||
total: z.number(),
|
||||
page: z.number(),
|
||||
page_size: z.number(),
|
||||
has_more: z.boolean(),
|
||||
});
|
||||
|
||||
export type AgentActionListResponse = z.infer<typeof AgentActionListResponseSchema>;
|
||||
|
||||
const RevertResponseSchema = z.object({
|
||||
status: z.literal("ok"),
|
||||
message: z.string(),
|
||||
new_action_id: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
export type RevertResponse = z.infer<typeof RevertResponseSchema>;
|
||||
|
||||
class AgentActionsApiService {
|
||||
listForThread = async (
|
||||
threadId: number,
|
||||
opts: { page?: number; pageSize?: number } = {}
|
||||
): Promise<AgentActionListResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", String(opts.page ?? 0));
|
||||
params.set("page_size", String(opts.pageSize ?? 50));
|
||||
return baseApiService.get(
|
||||
`/api/v1/threads/${threadId}/actions?${params.toString()}`,
|
||||
AgentActionListResponseSchema
|
||||
);
|
||||
};
|
||||
|
||||
revert = async (threadId: number, actionId: number): Promise<RevertResponse> => {
|
||||
return baseApiService.post(
|
||||
`/api/v1/threads/${threadId}/revert/${actionId}`,
|
||||
RevertResponseSchema,
|
||||
{ body: {} }
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const agentActionsApiService = new AgentActionsApiService();
|
||||
40
surfsense_web/lib/apis/agent-flags-api.service.ts
Normal file
40
surfsense_web/lib/apis/agent-flags-api.service.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { z } from "zod";
|
||||
import { baseApiService } from "./base-api.service";
|
||||
|
||||
const AgentFeatureFlagsSchema = z.object({
|
||||
disable_new_agent_stack: z.boolean(),
|
||||
|
||||
enable_context_editing: z.boolean(),
|
||||
enable_compaction_v2: z.boolean(),
|
||||
enable_retry_after: z.boolean(),
|
||||
enable_model_fallback: z.boolean(),
|
||||
enable_model_call_limit: z.boolean(),
|
||||
enable_tool_call_limit: z.boolean(),
|
||||
enable_tool_call_repair: z.boolean(),
|
||||
enable_doom_loop: z.boolean(),
|
||||
|
||||
enable_permission: z.boolean(),
|
||||
enable_busy_mutex: z.boolean(),
|
||||
enable_llm_tool_selector: z.boolean(),
|
||||
|
||||
enable_skills: z.boolean(),
|
||||
enable_specialized_subagents: z.boolean(),
|
||||
enable_kb_planner_runnable: z.boolean(),
|
||||
|
||||
enable_action_log: z.boolean(),
|
||||
enable_revert_route: z.boolean(),
|
||||
|
||||
enable_plugin_loader: z.boolean(),
|
||||
|
||||
enable_otel: z.boolean(),
|
||||
});
|
||||
|
||||
export type AgentFeatureFlags = z.infer<typeof AgentFeatureFlagsSchema>;
|
||||
|
||||
class AgentFlagsApiService {
|
||||
get = async (): Promise<AgentFeatureFlags> => {
|
||||
return baseApiService.get(`/api/v1/agent/flags`, AgentFeatureFlagsSchema);
|
||||
};
|
||||
}
|
||||
|
||||
export const agentFlagsApiService = new AgentFlagsApiService();
|
||||
90
surfsense_web/lib/apis/agent-permissions-api.service.ts
Normal file
90
surfsense_web/lib/apis/agent-permissions-api.service.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { z } from "zod";
|
||||
import { ValidationError } from "@/lib/error";
|
||||
import { baseApiService } from "./base-api.service";
|
||||
|
||||
const ActionEnum = z.enum(["allow", "deny", "ask"]);
|
||||
export type AgentPermissionAction = z.infer<typeof ActionEnum>;
|
||||
|
||||
const AgentPermissionRuleSchema = z.object({
|
||||
id: z.number(),
|
||||
search_space_id: z.number(),
|
||||
user_id: z.string().nullable(),
|
||||
thread_id: z.number().nullable(),
|
||||
permission: z.string(),
|
||||
pattern: z.string(),
|
||||
action: ActionEnum,
|
||||
created_at: z.string(),
|
||||
});
|
||||
|
||||
export type AgentPermissionRule = z.infer<typeof AgentPermissionRuleSchema>;
|
||||
|
||||
const AgentPermissionRuleListSchema = z.array(AgentPermissionRuleSchema);
|
||||
|
||||
const AgentPermissionRuleCreateSchema = z.object({
|
||||
permission: z
|
||||
.string()
|
||||
.min(1, "Permission is required")
|
||||
.max(255)
|
||||
.regex(/^[a-zA-Z0-9_:.\-*]+$/, "Use letters, digits, '.', '_', ':', '-', or '*' wildcards."),
|
||||
pattern: z.string().min(1).max(255).default("*"),
|
||||
action: ActionEnum,
|
||||
user_id: z.string().nullable().optional(),
|
||||
thread_id: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
export type AgentPermissionRuleCreate = z.infer<typeof AgentPermissionRuleCreateSchema>;
|
||||
|
||||
const AgentPermissionRuleUpdateSchema = z.object({
|
||||
pattern: z.string().min(1).max(255).optional(),
|
||||
action: ActionEnum.optional(),
|
||||
});
|
||||
|
||||
export type AgentPermissionRuleUpdate = z.infer<typeof AgentPermissionRuleUpdateSchema>;
|
||||
|
||||
class AgentPermissionsApiService {
|
||||
list = async (searchSpaceId: number): Promise<AgentPermissionRule[]> => {
|
||||
return baseApiService.get(
|
||||
`/api/v1/searchspaces/${searchSpaceId}/agent/permissions/rules`,
|
||||
AgentPermissionRuleListSchema
|
||||
);
|
||||
};
|
||||
|
||||
create = async (
|
||||
searchSpaceId: number,
|
||||
payload: AgentPermissionRuleCreate
|
||||
): Promise<AgentPermissionRule> => {
|
||||
const parsed = AgentPermissionRuleCreateSchema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
throw new ValidationError(parsed.error.issues.map((i) => i.message).join(", "));
|
||||
}
|
||||
return baseApiService.post(
|
||||
`/api/v1/searchspaces/${searchSpaceId}/agent/permissions/rules`,
|
||||
AgentPermissionRuleSchema,
|
||||
{ body: parsed.data }
|
||||
);
|
||||
};
|
||||
|
||||
update = async (
|
||||
searchSpaceId: number,
|
||||
ruleId: number,
|
||||
payload: AgentPermissionRuleUpdate
|
||||
): Promise<AgentPermissionRule> => {
|
||||
const parsed = AgentPermissionRuleUpdateSchema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
throw new ValidationError(parsed.error.issues.map((i) => i.message).join(", "));
|
||||
}
|
||||
return baseApiService.patch(
|
||||
`/api/v1/searchspaces/${searchSpaceId}/agent/permissions/rules/${ruleId}`,
|
||||
AgentPermissionRuleSchema,
|
||||
{ body: parsed.data }
|
||||
);
|
||||
};
|
||||
|
||||
remove = async (searchSpaceId: number, ruleId: number): Promise<void> => {
|
||||
await baseApiService.delete(
|
||||
`/api/v1/searchspaces/${searchSpaceId}/agent/permissions/rules/${ruleId}`
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const agentPermissionsApiService = new AgentPermissionsApiService();
|
||||
Loading…
Add table
Add a link
Reference in a new issue