1package core
2
3import (
4 "context"
5 "crypto/rand"
6 "fmt"
7 "strconv"
8 "strings"
9 "time"
10
11 "github.com/patriceckhart/zot/packages/provider"
12)
13
14// Compact summarizes the agent's transcript via the LLM and replaces
15// it with a single synthetic user message carrying the summary. A
16// small tail of recent messages is optionally preserved for continuity.
17//
18// keepTail is the number of most-recent messages to keep verbatim after
19// the summary. 0 means summarize everything; a typical useful value is
20// 4-8 (last couple of exchanges).
21//
22// The method blocks until the summary request completes. Emitted
23// events via sink are limited to text deltas from the summary call so
24// the UI can show progress.
25func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta string)) (summary string, err error) {
26 a.mu.Lock()
27 msgs := append([]provider.Message(nil), a.messages...)
28 a.mu.Unlock()
29
30 id := rand.Text()
31 if a.OnEvent != nil {
32 a.OnEvent(EvCompact{Phase: "pre", ID: id, MessageCount: len(msgs), TokenEstimate: len(serializeTranscript(msgs)) / 4})
33 defer func() {
34 after := a.Messages()
35 status := "completed"
36 if err != nil {
37 status = executionErrorStatus(err)
38 }
39 a.OnEvent(EvCompact{Phase: "post", ID: id, MessageCount: len(after), TokenEstimate: len(serializeTranscript(after)) / 4, Status: status, Err: err})
40 }()
41 }
42 if len(msgs) == 0 {
43 return "", fmt.Errorf("nothing to compact")
44 }
45 if keepTail < 0 {
46 keepTail = 0
47 }
48 if keepTail > len(msgs) {
49 keepTail = len(msgs)
50 }
51 summarizable := msgs[:len(msgs)-keepTail]
52 if len(summarizable) == 0 {
53 return "", fmt.Errorf("nothing to compact: keep-tail covers the whole transcript")
54 }
55
56 // Serialize the summarizable transcript to text and wrap it in tags
57 // so the model treats it as material to summarize, not to continue.
58 transcript := serializeTranscript(summarizable)
59
60 prompt := "<conversation>\n" + transcript + "\n</conversation>\n\n" + compactionPrompt
61
62 req := provider.Request{
63 Model: a.Model,
64 System: summarizationSystem,
65 MaxTokens: 4096,
66 Temperature: a.Temperature,
67 SessionID: a.SessionID,
68 Messages: []provider.Message{
69 {
70 Role: provider.RoleUser,
71 Content: []provider.Content{provider.TextBlock{Text: prompt}},
72 Time: time.Now(),
73 },
74 },
75 }
76
77 stream, err := a.Client.Stream(ctx, req)
78 if err != nil {
79 return "", err
80 }
81
82 var sb strings.Builder
83 for ev := range stream {
84 switch e := ev.(type) {
85 case provider.EventTextDelta:
86 sb.WriteString(e.Delta)
87 if sink != nil {
88 sink(e.Delta)
89 }
90 case provider.EventDone:
91 if e.Err != nil {
92 return "", e.Err
93 }
94 }
95 }
96 summary = strings.TrimSpace(sb.String())
97 if summary == "" {
98 return "", fmt.Errorf("empty summary from model")
99 }
100
101 // Estimate token count before compaction (rough: 1 token ~ 4 chars).
102 tokensBefore := len(transcript) / 4
103
104 // Replace transcript: one synthetic user message with the summary,
105 // followed by the preserved tail (if any).
106 var activatedTools []string
107 for _, message := range msgs {
108 for _, name := range message.AddedToolNames {
109 if !containsString(activatedTools, name) {
110 activatedTools = append(activatedTools, name)
111 }
112 }
113 }
114 synthetic := provider.Message{
115 Role: provider.RoleUser,
116 AddedToolNames: activatedTools,
117 Content: []provider.Content{
118 provider.TextBlock{Text: "## Context Summary (compacted)\n\n" + summary},
119 },
120 Time: time.Now(),
121 Meta: map[string]string{
122 "compaction": "true",
123 "tokens_before": strconv.Itoa(tokensBefore),
124 },
125 }
126
127 tail := msgs[len(msgs)-keepTail:]
128 // Repair the tail: remove orphaned tool_result blocks whose
129 // matching tool_use was in the compacted (now-removed) portion.
130 // Anthropic rejects transcripts where a tool_result references
131 // a tool_use ID that doesn't exist.
132 tail = repairOrphanedToolResults(tail)
133
134 next := make([]provider.Message, 0, 1+len(tail))
135 next = append(next, synthetic)
136 next = append(next, tail...)
137
138 a.mu.Lock()
139 a.messages = next
140 a.rev++
141 onCompacted := a.OnTranscriptCompacted
142 persisted := append([]provider.Message(nil), next...)
143 a.mu.Unlock()
144
145 if onCompacted != nil {
146 onCompacted(persisted)
147 }
148
149 return summary, nil
150}
151
152// repairOrphanedToolResults removes tool_result content blocks (and
153// entire messages that become empty) when the matching tool_use ID
154// does not appear anywhere in the given messages. This happens after
155// compaction when the tail preserves a tool_result but the tool_use
156// that produced it was summarized away.
157func repairOrphanedToolResults(msgs []provider.Message) []provider.Message {
158 return provider.RepairOrphanedToolResults(msgs)
159}
160
161// serializeTranscript renders a list of provider.Message into a plain
162// text transcript the summarization model can read without trying to
163// continue the conversation.
164func serializeTranscript(msgs []provider.Message) string {
165 var sb strings.Builder
166 for _, m := range msgs {
167 switch m.Role {
168 case provider.RoleUser:
169 sb.WriteString("\n--- user ---\n")
170 case provider.RoleAssistant:
171 sb.WriteString("\n--- assistant ---\n")
172 case provider.RoleTool:
173 sb.WriteString("\n--- tool ---\n")
174 }
175 for _, c := range m.Content {
176 switch v := c.(type) {
177 case provider.TextBlock:
178 sb.WriteString(v.Text)
179 sb.WriteString("\n")
180 case provider.ImageBlock:
181 fmt.Fprintf(&sb, "[image: %s, %d bytes]\n", v.MimeType, len(v.Data))
182 case provider.ToolCallBlock:
183 fmt.Fprintf(&sb, "[tool_call %s %s]\n", v.Name, string(v.Arguments))
184 case provider.ToolResultBlock:
185 for _, inner := range v.Content {
186 if tb, ok := inner.(provider.TextBlock); ok {
187 sb.WriteString("[tool_result] ")
188 sb.WriteString(tb.Text)
189 sb.WriteString("\n")
190 }
191 }
192 }
193 }
194 }
195 return sb.String()
196}
197
198const summarizationSystem = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.
199
200Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`
201
202const compactionPrompt = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
203
204Use this EXACT format:
205
206## Goal
207[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
208
209## Constraints & Preferences
210- [Any constraints, preferences, or requirements mentioned by user]
211- [Or "(none)" if none were mentioned]
212
213## Progress
214### Done
215- [x] [Completed tasks/changes]
216
217### In Progress
218- [ ] [Current work]
219
220### Blocked
221- [Issues preventing progress, if any]
222
223## Key Decisions
224- **[Decision]**: [Brief rationale]
225
226## Next Steps
2271. [Ordered list of what should happen next]
228
229## Critical Context
230- [Any data, examples, or references needed to continue]
231- [Or "(none)" if not applicable]
232
233Keep each section concise. Preserve exact file paths, function names, and error messages.`