From 51922b78fcb16576d0fa2b0aac43142ce0b72d67 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Sat, 25 Apr 2026 20:51:53 +0200 Subject: [PATCH] agent: mid-turn compaction and runCompact consolidation Extract runCompact() to handle the full compact cycle (pre-hook, compact, spawn-context re-injection, post-hook) consistently across all three call sites. Wire AutoCompact into agentConfig so loop.go triggers it after each tool round, not just before turns. Co-Authored-By: Claude Sonnet 4.6 --- pkg/agent/commands.go | 32 +--------------- pkg/agent/core.go | 88 +++++++++++++++++++++++++++---------------- pkg/agent/loop.go | 7 +++- 3 files changed, 62 insertions(+), 65 deletions(-) diff --git a/pkg/agent/commands.go b/pkg/agent/commands.go index ca02a52..bcbcad4 100644 --- a/pkg/agent/commands.go +++ b/pkg/agent/commands.go @@ -206,40 +206,10 @@ func (s *agent) handleCommand(ctx context.Context, input string, handler EventHa handler(infoEvent("nothing to compact")) return } - payload := map[string]string{"session_id": s.sessionID, "trigger": "manual", "cwd": s.CWD()} - pre := s.hooks.Run(ctx, HookPreCompact, payload, s.log) - // TODO: route hook info to debug/err file instead of chat - // if pre.Ran { - // handler(infoEvent(hooksRan(1))) - // } - if pre.Warning != "" { - handler(infoEvent(pre.Warning)) - } - if pre.Blocked { - handler(infoEvent("compact cancelled by hook")) - return - } - if pre.Context != "" { - s.session.appendUserMessage(pre.Context) - } snapshot := s.session.PreCompactionSnapshot() s.setState("compacting") - n, _, err := s.session.compact(ctx, s.cfg.Backend) + n, err := s.runCompact(ctx, "manual", handler) s.setState("idle") - if sc := s.spawnContext(ctx, handler); sc != "" { - s.session.appendUserMessage(sc) - } - post := s.hooks.Run(ctx, HookPostCompact, payload, s.log) - // TODO: route hook info to debug/err file instead of chat - // if post.Ran { - // handler(infoEvent(hooksRan(1))) - // } - if post.Warning != "" { - handler(infoEvent(post.Warning)) - } - if post.Context != "" { - s.session.appendUserMessage(post.Context) - } if err != nil { handler(infoEvent("compact error: " + err.Error())) return diff --git a/pkg/agent/core.go b/pkg/agent/core.go index eee34e9..8d822be 100644 --- a/pkg/agent/core.go +++ b/pkg/agent/core.go @@ -505,6 +505,41 @@ func (s *agent) spawnContext(ctx context.Context, handler EventHandler) string { return strings.Join(parts, "\n\n---\n\n") } +// runCompact executes a full compaction cycle: pre-hook, compact, spawn-context +// re-injection, post-hook. Returns (n compacted, error). Returns (0, nil) if +// the pre-hook blocked or there was nothing to compact. Caller manages setState. +func (s *agent) runCompact(ctx context.Context, trigger string, handler EventHandler) (int, error) { + payload := map[string]string{"session_id": s.sessionID, "trigger": trigger, "cwd": s.CWD()} + pre := s.hooks.Run(ctx, HookPreCompact, payload, s.log) + if pre.Warning != "" { + handler(infoEvent(pre.Warning)) + } + if pre.Blocked { + handler(infoEvent("compact cancelled by hook")) + return 0, nil + } + if pre.Context != "" { + s.session.appendUserMessage(pre.Context) + } + n, _, err := s.session.compact(ctx, s.cfg.Backend) + if err != nil { + return 0, err + } + if n > 0 { + if sc := s.spawnContext(ctx, handler); sc != "" { + s.session.appendUserMessage(sc) + } + } + post := s.hooks.Run(ctx, HookPostCompact, payload, s.log) + if post.Warning != "" { + handler(infoEvent(post.Warning)) + } + if post.Context != "" { + s.session.appendUserMessage(post.Context) + } + return n, nil +} + func (s *agent) saveSession() { if s.session == nil || s.sessionID == "" || s.sessionsDir == "" { return @@ -741,44 +776,31 @@ func (s *agent) executeTurn(ctx context.Context, input string, handler EventHand } return "" } + s.cfg.AutoCompact = func(ctx context.Context) { + if ctx.Err() != nil || s.session == nil { + return + } + limit := s.autoCompactLimit(ctx) + if limit <= 0 || s.session.estimateTokens() < limit { + return + } + emit(s.cfg, Event{Role: "info", Content: "auto-compacting context...\n"}) + s.setState("compacting") + if _, err := s.runCompact(ctx, "auto", handler); err != nil { + panic(fmt.Sprintf("mid-turn auto-compact: %v", err)) + } + s.setState("thinking") + } // Auto-compact before the turn if approaching the context limit. if s.session != nil { if limit := s.autoCompactLimit(ctx); limit > 0 && s.session.estimateTokens() >= limit { - payload := map[string]string{"session_id": s.sessionID, "trigger": "auto", "cwd": s.CWD()} - pre := s.hooks.Run(ctx, HookPreCompact, payload, s.log) - // TODO: route hook info to debug/err file instead of chat - // if pre.Ran { - // handler(infoEvent(hooksRan(1))) - // } - if pre.Warning != "" { - handler(infoEvent(pre.Warning)) - } - if !pre.Blocked { - if pre.Context != "" { - s.session.appendUserMessage(pre.Context) - } - emit(s.cfg, Event{Role: "info", Content: "auto-compacting context...\n"}) - s.setState("compacting") - if _, _, err := s.session.compact(ctx, s.cfg.Backend); err != nil { - panic(fmt.Sprintf("auto-compact: %v", err)) - } - s.setState("thinking") - if sc := s.spawnContext(ctx, handler); sc != "" { - s.session.appendUserMessage(sc) - } - post := s.hooks.Run(ctx, HookPostCompact, payload, s.log) - // TODO: route hook info to debug/err file instead of chat - // if post.Ran { - // handler(infoEvent(hooksRan(1))) - // } - if post.Warning != "" { - handler(infoEvent(post.Warning)) - } - if post.Context != "" { - s.session.appendUserMessage(post.Context) - } + emit(s.cfg, Event{Role: "info", Content: "auto-compacting context...\n"}) + s.setState("compacting") + if _, err := s.runCompact(ctx, "auto", handler); err != nil { + panic(fmt.Sprintf("auto-compact: %v", err)) } + s.setState("thinking") } } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index b639645..e39529d 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -22,7 +22,8 @@ type agentConfig struct { Output EventHandler preamble string // compiled system+agent prompt sent as the system role GenerationParams backend.GenerationParams - PopInject func() string // returns and clears pending inject, or "" + PopInject func() string // returns and clears pending inject, or "" + AutoCompact func(ctx context.Context) // called after each tool round; may compact in-place } func run(ctx context.Context, cfg agentConfig, state state) error { @@ -221,6 +222,10 @@ func run(ctx context.Context, cfg agentConfig, state state) error { break } + if cfg.AutoCompact != nil { + cfg.AutoCompact(ctx) + } + step++ }