Menu
popagent
publicLatest 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>;
}