Mark loop director plan implemented
ober
b86340ff6e78692e4d27fa904c091bfb6c829b39
new file mode 100644 --- /dev/null +++ b/stop-loop.md @@ -0,0 +1,1055 @@ +# stop-loop — turn the no-progress breaker into a loop director + +**Audience:** a local LLM (or human) implementing changes in this repository +(`jerboa-code`, the `jcode` coding agent). +**Written:** 2026-08-02. +**Status:** implemented in commit `8d9b7b9`. + +This document is the complete brief for reworking jcode's "no-progress loop +breaker" into a **loop director**: when the model repeats tool calls that +return no new information, the harness should (1) detect it even when the +model varies arguments to evade exact-match counting, (2) tell the model +*exactly* which calls looped and what to do instead, (3) escalate from +nudge → tool restriction → forced terminal summary, and (4) remember looped +calls across user turns so typing "continue" does not re-arm the same loop. + +It contains: the problem evidence, the current mechanisms with code anchors, +the target design, exact code to add/change (work packages WP0–WP5), the +verification protocol, and a pitfalls catalog. **Do not claim the work is +done until the full test suite passes** (§8 — `make test`). + +--- + +## 1. Ground rules for the implementer (MANDATORY) + +These come from `AGENTS.md` and are non-negotiable. Several exist because +previous sessions lost hours to mistakes they prevent in seconds. + +1. **NEVER edit `*.ss` or `*.sls` with raw `edit`/`write`/`sed`/`python`.** + Use the jerboa-mcp tools: + - Add a top-level form → `jerboa_balanced_insert` (anchor = one unique + complete form already in the file, e.g. the `def` above the insertion + point). + - Replace exact text → `jerboa_balanced_replace` (**dry-run by default**; + pass `dry_run: false` to actually write). + - File already unbalanced → STOP. `git checkout -- <file>` and redo, or + `jerboa_repair_balance` (dry-run first). +2. **After EVERY `.ss` change, run `jerboa_check_balance`** (the balanced + tools do this automatically), then `jerboa_verify` on the changed file + before building. +3. **Keep closer-runs ≤ ~4.** Flatten deep nesting with helper `def`s, + `let*`, or `cond` clauses. The code in this document already follows + that — do not "simplify" it into longer `))))` runs. +4. **No `(def ...)` after an expression in a body.** Internal defines come + first in a body, or use `let`/`let*`. All code below is top-level `def`s + and `let`-bound bodies — keep it that way. +5. **Jerboa ≠ Gerbil/Racket.** Before using any function you are not 100% + sure of, check it with `jerboa_function_signature` or + `jerboa_module_exports`. Every function used in the code below already + appears in the files you are editing, or is listed in §9 ("verified + vocabulary") — stay within that vocabulary. +6. **`stop-loop.md` (this file) is plain markdown.** Raw `write`/`edit` is + fine for this file only, never for `.ss`. + +--- + +## 2. The problem (evidence) + +The chat loop has a per-turn "no-progress breaker" +(`src/jcode/core/agent.ss`, `forge-no-progress?` at line 248, wired into the +agent loop at lines 1548–1562 and 1679–1695). When the model repeats the +same tool call 3× in one turn it injects one generic nudge; on the next +repeat it ends the turn with the assistant message +`[stopped: repeated the same tool call(s) with no progress]`. + +Session transcripts in `~/.jcode/sessions/` show it failing as a redirector: + +- **313462F8** (cwd `/Users/user/sfb`, 254 messages, breaker fired 3×): + the model ran `sed -n 'NNNN,MMMMp' continue` / `grep ... continue` / + `tail continue` variants ~100 times across four user turns. Each variant + (different line ranges, added `| head -N`, different `timeout` values) is + a *different* exact-match signature, so the breaker only tripped after + dozens of wasted rounds. After each dead stop the user typed "continue", + the per-turn breaker state reset, and the same loop re-ran. +- **867CE393** (cwd `/Users/user/mine/jerboa-gitsite`, 388 messages, + breaker fired 4×): the model ran `cat Makefile`, `cat src/gitsite.ss`, + `ls -la vendor/jerboa-git/` dozens of times via `bash`, with trivial + variations (`| head -20`, `| cat`, `-l` vs `-la`). Same pattern: nudge → + 1–2 superficially different calls → re-trip → dead stop → "continue" → + repeat. + +Root causes, in order of importance: + +1. **Detection is exact-match only.** Call signatures are + `name|raw-args-json`. Any arg variation (line range, pipe suffix, + `timeout`, `cd` prefix) produces a new signature, so the model + random-walks around the counter. Output *content* is never compared — + and the outputs of `cat Makefile`, `cat Makefile | head`, and + `sed -n '1,50p' Makefile` are (near-)identical. +2. **The nudge carries no information.** It names no calls, no counts, and + gives no concrete next action. Small local models cannot infer what to + change. Observed effect: nudge → immediate re-trip. +3. **The dead stop teaches the wrong loop.** The break message is stored as + an *assistant* message with no summary of findings; the user types + "continue"; `agent-run-once` creates a fresh `forge-breaker-state`; the + loop re-arms with a full budget. + +--- + +## 3. Current state — the THREE existing mechanisms + +Read these before writing anything. Do not merge or remove any of them in +this work package; you are adding alongside them. + +1. **Registry-level exact-call tracker** — `src/jcode/tool/registry.ss` + lines 49–65 (`record-tool-call!`), called from `tool-execute` at line + ~177. Per-turn, keyed by `(name . canonical-json-args)`: at 3 identical + calls the tool result is replaced by a "⚠ Loop warning" string; at 5+ + by a "Tool blocked" string (the tool does not execute). Reset per turn + by `reset-turn-tool-calls!` — **but only `agent-chat` calls that + (agent.ss line 1817); `agent-run-once` (the session/TUI path, + agent.ss line 1432) does not** (see WP0). +2. **Agent-level no-progress breaker (ATLAS)** — `src/jcode/core/agent.ss`: + - Parameters/state: lines 131–166 (`forge-max-repeated-calls` = 3, + `forge-max-similar-search-calls` = 2, `forge-breaker-state`, + `make-forge-breaker-state`, nudge-used? flag). + - Signatures/counting: lines 168–266 (`chat-call-signature`, + `forge-count-bump!`, `forge-no-progress?`). + - Wiring: `agent-run-once` line 1451 creates a fresh + `forge-breaker-state` per user turn; the check fires in + `agent-guardrails-step` (lines 1548–1562) and + `agent-guardrails-step-stream` (lines 1679–1695): first trip → + `forge-no-progress-nudge-message` (lines 144–149) as a user message; + second trip → `forge-no-progress-message` (lines 142–143) as a final + assistant message. + - Existing tests: `test/run.ss` lines 2378–2450. These MUST keep + passing unmodified (WP3 keeps the three nudge-flag functions' + semantics for exactly this reason). +3. **Hard round cap** — `*max-tool-rounds*` = 100 (agent.ss line 387); + the cap branch (lines 1529–1537 non-stream, 1653–1665 stream) executes + the final calls and then runs one last inference with `tools='()` to + force a terminal text answer. **This is the pattern WP3's tier-3 + "terminal summary" copies.** + +Also relevant: + +- `current-tool-allowlist` parameter (registry.ss line 66) gates both the + schema list (`get-tool-schemas`, registry.ss lines 212+) and execution + (`tool-disabled?`, registry.ss lines 82–87 → `tool-execute` returns + `disabled-tool-message` without running). WP3 tier 2 uses this. +- `execute-tool-calls` (agent.ss lines 1705–1730) returns tool-result + messages **in the same order as the input calls** (it maps + `thread-join!` over the spawned threads), so zipping calls ↔ results is + safe. +- `truncate-tool-output` (agent.ss lines 448–476) appends a trailer + containing a **random** file path + (`...~a truncated...\n\nFull output saved to: <random path>...`), so + identical oversized outputs do NOT produce identical tool-result + strings. WP1's output key strips this trailer before hashing. +- `chat-call-arg` (agent.ss lines 178–186) parses a tool call's JSON + arguments and returns one key — use it, do not re-parse JSON. + +--- + +## 4. Target design — the loop director + +Escalation tiers per user turn (state lives in the extended +`forge-breaker-state`): + +``` +tool batch proposed + │ + ▼ +forge-no-progress? ← exact-match fast path (unchanged) + │ + NEW: bash-normalized signatures + │ + NEW: identical-output counts (fed by + │ forge-record-outputs! after each execution) + ▼ trip +forge-note-loop! session-id ← remember worst offenders (WP4) +tier = forge-escalate! ← 1 → 2 → 3 (WP3) + │ + ├─ tier 1: inject forge-nudge-text (names the actual repeated + │ calls + counts + 3 concrete next actions), continue turn + │ + ├─ tier 2: inject forge-loop-restrict-directive, restrict + │ current-tool-allowlist to write/todo tools for the rest + │ of the turn ("discovery closed"), continue turn + │ + └─ tier 3: forge-terminal-summary — one final inference with + tools='() (copies the max-rounds pattern) so the turn + ends with a findings summary, not a bare dead stop +``` + +Detection additions (WP1), both evasion-resistant: + +- **Bash signature normalization** — `chat-call-signature` special-cases + `bash`: strip leading `cd DIR &&` segments, drop everything after the + first `|`, collapse whitespace, ignore every arg except `command`. + `cd /x && cat Makefile | head -20` (timeout 5000) and `cat Makefile` + (timeout 15000) become the SAME signature. +- **Identical-output counting** — after each `execute-tool-calls`, hash + the outputs of read-family calls (explicit tool allowlist + `bash` only + when its normalized command starts with a read prefix like + `cat `/`sed `/`grep `/…). Build/test commands (`make …`) are NEVER + output-hashed: identical build failures must not trip this path (they + remain on exact-match counting). Files being edited change output + between reads, so legitimate read-edit-read cycles self-clear. + +Cross-turn memory (WP4): on every tier trip, the worst offenders (top 5 +signatures + top 5 output keys) are stored in a module-level table keyed +by session-id (cap 10 each). The next turn seeds the fresh breaker state +with those counts at `limit - 1`, so the FIRST rerun of a remembered call +trips immediately instead of re-burning the repeat budget. + +--- + +## 5. Work packages + +All `.ss` changes are in `src/jcode/core/agent.ss` unless stated otherwise. +Use `jerboa_balanced_insert` for new top-level forms and +`jerboa_balanced_replace` (`dry_run: false`) for replacements. After every +single change: `jerboa_verify` on the file, then `make build` before moving +to the next work package. + +### WP0 — fix the missing per-turn reset (one line) + +`agent-chat` (agent.ss line 1817) calls `(reset-turn-tool-calls!)` at the +start of every user turn; `agent-run-once` (the TUI/session path) never +does, so the registry-level exact-call tracker can accumulate across turns. +Add the reset as the first body form of `agent-run-once` (agent.ss line +1432, directly under the existing comment block): + +```scheme +(def (agent-run-once session-id user-input) + (reset-turn-tool-calls!) + ;; Defensively repair the session before the new turn — ... (unchanged) + (try (session-repair-orphan-tool-calls! session-id) (catch (_) (void))) + ...) +``` + +`reset-turn-tool-calls!` is already exported by `:jcode/tool/registry`, +which agent.ss already imports. Idempotent; harmless if the turn already +ran on a fresh thread. + +### WP1 — detection: bash normalization + identical-output counting + +**WP1a. Extend the breaker state vector from 6 slots to 7.** + +Replace `make-forge-breaker-state` (lines 151–154): + +```scheme +(def (make-forge-breaker-state) + ;; #(last-batch-sig consecutive-count seen-batches seen-individual-calls + ;; seen-similar-search-paths escalation-tier seen-outputs) + (vector #f 0 '() '() '() 0 '())) +``` + +Slot 5 changes meaning: boolean `nudge-used?` → integer `escalation-tier` +(0–3). Slot 6 `seen-outputs` is an alist of +`output-key . (count . desc-string)`. WP3 updates the three nudge-flag +functions to match; do WP1 and WP3 together before building. + +**WP1b. Add the new parameter and detection helpers.** + +Insert the following block after `forge-no-progress-nudge-message` +(anchor: the complete `def` of `forge-no-progress-nudge-message`, +lines 144–149): + +```scheme +;; Max times read-family calls may return byte-identical output in one +;; turn before it counts as a no-progress loop. #f disables. Output +;; identity is evasion-proof: the model can rephrase arguments, but a +;; loop returns the same bytes. +(def forge-max-identical-outputs (make-parameter 3)) + +;; Tools whose outputs are always safe to hash for loop detection. +;; Build/test/execution tools (notably bare "bash") are excluded so an +;; identical `make build` failure never trips this path. +(def *forge-output-hash-tools* + '("read" "ls" "glob" "grep" "git_status" "git_diff" + "git_log" "git_show" "fetch" "websearch")) + +;; Normalized bash commands starting with one of these prefixes count as +;; read-family. "python3"/"python " cover the inline-script probes local +;; models loop on (heredocs, -c). Anything else ("make ...", "cargo ...") +;; stays on exact-match counting only. +(def *forge-bash-read-prefixes* + '("cat " "sed " "grep " "tail " "head " "ls " "find " + "stat " "wc " "file " "readlink " "awk " "python3" "python ")) + +(def (forge-collapse-ws s) + (string-join + (filter (lambda (p) (not (string=? p ""))) + (string-split s #\space)) + " ")) + +(def (forge-strip-cd-prefixes cmd) + ;; Drop leading `cd DIR && ` segments (loops are usually the same + ;; command re-issued after a redundant cd). + (if (and (string-prefix? "cd " cmd) + (string-contains cmd "&&")) + (let ((rest (substring cmd (+ (string-contains cmd "&&") 2) + (string-length cmd)))) + (forge-strip-cd-prefixes (string-trim rest))) + cmd)) + +(def (forge-normalize-bash-command cmd) + ;; Canonical form for LOOP DETECTION ONLY (never executed): + ;; strip cd-prefixes, drop everything after the first pipe, + ;; collapse whitespace. + (let* ((no-cd (forge-strip-cd-prefixes (string-trim cmd))) + (first (car (string-split no-cd #\|)))) + (string-trim (forge-collapse-ws first)))) + +(def (forge-starts-with-any? s prefixes) + (cond + ((null? prefixes) #f) + ((string-prefix? (car prefixes) s) #t) + (else (forge-starts-with-any? s (cdr prefixes))))) + +(def (forge-bash-command tc) + ;; The "command" arg of a bash tool call, or #f. + (and (equal? (tool-call-name tc) "bash") + (chat-call-arg tc "command"))) + +(def (forge-bash-read-command? cmd) + (forge-starts-with-any? (forge-normalize-bash-command cmd) + *forge-bash-read-prefixes*)) + +(def (forge-output-hashable-call? tc) + (let ((name (tool-call-name tc))) + (cond + ((member name *forge-output-hash-tools*) #t) + ((equal? name "bash") + (let ((cmd (forge-bash-command tc))) + (and cmd (forge-bash-read-command? cmd)))) + (else #f)))) + +(def (forge-strip-truncation-trailer text) + ;; truncate-tool-output appends a trailer containing a RANDOM path — + ;; strip it so identical truncated outputs hash identically. + (let ((marker (string-contains text "\n\n..."))) + (if (and marker (string-contains text "Full output saved to:")) + (substring text 0 marker) + text))) + +(def (forge-output-key text) + ;; Length + first/last 1KB: cheap, collision-safe enough for one turn. + (let* ((core (forge-strip-truncation-trailer text)) + (len (string-length core)) + (head (substring core 0 (min 1024 len))) + (tail (substring core (max 0 (- len 1024)) len))) + (string-append (number->string len) "\x1;" head "\x1;" tail))) + +(def (forge-call-desc tc) + ;; Human-readable one-liner for nudge reports, e.g. + ;; bash({"command":"sed -n '4935,4938p' continue"}) + (let* ((name (tool-call-name tc)) + (args (tool-call-arguments tc)) + (raw (if (string? args) args (format "~a" args))) + (flat (forge-collapse-ws raw))) + (if (> (string-length flat) 140) + (string-append name "(" (substring flat 0 137) "...)") + (string-append name "(" flat ")")))) + +;; seen-outputs entry accessors: entry = (key . (count . desc)) +(def (forge-out-entry-count e) (car (cdr e))) +(def (forge-out-entry-desc e) (cdr (cdr e))) + +(def (forge-bump-output! st key desc) + (let ((hit (assoc key (vector-ref st 6)))) + (if hit + (set-cdr! hit (cons (+ (forge-out-entry-count (cdr hit)) 1) + desc)) + (vector-set! st 6 + (cons (cons key (cons 1 desc)) (vector-ref st 6)))))) + +(def (forge-max-output-count st) + (let loop ((rest (vector-ref st 6)) (mx 0)) + (if (null? rest) + mx + (loop (cdr rest) + (max mx (forge-out-entry-count (cdr (car rest)))))))) + +(def (forge-record-outputs! calls results) + ;; Zip executed calls with their tool-result messages (same order — + ;; see execute-tool-calls) and count identical outputs from + ;; read-family calls. Call AFTER execute-tool-calls, before recursing. + (let ((st (forge-breaker-state))) + (when st + (for-each + (lambda (tc r) + (when (and (forge-output-hashable-call? tc) + (message-content r)) + (forge-bump-output! st + (forge-output-key (message-content r)) + (forge-call-desc tc)))) + calls results)))) +``` + +**WP1c. Normalize bash in `chat-call-signature`.** + +Replace `chat-call-signature` (lines 168–171): + +```scheme +(def (chat-call-signature/raw tc) + (let ((a (tool-call-arguments tc))) + (string-append (tool-call-name tc) "|" + (if (string? a) a (format "~a" a))))) + +(def (chat-call-signature tc) + ;; bash calls sign on the NORMALIZED command only — line ranges, pipe + ;; suffixes, cd prefixes and timeout values no longer evade counting. + (if (equal? (tool-call-name tc) "bash") + (let ((cmd (forge-bash-command tc))) + (if cmd + (string-append "bash|" (forge-normalize-bash-command cmd)) + (chat-call-signature/raw tc))) + (chat-call-signature/raw tc))) +``` + +**WP1d. Consult output counts in `forge-no-progress?`.** + +Replace `forge-no-progress?` (lines 248–266): + +```scheme +;; Update the per-turn breaker state with CALLS; report whether executing +;; them now crosses the configured repeat limit. Also trips when earlier +;; read-family executions already produced >= forge-max-identical-outputs +;; identical results, even if THIS batch is novel (a model that recovered +;; answers in text and never reaches this check). +(def (forge-no-progress? calls) + (let ((limit (forge-max-repeated-calls)) + (similar-limit (forge-max-similar-search-calls)) + (out-limit (forge-max-identical-outputs)) + (st (forge-breaker-state))) + (and st (pair? calls) (or limit similar-limit out-limit) + (let* ((sig (chat-calls-signature calls)) + (batch-seen (if limit (forge-count-bump! st 2 sig) 0)) + (call-seen (if limit (forge-max-call-count! st calls) 0)) + (similar-seen + (if similar-limit + (forge-max-similar-search-count! st calls) + 0))) + (if (equal? sig (vector-ref st 0)) + (vector-set! st 1 (+ (vector-ref st 1) 1)) + (begin (vector-set! st 0 sig) (vector-set! st 1 1))) + (or (and limit (>= (vector-ref st 1) limit)) + (and limit (>= batch-seen limit)) + (and limit (>= call-seen limit)) + (and similar-limit (>= similar-seen similar-limit)) + (and out-limit + (>= (forge-max-output-count st) out-limit))))))) +``` + +**WP1e. Record outputs after execution — two call sites.** + +In `agent-guardrails-step` (line 1568) and `agent-guardrails-step-stream` +(line 1699), the `else` branch contains: + +```scheme +(let ((results (execute-tool-calls calls))) + (for-each (lambda (r) (session-add-message session-id r)) results) +``` + +Insert `(forge-record-outputs! calls results)` as the new first form +inside that `let`, immediately after the binding (both sites, identical +edit). + +### WP2 — the nudge names names + +**WP2a. Report helpers.** Insert after `forge-record-outputs!` (anchor: +the complete `def` of `forge-record-outputs!`): + +```scheme +(def (forge-take-up-to lst n) + (if (or (null? lst) (<= n 0)) + '() + (cons (car lst) (forge-take-up-to (cdr lst) (- n 1))))) + +(def (forge-sig->desc sig) + (let ((flat (forge-collapse-ws sig))) + (if (> (string-length flat) 140) + (string-append (substring flat 0 137) "...") + flat))) + +(def (forge-top-repeats n) + ;; Merge individual-call counts (slot 3) and identical-output counts + ;; (slot 6) into a (desc . count) alist, highest count first. + (let ((st (forge-breaker-state))) + (if (not st) + '() + (let* ((from-calls + (map (lambda (e) (cons (forge-sig->desc (car e)) (cdr e))) + (vector-ref st 3))) + (from-outs + (map (lambda (e) + (cons (string-append (forge-out-entry-desc (cdr e)) + " [identical output]") + (forge-out-entry-count (cdr e)))) + (vector-ref st 6)))) + (forge-take-up-to + (list-sort (lambda (a b) (> (cdr a) (cdr b))) + (append from-calls from-outs)) + n))))) + +(def (forge-nudge-text) + ;; Tier-1 corrective: WHICH calls looped, how often, and exactly what + ;; to do instead. Small local models cannot infer this from a generic + ;; "stop repeating yourself". + (let ((repeats (forge-top-repeats 5))) + (string-append + "[jcode: loop detected] You re-ran calls that returned no new " + "information this turn:\n" + (if (null? repeats) + " (calls identical to earlier ones in this conversation)\n" + (string-join + (map (lambda (e) + (string-append " - " (car e) + " (x" (number->string (cdr e)) ")")) + repeats) + "\n")) + "\nTheir results are ALREADY in this conversation. Do NOT rerun " + "them or trivial variants (same file with slightly different " + "flags, ranges, or pipes).\n" + "Pick ONE next action:\n" + " 1. Found what you needed? Quote it and do the next task step now.\n" + " 2. Not found? Say so in one line, then use a DIFFERENT file, " + "pattern, or tool.\n" + " 3. Stuck? Write three lines — goal / what you learned / next " + "concrete step — then do that step."))) +``` + +The old constant `forge-no-progress-nudge-message` (lines 144–149) stays +in place (harmless; WP3 stops referencing it). + +**WP2b. System prompt line.** In `system-prompt` (the default branch, +around line 304), immediately after the existing line +`- Do NOT call the same tool repeatedly with the same or similar arguments. Use grep/glob to locate code instead of guessing paths.` +add one new line: + +``` +- If a '[jcode: loop detected]' message appears, obey it at once — the flagged results are already in the conversation; re-running those calls wastes your remaining budget. +``` + +### WP3 — tiered escalation: nudge → restrict → terminal summary + +**WP3a. Update the nudge-flag functions to the tier counter** (slot 5 is +now an integer; keep these three functions' external semantics so the +existing tests at `test/run.ss` lines 2447–2450 pass unmodified). +Replace lines 156–166 (`forge-no-progress-nudge-used?`, +`forge-mark-no-progress-nudged!`, `forge-no-progress-nudge-available?`): + +```scheme +(def (forge-no-progress-nudge-used?) + (let ((st (forge-breaker-state))) + (and st (>= (vector-ref st 5) 1)))) + +(def (forge-mark-no-progress-nudged!) + (let ((st (forge-breaker-state))) + (when st (vector-set! st 5 (max 1 (vector-ref st 5)))))) + +(def (forge-no-progress-nudge-available?) + (and (forge-breaker-state) + (not (forge-no-progress-nudge-used?)))) + +(def (forge-escalate!) + ;; Advance the escalation tier (slot 5); returns the NEW tier (1..3). + ;; 1 = specific nudge, 2 = tool restriction, 3 = terminal summary. + (let ((st (forge-breaker-state))) + (if st + (let ((tier (min 3 (+ (vector-ref st 5) 1)))) + (vector-set! st 5 tier) + tier) + 3))) +``` + +**WP3b. Restriction allowlist + directive.** Insert after +`forge-escalate!`: + +```scheme +(def *forge-loop-restricted-tools* + ;; Allowlist after escalation tier 2 ("discovery closed"): write, + ;; todo, and pre-commit status tools only. Discovery tools (bash, + ;; read, grep, glob, ls, fetch, git_log, git_show) are withheld from + ;; the schema AND rejected at execute time (tool-disabled? → + ;; disabled-tool-message), which itself tells the model why. + '("todowrite" "write" "edit" "edit_block" "multi-edit" "patch" + "apply_patch" "git_status" "git_diff" "git_commit" + "mcp_jerboa_jerboa" "respond")) + +(def (forge-restricted-allowlist) + ;; Intersect with any allowlist already in force (compact/small-context + ;; providers set one) so tier 2 can only ever NARROW the tool set. + (let ((current (or (current-tool-allowlist) (list-tools)))) + (filter (lambda (t) (member t *forge-loop-restricted-tools*)) + current))) + +(def forge-loop-restrict-directive + (string-append + "[jcode: loop detector — discovery closed] You repeated discovery " + "calls again after being warned. Discovery tools (bash, read, grep, " + "glob, ls, fetch, git_log, git_show) are now DISABLED for the rest " + "of this turn; calling them returns an error. Everything you need " + "is already in this conversation. Do ONE of:\n" + " 1. Make the edit / write the file that completes the task.\n" + " 2. Update the todo list and give the final answer.\n" + " 3. If truly blocked: reply with the single specific question for " + "the user.")) +``` + +**WP3c. Terminal summary.** Insert after `forge-loop-restrict-directive`: + +```scheme +(def forge-terminal-summary-directive + (string-append + "[jcode: loop detector] Tool use has been stopped for this turn: " + "repeated calls produced identical results. Tools are OFF now. " + "Using ONLY what is already in this conversation, write the final " + "reply with three short sections: (1) what the task needs, " + "(2) what you found so far, (3) the single most useful next action " + "for the user. Do NOT attempt tool calls.")) + +(def (forge-terminal-summary session-id provider round) + ;; Tier 3: end the turn with a forced findings summary (same pattern + ;; as the *max-tool-rounds* branch), never a bare dead stop. + (log-warn logger "no-progress-break" `((round . ,round))) + (session-add-message session-id + (make-user-message forge-terminal-summary-directive)) + (let* ((msgs (refresh-system-prompt (session-get-messages session-id))) + (resp (chat-with-expert provider msgs '())) + (txt (message-content resp)) + (final (if (and txt (not (string=? txt ""))) + (make-assistant-message txt #f) + (make-assistant-message forge-no-progress-message #f)))) + (session-add-message session-id final) + final)) + +(def (forge-terminal-summary-stream session-id provider raw-cb round) + ;; Streaming twin of forge-terminal-summary. + (log-warn logger "no-progress-break" `((round . ,round))) + (session-add-message session-id + (make-user-message forge-terminal-summary-directive)) + (let-values (((fc _tc _u) + (stream-chat-with-expert provider + (refresh-system-prompt (session-get-messages session-id)) + '() + (and raw-cb (make-tool-call-stream-filter raw-cb))))) + (let* ((use-fallback (or (not fc) (string=? fc ""))) + (msg (if use-fallback forge-no-progress-message fc))) + (when (and use-fallback raw-cb) (raw-cb msg)) + (let ((final (make-assistant-message msg #f))) + (session-add-message session-id final) + final)))) +``` + +**WP3d. Rewire the breaker branch in `agent-guardrails-step`.** + +Replace lines 1548–1562 (the `((forge-no-progress? calls) ...)` clause): + +```scheme + ;; Loop director: repeated no-progress calls escalate through + ;; specific nudge → tool restriction → terminal summary. + ((forge-no-progress? calls) + (forge-note-loop! session-id) + (let ((tier (forge-escalate!))) + (cond + ((= tier 1) + (log-warn logger "no-progress-nudge" `((round . ,round))) + (session-add-message session-id + (make-user-message (forge-nudge-text))) + (agent-loop session-id (session-get-messages session-id) + (+ round 1) gr)) + ((= tier 2) + (log-warn logger "no-progress-restrict" `((round . ,round))) + (session-add-message session-id + (make-user-message forge-loop-restrict-directive)) + (parameterize ((current-tool-allowlist + (forge-restricted-allowlist))) + (agent-loop session-id (session-get-messages session-id) + (+ round 1) gr))) + (else + (forge-terminal-summary session-id provider round))))) +``` + +**WP3e. Rewire the streaming twin** in `agent-guardrails-step-stream` +(replace lines 1679–1695): + +```scheme + ;; Loop director: repeated no-progress calls escalate through + ;; specific nudge → tool restriction → terminal summary. + ((forge-no-progress? calls) + (forge-note-loop! session-id) + (let ((tier (forge-escalate!))) + (cond + ((= tier 1) + (log-warn logger "no-progress-nudge" `((round . ,round))) + (session-add-message session-id + (make-user-message (forge-nudge-text))) + (agent-loop-stream session-id + (session-get-messages session-id) (+ round 1) gr)) + ((= tier 2) + (log-warn logger "no-progress-restrict" `((round . ,round))) + (session-add-message session-id + (make-user-message forge-loop-restrict-directive)) + (parameterize ((current-tool-allowlist + (forge-restricted-allowlist))) + (agent-loop-stream session-id + (session-get-messages session-id) (+ round 1) gr))) + (else + (forge-terminal-summary-stream + session-id provider raw-cb round))))) +``` + +**WP3f. Check `cli.ss` references.** Run +`grep -n "forge-no-progress\|forge-breaker\|nudge" src/jcode/ui/cli.ss`. +Keep every exported name it uses working unchanged +(`forge-no-progress-message` stays exported with its current text — it is +now only the fallback when the summary inference returns empty). If +cli.ss pattern-matches the stop text for display styling, leave that +alone. + +### WP4 — cross-turn loop memory + +Kills the `break → "continue" → same loop` cycle seen in both evidence +sessions. Insert after `forge-terminal-summary-stream`: + +```scheme +;; Per-session memory of looped calls, so a later user turn that reruns +;; them trips at once instead of re-burning the repeat budget. +;; session-id -> (sig-list outkey-list), each capped. +(def *session-loop-memory* (make-hash-table)) +(def *session-loop-memory-cap* 10) + +(def (forge-note-loop! session-id) + ;; Remember this turn's worst offenders. Called on every tier trip. + (when (and session-id (forge-breaker-state)) + (let* ((st (forge-breaker-state)) + (sigs (map car + (forge-take-up-to + (list-sort (lambda (a b) (> (cdr a) (cdr b))) + (vector-ref st 3)) + 5))) + (outs (map car + (forge-take-up-to + (list-sort + (lambda (a b) + (> (forge-out-entry-count (cdr a)) + (forge-out-entry-count (cdr b)))) + (vector-ref st 6)) + 5))) + (old (or (hash-get *session-loop-memory* session-id) + '(() ()))) + (new-sigs (forge-take-up-to (append sigs (car old)) + *session-loop-memory-cap*)) + (new-outs (forge-take-up-to (append outs (cadr old)) + *session-loop-memory-cap*))) + (hash-put! *session-loop-memory* session-id + (list new-sigs new-outs))))) + +(def (forge-seed-from-memory! session-id) + ;; Pre-load counts at limit-1 so the FIRST rerun of a remembered call + ;; (or identical output) trips immediately this turn. + (when session-id + (let ((mem (hash-get *session-loop-memory* session-id)) + (st (forge-breaker-state)) + (limit (forge-max-repeated-calls)) + (out-limit (forge-max-identical-outputs))) + (when (and st mem) + (when (and limit (>= limit 2)) + (for-each + (lambda (sig) + (vector-set! st 3 + (cons (cons sig (- limit 1)) (vector-ref st 3)))) + (car mem))) + (when (and out-limit (>= out-limit 2)) + (for-each + (lambda (key) + (vector-set! st 6 + (cons (cons key (cons (- out-limit 1) + "repeated in an earlier turn")) + (vector-ref st 6)))) + (cadr mem))))))) +``` + +Wire the seed into `agent-run-once`: inside the existing +`(parameterize ((forge-breaker-state (make-forge-breaker-state))) ...)` +(line 1451), add `(forge-seed-from-memory! session-id)` as the first form +of the parameterize body (before the `if`). Do the same in `agent-chat` +(line 1826) — there is no session-id there, so call +`(forge-seed-from-memory! #f)`… **no**: `#f` is a no-op by design, so in +`agent-chat` simply add nothing. Session memory is a session-path feature; +the one-shot CLI path does not need it. + +### WP5 — exports and tests + +**WP5a. Exports.** Add these to the `export` form at the top of agent.ss +(keep everything already there): + +```scheme + forge-max-identical-outputs + forge-record-outputs! + forge-escalate! + forge-nudge-text + forge-loop-restrict-directive + forge-restricted-allowlist + forge-note-loop! + forge-seed-from-memory! + forge-normalize-bash-command +``` + +**WP5b. Tests.** Append a new section to `test/run.ss` immediately after +the existing `=== agent chat no-progress breaker ===` section (anchor: +the block ending at line 2450, just before +`(section "=== expert escalation ===")`). Use `jerboa_balanced_insert` +with that `section` form as anchor, position `before`. House style: +`(check! desc result expected)` / `(check-pred! desc result pred)`; +tool calls via `(make-tool-call "name" "{\"k\":\"v\"}")`; tool results via +`(make-tool-result "id" "content")`. + +```scheme +(section "=== loop director: bash signature normalization ===") +;; Same semantic command, different cd/pipe/timeout → same signature → +;; counts as a repeat (the exact-match evasion from the field sessions). +(let ([mk (lambda (cmd timeout) + (list (make-tool-call "bash" + (string-append "{\"command\":\"" cmd + "\",\"timeout\":" (number->string timeout) + "}"))))]) + (parameterize ([forge-max-repeated-calls 3] + [forge-breaker-state (make-forge-breaker-state)]) + (check! "bash norm: first variant ok" + (forge-no-progress? (mk "cd /repo && cat Makefile | head -20" 5000)) #f) + (check! "bash norm: second variant ok" + (forge-no-progress? (mk "cat Makefile" 15000)) #f) + (check! "bash norm: third variant trips" + (forge-no-progress? (mk "cd /repo && cat Makefile|cat" 5000)) #t))) + +(section "=== loop director: identical-output detection ===") +;; The killer feature: same file re-read with DIFFERENT args producing +;; byte-identical output trips even when no two calls are identical. +(let ([sed-a (list (make-tool-call "bash" "{\"command\":\"sed -n '1,50p' x.ss\"}"))] + [sed-b (list (make-tool-call "bash" "{\"command\":\"sed -n '1,49p' x.ss | head\"}"))] + [cat-c (list (make-tool-call "bash" "{\"command\":\"cd /r && cat x.ss | tail -50\"}"))] + [out "line1\nline2\nline3\n...same bytes...")] + (parameterize ([forge-max-repeated-calls 3] + [forge-max-identical-outputs 3] + [forge-breaker-state (make-forge-breaker-state)]) + (forge-record-outputs! sed-a (list (make-tool-result "t1" out))) + (check! "outputs: distinct batch after 1st identical ok" + (forge-no-progress? sed-b) #f) + (forge-record-outputs! sed-b (list (make-tool-result "t2" out))) + (check! "outputs: distinct batch after 2nd identical ok" + (forge-no-progress? cat-c) #f) + (forge-record-outputs! cat-c (list (make-tool-result "t3" out))) + (check! "outputs: 3 identical outputs trip on next batch" + (forge-no-progress? sed-a) #t))) +;; Changed output (file edited between reads) does not accumulate. +(let ([rd (list (make-tool-call "read" "{\"path\":\"x.ss\"}"))]) + (parameterize ([forge-max-identical-outputs 3] + [forge-breaker-state (make-forge-breaker-state)]) + (forge-record-outputs! rd (list (make-tool-result "a" "v1"))) + (forge-record-outputs! rd (list (make-tool-result "b" "v2-after-edit"))) + (forge-record-outputs! rd (list (make-tool-result "c" "v3-after-edit"))) + (check! "outputs: distinct contents never trip" + (forge-no-progress? rd) #f))) +;; Build/test commands are never output-hashed. +(let ([mk-build (list (make-tool-call "bash" "{\"command\":\"make build 2>&1\"}"))] + [fail "ld: 1 error")] + (parameterize ([forge-max-identical-outputs 3] + [forge-breaker-state (make-forge-breaker-state)]) + (forge-record-outputs! mk-build (list (make-tool-result "a" fail))) + (forge-record-outputs! mk-build (list (make-tool-result "b" fail))) + (forge-record-outputs! mk-build (list (make-tool-result "c" fail))) + (check! "outputs: identical build failures do not trip output path" + (forge-max-output-count (forge-breaker-state)) 0))) +;; Truncation trailers (random paths) are stripped before hashing. +(let ([rd (list (make-tool-call "read" "{\"path\":\"big.ss\"}"))] + [core "aaaa-bbbb-core-bytes"] + [trailer "\n\n...90000 bytes truncated...\n\nFull output saved to: /tmp/tool-1-123.txt\nUse grep")] + (parameterize ([forge-max-identical-outputs 2] + [forge-breaker-state (make-forge-breaker-state)]) + (forge-record-outputs! rd + (list (make-tool-result "a" (string-append core trailer "1")))) + (forge-record-outputs! rd + (list (make-tool-result "b" (string-append core trailer "2")))) + (check! "outputs: trailers with different paths count identical" + (forge-no-progress? rd) #t))) + +(section "=== loop director: escalation tiers ===") +(parameterize ([forge-breaker-state (make-forge-breaker-state)]) + (check! "tier starts unused" (forge-no-progress-nudge-used?) #f) + (check! "first escalate → 1" (forge-escalate!) 1) + (check! "nudge-used? true at tier 1" (forge-no-progress-nudge-used?) #t) + (check! "second escalate → 2" (forge-escalate!) 2) + (check! "third escalate → 3" (forge-escalate!) 3) + (check! "escalate clamps at 3" (forge-escalate!) 3)) +;; Nudge text names the offending call and its count. +(let ([cat-x (list (make-tool-call "bash" "{\"command\":\"cat x.ss\"}"))]) + (parameterize ([forge-max-repeated-calls 3] + [forge-breaker-state (make-forge-breaker-state)]) + (forge-no-progress? cat-x) + (forge-no-progress? cat-x) + (check! "trip on third" (forge-no-progress? cat-x) #t) + (let ([txt (forge-nudge-text)]) + (check-pred! "nudge names the call" + txt (lambda (s) (string-contains s "cat x.ss"))) + (check-pred! "nudge shows the count" + txt (lambda (s) (string-contains s "(x3)"))) + (check-pred! "nudge has next actions" + txt (lambda (s) (string-contains s "Pick ONE next action")))))) + +(section "=== loop director: session memory ===") +;; A loop recorded in one "turn" makes the next turn trip on the first +;; rerun (this is the continue→fumble→break→continue killer). +(let ([sid "test-session-loop-mem"] + [cat-x (list (make-tool-call "bash" "{\"command\":\"cat x.ss\"}"))]) + (parameterize ([forge-max-repeated-calls 3] + [forge-breaker-state (make-forge-breaker-state)]) + (forge-no-progress? cat-x) + (forge-no-progress? cat-x) + (forge-no-progress? cat-x) + (forge-note-loop! sid)) + (parameterize ([forge-max-repeated-calls 3] + [forge-breaker-state (make-forge-breaker-state)]) + (forge-seed-from-memory! sid) + (check! "seeded turn: first rerun of remembered call trips" + (forge-no-progress? cat-x) #t)) + (hash-remove! *session-loop-memory* sid)) + +(section "=== loop director: restriction allowlist ===") +(let ([allow (forge-restricted-allowlist)]) + (check-pred! "bash withheld" allow + (lambda (a) (not (member "bash" a)))) + (check-pred! "read withheld" allow + (lambda (a) (not (member "read" a)))) + (check-pred! "edit kept" allow + (lambda (a) (and (member "edit" a) #t))) + (check-pred! "todowrite kept" allow + (lambda (a) (and (member "todowrite" a) #t)))) +(parameterize ([current-tool-allowlist '("read" "edit" "bash")]) + (let ([allow (forge-restricted-allowlist)]) + (check! "tier 2 only narrows an existing allowlist" allow '("edit")))) +``` + +Note: `forge-no-progress-nudge-used?` returns `(and st ...)` → `#f` on a +fresh state, and `check!` compares with `equal?` — the existing tests at +2447–2450 already rely on exactly this, so `#f`/values line up. + +`forge-max-output-count` is used in one test; it is internal (not in the +export list). Tests import `(jcode core agent)` and only see exports — +so EITHER export it too (add it to WP5a's list) or replace that one check +with a `forge-no-progress?`-based assertion. Pick exporting: add +`forge-max-output-count` to the exports. + +--- + +## 6. Verified vocabulary + +Every procedure used in this document's code either already appears in +`src/jcode/core/agent.ss` today or is defined by this document. Before +using ANYTHING else, check it with `jerboa_function_signature`. The ones +worth double-checking because they are easy to get wrong: + +- `(string-contains str sub)` → **index or #f**, never a boolean. +- `(string-prefix? prefix str)` — prefix FIRST. +- `(string-split str char)` — takes a CHAR (`#\space`, `#\|`). +- `(substring s start end)` — both bounds required. +- `(list-sort pred lst)` — this codebase's order (prelude shadowing); + used at agent.ss line 366. +- `(take lst n)` — **do not use**, errors when n > length; this document + defines `forge-take-up-to` instead. +- `(hash-get ht key)` → value or `#f`; `(hash-remove! ht key)` exists. +- `(make-tool-call name args-json-string)` / `(make-tool-result id content)` + — message.ss lines 42/58. +- `(member x lst)` → sublist or `#f`; wrap in `(and ... #t)` when a + boolean is needed. +- Parameters: `(make-parameter v)`, mutate by calling `(p new-v)`, + rebind with `(parameterize ((p v)) body ...)`. Parameter bindings are + thread-local and do NOT cross `spawn` — never touch + `forge-breaker-state` from inside a spawned thread. + +## 7. Pitfalls (all observed in prior sessions) + +1. **Editing `.ss` with raw tools.** Instant paren damage. Balanced tools + only (§1). `test/run.ss` is also `.ss` — same rule. +2. **`forge-no-progress?` has side effects.** It bumps counts. Tests that + call it to "check state" are changing state. Write each test with a + fresh `(make-forge-breaker-state)`. +3. **Do not renumber slots inconsistently.** Slot 5 is the escalation + tier everywhere; slot 6 is `seen-outputs`. `forge-no-progress-nudge-used?` + and `forge-mark-no-progress-nudged!` MUST keep their old semantics + (tests at `test/run.ss` 2447–2450 lock them). +4. **Do not hash outputs of `make build` / test runners.** Identical + build failures are legitimate iteration. That is why `bash` is only + hashed when its normalized command matches `*forge-bash-read-prefixes*`. +5. **Do not hash inside `execute-single-tool` or spawned threads.** + Parameter state is thread-local; you would silently update a copy. + Recording happens in the parent thread AFTER `execute-tool-calls` + returns (WP1e). +6. **Truncation trailer.** Never hash a tool result without passing + through `forge-output-key` (the random truncated-output path would + defeat identity). +7. **Tier 2 must only narrow.** Always compute the allowlist via + `forge-restricted-allowlist` (intersection), never by assigning + `*forge-loop-restricted-tools*` directly — compact providers already + run a reduced tool set. +8. **Do not touch the workflow/`verified` no-progress machinery.**