Add no-progress loop breaker on the workflow runner
ober
db10ba203b90434304935f9591113e15db0dd537
--- a/src/jcode/core/errors.ss +++ b/src/jcode/core/errors.ss @@ -35,6 +35,9 @@ &max-iterations max-iterations-error? max-iterations-error-iterations max-iterations-error-pending raise-max-iterations + &no-progress no-progress-error? + no-progress-error-repeats no-progress-error-signature + raise-no-progress &step-enforcement step-enforcement-error? step-enforcement-error-terminal step-enforcement-error-attempts step-enforcement-error-pending @@ -164,6 +167,25 @@ ") exceeded. Completed: " (join-names completed-list) ", Pending: " (join-names pending-list)))))) +;; NoProgressError — model repeated an identical *executed* tool-call batch +;; max-repeated-calls times in a row. A degenerate loop the other budgets miss: +;; the calls neither error (error-tracker) nor hit the terminal (step-enforcer), +;; so without this breaker they silently burn the whole iteration budget. +(define-condition-type &no-progress &forge-error + make-no-progress no-progress-error? + (repeats no-progress-error-repeats) + (signature no-progress-error-signature)) + +(def (raise-no-progress repeats signature completed-list) + (raise + (condition + (make-no-progress repeats signature) + (make-who-condition 'workflow-runner) + (make-message-condition + (string-append "No progress: the same tool call repeated " + (number->string repeats) " times in a row without advancing. " + "Completed steps: " (join-names completed-list)))))) + ;; StepEnforcementError — model called the terminal tool prematurely too often. (define-condition-type &step-enforcement &forge-error make-step-enforcement step-enforcement-error? --- a/src/jcode/core/workflow-runner.ss +++ b/src/jcode/core/workflow-runner.ss @@ -165,18 +165,34 @@ (string-append prefix (nudge-content nudge))))) tc-data))) +;; Stable string signature of a tool-call batch: tool name + serialized args per +;; call. Two batches with the same names and args produce the same string, so an +;; identical batch repeated across iterations is detectable by `equal?`. +(def (tool-calls-signature tcs) + (call-with-string-output-port + (lambda (p) + (for-each + (lambda (tc) + (display (wtool-call-tool tc) p) + (display "|" p) + (write (wtool-call-args tc) p) + (display ";" p)) + tcs)))) + (def (run-workflow workflow user-message responder . opt) "Execute WORKFLOW with USER-MESSAGE, driving the loop through RESPONDER. Returns the terminal tool's value. OPT is an optional options assoc: max-iterations (10) max-retries-per-step (3) max-tool-errors (2) - on-message (#f) prompt-vars ('()) initial-messages (#f) - cancel? (thunk -> bool, default never). + max-repeated-calls (#f = off) on-message (#f) prompt-vars ('()) + initial-messages (#f) cancel? (thunk -> bool, default never). Raises MaxIterationsError / StepEnforcementError / PrerequisiteError / - ToolExecutionError / WorkflowCancelledError on the corresponding conditions." + ToolExecutionError / WorkflowCancelledError / NoProgressError on the + corresponding conditions." (let* ((o (if (pair? opt) (car opt) '())) (max-iterations (or (opt-ref o 'max-iterations) 10)) (max-retries (or (opt-ref o 'max-retries-per-step) 3)) (max-tool-errors (or (opt-ref o 'max-tool-errors) 2)) + (max-repeated-calls (opt-ref o 'max-repeated-calls)) (on-message (opt-ref o 'on-message)) (prompt-vars (or (opt-ref o 'prompt-vars) '())) (initial-msgs (opt-ref o 'initial-messages)) @@ -195,7 +211,9 @@ (workflow-terminal-tools workflow) tool-prereqs)) (error-tracker (make-error-tracker max-retries max-tool-errors)) - (tool-specs (workflow-get-tool-specs workflow))) + (tool-specs (workflow-get-tool-specs workflow)) + (last-sig (vector #f)) ; signature of the previous executed batch + (repeat-n (vector 0))) ; consecutive identical-batch count ;; Step 3 — main loop (one responder call per iteration) (let loop ((iteration 0)) (cond @@ -241,7 +259,22 @@ (step-check-nudge prereq-check)) (loop (+ iteration 1))) (else - ;; 3c → 3e — execute the batch + ;; 3c — no-progress breaker (opt-in via + ;; max-repeated-calls). An identical *executed* batch + ;; repeated that many times in a row is a degenerate + ;; loop the error/step budgets miss (the calls neither + ;; error nor finish), so break early instead of + ;; silently burning the iteration budget. + (when max-repeated-calls + (let ((sig (tool-calls-signature tool-calls))) + (if (equal? sig (vector-ref last-sig 0)) + (vector-set! repeat-n 0 (+ (vector-ref repeat-n 0) 1)) + (begin (vector-set! last-sig 0 sig) + (vector-set! repeat-n 0 1))) + (when (>= (vector-ref repeat-n 0) max-repeated-calls) + (raise-no-progress max-repeated-calls sig + (step-enforcer-completed enforcer))))) + ;; 3d → 3e — execute the batch (let ((outcome (execute-batch! emit! workflow enforcer error-tracker tool-calls))) (if (and (pair? outcome) (eq? (car outcome) 'terminal)) --- a/src/jcode/eval/runner.ss +++ b/src/jcode/eval/runner.ss @@ -39,6 +39,7 @@ (cond ((not (condition? e)) "error") ((max-iterations-error? e) "max_iterations") + ((no-progress-error? e) "no_progress") ((step-enforcement-error? e) "step_enforcement") ((prerequisite-error? e) "prerequisite") ((tool-execution-error? e) "tool_execution") --- a/src/jcode/ui/cli.ss +++ b/src/jcode/ui/cli.ss @@ -569,6 +569,52 @@ EXAMPLES: (printf "With k=1 the lone buggy candidate is rejected; k=2 resamples and the~n") (printf "verify-gate selects correct code. Composes with /forge verify.~n")) +;; /forge breaker — ATLAS's cheap no-progress loop breaker on forge's runner. +;; A model stuck repeating one *executed* tool call trips neither the error +;; budget (the call succeeds) nor step enforcement (no terminal), so without a +;; breaker it burns the whole iteration budget. The opt-in max-repeated-calls +;; breaker detects the identical-batch loop and stops early. +(def (forge-breaker-stuck-wf) + (make-workflow "stuck" "Look, then answer." + (list + (make-tool-def (make-tool-spec "look" "Look around." '(("type" . "object"))) + (lambda (args) "looked") '()) + (make-tool-def (make-tool-spec "answer" "Answer." '(("type" . "object"))) + (lambda (args) "ANSWER") '())) + '() "answer" "p")) + +;; Run a stuck look-loop with the given breaker threshold (#f = off). Returns +;; (error-type . iterations-used). +(def (forge-breaker-run max-repeated) + (let ((calls (vector 0)) + (n (vector 0)) + (script (make-list 12 (list (make-wtool-call "look" '() "looking"))))) + (let ((resp (lambda (messages tool-specs step) + (vector-set! calls 0 (+ (vector-ref calls 0) 1)) + (let ((r (if (< (vector-ref n 0) (length script)) + (list-ref script (vector-ref n 0)) + (make-text-response "stuck")))) + (vector-set! n 0 (+ (vector-ref n 0) 1)) r))) + (opts (if max-repeated + (list (cons 'max-iterations 8) (cons 'max-repeated-calls max-repeated)) + (list (cons 'max-iterations 8))))) + (guard (e [#t (cons (eval-error-type e) (vector-ref calls 0))]) + (run-workflow (forge-breaker-stuck-wf) "explore" resp opts) + (cons "completed" (vector-ref calls 0)))))) + +(def (forge-print-breaker) + (printf "No-progress breaker: ATLAS's cheap loop breaker on forge's runner.~n") + (printf " an identical executed tool-call batch repeated max-repeated-calls~n") + (printf " times in a row breaks early — the error/step budgets never catch it.~n") + (let ((off (forge-breaker-run #f)) + (on (forge-breaker-run 3))) + (printf " off error=~a iterations=~a (burned the budget)~n" + (car off) (cdr off)) + (printf " max-repeated 3 error=~a iterations=~a (stopped early)~n" + (car on) (cdr on))) + (printf "Only identical *executed* batches count, so a real edit/verify repair~n") + (printf "cycle (alternating tools) is never affected. Composes with best-of-k.~n")) + ;; `jcode proxy` — serve the configured provider behind the guardrail proxy. (def (proxy-main args) (let loop ((args args) (port 8080) (bind "127.0.0.1")) @@ -597,7 +643,7 @@ EXAMPLES: (let ((cmd (string-trim (substring input 1 (string-length input))))) (cond ((equal? cmd "help") - (display "\nCommands:\n /help Show this help\n /model [name] Show or set model\n /provider [name] Show or set provider\n /plan Switch to PLAN mode (read-only)\n /build Switch to BUILD mode (read+write)\n /mode Show current mode\n /mcp Toggle MCP tools on/off\n /tools List available tools\n /clear Start a new session\n /sessions List saved sessions\n /compact Show message count\n /undo [N] Revert last N checkpoint(s) (default 1)\n /checkpoints List recent shadow-git checkpoints\n /forge [on|off] Show or toggle forge guardrails\n /forge sampling <off|on|strict> Per-model sampling policy\n /forge workflow Describe + self-test the workflow engine\n /forge proxy Describe + self-test the OpenAI-compatible proxy\n /forge ablation Describe + self-test the eval/ablation harness\n /forge verify Describe + self-test the verify-gate (ATLAS verify+repair)\n /forge bestofk Describe + self-test best-of-k diverse-gen (ATLAS Phase-1)\n /quit Exit\n\nMulti-line: end a line with \\ to continue on the next line.\n\n")) + (display "\nCommands:\n /help Show this help\n /model [name] Show or set model\n /provider [name] Show or set provider\n /plan Switch to PLAN mode (read-only)\n /build Switch to BUILD mode (read+write)\n /mode Show current mode\n /mcp Toggle MCP tools on/off\n /tools List available tools\n /clear Start a new session\n /sessions List saved sessions\n /compact Show message count\n /undo [N] Revert last N checkpoint(s) (default 1)\n /checkpoints List recent shadow-git checkpoints\n /forge [on|off] Show or toggle forge guardrails\n /forge sampling <off|on|strict> Per-model sampling policy\n /forge workflow Describe + self-test the workflow engine\n /forge proxy Describe + self-test the OpenAI-compatible proxy\n /forge ablation Describe + self-test the eval/ablation harness\n /forge verify Describe + self-test the verify-gate (ATLAS verify+repair)\n /forge bestofk Describe + self-test best-of-k diverse-gen (ATLAS Phase-1)\n /forge breaker Describe + self-test the no-progress loop breaker\n /quit Exit\n\nMulti-line: end a line with \\ to continue on the next line.\n\n")) ((equal? cmd "model") (printf "Provider: ~a~n" (or (current-provider-override) (config-provider))) (printf "Model: ~a~n" (or (current-model-override) (config-model))) @@ -694,6 +740,8 @@ EXAMPLES: (forge-print-verify)) ((or (equal? cmd "forge bestofk") (equal? cmd "forge best-of-k")) (forge-print-bestofk)) + ((or (equal? cmd "forge breaker") (equal? cmd "forge no-progress")) + (forge-print-breaker)) ((or (equal? cmd "forge on") (equal? cmd "forge enforce") (equal? cmd "forge enforce on")) (forge-respond-enforced? #t) --- a/test/run.ss +++ b/test/run.ss @@ -1438,6 +1438,53 @@ (raises? (lambda () (run-best-of-k (bok-workflow (vector "UNWRITTEN")) "go" (bok-factory) 0 bok-opts))) #t) +(section "=== no-progress breaker: ATLAS cheap loop breaker ===") +;; A model stuck calling the same executed tool forever. The error budget never +;; trips (the call succeeds) and the terminal is never reached, so without a +;; breaker it silently burns the whole iteration budget → MaxIterationsError. +;; The opt-in max-repeated-calls breaker detects the identical-batch loop and +;; breaks early with NoProgressError, wasting far less compute. (look has no +;; prerequisite and is not a required step, so each call genuinely executes.) +(define (mk-stuck-wf) + (make-workflow "stuck" "Look, then answer." + (list + (make-tool-def (make-tool-spec "look" "l" '(("type" . "object"))) (lambda (a) "looked") '()) + (make-tool-def (make-tool-spec "answer" "a" '(("type" . "object"))) (lambda (a) "ANSWER") '())) + '() "answer" "p")) +;; Without the breaker: 6 identical looks burn all 6 iterations → MaxIterations. +(let* ([w (mk-stuck-wf)] + [calls (vector 0)] + [base (scripted-responder (make-list 10 (list (make-wtool-call "look" '() #f))))] + [resp (lambda (m s i) (vector-set! calls 0 (+ (vector-ref calls 0) 1)) (base m s i))]) + (check! "no breaker: stuck loop raises MaxIterationsError" + (raises-pred? (lambda () (run-workflow w "go" resp (list (cons 'max-iterations 6)))) + max-iterations-error?) #t) + (check! "no breaker burned all 6 iterations" (vector-ref calls 0) 6)) +;; With the breaker (3): breaks at the 3rd identical executed batch → NoProgress. +(let* ([w (mk-stuck-wf)] + [calls (vector 0)] + [base (scripted-responder (make-list 10 (list (make-wtool-call "look" '() #f))))] + [resp (lambda (m s i) (vector-set! calls 0 (+ (vector-ref calls 0) 1)) (base m s i))]) + (check! "breaker: stuck loop raises NoProgressError" + (raises-pred? (lambda () (run-workflow w "go" resp + (list (cons 'max-iterations 6) (cons 'max-repeated-calls 3)))) + no-progress-error?) #t) + (check! "breaker fired at 3 calls, not 6 (half the wasted compute)" + (vector-ref calls 0) 3)) +;; The breaker only counts identical *executed* batches: a real edit→verify→edit +;; repair cycle alternates tools, so it never trips even with a tight threshold. +(let* ([code (vector "UNWRITTEN")] + [w (bok-workflow code)] + [resp (scripted-responder + (list (list (make-wtool-call "edit" '(("content" . "buggy")) #f)) + (list (make-wtool-call "verify" '() #f)) + (list (make-wtool-call "edit" '(("content" . "correct")) #f)) + (list (make-wtool-call "verify" '() #f)) + (list (make-wtool-call "done" '(("summary" . "ok")) #f))))] + [result (run-workflow w "go" resp (list (cons 'max-iterations 8) + (cons 'max-repeated-calls 2)))]) + (check! "breaker leaves real edit/verify repair alone" result "SHIPPED")) + ;; ── Results ─────────────────────────────────────────────────────── (printf "~n~a passed, ~a failed~n" pass-count fail-count)