/** * Auto-proceed loop guard. * * ## Problem * * The REPL and TUI both implement an "auto" autonomy mode that, after every * completed agent turn, feeds the next suggestion (or top auto="true" item) * back as a fresh prompt. When the agent emits the same `` block * on every reply — which happens whenever the model's output is stable but * autonomy treats the next step as "still actionable" — the loop self-feeds * the same instruction 2–3 times in a row before the longer * `autoProceedMaxIterations` cap (default 0 = unlimited) trips. * * The user reports the visible symptom: the same response, including the same * `` block, repeats. Manual input from the user is the only thing * that breaks out. They suspect the next-steps mechanism is the source. * * The 50-iteration cap is intentionally loose — it is a runaway safety net, * not a UX signal. By the time it trips, the user has already watched the * same response 50 times. * * ## Approach * * A small, pure, browser-safe stateful helper that records the last few * prompts fed through the auto-proceed / auto-submit path. It normalizes * prompts (whitespace, casing) so trivial rewordings are not collapsed, but * identical re-feeds are. When the same normalized prompt is fed * `repeatThreshold` (default 2) times in a row, the guard returns * `{ shouldHalt: true }`. * * Callers are expected to: * 1. Call `record(prompt)` immediately before each auto-feed. * 2. If `shouldHalt` is true, clear the suggestion + auto-suggestion store, * cancel any pending countdown, and surface a "we detected a loop" * message asking the user what's happening. Do NOT feed the prompt. * 3. Call `reset()` whenever the user types anything manually (REPL manual * input or any user-driven submit) so the next auto-feed starts clean. * * The default `repeatThreshold` is 2 — i.e. the second consecutive identical * feed (the first re-feed of an already-seen prompt) halts the loop. That * matches the user's report ("receiving the same prompt 2-3 times in a row * causes the system to enter a loop"). Setting it to 2 catches the loop on * the first identical re-feed rather than after the 50-iteration runaway cap trips. * * The window size is intentionally small (default 3). The loop we care about * is the immediate repetition — a longer history would misfire on legitimate * "do X, then do Y, then do X" sequences the model occasionally returns to. * * This module is BROWSER-SAFE — no Node-only imports — so it can be imported * from Vite-bundled WebUI as well as Node-based CLI/TUI. */ /** * Normalize a prompt for repetition comparison. Whitespace is collapsed and * trimmed; case is folded to lowercase. Nothing semantic is removed, so two * prompts that differ in any meaningful way (extra word, different code, * different file path) compare as distinct. Two prompts that differ only by * leading/trailing whitespace and casing are treated as identical. */ export declare function normalizeForRepetition(prompt: string): string; export interface LoopGuardOptions { /** * How many consecutive identical prompts (after normalization) trigger a * halt. Default 2 — i.e. the second consecutive identical feed (the first * re-feed of an already-seen prompt) halts the loop. Clamped to >= 2. */ repeatThreshold?: number; /** * How many of the most recent feeds to retain for comparison. The window * is searched from newest to oldest; the guard cares about the run of * identical entries ending at the most recent one, not about identical * entries anywhere in history. Default 3. */ windowSize?: number; } export interface RepetitionSignal { /** The normalized prompt that just got recorded. */ normalized: string; /** * Number of consecutive identical feeds ending at the most recent feed, * including this one. Always >= 1. */ runLength: number; /** * True when the run length has crossed `repeatThreshold`. Callers must * stop feeding and surface a user prompt instead. */ shouldHalt: boolean; } /** * Steer text appended (once) to a grounded todo continuation when the board * has not moved between two consecutive automatic turns. Grounded prompts are * synthesized from durable todo state, so an identical re-feed means the whole * previous turn produced zero board movement — usually the model worked but * forgot to update the board. One explicit steer fixes that far more often * than halting; the halt only fires if the board stays frozen AFTER the steer. */ export declare const GROUNDED_NO_PROGRESS_STEER: string; export type GroundedRepetitionAction = 'feed' | 'steer' | 'halt'; export interface GroundedRepetitionSignal extends RepetitionSignal { /** * What the caller should do with this grounded (todo-sourced) prompt: * - `feed` — no repetition; feed the prompt as-is. * - `steer` — first repetition; feed the prompt with * {@link GROUNDED_NO_PROGRESS_STEER} appended instead of halting. * - `halt` — the board stayed frozen even after a steer; break the loop * exactly like a `shouldHalt` from {@link AutoProceedLoopGuard.record}. */ action: GroundedRepetitionAction; } export interface AutoProceedLoopGuard { /** * Record a prompt that is about to be auto-fed. Returns the repetition * signal for that prompt. When `shouldHalt` is true the caller MUST NOT * feed the prompt — it has been recorded for post-mortem inspection, but * the loop must be broken instead. */ record(prompt: string): RepetitionSignal; /** * Record a GROUNDED prompt — one synthesized from durable state (the todo * board) rather than echoed from model output. Repetition of a grounded * prompt is a "no board progress" signal, not necessarily an echo loop, so * the first repetition asks the caller to steer (append * {@link GROUNDED_NO_PROGRESS_STEER} to the fed text) and only a repetition * that survives the steer halts. The steer state resets whenever a * different prompt is recorded (board moved) or on {@link reset}. */ recordGrounded(prompt: string): GroundedRepetitionSignal; /** * Drop the history. Call this on any manual user input so a fresh run * starts with no memory of the prior cycle. */ reset(): void; /** * Read-only view of the most recent normalized prompts (newest last). * Useful for diagnostics and for the message shown to the user when the * guard halts the loop. */ history(): readonly string[]; /** * How many entries the guard is currently retaining. */ size(): number; } /** * Build a fresh loop guard. Defaults: `repeatThreshold = 2`, `windowSize = 3`. * * @example * const guard = createAutoProceedLoopGuard(); * for (const prompt of candidates) { * const signal = guard.record(prompt); * if (signal.shouldHalt) { * haltAutoProceedAndAskUser(signal); * break; * } * feedPrompt(prompt); * } */ export interface ContinuationAttemptTracker { /** Record an attempt against a todo. Returns the new attempt count (≥ 1). */ recordAttempt(todoId: string): number; /** How many times continuation has been attempted for this todo (0 = never). */ getAttempts(todoId: string): number; /** Mark a todo as "skipped" — a later attempt tried advancing past it. */ markSkipped(todoId: string): void; /** True when this todo has been skipped due to no board progress. */ isSkipped(todoId: string): boolean; /** Reset every tracked todo (call on manual user input). */ reset(): void; /** Read-only snapshot for diagnostic use. */ snapshot(): Record; } export declare function createContinuationAttemptTracker(): ContinuationAttemptTracker; /** * Build a continuation-advancement prompt when the normal todo-sourced * prompt has stalled (no board progress). The text varies by attempt count * to avoid triggering the repetition guard. * * @param stalledTodo The todo that didn't progress (used for its id/label). * @param nextTodo The next open todo to advance to, or null if none left. * @param attemptCount How many times stalledTodo has been retried. * @param todos The full current todo list for board context. * @returns A varied advancement prompt for the next auto-feed. */ export declare function generateAdvancementPrompt(stalledTodo: { id: string; content: string; }, nextTodo: { id: string; content: string; status: string; activeForm?: string | undefined; } | null, attemptCount: number, todos: readonly { id: string; content: string; status: string; activeForm?: string | undefined; }[]): string; export interface StalledTodoMatch { id: string; content: string; } /** * Identify the todo whose content appears in a stalled continuation prompt. * Scans the board for the in_progress item first (that's what * `resolveContinuation` picks), then falls back to any pending item whose * text appears in the prompt. Last resort: the first non-completed todo. * * Returns `null` when the board is empty or all items are completed. */ export declare function matchTodoIdFromPrompt(todos: readonly { id: string; content: string; status: string; activeForm?: string | undefined; }[], prompt: string): StalledTodoMatch | null; /** * Maximum total advancement attempts before we give up on the board and * halt. Otherwise a board where no agent turn makes progress cycles through * the same todos indefinitely. Reset on manual user input. */ export declare const MAX_ADVANCEMENT_ATTEMPTS = 9; export declare function createAutoProceedLoopGuard(options?: LoopGuardOptions): AutoProceedLoopGuard; //# sourceMappingURL=auto-proceed-loop-guard.d.ts.map