Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ function assert(condition: unknown, message: string): asserts condition {
}

try {
// Default: off, 20k budget, no file created by reading.
// Default: off, quick-checkpoint auto-apply on, 20k budget, no file created by reading.
const initial = readPersistentRoomMaintenanceSettings(agentId);
assert(initial.fastPathSecondApproval === false, "default fast-path must be off");
assert(initial.quickCheckpointAutoApply === true, "default quick-checkpoint auto-apply must be on");
assert(initial.memoryBudgetTokens === 20_000, "default memory budget must be 20k tokens");
assert(!fs.existsSync(persistentRoomMaintenanceSettingsPath(agentId)), "read must not create the settings file");

Expand Down Expand Up @@ -61,6 +62,20 @@ try {
writePersistentRoomMaintenanceSettings(agentId, { fastPathSecondApproval: false });
assert(readPersistentRoomMaintenanceSettings(agentId).fastPathSecondApproval === false, "toggle off should persist");

// Quick-checkpoint auto-apply: off round-trips, other writes preserve it,
// and settings files written before the field existed keep auto-apply on.
const quickOff = writePersistentRoomMaintenanceSettings(agentId, { quickCheckpointAutoApply: false });
assert(quickOff.quickCheckpointAutoApply === false, "quick auto-apply off should persist");
assert(quickOff.fastPathSecondApproval === false, "quick auto-apply write should preserve the fast-path toggle");
assert(readPersistentRoomMaintenanceSettings(agentId).quickCheckpointAutoApply === false, "reread should see quick auto-apply off");
assert(writePersistentRoomMaintenanceSettings(agentId, { memoryBudgetTokens: 25_000 }).quickCheckpointAutoApply === false, "budget-only write should preserve quick auto-apply");
writePersistentRoomMaintenanceSettings(agentId, { quickCheckpointAutoApply: true });
assert(readPersistentRoomMaintenanceSettings(agentId).quickCheckpointAutoApply === true, "quick auto-apply back on should persist");
const legacy = JSON.parse(fs.readFileSync(persistentRoomMaintenanceSettingsPath(agentId), "utf-8"));
delete legacy.quickCheckpointAutoApply;
fs.writeFileSync(persistentRoomMaintenanceSettingsPath(agentId), JSON.stringify(legacy, null, 2) + "\n", "utf-8");
assert(readPersistentRoomMaintenanceSettings(agentId).quickCheckpointAutoApply === true, "legacy settings without the field should keep auto-apply on");

// Validation.
let threw = false;
try {
Expand All @@ -69,6 +84,13 @@ try {
threw = /must be a boolean/.test((error as Error).message);
}
assert(threw, "non-boolean input should be rejected");
let quickThrew = false;
try {
writePersistentRoomMaintenanceSettings(agentId, { quickCheckpointAutoApply: "yes" as unknown as boolean });
} catch (error) {
quickThrew = /quickCheckpointAutoApply must be a boolean/.test((error as Error).message);
}
assert(quickThrew, "non-boolean quick auto-apply should be rejected");
let threwId = false;
try {
readPersistentRoomMaintenanceSettings("../escape");
Expand All @@ -80,6 +102,7 @@ try {
// Corrupt file falls back to defaults.
fs.writeFileSync(persistentRoomMaintenanceSettingsPath(agentId), "not json", "utf-8");
assert(readPersistentRoomMaintenanceSettings(agentId).fastPathSecondApproval === false, "corrupt settings should fall back to default off");
assert(readPersistentRoomMaintenanceSettings(agentId).quickCheckpointAutoApply === true, "corrupt settings should fall back to auto-apply on");

fs.rmSync(root, { recursive: true, force: true });
console.log("persistent-room maintenance settings smoke passed");
Expand Down
2 changes: 1 addition & 1 deletion apps/web-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1162,7 +1162,7 @@ app.put("/api/persistent-agents/:id/maintenance-settings", async (req, reply) =>
try {
const status = getUsablePersistentAgentStatusForNormalUse(idRaw);
const body = (req.body ?? {}) as any;
const settings = writePersistentRoomMaintenanceSettings(status.id, { fastPathSecondApproval: body.fastPathSecondApproval, memoryBudgetTokens: body.memoryBudgetTokens });
const settings = writePersistentRoomMaintenanceSettings(status.id, { fastPathSecondApproval: body.fastPathSecondApproval, quickCheckpointAutoApply: body.quickCheckpointAutoApply, memoryBudgetTokens: body.memoryBudgetTokens });
return { agentId: status.id, settings };
} catch (e) {
return persistentAgentNormalUseErrorReply(reply, e);
Expand Down
16 changes: 15 additions & 1 deletion apps/web-server/src/persistent-room-maintenance-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ import { DEFAULT_PERSISTENT_ROOM_AGENTS_ROOT, persistentAgentRootPath } from "./
* and the server-side propose/approve split is unchanged — this file only
* stores the preference.
*
* `quickCheckpointAutoApply` governs the default Checkpoint button: when true
* (the default, matching historical behaviour) a blocker-free proposal is
* approved immediately without showing the preview; when false the quick
* button still generates the proposal but always falls back to the manual
* preview for approval. Proposals with blockers fall back to the preview
* either way, and the server-side propose/approve split is unchanged.
*
* `memoryBudgetTokens` is the room's advisory memory budget: the size the
* whole L1b should stay near. It never gates anything — it drives the
* settings meter, the room-card nudge, and a target line in the absorb /
Expand All @@ -21,6 +28,7 @@ import { DEFAULT_PERSISTENT_ROOM_AGENTS_ROOT, persistentAgentRootPath } from "./
export interface PersistentRoomMaintenanceSettings {
schemaVersion: 1;
fastPathSecondApproval: boolean;
quickCheckpointAutoApply: boolean;
memoryBudgetTokens: number;
updatedAt: string;
}
Expand All @@ -41,6 +49,7 @@ function clampMemoryBudgetTokens(value: unknown): number {
const DEFAULT_SETTINGS: PersistentRoomMaintenanceSettings = {
schemaVersion: 1,
fastPathSecondApproval: false,
quickCheckpointAutoApply: true,
memoryBudgetTokens: MEMORY_BUDGET_DEFAULT_TOKENS,
updatedAt: "",
};
Expand All @@ -65,6 +74,9 @@ export function readPersistentRoomMaintenanceSettings(agentIdRaw: string, option
return {
schemaVersion: 1,
fastPathSecondApproval: raw.fastPathSecondApproval === true,
// Settings files written before this field existed must keep the
// historical auto-apply behaviour, so only an explicit false disables.
quickCheckpointAutoApply: raw.quickCheckpointAutoApply !== false,
memoryBudgetTokens: clampMemoryBudgetTokens(raw.memoryBudgetTokens),
updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : "",
};
Expand All @@ -73,14 +85,16 @@ export function readPersistentRoomMaintenanceSettings(agentIdRaw: string, option
}
}

export function writePersistentRoomMaintenanceSettings(agentIdRaw: string, input: { fastPathSecondApproval?: unknown; memoryBudgetTokens?: unknown }, options: PersistentRoomMaintenanceSettingsStorageOptions = {}, now = new Date()): PersistentRoomMaintenanceSettings {
export function writePersistentRoomMaintenanceSettings(agentIdRaw: string, input: { fastPathSecondApproval?: unknown; quickCheckpointAutoApply?: unknown; memoryBudgetTokens?: unknown }, options: PersistentRoomMaintenanceSettingsStorageOptions = {}, now = new Date()): PersistentRoomMaintenanceSettings {
if (input?.fastPathSecondApproval !== undefined && typeof input.fastPathSecondApproval !== "boolean") throw new Error("fastPathSecondApproval must be a boolean");
if (input?.quickCheckpointAutoApply !== undefined && typeof input.quickCheckpointAutoApply !== "boolean") throw new Error("quickCheckpointAutoApply must be a boolean");
if (input?.memoryBudgetTokens !== undefined && (typeof input.memoryBudgetTokens !== "number" || !Number.isFinite(input.memoryBudgetTokens))) throw new Error("memoryBudgetTokens must be a number");
const current = readPersistentRoomMaintenanceSettings(agentIdRaw, options);
const settingsPath = persistentRoomMaintenanceSettingsPath(agentIdRaw, options);
const settings: PersistentRoomMaintenanceSettings = {
schemaVersion: 1,
fastPathSecondApproval: input?.fastPathSecondApproval !== undefined ? input.fastPathSecondApproval as boolean : current.fastPathSecondApproval,
quickCheckpointAutoApply: input?.quickCheckpointAutoApply !== undefined ? input.quickCheckpointAutoApply as boolean : current.quickCheckpointAutoApply,
memoryBudgetTokens: input?.memoryBudgetTokens !== undefined ? clampMemoryBudgetTokens(input.memoryBudgetTokens) : current.memoryBudgetTokens,
updatedAt: now.toISOString(),
};
Expand Down
16 changes: 15 additions & 1 deletion apps/web-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3246,6 +3246,10 @@ export function App() {
setCheckpointQuickBlockedReasons(blockers);
return;
}
if (!(await roomQuickCheckpointAutoApplyEnabled(targetChat.agentId))) {
setCheckpointQuickBlockedReasons(["automatic apply is turned off for this room"]);
return;
}
setCheckpointApprovalLoading(true);
try {
const approvedRecentContext = buildApprovedRecentContextMarkdown(proposal, {
Expand All @@ -3257,7 +3261,7 @@ export function App() {
await bindToApprovedCheckpointRuntime(approval, targetChat);
void refreshPersistentAgentStatus();
resetCheckpointInput();
const savedNote = approval.warnings.length > 0 ? `Checkpoint saved to memory. ${approval.warnings.join(" ")}` : "Checkpoint saved to memory.";
const savedNote = approval.warnings.length > 0 ? `Checkpoint saved to memory automatically. ${approval.warnings.join(" ")}` : "Checkpoint saved to memory automatically.";
setItems((s) => [...s, { kind: "system", id: nid(), text: savedNote }]);
} catch (e) {
setCheckpointApprovalError(`The checkpoint could not save automatically. Review and approve it manually. ${(e as Error).message}`);
Expand Down Expand Up @@ -3806,6 +3810,16 @@ export function App() {
}
}

async function roomQuickCheckpointAutoApplyEnabled(agentId: PersistentAgentId): Promise<boolean> {
try {
return (await fetchPersistentRoomMaintenanceSettings(agentId)).settings.quickCheckpointAutoApply !== false;
} catch {
// When the preference cannot be read, fall back to manual review —
// the safe direction is showing the proposal, not silently applying it.
return false;
}
}

/**
* Fast path: when the room opts in and the proposal is warning-free, apply
* it immediately instead of showing the second approval screen. Returns
Expand Down
43 changes: 43 additions & 0 deletions apps/web-ui/src/components/RoomMaintenanceSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ function fmtTokensK(value: number): string {

export function RoomMaintenanceSection({ status }: { status: PersistentAgentStatus }) {
const [fastPath, setFastPath] = useState<boolean | null>(null);
const [quickAutoApply, setQuickAutoApply] = useState<boolean | null>(null);
const [budget, setBudget] = useState<number | null>(null);
const [savedBudget, setSavedBudget] = useState<number | null>(null);
const [saving, setSaving] = useState(false);
Expand All @@ -23,13 +24,15 @@ export function RoomMaintenanceSection({ status }: { status: PersistentAgentStat
useEffect(() => {
let cancelled = false;
setFastPath(null);
setQuickAutoApply(null);
setBudget(null);
setSavedBudget(null);
setError(null);
fetchPersistentRoomMaintenanceSettings(status.id)
.then((response) => {
if (cancelled) return;
setFastPath(response.settings.fastPathSecondApproval);
setQuickAutoApply(response.settings.quickCheckpointAutoApply);
setBudget(response.settings.memoryBudgetTokens);
setSavedBudget(response.settings.memoryBudgetTokens);
})
Expand Down Expand Up @@ -78,6 +81,23 @@ export function RoomMaintenanceSection({ status }: { status: PersistentAgentStat
}
}

async function toggleQuickAutoApply(next: boolean) {
if (saving || quickAutoApply === null) return;
setSaving(true);
setError(null);
const previous = quickAutoApply;
setQuickAutoApply(next);
try {
const response = await updatePersistentRoomMaintenanceSettings(status.id, { quickCheckpointAutoApply: next });
setQuickAutoApply(response.settings.quickCheckpointAutoApply);
} catch (e) {
setQuickAutoApply(previous);
setError((e as Error).message);
} finally {
setSaving(false);
}
}

const currentMemoryTokens = status.promptBudget?.l1bEstimatedTokens ?? null;
const usagePercent = budget !== null && currentMemoryTokens !== null ? Math.round((currentMemoryTokens / budget) * 100) : null;
const overBudget = usagePercent !== null && usagePercent > 100;
Expand Down Expand Up @@ -106,6 +126,29 @@ export function RoomMaintenanceSection({ status }: { status: PersistentAgentStat
an audit record.
</p>
</details>
<label className="workspaces-tool-row">
<span>Quick checkpoint applies automatically</span>
<input
className="workspaces-tool-switch"
type="checkbox"
checked={quickAutoApply === true}
disabled={quickAutoApply === null || saving}
onChange={(e) => void toggleQuickAutoApply(e.target.checked)}
aria-label="Quick checkpoint applies automatically"
/>
</label>
<p className="room-maintenance-hint">Save blocker-free Checkpoint proposals without showing the preview.</p>
<details className="room-settings-details">
<summary>How it works</summary>
<p>
The Checkpoint button drafts a memory proposal and, when nothing deterministic
blocks it, saves it without showing you the content first. Turn this off to
always review the proposed entry before it becomes memory. Proposals with
blockers — trimmed transcripts or incomplete drafts — always come back for
review either way, and every save still archives the previous memory and writes
an audit record.
</p>
</details>
<div className="memory-budget-block">
<div className="workspaces-tool-row memory-budget-row">
<span>Memory budget</span>
Expand Down
3 changes: 2 additions & 1 deletion apps/web-ui/src/persistent-room-management-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export function renamePersistentRoom(agentId: PersistentAgentId, displayName: st
export interface PersistentRoomMaintenanceSettings {
schemaVersion: 1;
fastPathSecondApproval: boolean;
quickCheckpointAutoApply: boolean;
memoryBudgetTokens: number;
updatedAt: string;
}
Expand All @@ -72,7 +73,7 @@ export function fetchPersistentRoomMaintenanceSettings(agentId: PersistentAgentI
);
}

export function updatePersistentRoomMaintenanceSettings(agentId: PersistentAgentId, update: { fastPathSecondApproval?: boolean; memoryBudgetTokens?: number }): Promise<PersistentRoomMaintenanceSettingsResponse> {
export function updatePersistentRoomMaintenanceSettings(agentId: PersistentAgentId, update: { fastPathSecondApproval?: boolean; quickCheckpointAutoApply?: boolean; memoryBudgetTokens?: number }): Promise<PersistentRoomMaintenanceSettingsResponse> {
return fetchJson<PersistentRoomMaintenanceSettingsResponse>(
`/api/persistent-agents/${encodeURIComponent(agentId)}/maintenance-settings`,
{
Expand Down
Loading