add auto compaction

This commit is contained in:
patriceckhart 2026-04-18 10:34:08 +02:00
parent dbe6763736
commit 6324668df8
4 changed files with 105 additions and 24 deletions

View file

@ -100,6 +100,11 @@ type Interactive struct {
// survive past the ctx of the key event that enqueued them.
runCtx context.Context
// autoCompacting is true while a model-triggered compaction is in
// flight. Surfaced in the status bar so the user can tell a
// condense pass from a regular assistant turn.
autoCompacting bool
dialog *loginDialog
modelDialog *modelDialog
sessionDialog *sessionDialog
@ -387,18 +392,19 @@ func (i *Interactive) redraw() {
ctxMax = m.ContextWindow
}
status := tui.StatusBar(tui.StatusBarParams{
Theme: i.cfg.Theme,
Provider: i.cfg.Provider,
Model: i.cfg.Model,
Busy: i.busy,
BusyPrefix: busyPrefix,
CWD: i.cfg.CWD,
Locked: i.cfg.Sandbox.Locked(),
Usage: i.cumUsage,
Subscription: i.cfg.AuthMethod == "oauth",
ContextUsed: i.lastCtxInput,
ContextMax: ctxMax,
Cols: cols,
Theme: i.cfg.Theme,
Provider: i.cfg.Provider,
Model: i.cfg.Model,
Busy: i.busy,
BusyPrefix: busyPrefix,
CWD: i.cfg.CWD,
Locked: i.cfg.Sandbox.Locked(),
Usage: i.cumUsage,
Subscription: i.cfg.AuthMethod == "oauth",
ContextUsed: i.lastCtxInput,
ContextMax: ctxMax,
AutoCompacting: i.autoCompacting,
Cols: cols,
})
edLines, curR, curC := i.ed.Render(cols)
@ -728,7 +734,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
case "/sessions":
i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
case "/compact":
i.runCompact(ctx)
i.runCompact(ctx, false)
case "/lock":
if i.cfg.Sandbox == nil {
i.mu.Lock()
@ -946,7 +952,11 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
// runCompact invokes core.Agent.Compact and reflects the progress in
// the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
// cancel via the same cancelTurn channel used for normal turns.
func (i *Interactive) runCompact(parent context.Context) {
//
// When auto is true the spinner message is pinned to "condensing
// history" and the status bar surfaces "(auto)" next to the context
// percentage so it's obvious the system triggered this, not the user.
func (i *Interactive) runCompact(parent context.Context, auto bool) {
if i.agent == nil {
i.mu.Lock()
i.statusErr = "not logged in. type /login first."
@ -956,10 +966,16 @@ func (i *Interactive) runCompact(parent context.Context) {
ctx, cancel := context.WithCancel(parent)
i.mu.Lock()
i.busy = true
i.spin.Start()
if auto {
i.spin.StartFixed("condensing history")
i.autoCompacting = true
i.statusOK = "condensing history… (esc to cancel)"
} else {
i.spin.Start()
i.statusOK = "compacting..."
}
i.cancelTurn = cancel
i.statusErr = ""
i.statusOK = "compacting..."
i.streaming.Reset()
i.streamOn = true
i.scrollOffset = 0
@ -980,10 +996,15 @@ func (i *Interactive) runCompact(parent context.Context) {
i.streamOn = false
i.streaming.Reset()
i.cancelTurn = nil
i.autoCompacting = false
switch {
case err != nil && ctx.Err() != nil:
i.statusErr = ""
i.statusOK = "compaction cancelled"
if auto {
i.statusOK = "auto-condense cancelled"
} else {
i.statusOK = "compaction cancelled"
}
case err != nil:
i.statusErr = "compaction failed: " + err.Error()
i.statusOK = ""
@ -1045,18 +1066,51 @@ func (i *Interactive) startTurn(parent context.Context, prompt string) {
if ctx.Err() != nil || err != nil {
i.queued = nil
}
// Decide whether the next thing to do is an auto-compaction.
// Only fires when the turn completed cleanly AND the queue is
// empty (otherwise a queued message would race the condense).
shouldAutoCompact := !hasNext && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
i.mu.Unlock()
i.invalidate()
if hasNext {
parent := i.runCtx
if parent == nil {
parent = context.Background()
}
parent := i.runCtx
if parent == nil {
parent = context.Background()
}
switch {
case hasNext:
i.startTurn(parent, next)
case shouldAutoCompact:
i.runCompact(parent, true)
}
}()
}
// autoCompactThreshold is the context-window fraction at which the
// agent will auto-compact after a turn ends. 0.85 leaves enough
// headroom for one more user prompt + response before we bump the
// hard limit.
const autoCompactThreshold = 0.85
// shouldAutoCompactLocked reports whether the last turn pushed context
// usage past the auto-compact threshold. Must be called with i.mu
// held; it reads lastCtxInput and the current model's context window.
func (i *Interactive) shouldAutoCompactLocked() bool {
if i.agent == nil {
return false
}
if i.autoCompacting {
return false
}
m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
if err != nil || m.ContextWindow <= 0 {
return false
}
if i.lastCtxInput <= 0 {
return false
}
return float64(i.lastCtxInput)/float64(m.ContextWindow) >= autoCompactThreshold
}
func (i *Interactive) handleEvent(ev core.AgentEvent) {
i.mu.Lock()
defer i.mu.Unlock()

View file

@ -14,6 +14,11 @@ type spinner struct {
startedAt time.Time
msgIdx int
lastSwap time.Time
// fixedMsg overrides the rotating funnyWorkingLines message when
// set. Used for auto-compaction so the spinner clearly says what's
// happening instead of cycling jokes.
fixedMsg string
}
// funnyWorkingLines is the rotating text. Kept deliberately short so it
@ -59,6 +64,15 @@ func (s *spinner) Start() {
s.startedAt = time.Now()
s.msgIdx = rand.Intn(len(s.messages))
s.lastSwap = s.startedAt
s.fixedMsg = ""
}
// StartFixed is like Start but pins the status text to msg for the
// duration of this spinner run. Cleared by the next Start() call.
func (s *spinner) StartFixed(msg string) {
s.startedAt = time.Now()
s.lastSwap = s.startedAt
s.fixedMsg = msg
}
// Frame returns the current spinner glyph for the running animation.
@ -73,8 +87,13 @@ func (s *spinner) Frame() string {
}
// Message returns the current rotating status text. The text changes
// every ~2.5 seconds so the spinner doesn't look frozen.
// every ~2.5 seconds so the spinner doesn't look frozen. When the
// spinner was started via StartFixed, the pinned message is returned
// unchanged.
func (s *spinner) Message() string {
if s.fixedMsg != "" {
return s.fixedMsg
}
if time.Since(s.lastSwap) > 2500*time.Millisecond {
s.msgIdx = (s.msgIdx + 1) % len(s.messages)
s.lastSwap = time.Now()

View file

@ -185,7 +185,7 @@ func serveLogo(w http.ResponseWriter, r *http.Request) {
func oauthSuccessHTML(provider string) string {
p := strings.ToLower(provider)
return `<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot · logged in</title>` + monoStyle + `</head><body>
return `<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot - logged in</title>` + monoStyle + `</head><body>
` + logoTag + `
<h1><span class="mark"></span> logged in to ` + p + `</h1>
<hr class="rule">

View file

@ -610,6 +610,11 @@ type StatusBarParams struct {
ContextUsed int
ContextMax int // model's context window; 0 disables the percentage
// AutoCompacting is true when the agent is currently running a
// model-triggered condense pass. Surfaces as "(auto)" after the
// context percentage so it's clear where the spinner is coming from.
AutoCompacting bool
Cols int // terminal width; drives right-alignment of cwd
}
@ -648,6 +653,9 @@ func StatusBar(p StatusBarParams) string {
// Context %. Color-coded: yellow >70, red >90.
ctx, ctxColor := piContextUsage(th, p.ContextUsed, p.ContextMax)
if ctx != "" {
if p.AutoCompacting {
ctx += " (auto)"
}
stats = append(stats, th.FG256(ctxColor, ctx))
}