feat: updated agent harness

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-04-28 09:22:19 -07:00
parent 9ec9b64348
commit 31a372bb84
139 changed files with 12583 additions and 1111 deletions

View file

@ -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>
);
}

View file

@ -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>
);
}

View 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 });
});

View 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(),
}));

View file

@ -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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -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}

View file

@ -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} />;

View file

@ -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 />
</>
);
}

View file

@ -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} />
)}

View file

@ -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 {

View file

@ -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 />}

View 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";
}

View 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();

View 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();

View 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();