compact: retain newest ~20% of context; refactor startup into internal/app
- Replace fixed 6-message compact retention with ratio-based logic: retain ~20% of compactable estimated tokens, with a 6-message floor and user-turn boundary alignment - Add compactRetainedTokenBudget, compactRecentStart, compactTurnBoundary helpers in internal/agent/agent.go - Add TestAgentCompactKeepsTwentyPercentRecentContext - Move startup/session/repl/bootstrap logic from root into internal/app so main.go is a thin binary entry point (15 lines) - Update /compact docs in README.md
This commit is contained in:
+59
-7
@@ -17,7 +17,8 @@ import (
|
||||
const (
|
||||
defaultMaxToolRounds = 50
|
||||
doomLoopThreshold = 3
|
||||
compactRecentMessages = 6
|
||||
compactMinRecentMessages = 6
|
||||
compactRecentRetentionPercent = 20
|
||||
summaryMessageLimit = 8000
|
||||
contextSummaryPrefix = "[context summary]"
|
||||
toolLogArgumentLimit = 400
|
||||
@@ -181,6 +182,61 @@ func (a *Agent) SetLastUsage(usage *llm.Usage) {
|
||||
copy := *usage
|
||||
a.lastUsage = ©
|
||||
}
|
||||
// compactRetainedTokenBudget returns the number of estimated tokens to retain
|
||||
// based on compactRecentRetentionPercent, using integer ceiling division.
|
||||
func compactRetainedTokenBudget(totalTokens int) int {
|
||||
if totalTokens <= 0 {
|
||||
return 1
|
||||
}
|
||||
return (totalTokens*compactRecentRetentionPercent + 99) / 100
|
||||
}
|
||||
|
||||
// compactTurnBoundary walks backward from start until it finds a user message
|
||||
// or reaches prefixEnd, ensuring the retained suffix starts at a user turn.
|
||||
func compactTurnBoundary(messages []llm.Message, prefixEnd int, start int) int {
|
||||
for start > prefixEnd && messages[start].Role != llm.RoleUser {
|
||||
start--
|
||||
}
|
||||
return start
|
||||
}
|
||||
|
||||
// compactRecentStart returns the index where the retained recent suffix begins.
|
||||
// It balances the 20% token-budget ratio with the compactMinRecentMessages floor
|
||||
// and aligns to a user-turn boundary.
|
||||
func compactRecentStart(messages []llm.Message, prefixEnd int) int {
|
||||
compactableCount := len(messages) - prefixEnd
|
||||
if compactableCount <= compactMinRecentMessages {
|
||||
return len(messages)
|
||||
}
|
||||
|
||||
compactable := messages[prefixEnd:]
|
||||
totalTokens := llm.EstimateTokens(compactable, nil)
|
||||
retainTokens := compactRetainedTokenBudget(totalTokens)
|
||||
|
||||
// Walk backward from the end to find the earliest suffix fitting the budget.
|
||||
ratioStart := len(messages) - 1
|
||||
for i := len(messages) - 1; i >= prefixEnd; i-- {
|
||||
if llm.EstimateTokens(messages[i:], nil) <= retainTokens {
|
||||
ratioStart = i
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce the minimum recent-message floor.
|
||||
minStart := len(messages) - compactMinRecentMessages
|
||||
if minStart < prefixEnd {
|
||||
minStart = prefixEnd
|
||||
}
|
||||
// Choose the earlier start so the floor is never weakened.
|
||||
start := minStart
|
||||
if ratioStart < start {
|
||||
start = ratioStart
|
||||
}
|
||||
|
||||
return compactTurnBoundary(messages, prefixEnd, start)
|
||||
}
|
||||
|
||||
|
||||
// Compact summarizes older conversation messages while preserving the system
|
||||
// prompt and recent messages intact.
|
||||
@@ -196,12 +252,8 @@ func (a *Agent) Compact(ctx context.Context) error {
|
||||
if a.messages[0].Role == llm.RoleSystem {
|
||||
prefixEnd = 1
|
||||
}
|
||||
if len(a.messages)-prefixEnd <= compactRecentMessages {
|
||||
return nil
|
||||
}
|
||||
|
||||
recentStart := len(a.messages) - compactRecentMessages
|
||||
if recentStart <= prefixEnd {
|
||||
recentStart := compactRecentStart(a.messages, prefixEnd)
|
||||
if recentStart <= prefixEnd || recentStart >= len(a.messages) {
|
||||
return nil
|
||||
}
|
||||
middle := append([]llm.Message(nil), a.messages[prefixEnd:recentStart]...)
|
||||
|
||||
@@ -145,6 +145,9 @@ func TestAgentCompact(t *testing.T) {
|
||||
if messages[len(messages)-1].Content != "recent-answer-2" {
|
||||
t.Fatalf("last recent message = %#v", messages[len(messages)-1])
|
||||
}
|
||||
if messages[2].Content != "recent-user-0" {
|
||||
t.Fatalf("first retained recent message = %#v", messages[2])
|
||||
}
|
||||
if usage := a.LastUsage(); usage == nil || usage.TotalTokens != 12 {
|
||||
t.Fatalf("usage = %#v", usage)
|
||||
}
|
||||
@@ -217,6 +220,48 @@ func longConversation() []llm.Message {
|
||||
return messages
|
||||
}
|
||||
|
||||
func manyTurnConversation(turns int) []llm.Message {
|
||||
messages := []llm.Message{{Role: llm.RoleSystem, Content: "system"}}
|
||||
for i := 0; i < turns; i++ {
|
||||
messages = append(messages,
|
||||
llm.Message{Role: llm.RoleUser, Content: fmt.Sprintf("user-%02d", i)},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: fmt.Sprintf("assistant-%02d", i)},
|
||||
)
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
func TestAgentCompactKeepsTwentyPercentRecentContext(t *testing.T) {
|
||||
provider := &compactProvider{}
|
||||
a := New(Options{Provider: provider, Model: "test", SystemPrompt: "system"})
|
||||
a.SetMessages(manyTurnConversation(25))
|
||||
|
||||
if err := a.Compact(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
messages := a.Messages()
|
||||
// 1 system + 1 summary + 10 retained recent messages (5 user/assistant turns)
|
||||
if len(messages) != 12 {
|
||||
t.Fatalf("messages = %d, want 12; contents: %v", len(messages), joinMessageContents(messages))
|
||||
}
|
||||
if provider.summaryCalls != 1 {
|
||||
t.Fatalf("summaryCalls = %d, want 1", provider.summaryCalls)
|
||||
}
|
||||
if messages[2].Content != "user-20" {
|
||||
t.Fatalf("first retained message = %#v, want user-20", messages[2])
|
||||
}
|
||||
if messages[len(messages)-1].Content != "assistant-24" {
|
||||
t.Fatalf("last retained message = %#v, want assistant-24", messages[len(messages)-1])
|
||||
}
|
||||
if !strings.Contains(provider.lastSummary, "user-19") {
|
||||
t.Fatalf("summary should contain user-19 (part of summarized 80%%): %q", provider.lastSummary)
|
||||
}
|
||||
if strings.Contains(provider.lastSummary, "user-20") {
|
||||
t.Fatalf("summary should not contain user-20 (part of retained 20%%): %q", provider.lastSummary)
|
||||
}
|
||||
}
|
||||
|
||||
func joinMessageContents(messages []llm.Message) string {
|
||||
var b strings.Builder
|
||||
for _, msg := range messages {
|
||||
|
||||
Reference in New Issue
Block a user