Constrain local prompt growth

ober

388f968a2574a5f71176c6089d6de57c5780e886

diff --git a/src/jcode/core/agent.ss b/src/jcode/core/agent.ss
index 9062a25..a766eb0 100644
--- a/src/jcode/core/agent.ss
+++ b/src/jcode/core/agent.ss
@@ -246,6 +246,7 @@ Be concise. Prefer edit over write for modifying existing files.
 (def *max-tool-rounds* 100)
 (def *max-tool-lines* 2000)
 (def *max-tool-bytes* 51200)  ;; 50KB
+(def *local-max-tool-bytes* 8192)
 
 (def (refresh-system-prompt messages)
   "Replace the leading system message (if any) with a fresh one reflecting
@@ -273,25 +274,36 @@ Be concise. Prefer edit over write for modifying existing files.
     (unless (file-exists? dir) (mkdir dir))
     dir))
 
+(def (tool-output-byte-limit)
+  "Return the tool-result preview byte cap for the active model. Small local
+   context budgets need tighter previews so a few reads cannot build a prompt
+   large enough to stall local prefill."
+  (let* ((mdl (or (current-model-override) (config-ref "model") ""))
+         (win (model-context-window mdl)))
+    (if (and win (<= win 4096))
+      *local-max-tool-bytes*
+      *max-tool-bytes*)))
+
 (def (truncate-tool-output text)
   "Cap tool output at *max-tool-lines* / *max-tool-bytes*. If truncated,
    save the full output to ~/.jcode/truncated/ and return a preview + hint."
-  (let* ((lines (string-split text #\newline))
+  (let* ((byte-limit (tool-output-byte-limit))
+         (lines (string-split text #\newline))
          (total-bytes (string-length text))
          (total-lines (length lines)))
     (if (and (<= total-lines *max-tool-lines*)
-             (<= total-bytes *max-tool-bytes*))
+             (<= total-bytes byte-limit))
       text
       ;; Truncate: keep first N lines up to byte limit
       (let loop ((remaining lines) (count 0) (bytes 0) (acc '()))
         (if (or (null? remaining)
                 (>= count *max-tool-lines*)
-                (>= bytes *max-tool-bytes*))
+                (>= bytes byte-limit))
           ;; Save full output, return preview + hint
           (let* ((preview (string-join (reverse acc) "\n"))
                  (file (path-join (truncated-dir)
                          (format "tool-~a.txt" (time-second (current-time)))))
-                 (removed (if (> total-bytes *max-tool-bytes*)
+                 (removed (if (> total-bytes byte-limit)
                             (format "~a bytes" (- total-bytes (string-length preview)))
                             (format "~a lines" (- total-lines count)))))
             (with-output-to-file file (lambda () (display text)))
diff --git a/src/jcode/core/compaction-strategy.ss b/src/jcode/core/compaction-strategy.ss
index bb917bf..d065edc 100644
--- a/src/jcode/core/compaction-strategy.ss
+++ b/src/jcode/core/compaction-strategy.ss
@@ -85,10 +85,10 @@
 ;; ── Shared helpers ──────────────────────────────────────────────────
 
 (def (strategy-estimate-tokens messages)
-  ;; forge _estimate_tokens: sum of content lengths // 4 (content only).
-  (quotient
-    (apply + (map (lambda (m) (string-length (or (message-content m) ""))) messages))
-    4))
+  ;; Keep the strategy's trigger aligned with the outer compaction gate.
+  ;; That estimator includes tool-call argument bytes, which matter in
+  ;; file-heavy coding sessions.
+  (estimate-message-tokens messages))
 
 (def (distinct-consecutive xs)
   ;; Append a value whenever it differs from the previously appended one;
@@ -247,7 +247,8 @@
   "Pick the strategy from jcode.json (compaction.strategy, default tiered),
    run it against BUDGET tokens, log the phase reached, return the messages."
   (let* ((name  (compaction-block-ref "strategy" "tiered"))
-         (keep  (compaction-block-ref "strategy_keep_recent" 2))
+         (keep  (compaction-block-ref "strategy_keep_recent"
+                  (if (and budget (<= budget 4096)) 1 2)))
          (thr   (compaction-block-ref "strategy_threshold" 0.75))
          (strat (compaction-strategy-by-name name keep thr))
          (res   (strat messages budget))
diff --git a/src/jcode/core/models.ss b/src/jcode/core/models.ss
index ac43045..c2d0102 100644
--- a/src/jcode/core/models.ss
+++ b/src/jcode/core/models.ss
@@ -460,11 +460,11 @@
       ;; weights leave only ~4 GB under the 32 GB wired cap, so keep the window
       ;; conservative — compaction must fire before prefill OOMs the GPU.
       ((string-contains mid "TurboQuant")               16384)
-      ;; The local qwen3-coder-next MLX server stalls around 26k-token prefill
-      ;; turns. jcode's message estimator does not include the fixed tool
-      ;; schema overhead (roughly another 8-9k tokens), so this is an effective
-      ;; message budget rather than the model's architectural context window.
-      ((string-contains mid "qwen3-coder-next-mlx")      8192)
+      ;; The local qwen3-coder-next MLX server stalls around large prefill
+      ;; turns. jcode's estimator does not include the fixed tool-schema
+      ;; overhead, so this is an effective working budget rather than the
+      ;; model's architectural context window.
+      ((string-contains mid "qwen3-coder-next-mlx")      4096)
       ;; grok-build: Grok CLI proxy advertises a 512k context window in
       ;; ~/.grok/models_cache.json (info.context_window). Hardcode here so
       ;; the TUI ctx% bar has a value even before models_cache.json is read.
diff --git a/test/run.ss b/test/run.ss
index fb7674b..9d575f0 100644
--- a/test/run.ss
+++ b/test/run.ss
@@ -779,7 +779,7 @@
   (message-thinking-set! m "pondering")
   (check! "type: reasoning (thinking+empty content)" (message-derived-type m) message-type-reasoning))
 
-;; estimate = sum(content lengths) // 4
+;; estimate = message content plus tool-call bytes, divided by 4
 (check! "estimate-tokens content/4"
   (strategy-estimate-tokens (list (make-user-message (make-string 400 #\x)))) 100)
 
@@ -812,8 +812,8 @@
         (make-assistant-message "final")))
 (check! "tier step indices" (derive-step-indices hist-tier) (list #f #f 1 1 2 3 3 4 5 5 6))
 (check! "tier eligible-end keep=1" (find-eligible-end hist-tier 1) 10)
-;; original estimate 5005//4 = 1251 tokens
-(check! "tier original estimate" (strategy-estimate-tokens hist-tier) 1251)
+;; original estimate includes three tool-call overheads: (5005 + 90)//4 = 1273
+(check! "tier original estimate" (strategy-estimate-tokens hist-tier) 1273)
 
 ;; SlidingWindow keep=1: below threshold → phase 0; above → keep head+protected tail
 (let* ([s (make-sliding-window 1 0.75)] [r (s hist-tier 10000)])
@@ -1821,7 +1821,7 @@
 (check! "ctx window grok-build = 512000" (model-context-window "grok-build") 512000)
 (check! "ctx window grok-3 unaffected (no clause)" (model-context-window "grok-3") #f)
 (check! "ctx window qwen3-coder-next mlx conservative"
-  (model-context-window "/Users/user/models/qwen3-coder-next-mlx") 8192)
+  (model-context-window "/Users/user/models/qwen3-coder-next-mlx") 4096)
 (check! "ctx window generic qwen3 remains 32768"
   (model-context-window "qwen3:8b-q4_K_M") 32768)