Skip to content

Commit 0992ac8

Browse files
committed
perf: refine context compaction flow and UI feedback
1 parent 012ae40 commit 0992ac8

11 files changed

Lines changed: 449 additions & 243 deletions

File tree

go.mod

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/voocel/codebot
22

3-
go 1.25
3+
go 1.25.0
44

55
require (
66
github.com/atotto/clipboard v0.1.4
@@ -10,7 +10,7 @@ require (
1010
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
1111
github.com/modelcontextprotocol/go-sdk v1.4.0
1212
github.com/muesli/reflow v0.3.0
13-
github.com/voocel/agentcore v1.5.1
13+
github.com/voocel/agentcore v1.5.2
1414
)
1515

1616
require (
@@ -40,15 +40,15 @@ require (
4040
github.com/rivo/uniseg v0.4.7 // indirect
4141
github.com/segmentio/asm v1.1.3 // indirect
4242
github.com/segmentio/encoding v0.5.3 // indirect
43-
github.com/voocel/litellm v1.6.0 // indirect
43+
github.com/voocel/litellm v1.6.2 // indirect
4444
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
4545
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
4646
github.com/yuin/goldmark v1.7.13 // indirect
4747
github.com/yuin/goldmark-emoji v1.0.5 // indirect
48-
golang.org/x/image v0.36.0 // indirect
48+
golang.org/x/image v0.37.0 // indirect
4949
golang.org/x/net v0.47.0 // indirect
5050
golang.org/x/oauth2 v0.34.0 // indirect
5151
golang.org/x/sys v0.40.0 // indirect
5252
golang.org/x/term v0.37.0 // indirect
53-
golang.org/x/text v0.34.0 // indirect
53+
golang.org/x/text v0.35.0 // indirect
5454
)

go.sum

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,10 @@ github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
8383
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
8484
github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w=
8585
github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
86-
github.com/voocel/agentcore v1.5.1 h1:gEVpBXZfXH4fkq4fLISo2dYfoQ+SaJ0NsetU/Y0hKrI=
87-
github.com/voocel/agentcore v1.5.1/go.mod h1:fjksENApgfL1QXbcJY8RUUU5Gl03YOYExFAZ040X/zU=
88-
github.com/voocel/litellm v1.6.0 h1:jc0Y7q+cp6QQcag3Mhmd6wMKkfzf7mXjXY0Uvj5VBQw=
89-
github.com/voocel/litellm v1.6.0/go.mod h1:6MBUu3I4DHm7h72Vl+3nqLruSwYmgqMf/I9BGoordJ4=
86+
github.com/voocel/agentcore v1.5.2 h1:nqZQ+W1VdwknmWSwINy8TrD3wHSBTit1LAlhmTUNSBo=
87+
github.com/voocel/agentcore v1.5.2/go.mod h1:A78rOGj/nr6FZtL2ri30SZGzM6HL5mq4qUBydypqSaU=
88+
github.com/voocel/litellm v1.6.2 h1:TJ1s7B7UqgV86O1EcuwQTZua0FK1tbOg0+oUsDmgmuA=
89+
github.com/voocel/litellm v1.6.2/go.mod h1:6MBUu3I4DHm7h72Vl+3nqLruSwYmgqMf/I9BGoordJ4=
9090
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
9191
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
9292
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
@@ -98,8 +98,8 @@ github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC
9898
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
9999
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
100100
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
101-
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
102-
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
101+
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
102+
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
103103
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
104104
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
105105
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
@@ -110,7 +110,7 @@ golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
110110
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
111111
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
112112
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
113-
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
114-
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
115-
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
116-
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
113+
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
114+
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
115+
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
116+
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=

internal/agent/events.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,8 @@ type SessionEvent struct {
4444
RetrySuccess bool
4545

4646
// Compaction reason: "overflow" or "threshold"
47-
CompactionReason string
47+
CompactionReason string
48+
CompactionChanged bool
49+
TokensBefore int
50+
TokensAfter int
4851
}
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
package agent
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"time"
8+
9+
"github.com/voocel/agentcore"
10+
"github.com/voocel/agentcore/memory"
11+
)
12+
13+
type CompactionResult struct {
14+
Changed bool
15+
TokensBefore int
16+
TokensAfter int
17+
Reason string
18+
}
19+
20+
const (
21+
microcompactThreshold = 40000
22+
microcompactMinSaving = 20000
23+
largeTextThreshold = 4000
24+
microcompactPreserveHead = 1500
25+
microcompactPreserveTail = 1500
26+
microcompactKeepRecent = 3
27+
)
28+
29+
func (s *Session) Compact() (CompactionResult, error) {
30+
return s.context.compactWithReason("manual")
31+
}
32+
33+
func (c *sessionContextController) microcompact() bool {
34+
msgs := c.session.agent.Messages()
35+
totalTokens := memory.EstimateTotal(msgs)
36+
if totalTokens < microcompactThreshold {
37+
return false
38+
}
39+
40+
type candidate struct {
41+
idx int
42+
savings int
43+
}
44+
var candidates []candidate
45+
for i, m := range msgs {
46+
msg, ok := m.(agentcore.Message)
47+
if !ok {
48+
continue
49+
}
50+
for _, b := range msg.Content {
51+
if b.Type == agentcore.ContentText && len([]rune(b.Text)) > largeTextThreshold {
52+
saved := len(b.Text) - microcompactPreserveHead*3
53+
if saved > 0 {
54+
candidates = append(candidates, candidate{idx: i, savings: saved / 4})
55+
}
56+
break
57+
}
58+
}
59+
}
60+
61+
if len(candidates) == 0 {
62+
return false
63+
}
64+
65+
protectCount := min(microcompactKeepRecent, len(candidates))
66+
eligible := candidates[:len(candidates)-protectCount]
67+
if len(eligible) == 0 {
68+
return false
69+
}
70+
71+
var totalSavings int
72+
for _, candidate := range eligible {
73+
totalSavings += candidate.savings
74+
}
75+
if totalSavings < microcompactMinSaving {
76+
return false
77+
}
78+
79+
eligibleSet := make(map[int]struct{}, len(eligible))
80+
for _, candidate := range eligible {
81+
eligibleSet[candidate.idx] = struct{}{}
82+
}
83+
84+
newMsgs := make([]agentcore.AgentMessage, len(msgs))
85+
for i, m := range msgs {
86+
if _, ok := eligibleSet[i]; ok {
87+
if msg, ok := m.(agentcore.Message); ok {
88+
newMsgs[i] = truncateMessageText(msg)
89+
continue
90+
}
91+
}
92+
newMsgs[i] = m
93+
}
94+
95+
if err := c.session.agent.SetMessages(newMsgs); err != nil {
96+
return false
97+
}
98+
return true
99+
}
100+
101+
func (c *sessionContextController) compactWithReason(reason string) (result CompactionResult, err error) {
102+
result = CompactionResult{Reason: reason}
103+
c.session.emit(SessionEvent{Type: SEAutoCompactionStart, CompactionReason: reason})
104+
defer func() {
105+
if err != nil {
106+
return
107+
}
108+
c.session.emit(SessionEvent{
109+
Type: SEAutoCompactionEnd,
110+
CompactionReason: reason,
111+
CompactionChanged: result.Changed,
112+
TokensBefore: result.TokensBefore,
113+
TokensAfter: result.TokensAfter,
114+
})
115+
}()
116+
117+
msgs := c.session.agent.Messages()
118+
if len(msgs) == 0 {
119+
return result, nil
120+
}
121+
122+
tokensBefore := memory.EstimateTotal(msgs)
123+
result.TokensBefore = tokensBefore
124+
125+
c.session.mu.Lock()
126+
prov := c.session.provider
127+
model := c.session.modelName
128+
ctxWindow := c.session.settings.ContextWindow
129+
store := c.session.store
130+
c.session.mu.Unlock()
131+
132+
apiKey, baseURL := c.session.resolveCredentials(prov)
133+
compactModel, err := c.session.createModel(prov, model, apiKey, baseURL)
134+
if err != nil {
135+
return result, fmt.Errorf("create compaction model: %w", err)
136+
}
137+
138+
transform := memory.NewCompaction(memory.CompactionConfig{
139+
Model: compactModel,
140+
ContextWindow: ctxWindow,
141+
})
142+
143+
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
144+
defer cancel()
145+
146+
compacted, err := transform(ctx, msgs)
147+
if err != nil {
148+
return result, fmt.Errorf("compact context: %w", err)
149+
}
150+
151+
tokensAfter := memory.EstimateTotal(compacted)
152+
if tokensAfter >= tokensBefore {
153+
result.TokensAfter = tokensBefore
154+
return result, nil
155+
}
156+
157+
summary, keptRaw := extractCompactionPayload(compacted)
158+
if summary == "" {
159+
result.TokensAfter = tokensBefore
160+
return result, nil
161+
}
162+
163+
if store != nil {
164+
if err := store.AppendCompaction(summary, keptRaw); err != nil {
165+
return result, fmt.Errorf("append compaction entry: %w", err)
166+
}
167+
}
168+
169+
if err := c.session.agent.SetMessages(compacted); err != nil {
170+
return result, fmt.Errorf("set compacted messages: %w", err)
171+
}
172+
result.Changed = true
173+
result.TokensAfter = tokensAfter
174+
return result, nil
175+
}
176+
177+
func extractCompactionPayload(msgs []agentcore.AgentMessage) (string, []json.RawMessage) {
178+
var summary string
179+
start := -1
180+
for i, m := range msgs {
181+
cs, ok := m.(memory.CompactionSummary)
182+
if !ok {
183+
continue
184+
}
185+
summary = cs.Summary
186+
start = i + 1
187+
break
188+
}
189+
if summary == "" || start < 0 || start > len(msgs) {
190+
return "", nil
191+
}
192+
193+
var keptRaw []json.RawMessage
194+
for _, m := range msgs[start:] {
195+
msg, ok := m.(agentcore.Message)
196+
if !ok {
197+
continue
198+
}
199+
data, err := json.Marshal(msg)
200+
if err != nil {
201+
continue
202+
}
203+
keptRaw = append(keptRaw, data)
204+
}
205+
return summary, keptRaw
206+
}
207+
208+
func (c *sessionContextController) checkAutoCompaction() {
209+
if !c.session.settings.AutoCompaction {
210+
return
211+
}
212+
cu := c.session.agent.ContextUsage()
213+
if cu == nil || cu.Percent < 80 {
214+
return
215+
}
216+
if _, err := c.compactWithReason("threshold"); err != nil {
217+
c.session.emit(SessionEvent{
218+
Type: SEError,
219+
Error: fmt.Errorf("auto compact failed: %w", err),
220+
})
221+
}
222+
}
223+
224+
func truncateMessageText(msg agentcore.Message) agentcore.Message {
225+
newContent := make([]agentcore.ContentBlock, len(msg.Content))
226+
for i, b := range msg.Content {
227+
if b.Type == agentcore.ContentText && len([]rune(b.Text)) > largeTextThreshold {
228+
runes := []rune(b.Text)
229+
head := string(runes[:microcompactPreserveHead])
230+
tail := string(runes[len(runes)-microcompactPreserveTail:])
231+
trimmed := len(runes) - microcompactPreserveHead - microcompactPreserveTail
232+
newContent[i] = agentcore.ContentBlock{
233+
Type: agentcore.ContentText,
234+
Text: fmt.Sprintf("%s\n[%d characters trimmed]\n%s", head, trimmed, tail),
235+
}
236+
} else {
237+
newContent[i] = b
238+
}
239+
}
240+
241+
return agentcore.Message{
242+
Role: msg.Role,
243+
Content: newContent,
244+
StopReason: msg.StopReason,
245+
Usage: msg.Usage,
246+
Metadata: msg.Metadata,
247+
Timestamp: msg.Timestamp,
248+
}
249+
}

0 commit comments

Comments
 (0)