Stop repeated sub-agent tool loops

ober

35e127f5168360bd885841ff4287deb466d55ba2

diff --git a/src/jcode/core/agent.ss b/src/jcode/core/agent.ss
index 7af22fd..39d561e 100644
--- a/src/jcode/core/agent.ss
+++ b/src/jcode/core/agent.ss
@@ -13,6 +13,9 @@
         get-current-provider
         forge-respond-enforced?
         forge-max-repeated-calls
+        forge-breaker-state
+        make-forge-breaker-state
+        forge-no-progress?
         try-parse-text-tool-calls
         try-parse-xml-tool-calls)
 
@@ -52,34 +55,64 @@
 ;; turn on for small local models that can't be trusted to pick tool-vs-text.
 (def forge-respond-enforced? (make-parameter #f))
 
-;; No-progress loop breaker (ATLAS) for the chat loop. When the model emits the
-;; same tool-call batch this many times in a row — a degenerate loop the
-;; retry/error budgets miss, since the calls neither error nor finish — the turn
-;; is stopped instead of silently burning rounds. #f disables. Per-turn state
-;; lives in forge-breaker-state, reset by agent-run at the start of each turn.
+;; No-progress loop breaker (ATLAS) for the chat loop. When the model repeats
+;; the same tool-call batch or individual tool call this many times in one turn,
+;; the turn is stopped instead of silently burning rounds. Consecutive batch
+;; repeats are still tracked, but low-progress loops often alternate a failed
+;; search with ls/read calls, so per-turn seen-counts catch non-consecutive
+;; repeats too. #f disables. Per-turn state lives in forge-breaker-state.
 (def forge-max-repeated-calls (make-parameter 3))
 (def forge-breaker-state (make-parameter #f))
+(def forge-no-progress-message
+  "[stopped: repeated the same tool call(s) with no progress]")
+
+(def (make-forge-breaker-state)
+  ;; #(last-batch-sig consecutive-count seen-batches seen-individual-calls)
+  (vector #f 0 '() '()))
+
+(def (chat-call-signature tc)
+  (let ((a (tool-call-arguments tc)))
+    (string-append (tool-call-name tc) "|"
+                   (if (string? a) a (format "~a" a)))))
 
 (def (chat-calls-signature calls)
   (string-join
-    (map (lambda (tc)
-           (let ((a (tool-call-arguments tc)))
-             (string-append (tool-call-name tc) "|"
-                            (if (string? a) a (format "~a" a)))))
-         calls)
+    (map chat-call-signature calls)
     ";"))
 
+(def (forge-count-bump! st slot sig)
+  (let ((hit (assoc sig (vector-ref st slot))))
+    (if hit
+      (let ((n (+ (cdr hit) 1)))
+        (set-cdr! hit n)
+        n)
+      (begin
+        (vector-set! st slot (cons (cons sig 1) (vector-ref st slot)))
+        1))))
+
+(def (forge-max-call-count! st calls)
+  (let loop ((rest calls) (mx 0))
+    (if (null? rest)
+      mx
+      (loop (cdr rest)
+            (max mx (forge-count-bump! st 3
+                      (chat-call-signature (car rest))))))))
+
 ;; Update the per-turn breaker state with CALLS; report whether executing them
-;; now is the Nth identical repeat in a row (>= the configured limit).
+;; now crosses the configured repeat limit.
 (def (forge-no-progress? calls)
   (let ((limit (forge-max-repeated-calls))
         (st    (forge-breaker-state)))
     (and limit st (pair? calls)
-         (let ((sig (chat-calls-signature calls)))
+         (let* ((sig (chat-calls-signature calls))
+                (batch-seen (forge-count-bump! st 2 sig))
+                (call-seen  (forge-max-call-count! st calls)))
            (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)))
-           (>= (vector-ref st 1) limit)))))
+           (or (>= (vector-ref st 1) limit)
+               (>= batch-seen limit)
+               (>= call-seen limit))))))
 
 (def (system-prompt)
   (format "You are an expert AI coding assistant. You help users with software development tasks.
@@ -1139,7 +1172,7 @@ Be concise. Prefer edit over write for modifying existing files.
   ;; One guardrails instance per user turn — its retry/error budget persists
   ;; across the tool-call rounds of this turn, then resets for the next.
   (let ((gr (make-guardrails (list-tools))))
-    (parameterize ((forge-breaker-state (vector #f 0)))
+    (parameterize ((forge-breaker-state (make-forge-breaker-state)))
       (if (current-stream-cb)
         (agent-loop-stream session-id (session-get-messages session-id) 0 gr)
         (agent-loop        session-id (session-get-messages session-id) 0 gr)))))
@@ -1234,11 +1267,10 @@ Be concise. Prefer edit over write for modifying existing files.
             (let ((final (make-assistant-message (respond-call->text rc) #f)))
               (session-add-message session-id final)
               final))
-           ;; No-progress breaker: same tool batch repeated — stop the turn.
+           ;; No-progress breaker: repeated tool calls — stop the turn.
            ((forge-no-progress? calls)
             (log-warn logger "no-progress-break" `((round . ,round)))
-            (let ((final (make-assistant-message
-                           "[stopped: repeated the same tool call(s) with no progress]" #f)))
+            (let ((final (make-assistant-message forge-no-progress-message #f)))
               (session-add-message session-id final)
               final))
            (else
@@ -1355,10 +1387,10 @@ Be concise. Prefer edit over write for modifying existing files.
               (let ((final (make-assistant-message msg #f)))
                 (session-add-message session-id final)
                 final)))
-           ;; No-progress breaker: same tool batch repeated — stop the turn.
+           ;; No-progress breaker: repeated tool calls — stop the turn.
            ((forge-no-progress? calls)
             (log-warn logger "no-progress-break" `((round . ,round)))
-            (let ((msg "[stopped: repeated the same tool call(s) with no progress]"))
+            (let ((msg forge-no-progress-message))
               (when raw-cb (raw-cb msg))
               (let ((final (make-assistant-message msg #f)))
                 (session-add-message session-id final)
@@ -1472,9 +1504,10 @@ Be concise. Prefer edit over write for modifying existing files.
          (messages (list
                      (make-system-message (system-prompt))
                      (make-user-message user-input))))
-    (if (current-stream-cb)
-      (agent-chat-loop-stream provider messages tools 0)
-      (agent-chat-loop provider messages tools 0))))
+    (parameterize ((forge-breaker-state (make-forge-breaker-state)))
+      (if (current-stream-cb)
+        (agent-chat-loop-stream provider messages tools 0)
+        (agent-chat-loop provider messages tools 0)))))
 
 (def (agent-chat-loop provider messages tools round)
   (let* ((msgs messages)
@@ -1486,6 +1519,9 @@ Be concise. Prefer edit over write for modifying existing files.
               (new-messages (append msgs (list response) results))
               (final (chat-with-expert provider new-messages '())))
          (or (message-content final) "")))
+      ((forge-no-progress? (message-tool-calls response))
+       (log-warn logger "no-progress-break" `((round . ,round)))
+       forge-no-progress-message)
       (else
        (let* ((results (execute-tool-calls (message-tool-calls response)))
               (new-messages (append msgs (list response) results)))
@@ -1517,6 +1553,10 @@ Be concise. Prefer edit over write for modifying existing files.
                            (stream-chat-with-expert provider new-msgs '()
                              (and raw-cb (make-tool-call-stream-filter raw-cb)))))
                fc)))
+          ((forge-no-progress? effective-tcs)
+           (log-warn logger "no-progress-break" `((round . ,round)))
+           (when raw-cb (raw-cb forge-no-progress-message))
+           forge-no-progress-message)
           (else
            (let* ((response  (make-assistant-message effective-content effective-tcs))
                   (results   (execute-tool-calls effective-tcs))
@@ -1530,7 +1570,8 @@ Be concise. Prefer edit over write for modifying existing files.
    transcript and continue the same sub-agent conversation later."
   (let ((provider (get-current-provider))
         (tools (get-tool-schemas)))
-    (agent-chat-loop-track provider messages tools 0)))
+    (parameterize ((forge-breaker-state (make-forge-breaker-state)))
+      (agent-chat-loop-track provider messages tools 0))))
 
 (def (agent-chat-loop-track provider messages tools round)
   (let ((response (chat-with-expert provider messages tools)))
@@ -1544,6 +1585,11 @@ Be concise. Prefer edit over write for modifying existing files.
               (final (chat-with-expert provider new-messages '())))
          (values (or (message-content final) "")
                  (append new-messages (list final)))))
+      ((forge-no-progress? (message-tool-calls response))
+       (log-warn logger "no-progress-break" `((round . ,round)))
+       (let ((final (make-assistant-message forge-no-progress-message #f)))
+         (values forge-no-progress-message
+                 (append messages (list final)))))
       (else
        (let* ((results (execute-tool-calls (message-tool-calls response)))
               (new-messages (append messages (list response) results)))
diff --git a/test/run.ss b/test/run.ss
index cc028de..c767f6d 100644
--- a/test/run.ss
+++ b/test/run.ss
@@ -9,6 +9,7 @@
         (jcode core message)
         (jcode core grok-auth)
         (jcode core config)
+        (jcode core agent)
         (jcode core models)
         (jcode core secrets-import)
         (jcode provider provider)
@@ -1575,6 +1576,21 @@
                                                (cons 'max-repeated-calls 2)))])
   (check! "breaker leaves real edit/verify repair alone" result "SHIPPED"))
 
+(section "=== agent chat no-progress breaker ===")
+;; Sub-agents use agent-chat-messages, not agent-run. This tests the lower-level
+;; per-turn breaker directly: repeated glob misses separated by ls calls must
+;; still trip on the third identical tool-call signature.
+(let ([glob-bin (list (make-tool-call "glob" "{\"pattern\":\"bin/**/*.ss\",\"path\":\"/repo\"}"))]
+      [ls-bin   (list (make-tool-call "ls" "{\"path\":\"/repo/bin\"}"))])
+  (parameterize ([forge-max-repeated-calls 3]
+                 [forge-breaker-state (make-forge-breaker-state)])
+    (check! "agent breaker first glob ok" (forge-no-progress? glob-bin) #f)
+    (check! "agent breaker interleaved ls ok" (forge-no-progress? ls-bin) #f)
+    (check! "agent breaker second glob ok" (forge-no-progress? glob-bin) #f)
+    (check! "agent breaker second ls ok" (forge-no-progress? ls-bin) #f)
+    (check! "agent breaker non-consecutive third glob trips"
+      (forge-no-progress? glob-bin) #t)))
+
 (section "=== verified-run: coding workflow on a REAL file + REAL shell verify ===")
 ;; The verify-gate/best-of-k tests above use mock callables. This one drives the
 ;; live-model bridge's genuinely new code — do-edit (real write-file-string),