AkurAI Build
Menu

popagent

public

Latest change 682410a285bb0a73662cc4650c92c31c918e1cc9 - Add idle autonomous improvement workflow by Ólafur Búi Ólafsson

import { useCallback, useEffect, useRef, useState } from "react";
import { AlertTriangle, CheckCircle2, Clock3, RotateCcw, ShieldCheck } from "lucide-react";
import type {
  AgentWorkspace,
  AutonomySettings,
  AutomationStatus,
  EvolutionRevision,
  EvolutionRevisionListResponse,
  EvolutionSignal,
  EvolutionSignalListResponse,
} from "../api-types";
import { apiFetch } from "./api";
import { toast } from "./toast";
import { useFocusTrap } from "./use-focus-trap";

const HISTORY_LIMIT = 100;
const HISTORY_REFRESH_MS = 2_000;
export type AutonomyHistoryPoll = {
  signal: AbortSignal;
  stop: () => void;
};

export function startAutonomyHistoryPolling(
  refresh: (signal: AbortSignal, initial: boolean) => Promise<void>,
  intervalMs = HISTORY_REFRESH_MS,
): AutonomyHistoryPoll {
  const controller = new AbortController();
  let timer: Timer | undefined;
  let initial = true;
  const run = async () => {
    if (controller.signal.aborted) return;
    try {
      await refresh(controller.signal, initial);
    } catch {
      // Preserve the last rendered history and retry after transient refresh failures.
    }
    initial = false;
    if (!controller.signal.aborted) timer = setTimeout(() => void run(), intervalMs);
  };
  void run();
  return {
    signal: controller.signal,
    stop: () => {
      controller.abort();
      clearTimeout(timer);
    },
  };
}

function dateTime(value: string): string {
  return new Date(value).toLocaleString();
}

const STATUS_CLASSES: Record<string, string> = {
  applied: "text-emerald-500",
  "dead-letter": "text-red-400",
  reverted: "text-amber-500",
  ignored: "text-amber-500",
};

export function countAutonomySignalHealth(signals: EvolutionSignal[]) {
  const counts = { pending: 0, processing: 0, applied: 0, ignored: 0, retried: 0, deadLetters: 0 };
  for (const signal of signals) {
    if (signal.status === "pending") counts.pending++;
    else if (signal.status === "processing") counts.processing++;
    else if (signal.status === "applied") counts.applied++;
    else if (signal.status === "ignored") counts.ignored++;
    else counts.deadLetters++;
    if (signal.attempts > 1) counts.retried++;
  }
  return counts;
}

export function AutonomySignalList({ signals }: { signals: EvolutionSignal[] }) {
  if (!signals.length) return <p className="text-sm text-ink-dim">No evolution signals have been recorded.</p>;
  return <ol className="space-y-2">
    {signals.map((signal) => <li key={signal.id} className="rounded border border-line bg-bg p-3">
      <div className="flex flex-wrap items-start justify-between gap-2">
        <div className="min-w-0">
          <p className="text-sm leading-relaxed">{signal.summary}</p>
          <p className="mt-1 text-xs text-ink-dim">{signal.kind} · {signal.agentId}{signal.workspaceId ? ` · ${signal.workspaceId}` : ""} · {dateTime(signal.createdAt)}</p>
        </div>
        <span className={`text-xs font-semibold uppercase tracking-wide ${STATUS_CLASSES[signal.status] ?? "text-ink-dim"}`}>{signal.status}</span>
      </div>
      <p className="mt-2 text-xs text-ink-dim">{signal.attempts === 1 ? "1 attempt" : `${signal.attempts} attempts`}{signal.nextAttemptAt ? ` · retry ${dateTime(signal.nextAttemptAt)}` : ""}{signal.turnId ? ` · turn ${signal.turnId}` : ""}{signal.traceId ? ` · trace ${signal.traceId}` : ""}</p>
      {signal.error ? <p className="mt-2 rounded border border-red-500/20 bg-red-500/5 px-3 py-2 text-xs text-red-400">{signal.error}</p> : null}
    </li>)}
  </ol>;
}

