1package modes
2
3import (
4 "context"
5 "encoding/json"
6 "strings"
7 "unicode"
8 "unicode/utf8"
9
10 "github.com/bnema/zut/packages/agent/tools"
11 "github.com/bnema/zut/packages/core"
12 "github.com/bnema/zut/packages/provider"
13 "github.com/bnema/zut/packages/tui"
14)
15
16func shellEscapeCommand(text string) (string, bool) {
17 return ShellEscapeCommand(text)
18}
19func ShellEscapeCommand(text string) (string, bool) {
20 trimmed := strings.TrimLeft(text, " \t")
21 if !strings.HasPrefix(trimmed, "!") {
22 return "", false
23 }
24 cmd := strings.TrimSpace(strings.TrimPrefix(trimmed, "!"))
25 if cmd == "" {
26 return "", false
27 }
28 return cmd, true
29}
30func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
31 i.mu.Lock()
32 if i.busy || i.shellRunning {
33 i.statusErr = "busy — wait for the current turn to finish before running a shell command"
34 i.statusOK = ""
35 i.mu.Unlock()
36 i.invalidate()
37 return
38 }
39 if parent == nil {
40 parent = i.runCtx
41 }
42 if parent == nil {
43 parent = context.Background()
44 }
45 ctx, cancel := context.WithCancel(parent)
46 i.busy = true
47 i.shellRunning = true
48 i.shellLive = "$ " + cmd + "\n\n"
49 i.cancelTurn = cancel
50 i.statusErr = ""
51 i.statusOK = ""
52 i.spin.Start()
53 i.activity = agentActivity{activity: activity{kind: activityRunningShellCommand}}
54 // Clear stale extension notes the same way a new turn would so the
55 // screen doesn't accumulate transient state.
56 i.extNotes = nil
57 i.scrollOffset = 0
58 i.parkedTurn = 0
59 i.parkedTotal = 0
60 i.helpBlock = nil
61 sandbox := i.cfg.Sandbox
62 cwd := i.cfg.CWD
63 i.mu.Unlock()
64 i.invalidate()
65
66 go func() {
67 defer cancel()
68 raw, _ := json.Marshal(map[string]any{
69 "command": cmd,
70 "timeout": ShellEscapeTimeoutSeconds,
71 })
72 bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
73 progress := func(chunk string) {
74 i.mu.Lock()
75 i.shellLive += chunk
76 i.mu.Unlock()
77 i.invalidate()
78 }
79 res, err := bash.Execute(ctx, raw, progress)
80
81 var out string
82 if err != nil {
83 out = "$ " + cmd + "\n\n" + err.Error() + "\n\n[error]"
84 } else {
85 for _, c := range res.Content {
86 if tb, ok := c.(provider.TextBlock); ok {
87 out += tb.Text
88 }
89 }
90 }
91 cancelled := ctx.Err() != nil
92 failed := err != nil || res.IsError || cancelled
93 if cancelled {
94 out += "\n\n[cancelled]"
95 }
96
97 if i.agent != nil {
98 i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"})
99 }
100
101 i.mu.Lock()
102 i.shellRunning = false
103 i.shellLive = ""
104 i.busy = false
105 i.cancelTurn = nil
106 pendingIdleWork := i.takePendingIdleWorkLocked()
107 awaitingPre := i.awaitingStartupPre
108 if failed {
109 if cancelled {
110 i.statusErr = "shell command cancelled"
111 } else {
112 i.statusErr = "shell command failed"
113 }
114 i.statusOK = ""
115 } else {
116 i.statusOK = "shell command finished"
117 i.statusErr = ""
118 }
119 i.mu.Unlock()
120 runPendingIdleWork(pendingIdleWork)
121 i.invalidate()
122 if awaitingPre {
123 i.completeStartupPre()
124 }
125 }()
126}
127func (i *Interactive) startTurn(parent context.Context, prompt string) {
128 i.startTurnWithImages(parent, prompt, nil)
129}
130func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
131 i.startTurnRequest(parent, prompt, images, false, false)
132}
133func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, continueExisting, overflowRecoveryAttempted bool) {
134 if i.agent == nil {
135 // Text startup pre cannot run without credentials; continue so
136 // deferred InitialInput (pre-fill or auto-submit) still applies.
137 i.mu.Lock()
138 awaitingPre := i.awaitingStartupPre
139 i.mu.Unlock()
140 if awaitingPre {
141 i.completeStartupPre()
142 }
143 return
144 }
145 // Pre-turn safety: if the most recent context measurement is
146 // already past the auto-compact threshold, condense before
147 // sending so the next outbound request stays under the limit.
148 // The condense flow re-fires the user's queued prompt for us, so
149 // we just hand it off and exit.
150 i.mu.Lock()
151 var resetHandoff json.RawMessage
152 var persistResetHandoff bool
153 if !continueExisting {
154 resetHandoff, persistResetHandoff = i.resetCompactContinuationLocked()
155 }
156 needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
157 if needsPreCompact {
158 // Reserve the compaction hand-off before releasing i.mu. A completion
159 // update arriving in the small gap before runCompact acquires the
160 // mutex must join the host queue rather than starting a competing turn.
161 i.busy = true
162 i.compacting = true
163 i.autoCompacting = true
164 if continueExisting {
165 i.continueAfterCompact = true
166 } else {
167 i.pendingCompactPrompt = prompt
168 i.pendingCompactImages = append([]provider.ImageBlock(nil), images...)
169 i.hasPendingCompactPrompt = true
170 }
171 i.statusErr = ""
172 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
173 i.pendingPostCompactNote = "context auto-compacted; sending your last message"
174 i.mu.Unlock()
175 if persistResetHandoff {
176 i.persistCompactHandoff(resetHandoff)
177 }
178 i.invalidate()
179 i.runCompact(parent, compactContinuationRequest{origin: compactOriginPreTurnThreshold})
180 return
181 }
182 i.mu.Unlock()
183 if persistResetHandoff {
184 i.persistCompactHandoff(resetHandoff)
185 }
186
187 ctx, cancel := context.WithCancel(parent)
188 i.mu.Lock()
189 i.busy = true
190 i.spin.Start()
191 i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
192 i.cancelTurn = cancel
193 i.statusErr = ""
194 i.statusOK = ""
195 i.streaming.Reset()
196 i.streamOn = true
197 i.pendingAlert = nil
198 i.toolCalls = map[string]*tui.ToolCallView{}
199 i.toolOrder = nil
200 i.toolGate = map[string]int{}
201 i.extNotes = nil // ext notes are one-shot; a new prompt clears them
202 i.scrollOffset = 0 // jump back to the bottom on new turn
203 // Lift the resume tail cap once the user starts interacting. The
204 // cap is purely a first-paint optimization (don't markdown the
205 // whole history before showing anything). Keeping it active during
206 // a turn makes the rendered chat a sliding window: appended
207 // messages push older ones off the TOP of the buffer, which the
208 // renderer must treat as a change above the viewport and repaint
209 // fully, snapping the terminal's native scrollback to the bottom on
210 // every streamed chunk. A fresh session has no cap (append-only),
211 // which is why the jump only shows up in resumed sessions. Dropping
212 // the cap here makes resumed turns append-only too.
213 i.view.TailLimit = 0
214 // Reset the auto-follow baseline so the very next render at
215 // interactive.go:1053 doesn't see a synthetic shrink between
216 // "last frame had the previous turn's tool overlay" and
217 // "this frame had it cleared above". Without this, the guard
218 // reads delta = -(rows in cleared overlay) and decrements
219 // scrollOffset, which on terminals that mirror zut's pane
220 // scroll into the host scrollbar visibly yanks the viewport.
221 // See autofollow_shrink_test.go for the exact arithmetic.
222 i.prevChatLen = 0
223 i.prevChatCols = 0
224 i.parkedTurn = 0 // starting a turn clears the /jump parked state
225 i.parkedTotal = 0
226 i.helpBlock = nil // hide the help block once the user asks something
227 i.mu.Unlock()
228 i.invalidate()
229
230 var (
231 lastStop provider.StopReason
232 lastTurnErr error
233 )
234 sink := func(ev core.AgentEvent) {
235 if e, ok := ev.(core.EvTurnEnd); ok {
236 lastStop = e.Stop
237 lastTurnErr = e.Err
238 }
239 i.observeGoalRun(ev)
240 i.handleEventForPresentation(ev)
241 }
242
243 releaseCompletionHold := i.beginCompletionDeliveryHold()
244 go func() {
245 defer releaseCompletionHold()
246 var err error
247 if continueExisting {
248 err = i.agent.Continue(ctx, sink)
249 } else {
250 err = i.agent.Prompt(ctx, prompt, images, sink)
251 }
252 i.mu.Lock()
253 // Keep busy asserted through final cleanup and queue selection. A
254 // watcher may enqueue a completion summary concurrently with the
255 // provider's final event; publishing idle before inspecting queues can
256 // strand that summary in the core agent queue or start an overlapping
257 // turn.
258 // Don't touch streamPending / streamFlushPending here — the
259 // pacer may still be draining the final deltas and needs to
260 // paint them even though Prompt has returned. It will reset
261 // streamOn on its own once the buffer empties.
262 if len(i.streamPending) == 0 {
263 i.streamOn = false
264 }
265 i.cancelTurn = nil
266 pendingIdleWork := i.takePendingIdleWorkLocked()
267 if err != nil && ctx.Err() == nil {
268 i.statusErr = err.Error()
269 }
270 // Decide whether to offer a model rescue picker for recoverable
271 // provider failures (auth/rate/temporary). The picker opens after
272 // the mutex is released so it can take its own locks freely.
273 var (
274 offer bool
275 rescueWhy string
276 rescueImgs []provider.ImageBlock
277 rescueModel string
278 rescueProv string
279 rescueFprov string
280 )
281 if err != nil && ctx.Err() == nil {
282 if ok, reason := classifyRescueError(err); ok {
283 offer = true
284 rescueWhy = reason
285 rescueImgs = images
286 rescueModel = i.cfg.Model
287 rescueProv = i.cfg.Provider
288 rescueFprov = extractFailedProvider(err)
289 if rescueFprov == "" {
290 rescueFprov = i.cfg.Provider
291 }
292 // Suppress the red banner — the rescue dialog already
293 // surfaces the failure.
294 i.statusErr = ""
295 }
296 }
297 // Detect responses that reject the current context, either as an
298 // HTTP 413 payload limit or a model context-window error. Token-
299 // based auto-compact can miss both when metadata is stale or the
300 // limit is measured in raw bytes. Compact once, then continue the
301 // user message already present in the transcript.
302 contextOverflow := err != nil && ctx.Err() == nil && provider.IsContextOverflowError(err)
303 recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
304 if recoverContextOverflow {
305 i.statusErr = ""
306 i.continueAfterCompact = true
307 i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
308 i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
309 }
310 // Persist the assistant's reply (and every tool row before
311 // it) to the session file while the turn memory is hot.
312 // Without this, WriteNewTranscript only fires at zut exit,
313 // meaning a crash or ungraceful kill drops the whole
314 // conversation. FlushSession is idempotent (it advances the
315 // baseline so subsequent flushes only write new rows).
316 flush := i.cfg.FlushSession
317 i.mu.Unlock()
318 runPendingIdleWork(pendingIdleWork)
319 if flush != nil {
320 flush()
321 }
322 terminalGoalError := ctx.Err() == nil && !offer && !recoverContextOverflow && (err != nil || lastTurnErr != nil || lastStop == provider.StopError)
323 if terminalGoalError {
324 i.updateActiveGoal(core.GoalBlocked, "turn ended with an error")
325 }
326 continueGoal := i.finishGoalRun()
327 i.mu.Lock()
328 awaitingPre := i.awaitingStartupPre
329 // A newer explicit prompt may have cleared the handoff while the
330 // completed turn was being flushed. Re-read it under the mutex before
331 // deciding whether this continuation may spend another rescue attempt.
332 statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue
333 // Pop the next queued message, if any, and relaunch.
334 var next core.QueuedMessage
335 var hasNext bool
336 if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil {
337 next, i.queued = i.queued[0], i.queued[1:]
338 hasNext = true
339 }
340 // If the turn was cancelled or errored, drop ordinary input so the
341 // user isn't bombarded with stale messages after an interrupt.
342 // Scheduler follow-ups deliberately remain: they are distinct turns
343 // whose due time must not let them steer this failed turn.
344 if ctx.Err() != nil || (err != nil && !recoverContextOverflow) {
345 i.queued = nil
346 if i.agent != nil {
347 i.agent.DrainQueuedMessages()
348 }
349 }
350 var scheduled scheduledFollowUp
351 var hasScheduled bool
352 if !awaitingPre && !hasNext && len(i.scheduled) > 0 {
353 scheduled, i.scheduled = i.scheduled[0], i.scheduled[1:]
354 hasScheduled = true
355 }
356 // Decide whether the next thing to do is an auto-compaction.
357 // Only fires when the turn completed cleanly AND no host-side
358 // or agent-side queued messages are waiting (otherwise a queued
359 // message would race the condense).
360 agentQueued := 0
361 if i.agent != nil {
362 agentQueued = i.agent.QueuedMessageCount()
363 }
364 continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil
365 shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
366 continueStatusRescue := false
367 var handoff json.RawMessage
368 var persistHandoff bool
369 if statusRescueActive && i.agent != nil && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil {
370 followUpMessages := i.agent.Messages()
371 reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages)
372 if reason == compactContinuationStatusRescue && i.compactContinuation.rescueAttempts < maxStatusRescueContinuations {
373 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
374 reason: compactContinuationStatusRescue,
375 rescueAttempts: i.compactContinuation.rescueAttempts + 1,
376 })
377 continueStatusRescue = true
378 } else {
379 handoff, persistHandoff = i.resetCompactContinuationLocked()
380 }
381 }
382 if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || hasScheduled || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {
383 handoff, persistHandoff = i.resetCompactContinuationLocked()
384 }
385 // The agent run can finish before the paced final text reaches the
386 // transcript. A compaction replaces that transcript, so it must never
387 // race the still-live stream frame; otherwise stale deltas can repaint
388 // after the replacement and corrupt the scrollback renderer's model.
389 if recoverContextOverflow || shouldAutoCompact {
390 i.resetStreamingStateLocked()
391 }
392 alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || hasScheduled || agentQueued > 0 || continueGoal, offer, recoverContextOverflow, shouldAutoCompact)
393 i.busy = hasNext || hasScheduled || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact
394 i.mu.Unlock()
395 if persistHandoff {
396 i.persistCompactHandoff(handoff)
397 }
398 if alertReason != "" {
399 i.scheduleMainAlert(alertReason)
400 }
401 i.invalidate()
402 parent := i.runCtx
403 if parent == nil {
404 parent = context.Background()
405 }
406 if awaitingPre {
407 i.completeStartupPre()
408 return
409 }
410 switch {
411 case hasNext:
412 i.startTurnWithImages(parent, next.Text, next.Images)
413 case hasScheduled:
414 // The scheduler holds the session transition read lock until this
415 // acknowledgement, so accepting here binds the prompt to the agent
416 // that just completed rather than a subsequently loaded session.
417 scheduled.accepted <- nil
418 i.startTurn(parent, scheduled.text)
419 case continueQueued:
420 i.startTurnRequest(parent, "", nil, true, false)
421 case continueStatusRescue:
422 i.startAutoCompactContinuation(parent)
423 case offer:
424 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
425 case recoverContextOverflow:
426 i.runCompact(parent, compactContinuationRequest{origin: compactOriginRecovery})
427 case shouldAutoCompact:
428 i.runCompact(parent, compactContinuationRequest{
429 origin: compactOriginAfterTurnThreshold,
430 force: lastStop == provider.StopLength,
431 lastStop: lastStop,
432 turnError: lastTurnErr,
433 })
434 case continueGoal:
435 i.requestGoalContinuationIfIdle(parent)
436 }
437 }()
438}
439func mainAlertReason(ctx context.Context, err, turnErr error, stop provider.StopReason, awaitingPre, hasNext, rescue, recovering, autoCompacting bool) string {
440 if awaitingPre || ctx.Err() != nil || hasNext || recovering || autoCompacting {
441 return ""
442 }
443 if rescue {
444 return "rescue_required"
445 }
446 if stop == provider.StopAborted {
447 return ""
448 }
449 if err != nil || turnErr != nil || stop == provider.StopError {
450 return "agent_error"
451 }
452 if stop == provider.StopLength {
453 return "response_truncated"
454 }
455 return "agent_done"
456}
457func (i *Interactive) openRescueDialog(activeProvider, failedProvider, failedModel, reason, prompt string, images []provider.ImageBlock) {
458 if i.rescueDialog == nil {
459 return
460 }
461 loggedIn := []string{}
462 if i.cfg.LoggedInProviders != nil {
463 loggedIn = i.cfg.LoggedInProviders()
464 }
465 fprov := failedProvider
466 if fprov == "" {
467 fprov = activeProvider
468 }
469 i.mu.Lock()
470 i.pendingRescuePrompt = prompt
471 i.pendingRescueImages = images
472 i.mu.Unlock()
473 i.rescueDialog.Open(failedModel, loggedIn, fprov, failedModel, reason, prompt)
474 i.invalidate()
475}
476func (i *Interactive) applyRescueSelection(prov, model, prompt string) {
477 if model == "" {
478 return
479 }
480 i.applyRescueModelSelection(prov, model)
481 i.mu.Lock()
482 images := i.pendingRescueImages
483 if prompt == "" {
484 prompt = i.pendingRescuePrompt
485 }
486 i.pendingRescuePrompt = ""
487 i.pendingRescueImages = nil
488 i.mu.Unlock()
489 parent := i.runCtx
490 if parent == nil {
491 parent = context.Background()
492 }
493 i.startTurnWithImages(parent, prompt, images)
494}
495func stripAutoCompactNotes(notes []string) []string {
496 if len(notes) == 0 {
497 return notes
498 }
499 out := notes[:0]
500 for _, n := range notes {
501 if strings.Contains(n, "condensing history") {
502 continue
503 }
504 out = append(out, n)
505 }
506 return out
507}
508func autoCompactNoteLine(th tui.Theme, msg string) string {
509 return " " + th.FGColor(th.Warning, "⚠ "+msg)
510}
511func NormalizeAutoCompactThreshold(threshold *int) int {
512 const defaultAutoCompactThreshold = 85
513 if threshold == nil {
514 return defaultAutoCompactThreshold
515 }
516 switch *threshold {
517 case 0, 70, 80, 85, 90, 95:
518 return *threshold
519 default:
520 return defaultAutoCompactThreshold
521 }
522}
523func ShouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool {
524 if inputTokens <= 0 || contextWindow <= 0 || thresholdPercent <= 0 {
525 return false
526 }
527 return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100
528}
529func classifyCompactionContinuation(origin compactContinuationOrigin, statusRescueActive bool, stop provider.StopReason, turnErr error, msgs []provider.Message) compactContinuationReason {
530 if origin != compactOriginManual && structuralCompactionContinuation(msgs) {
531 return compactContinuationStructuralTail
532 }
533 if (origin != compactOriginAfterTurnThreshold && !statusRescueActive) || stop != provider.StopEnd || turnErr != nil {
534 return compactContinuationNone
535 }
536 if likelyForwardWorkStatus(msgs) {
537 return compactContinuationStatusRescue
538 }
539 return compactContinuationNone
540}
541func structuralCompactionContinuation(msgs []provider.Message) bool {
542 if len(msgs) == 0 {
543 return false
544 }
545 last := msgs[len(msgs)-1]
546 if last.Role != provider.RoleAssistant {
547 return true
548 }
549 hasText := false
550 for _, content := range last.Content {
551 switch block := content.(type) {
552 case provider.ToolCallBlock:
553 return true
554 case provider.TextBlock:
555 if strings.TrimSpace(block.Text) != "" {
556 hasText = true
557 }
558 case provider.ReasoningBlock:
559 // Reasoning without a visible answer is not terminal.
560 default:
561 return true
562 }
563 }
564 return !hasText
565}
566func likelyForwardWorkStatus(msgs []provider.Message) bool {
567 if len(msgs) == 0 {
568 return false
569 }
570 last := msgs[len(msgs)-1]
571 if last.Role != provider.RoleAssistant {
572 return false
573 }
574 var text strings.Builder
575 for _, content := range last.Content {
576 block, ok := content.(provider.TextBlock)
577 if !ok {
578 if _, hasTool := content.(provider.ToolCallBlock); hasTool {
579 return false
580 }
581 continue
582 }
583 if strings.TrimSpace(block.Text) == "" {
584 continue
585 }
586 if text.Len() > 0 {
587 text.WriteByte(' ')
588 }
589 text.WriteString(block.Text)
590 }
591 if text.Len() == 0 {
592 return false
593 }
594 normalized := strings.ToLower(strings.Join(strings.Fields(text.String()), " "))
595 normalized = strings.NewReplacer("’", "'", "‘", "'", "`", "'").Replace(normalized)
596 for _, phrase := range []string{
597 "next i will",
598 "next, i will",
599 "i'll now",
600 "i will now",
601 "i am going to",
602 "i need to continue",
603 "then i will",
604 "then, i will",
605 "i will proceed",
606 } {
607 if containsFutureWorkPhrase(normalized, phrase) {
608 return true
609 }
610 }
611 return false
612}
613func containsFutureWorkPhrase(text, phrase string) bool {
614 for offset := 0; offset < len(text); {
615 index := strings.Index(text[offset:], phrase)
616 if index < 0 {
617 return false
618 }
619 index += offset
620 end := index + len(phrase)
621 if !wordRuneBefore(text, index) && !wordRuneAfter(text, end) {
622 return true
623 }
624 offset = end
625 }
626 return false
627}
628func wordRuneBefore(text string, index int) bool {
629 if index == 0 {
630 return false
631 }
632 rune_, _ := utf8.DecodeLastRuneInString(text[:index])
633 return unicode.IsLetter(rune_) || unicode.IsDigit(rune_) || rune_ == '_'
634}
635func wordRuneAfter(text string, index int) bool {
636 if index == len(text) {
637 return false
638 }
639 rune_, _ := utf8.DecodeRuneInString(text[index:])
640 return unicode.IsLetter(rune_) || unicode.IsDigit(rune_) || rune_ == '_'
641}
642func (i *Interactive) shouldAutoCompactLocked() bool {
643 if i.agent == nil {
644 return false
645 }
646 if i.autoCompacting {
647 return false
648 }
649 m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
650 if err != nil || m.ContextWindow <= 0 {
651 return false
652 }
653 threshold := NormalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
654 return ShouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)
655}