diff --git a/agent/context.go b/agent/context.go index ceb1ba0..80c2e1c 100644 --- a/agent/context.go +++ b/agent/context.go @@ -96,6 +96,36 @@ func (cb *ContextBuilder) Truncate(i int) { } } +// EvictedMessages returns the messages that would be dropped by buildBounded. +func (cb *ContextBuilder) EvictedMessages() []backend.Message { + var rest []backend.Message + for _, m := range cb.messages { + if m.Role != "system" { + rest = append(rest, m) + } + } + ts := computeTailStart(rest, cb.cfg.TailMessages) + older := rest[:ts] + + used := cb.cfg.FixedOverheadChars + msgSliceChars(cb.messages) - msgSliceChars(older) + budget := cb.cfg.SoftLimit - used + + var included []backend.Message + for i := len(older) - 1; i >= 0; i-- { + size := msgChars(older[i]) + if budget-size < 0 { + break + } + budget -= size + included = append(included, older[i]) + } + evictCount := len(older) - len(included) + if evictCount == 0 { + return nil + } + return older[:evictCount] +} + // BoundedHistory returns a context-window-safe slice of messages. func (cb *ContextBuilder) BoundedHistory() []backend.Message { return cb.buildBounded(false) diff --git a/agent/session.go b/agent/session.go index 9a97464..4757bc1 100644 --- a/agent/session.go +++ b/agent/session.go @@ -1,6 +1,12 @@ package agent -import "ollie/backend" +import ( + "context" + "fmt" + "strings" + + "ollie/backend" +) // Session is an ephemeral in-memory state backend. // It lives only for the duration of the process; nothing is persisted. @@ -57,6 +63,53 @@ func (s *Session) MarkComplete() error { return nil } +// Compact summarizes messages outside the bounded window via an LLM call, +// replacing them with a single summary system message. +// Returns the number of messages replaced, or 0 if nothing to compact. +func (s *Session) Compact(ctx context.Context, b backend.Backend, model string) (int, error) { + evicted := s.ctx.EvictedMessages() + if len(evicted) == 0 { + return 0, nil + } + + // Build a prompt asking the model to summarize the evicted messages. + var sb strings.Builder + for _, m := range evicted { + fmt.Fprintf(&sb, "%s: %s\n", m.Role, m.Content) + } + prompt := "Summarize the following conversation history concisely, preserving key facts, decisions, and context:\n\n" + sb.String() + + ch, err := b.ChatStream(ctx, model, []backend.Message{ + {Role: "user", Content: prompt}, + }, nil) + if err != nil { + return 0, fmt.Errorf("compact: %w", err) + } + + var summary strings.Builder + for ev := range ch { + if ev.Content != "" { + summary.WriteString(ev.Content) + } + if ev.Done { + break + } + } + + // Replace evicted messages with summary. + all := s.ctx.Messages() + s.ctx.Truncate(0) + s.ctx.Append(backend.Message{ + Role: "system", + Content: "[conversation summary: " + strings.TrimSpace(summary.String()) + "]", + }) + // Re-append the non-evicted messages. + for _, m := range all[len(evicted):] { + s.ctx.Append(m) + } + return len(evicted), nil +} + // Rollback removes any trailing non-user messages from history, discarding // an incomplete assistant turn caused by an interruption. func (s *Session) Rollback() { diff --git a/main.go b/main.go index 338def4..bd0b844 100644 --- a/main.go +++ b/main.go @@ -854,6 +854,21 @@ func (m *model) handleCommand(input string) bool { m.display = append(m.display, fmt.Sprintf("agent: %s", name)) return true + case "/compact": + if m.session == nil { + m.display = append(m.display, "nothing to compact") + return true + } + n, err := m.session.Compact(context.Background(), m.loopcfg.Backend, m.loopcfg.Model) + if err != nil { + m.display = append(m.display, "compact error: "+err.Error()) + } else if n == 0 { + m.display = append(m.display, "nothing to compact") + } else { + m.display = append(m.display, fmt.Sprintf("compacted %d messages", n)) + } + return true + case "/clear": m.session = nil m.display = nil @@ -865,6 +880,7 @@ func (m *model) handleCommand(input string) bool { m.display = append(m.display, " /agent [name] - Show or switch active agent") m.display = append(m.display, " /backend - Switch backend (ollama, openai)") m.display = append(m.display, " /model - Switch model") + m.display = append(m.display, " /compact - Summarize evicted context messages") m.display = append(m.display, " /clear - Clear session and display") m.display = append(m.display, " /help - Show this help") return true