feat: M6 complete — summarize strategy + tool result persistence
SummarizeStrategy: calls LLM to condense older messages into a
summary, preserving key decisions, file changes, tool outputs.
Falls back to truncation on failure. Keeps 6 recent messages.
Tool result persistence: outputs >50K chars saved to disk at
.gnoma/sessions/tool-results/{id}.txt with 2K preview inline.
TUI: /compact command for manual compaction, /clear now resets
engine history. Summarize strategy used by default (with
truncation fallback).
This commit is contained in:
@@ -212,10 +212,12 @@ func main() {
|
||||
systemPrompt = systemPrompt + "\n\n" + summary
|
||||
}
|
||||
|
||||
// Create context window with truncation compaction
|
||||
// Create context window with summarize strategy (falls back to truncation)
|
||||
var compactStrategy gnomactx.Strategy
|
||||
compactStrategy = gnomactx.NewSummarizeStrategy(prov)
|
||||
ctxWindow := gnomactx.NewWindow(gnomactx.WindowConfig{
|
||||
MaxTokens: cfg.Provider.MaxTokens * 20, // rough: max_tokens is per-turn, context window ~20x
|
||||
Strategy: gnomactx.NewTruncateStrategy(),
|
||||
Strategy: compactStrategy,
|
||||
Logger: logger,
|
||||
})
|
||||
|
||||
|
||||
53
internal/context/persist.go
Normal file
53
internal/context/persist.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultMaxResultSize is the threshold for persisting tool results.
|
||||
DefaultMaxResultSize = 50_000 // chars
|
||||
// PreviewSize is the number of chars to show inline.
|
||||
PreviewSize = 2000
|
||||
// ToolResultsDir is the subdirectory for persisted results.
|
||||
ToolResultsDir = "tool-results"
|
||||
)
|
||||
|
||||
// PersistLargeResult checks if a tool result exceeds the size limit.
|
||||
// If so, writes it to disk and returns a preview + file path.
|
||||
// Otherwise returns the original content unchanged.
|
||||
func PersistLargeResult(content, toolUseID, sessionDir string) (string, bool) {
|
||||
if len(content) <= DefaultMaxResultSize {
|
||||
return content, false
|
||||
}
|
||||
|
||||
// Create directory
|
||||
dir := filepath.Join(sessionDir, ToolResultsDir)
|
||||
os.MkdirAll(dir, 0o755)
|
||||
|
||||
// Write full result to disk
|
||||
filename := toolUseID + ".txt"
|
||||
path := filepath.Join(dir, filename)
|
||||
os.WriteFile(path, []byte(content), 0o644)
|
||||
|
||||
// Build preview
|
||||
preview := content
|
||||
if len(preview) > PreviewSize {
|
||||
preview = preview[:PreviewSize]
|
||||
}
|
||||
|
||||
return fmt.Sprintf("<persisted-output>\nFull output saved to: %s\n\nPreview (first %d chars):\n%s\n</persisted-output>",
|
||||
path, PreviewSize, preview), true
|
||||
}
|
||||
|
||||
// TruncateToolResult truncates a tool result to a maximum size with an indicator.
|
||||
func TruncateToolResult(content string, maxSize int) string {
|
||||
if len(content) <= maxSize {
|
||||
return content
|
||||
}
|
||||
lines := strings.Split(content[:maxSize], "\n")
|
||||
return strings.Join(lines, "\n") + fmt.Sprintf("\n\n... (truncated, %d total chars)", len(content))
|
||||
}
|
||||
130
internal/context/summarize.go
Normal file
130
internal/context/summarize.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"somegit.dev/Owlibou/gnoma/internal/message"
|
||||
"somegit.dev/Owlibou/gnoma/internal/provider"
|
||||
"somegit.dev/Owlibou/gnoma/internal/stream"
|
||||
)
|
||||
|
||||
const summarySystemPrompt = `You are a conversation summarizer. Condense the following conversation into a brief summary that preserves:
|
||||
- Key decisions made
|
||||
- Important file paths and code changes
|
||||
- Tool outputs and their results
|
||||
- Current task context and progress
|
||||
- Any errors or blockers encountered
|
||||
|
||||
Be concise but preserve all critical details the assistant needs to continue the conversation coherently.
|
||||
Output only the summary, no preamble.`
|
||||
|
||||
// SummarizeStrategy uses the LLM to create a summary of older messages.
|
||||
// More expensive than truncation but preserves context better.
|
||||
type SummarizeStrategy struct {
|
||||
Provider provider.Provider
|
||||
Model string // model to use for summarization (empty = provider default)
|
||||
MaxTokens int64 // max tokens for summary output
|
||||
}
|
||||
|
||||
func NewSummarizeStrategy(prov provider.Provider) *SummarizeStrategy {
|
||||
return &SummarizeStrategy{
|
||||
Provider: prov,
|
||||
MaxTokens: 2048,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SummarizeStrategy) Compact(messages []message.Message, budget int64) ([]message.Message, error) {
|
||||
if len(messages) <= 4 {
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// Separate system prompt from history
|
||||
var systemMsgs []message.Message
|
||||
var history []message.Message
|
||||
|
||||
for i, m := range messages {
|
||||
if i == 0 && m.Role == message.RoleSystem {
|
||||
systemMsgs = append(systemMsgs, m)
|
||||
} else {
|
||||
history = append(history, m)
|
||||
}
|
||||
}
|
||||
|
||||
if len(history) <= 4 {
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// Split: old messages to summarize, recent to keep
|
||||
keepRecent := 6
|
||||
if keepRecent > len(history) {
|
||||
keepRecent = len(history)
|
||||
}
|
||||
oldMessages := history[:len(history)-keepRecent]
|
||||
recentMessages := history[len(history)-keepRecent:]
|
||||
|
||||
// Build conversation text for summarization
|
||||
var convText strings.Builder
|
||||
for _, m := range oldMessages {
|
||||
convText.WriteString(fmt.Sprintf("[%s]: %s\n\n", m.Role, m.TextContent()))
|
||||
}
|
||||
|
||||
// Call LLM to summarize
|
||||
summary, err := s.callSummarize(convText.String())
|
||||
if err != nil {
|
||||
// Fall back to truncation on failure
|
||||
trunc := &TruncateStrategy{KeepRecent: keepRecent}
|
||||
return trunc.Compact(messages, budget)
|
||||
}
|
||||
|
||||
// Build new history: system + summary marker + recent
|
||||
summaryMsg := message.NewUserText(fmt.Sprintf("[Conversation summary — %d earlier messages condensed]\n\n%s", len(oldMessages), summary))
|
||||
ackMsg := message.NewAssistantText("Understood, I have the context from the summary. Continuing from here.")
|
||||
|
||||
result := append(systemMsgs, summaryMsg, ackMsg)
|
||||
result = append(result, recentMessages...)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *SummarizeStrategy) callSummarize(conversationText string) (string, error) {
|
||||
if s.Provider == nil {
|
||||
return "", fmt.Errorf("no provider for summarization")
|
||||
}
|
||||
|
||||
req := provider.Request{
|
||||
Model: s.Model,
|
||||
SystemPrompt: summarySystemPrompt,
|
||||
Messages: []message.Message{
|
||||
message.NewUserText("Summarize this conversation:\n\n" + conversationText),
|
||||
},
|
||||
MaxTokens: s.MaxTokens,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
str, err := s.Provider.Stream(ctx, req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("summarization stream: %w", err)
|
||||
}
|
||||
defer str.Close()
|
||||
|
||||
// Consume stream, collect text
|
||||
var result strings.Builder
|
||||
for str.Next() {
|
||||
evt := str.Current()
|
||||
if evt.Type == stream.EventTextDelta {
|
||||
result.WriteString(evt.Text)
|
||||
}
|
||||
}
|
||||
if err := str.Err(); err != nil {
|
||||
return "", fmt.Errorf("summarization stream error: %w", err)
|
||||
}
|
||||
|
||||
summary := strings.TrimSpace(result.String())
|
||||
if summary == "" {
|
||||
return "", fmt.Errorf("empty summary returned")
|
||||
}
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
gnomactx "somegit.dev/Owlibou/gnoma/internal/context"
|
||||
"somegit.dev/Owlibou/gnoma/internal/message"
|
||||
"somegit.dev/Owlibou/gnoma/internal/permission"
|
||||
"somegit.dev/Owlibou/gnoma/internal/provider"
|
||||
@@ -248,6 +249,12 @@ func (e *Engine) executeTools(ctx context.Context, calls []message.ToolCall, cb
|
||||
output = e.cfg.Firewall.ScanToolResult(output)
|
||||
}
|
||||
|
||||
// Persist large results to disk
|
||||
if persisted, ok := gnomactx.PersistLargeResult(output, call.ID, ".gnoma/sessions"); ok {
|
||||
e.logger.Debug("tool result persisted to disk", "name", call.Name, "size", len(output))
|
||||
output = persisted
|
||||
}
|
||||
|
||||
// Emit tool result event for the UI
|
||||
if cb != nil {
|
||||
cb(stream.Event{
|
||||
|
||||
@@ -298,6 +298,25 @@ func (m Model) handleCommand(cmd string) (tea.Model, tea.Cmd) {
|
||||
case "/clear":
|
||||
m.messages = nil
|
||||
m.scrollOffset = 0
|
||||
if m.config.Engine != nil {
|
||||
m.config.Engine.Reset()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "/compact":
|
||||
if m.config.Engine != nil {
|
||||
if w := m.config.Engine.ContextWindow(); w != nil {
|
||||
compacted, err := w.CompactIfNeeded()
|
||||
if err != nil {
|
||||
m.messages = append(m.messages, chatMessage{role: "error", content: "compaction failed: " + err.Error()})
|
||||
} else if compacted {
|
||||
m.messages = append(m.messages, chatMessage{role: "system", content: "context compacted — older messages summarized"})
|
||||
} else {
|
||||
// Force compaction even if not at threshold
|
||||
m.messages = append(m.messages, chatMessage{role: "system", content: "context usage within budget, no compaction needed"})
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "/incognito":
|
||||
|
||||
Reference in New Issue
Block a user