export function AutonomyRevisionList({
  revisions,
  busyId,
  onRevert,
}: {
  revisions: EvolutionRevision[];
  busyId?: string;
  onRevert: (revision: EvolutionRevision) => void;
}) {
  if (!revisions.length) return <p className="text-sm text-ink-dim">No learned changes have been applied.</p>;
  const latestTargets = new Set<string>();
  const revertedRevisionIds = new Set(revisions.flatMap((revision) =>
    revision.revertsRevisionId ? [revision.revertsRevisionId] : []
  ));
  const revertibleIds = new Set<string>();
  for (const revision of revisions) {
    const target = `${revision.agentId}\0${revision.targetType}\0${revision.targetKey}`;
    if (latestTargets.has(target)) continue;
    latestTargets.add(target);
    if (revision.status === "applied" && !revertedRevisionIds.has(revision.id)) {
      revertibleIds.add(revision.id);
    }
  }
  return <div className="space-y-3">
    {revisions.map((revision) => <article key={revision.id} className="rounded border border-line bg-bg p-4">
      <div className="flex flex-wrap items-start justify-between gap-3">
        <div className="min-w-0">
          <p className="font-medium">{revision.targetType === "overlay" ? "Learned instruction overlay" : "Agent-created skill"}</p>
          <p className="mt-1 break-all font-mono text-xs text-ink-dim">{revision.agentId} / {revision.targetKey}</p>
        </div>
        <span className={`text-xs font-semibold uppercase tracking-wide ${STATUS_CLASSES[revision.status] ?? "text-ink-dim"}`}>{revision.status}</span>
      </div>
      <p className="mt-3 text-sm leading-relaxed text-ink-muted">{revision.rationale}</p>
      <p className="mt-2 break-all text-xs text-ink-dim">{dateTime(revision.appliedAt)} · {revision.evidenceIds.length} evidence {revision.evidenceIds.length === 1 ? "item" : "items"}{revision.revertsRevisionId ? ` · reverts ${revision.revertsRevisionId}` : ""}</p>
      <details className="mt-3 border-t border-line pt-3 text-sm">
        <summary className="cursor-pointer text-ink-muted outline-none focus:text-ink">Inspect learned change</summary>
        <div className="mt-3 grid gap-3 lg:grid-cols-2">
          <div><h3 className="text-xs font-semibold uppercase tracking-wide text-ink-dim">Before</h3><pre className="mt-1 max-h-64 overflow-auto whitespace-pre-wrap rounded bg-panel-2 p-3 text-xs">{revision.beforeContent || "Empty"}</pre></div>
          <div><h3 className="text-xs font-semibold uppercase tracking-wide text-ink-dim">After</h3><pre className="mt-1 max-h-64 overflow-auto whitespace-pre-wrap rounded bg-panel-2 p-3 text-xs">{revision.afterContent || "Empty"}</pre></div>
        </div>
      </details>
      {revertibleIds.has(revision.id) ? <div className="mt-3 flex justify-end"><button type="button" disabled={Boolean(busyId)} onClick={() => onRevert(revision)} className="flex min-h-11 items-center gap-2 rounded border border-amber-500/30 px-3 text-sm text-amber-500 disabled:opacity-50"><RotateCcw className="size-4" />{busyId === revision.id ? "Reverting…" : "Revert change"}</button></div> : null}
    </article>)}
  </div>;
}

function NumberField({ label, value, min, max, step = 1, onChange }: { label: string; value: number; min: number; max: number; step?: number; onChange: (value: number) => void }) {
  return <label className="text-sm font-medium">{label}<input type="number" aria-label={label} value={value} min={min} max={max} step={step} onChange={(event) => { const next = event.currentTarget.valueAsNumber; if (Number.isFinite(next)) onChange(next); }} className="mt-2 min-h-11 w-full rounded border border-line bg-bg px-3 text-ink outline-none focus:border-accent/50" /></label>;
}

