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 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-04-25 20:51:53 +02:00
parent 77dd5c6168
commit 51922b78fc
3 changed files with 62 additions and 65 deletions

View File

@ -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

View File

@ -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")
}
}

View File

@ -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++
}