Stop repeated sub-agent tool loops
ober
35e127f5168360bd885841ff4287deb466d55ba2
--- 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))) --- 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),