export function AutonomySettingsPanel() {
  const [settings, setSettings] = useState<AutonomySettings>();
  const [signals, setSignals] = useState<EvolutionSignal[]>();
  const [revisions, setRevisions] = useState<EvolutionRevision[]>();
  const [workspaces, setWorkspaces] = useState<AgentWorkspace[]>([]);
  const [status, setStatus] = useState<AutomationStatus>();
  const [loadError, setLoadError] = useState("");
  const [saving, setSaving] = useState(false);
  const [reverting, setReverting] = useState<string>();
  const [confirmRevision, setConfirmRevision] = useState<EvolutionRevision>();
  const confirmRef = useRef<HTMLDivElement>(null);
  const cancelRef = useRef<HTMLButtonElement>(null);
  const lifecycleSignalRef = useRef<AbortSignal | null>(null);
  const historyRequestRef = useRef(0);
  const historyAppliedRef = useRef(0);
  useFocusTrap(confirmRef, Boolean(confirmRevision));

  const fetchHistory = useCallback(async (signal?: AbortSignal) => {
    const [signalsResponse, revisionsResponse] = await Promise.all([
      apiFetch(`/api/autonomy/signals?limit=${HISTORY_LIMIT}`, { signal }),
      apiFetch(`/api/autonomy/revisions?limit=${HISTORY_LIMIT}`, { signal }),
    ]);
    if (!signalsResponse.ok || !revisionsResponse.ok) {
      throw new Error("Unable to load autonomy state.");
    }
    const [signalHistory, revisionHistory] = await Promise.all([
      signalsResponse.json() as Promise<EvolutionSignalListResponse>,
      revisionsResponse.json() as Promise<EvolutionRevisionListResponse>,
    ]);
    return { signals: signalHistory.signals, revisions: revisionHistory.revisions };
  }, []);
  const load = useCallback(async (signal?: AbortSignal) => {
    const historyRequest = ++historyRequestRef.current;
    setLoadError("");
    try {
      const [settingsResponse, workspacesResponse, statusResponse, history] = await Promise.all([
        apiFetch("/api/settings/autonomy", { signal }),
        apiFetch("/api/workspaces", { signal }),
        apiFetch("/api/autonomy/status", { signal }),
        fetchHistory(signal),
      ]);
      if (!settingsResponse.ok || !workspacesResponse.ok || !statusResponse.ok) throw new Error("Unable to load autonomy state.");
      const [nextSettings, workspaceResult, nextStatus] = await Promise.all([
        settingsResponse.json() as Promise<AutonomySettings>,
        workspacesResponse.json() as Promise<{ workspaces: AgentWorkspace[] }>,
        statusResponse.json() as Promise<AutomationStatus>,
      ]);
      if (signal?.aborted) return;
      setSettings(nextSettings);
      setWorkspaces(workspaceResult.workspaces);
      setStatus(nextStatus);
      if (historyRequest < historyAppliedRef.current) return;
      historyAppliedRef.current = historyRequest;
      setSignals(history.signals);
      setRevisions(history.revisions);
    } catch (error) {
      if (signal?.aborted || historyRequest < historyAppliedRef.current) return;
      setLoadError(error instanceof Error ? error.message : "Unable to load autonomy state.");
    }
  }, [fetchHistory]);

  useEffect(() => {
    const poll = startAutonomyHistoryPolling(async (signal, initial) => {
      if (initial) {
        await load(signal);
        return;
      }
      const historyRequest = ++historyRequestRef.current;
      const history = await fetchHistory(signal);
      if (signal.aborted || historyRequest < historyAppliedRef.current) return;
      historyAppliedRef.current = historyRequest;
      setSignals(history.signals);
      setRevisions(history.revisions);
    });
    lifecycleSignalRef.current = poll.signal;
    return () => {
      poll.stop();
      if (lifecycleSignalRef.current === poll.signal) lifecycleSignalRef.current = null;
    };
  }, [fetchHistory, load]);
  useEffect(() => {
    if (!confirmRevision) return;
    cancelRef.current?.focus();
    const close = (event: KeyboardEvent) => {
      if (event.key === "Escape" && !reverting) setConfirmRevision(undefined);
    };
    document.addEventListener("keydown", close);
    return () => document.removeEventListener("keydown", close);
  }, [confirmRevision, reverting]);

  const update = <Key extends keyof AutonomySettings>(key: Key, value: AutonomySettings[Key]) => {
    setSettings((current) => current ? { ...current, [key]: value } : current);
  };
  const save = async () => {
    if (!settings) return;
    setSaving(true);
    try {
      const { updatedAt, lastUserActivityAt, ...input } = settings;
      void updatedAt;
      void lastUserActivityAt;
      const response = await apiFetch("/api/settings/autonomy", { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(input) });
      if (!response.ok) throw new Error("Unable to save autonomy settings.");
      setSettings(await response.json() as AutonomySettings);
      toast("Autonomy settings saved.");
    } catch (error) {
      toast(error instanceof Error ? error.message : "Unable to save autonomy settings.");
    } finally {
      setSaving(false);
    }
  };
  const revert = async () => {
    if (!confirmRevision) return;
    const id = confirmRevision.id;
    setReverting(id);
    try {
      const response = await apiFetch(`/api/autonomy/revisions/${encodeURIComponent(id)}/revert`, { method: "POST" });
      if (!response.ok) throw new Error(response.status === 404 ? "This revision can no longer be reverted." : "Unable to revert revision.");
      setConfirmRevision(undefined);
      await load(lifecycleSignalRef.current ?? undefined);
      toast("Revision reverted.");
    } catch (error) {
      toast(error instanceof Error ? error.message : "Unable to revert revision.");
    } finally {
      setReverting(undefined);
    }
  };

  if (loadError) return <div className="w-full max-w-5xl"><h1 className="text-xl font-semibold">Autonomy</h1><div role="alert" className="mt-6 rounded border border-red-500/20 bg-panel p-5"><div className="flex items-start gap-3"><AlertTriangle className="mt-0.5 size-5 text-red-400" /><div><p className="font-medium">Autonomy state unavailable</p><p className="mt-1 text-sm text-ink-dim">{loadError}</p><button type="button" onClick={() => void load(lifecycleSignalRef.current ?? undefined)} className="mt-4 min-h-11 rounded border border-line px-4 text-sm">Try again</button></div></div></div></div>;
  if (!settings || !signals || !revisions) return <div className="w-full max-w-5xl"><h1 className="text-xl font-semibold">Autonomy</h1><p role="status" className="mt-6 text-sm text-ink-dim">Loading autonomy state and history…</p></div>;

  const health = countAutonomySignalHealth(signals);
  return <div className="w-full max-w-5xl">
    <h1 className="text-xl font-semibold">Autonomy</h1>
    <p className="mt-1 text-sm text-ink-dim">Control bounded self-improvement, inspect its evidence, and undo learned changes without altering base instructions or capabilities.</p>

    <section className="mt-6 rounded border border-line bg-panel p-5" aria-labelledby="autonomy-controls-title">
      <div className="flex items-start gap-3"><ShieldCheck className="mt-0.5 size-5 text-accent" /><div><h2 id="autonomy-controls-title" className="font-medium">Safety-controlled learning</h2><p className="mt-1 text-xs leading-relaxed text-ink-dim">Learning is limited to a separate instruction overlay and agent-created skills. Workspace access, tools, secrets, hooks, authentication, and execution bounds remain fixed.</p></div></div>
      <div className="mt-5 grid gap-3 sm:grid-cols-2">
        {([
          ["enabled", "Enable self-improvement", "Reflect on bounded signals and propose learned changes."],
          ["autoApplyStrategies", "Auto-apply strategies", "Apply eligible learned instruction overlays automatically."],
          ["autoCreateSkills", "Auto-create skills", "Create agent-owned skills when repeated evidence supports one."],
          ["selfUpdateEnabled", "Enable source self-update", "Queue contained reviewed repository improvements."],
          ["idleImprovementEnabled", "Enable idle improvements", "Run one selected repository only after the idle grace period."],
          ["idleDeploymentEnabled", "Publish and deploy", "After review, use the repository's maintained publish and deploy path."],
        ] as const).map(([key, label, description]) => <label key={key} className="flex min-h-20 items-start justify-between gap-4 rounded border border-line bg-bg p-3"><span><span className="block text-sm font-medium">{label}</span><span className="mt-1 block text-xs leading-relaxed text-ink-dim">{description}</span></span><input type="checkbox" aria-label={label} checked={settings[key]} onChange={(event) => update(key, event.currentTarget.checked)} className="mt-1 size-5 shrink-0 accent-accent" /></label>)}
      </div>
      <div className="mt-5 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
        <NumberField label="Reflection interval (ms)" value={settings.reflectionIntervalMs} min={60_000} max={86_400_000} step={60_000} onChange={(value) => update("reflectionIntervalMs", value)} />
        <NumberField label="Signals per reflection" value={settings.batchSize} min={1} max={100} onChange={(value) => update("batchSize", value)} />
        <NumberField label="Maximum attempts" value={settings.maxAttempts} min={1} max={10} onChange={(value) => update("maxAttempts", value)} />
        <label className="text-sm font-medium">Self-update cron<input aria-label="Self-update cron" value={settings.selfUpdateCron} maxLength={100} onChange={(event) => update("selfUpdateCron", event.currentTarget.value)} className="mt-2 min-h-11 w-full rounded border border-line bg-bg px-3 font-mono text-sm text-ink outline-none focus:border-accent/50" /></label>
      </div>
      <NumberField label="Idle grace (ms)" value={settings.idleGraceMs} min={60_000} max={86_400_000} step={60_000} onChange={(value) => update("idleGraceMs", value)} />
      <fieldset className="mt-5"><legend className="text-sm font-medium">Idle repositories</legend><div className="mt-2 grid gap-2 sm:grid-cols-2">{workspaces.map((workspace) => <label key={workspace.id} className="flex min-h-11 items-center gap-3 rounded border border-line bg-bg px-3 text-sm"><input type="checkbox" checked={settings.idleWorkspaceIds.includes(workspace.id)} onChange={(event) => update("idleWorkspaceIds", event.currentTarget.checked ? [...settings.idleWorkspaceIds, workspace.id] : settings.idleWorkspaceIds.filter((id) => id !== workspace.id))} className="size-5 accent-accent" /><span>{workspace.name}</span></label>)}</div></fieldset>
      {status ? <p role="status" className="mt-4 text-sm text-ink-dim">Automation: {status.state} · eligible {dateTime(status.eligibleAt)}</p> : null}
      <div className="mt-5 flex justify-end"><button type="button" disabled={saving} onClick={() => void save()} className="min-h-11 rounded bg-accent px-4 text-sm font-medium text-white disabled:opacity-60">{saving ? "Saving…" : "Save autonomy settings"}</button></div>
    </section>

    <section className="mt-5" aria-labelledby="autonomy-health-title">
      <div className="flex items-center gap-2"><CheckCircle2 className="size-4 text-accent" /><h2 id="autonomy-health-title" className="font-medium">Recent signal health</h2></div>
      <div className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
        {[["Pending", health.pending], ["Processing", health.processing], ["Applied", health.applied], ["Ignored", health.ignored], ["Retried", health.retried], ["Dead letters", health.deadLetters]].map(([label, value]) => <div key={label} className="rounded border border-line bg-panel p-4"><p className="text-xs uppercase tracking-wide text-ink-dim">{label}</p><p className="mt-1 text-2xl font-semibold tabular-nums">{value}</p></div>)}
      </div>
    </section>

    <div className="mt-5 grid gap-5 xl:grid-cols-2">
      <section className="rounded border border-line bg-panel p-5" aria-labelledby="signal-history-title"><div className="flex items-center gap-2"><Clock3 className="size-4 text-accent" /><h2 id="signal-history-title" className="font-medium">Recent signals</h2></div><div className="mt-4"><AutonomySignalList signals={signals} /></div></section>
      <section className="rounded border border-line bg-panel p-5" aria-labelledby="revision-history-title"><div className="flex items-center gap-2"><RotateCcw className="size-4 text-accent" /><h2 id="revision-history-title" className="font-medium">Learned revisions</h2></div><div className="mt-4"><AutonomyRevisionList revisions={revisions} busyId={reverting} onRevert={setConfirmRevision} /></div></section>
    </div>

    {confirmRevision ? <div className="fixed inset-0 z-[60] grid place-items-center bg-black/60 p-4" role="presentation"><div ref={confirmRef} role="alertdialog" aria-modal="true" aria-labelledby="revert-revision-title" aria-describedby="revert-revision-description" className="w-full max-w-md rounded border border-line bg-panel p-5 shadow-2xl"><h2 id="revert-revision-title" className="font-semibold">Revert learned change?</h2><p id="revert-revision-description" className="mt-2 text-sm leading-relaxed text-ink-dim">Restore the previous {confirmRevision.targetType === "overlay" ? "instruction overlay" : "skill content"} for <span className="font-mono text-ink">{confirmRevision.targetKey}</span>. Base agent instructions and capabilities are not changed.</p><div className="mt-5 flex justify-end gap-2"><button ref={cancelRef} type="button" disabled={Boolean(reverting)} onClick={() => setConfirmRevision(undefined)} className="min-h-11 rounded border border-line px-4 text-sm disabled:opacity-50">Keep change</button><button type="button" disabled={Boolean(reverting)} onClick={() => void revert()} className="min-h-11 rounded bg-amber-600 px-4 text-sm font-medium text-white disabled:opacity-50">{reverting ? "Reverting…" : "Revert change"}</button></div></div></div> : null}
  </div>;
}