first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+133
View File
@@ -0,0 +1,133 @@
// This file (US-003) ties the compaction pieces together: given a message list
// and settings, it finds the cut point, extracts file operations from the
// summarized range, generates the structured summary, and returns a
// CompactionResult ready to be persisted as a session compaction entry and used
// to rebuild the agent context. Mirrors pi's prepareCompaction + compact.
package compaction
import (
"context"
"encoding/json"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/provider"
)
// CompactionDetails records the files touched in the compacted history, stored
// alongside a compaction entry so a later iterative compaction can seed its
// file lists. Mirrors pi's CompactionDetails.
type CompactionDetails struct {
// ReadFiles are files read but not modified in the compacted range.
ReadFiles []string `json:"readFiles"`
// ModifiedFiles are files written or edited in the compacted range.
ModifiedFiles []string `json:"modifiedFiles"`
}
// CompactionResult is the outcome of a compaction: the summary text (with file
// metadata appended), the index where retained history begins, the estimated
// tokens before compaction, and the extracted file details. Mirrors pi's
// CompactionResult, adapted to pigo's flat message list (index vs entry id).
type CompactionResult struct {
// Summary is the structured summary that replaces the compacted history.
Summary string
// FirstKeptIndex is the index of the first retained message.
FirstKeptIndex int
// TokensBefore is the estimated context tokens before compaction.
TokensBefore int
// Details holds the file operations extracted from the compacted range.
Details CompactionDetails
}
// Compact prepares and generates a compaction over msgs. It cuts at
// FindCutPoint(msgs, settings.KeepRecentTokens), summarizes messages in
// [prevCompactionIndex+1, firstKeptIndex) (seeding file ops from a prior
// compaction's details when provided), and appends <read-files>/<modified-files>
// metadata to the summary. previousSummary, when non-empty, switches to the
// iterative update template.
//
// prevCompactionIndex is the index of the last already-applied compaction point
// (-1 when none); summarization starts just after it so each compaction only
// covers the newly accumulated history. prevDetails carries that compaction's
// file lists so long-lived reads/edits survive across successive compactions.
//
// It returns (nil, nil) when there is nothing to compact (no valid cut point or
// an empty summarization range).
func Compact(
ctx context.Context,
stream provider.StreamFn,
model provider.Model,
msgs []agentcore.Message,
settings CompactionSettings,
prevCompactionIndex int,
prevDetails *CompactionDetails,
previousSummary string,
cfg provider.StreamConfig,
) (*CompactionResult, error) {
cut := FindCutPoint(msgs, settings.KeepRecentTokens)
start := prevCompactionIndex + 1
if start < 0 {
start = 0
}
if start >= cut.FirstKeptIndex {
// Nothing new to summarize.
return nil, nil
}
toSummarize := msgs[start:cut.FirstKeptIndex]
// Seed file ops from the previous compaction, then fold in this range.
ops := NewFileOps()
if prevDetails != nil {
for _, f := range prevDetails.ReadFiles {
ops.Read[f] = struct{}{}
}
for _, f := range prevDetails.ModifiedFiles {
ops.Edited[f] = struct{}{}
}
}
for _, m := range toSummarize {
extractFileOpsFromMessage(m, ops)
}
readFiles, modifiedFiles := computeFileLists(ops)
summary, err := GenerateSummary(ctx, stream, model, toSummarize, settings.ReserveTokens, previousSummary, cfg)
if err != nil {
return nil, err
}
summary += formatFileOperations(readFiles, modifiedFiles)
return &CompactionResult{
Summary: summary,
FirstKeptIndex: cut.FirstKeptIndex,
TokensBefore: EstimateContextTokens(msgs).Tokens,
Details: CompactionDetails{ReadFiles: readFiles, ModifiedFiles: modifiedFiles},
}, nil
}
// Message builds the CompactionMessage to persist for this result: the summary
// text plus the estimated tokens-before and the file details (as raw JSON).
func (r *CompactionResult) Message(now int64) agentcore.CompactionMessage {
var details json.RawMessage
if b, err := json.Marshal(r.Details); err == nil {
details = b
}
return agentcore.CompactionMessage{
RoleField: agentcore.RoleCompaction,
Summary: r.Summary,
TokensBefore: r.TokensBefore,
Details: details,
Timestamp: now,
}
}
// RebuildContext returns the post-compaction message list: the compaction
// checkpoint followed by the retained recent messages (msgs[FirstKeptIndex:]).
// The summarized prefix is dropped and replaced by the single checkpoint,
// keeping context continuous while staying within the window. Mirrors pi's
// post-compact context reconstruction (summary entry + retained tail).
func (r *CompactionResult) RebuildContext(msgs []agentcore.Message, now int64) agentcore.MessageList {
out := make(agentcore.MessageList, 0, len(msgs)-r.FirstKeptIndex+1)
out = append(out, r.Message(now))
out = append(out, msgs[r.FirstKeptIndex:]...)
return out
}
+97
View File
@@ -0,0 +1,97 @@
package compaction
import "github.com/smallnest/pigo/internal/agentcore"
// CutPointResult describes the cut selected for compaction, mirroring pi's
// CutPointResult (adapted to pigo's flat message list).
type CutPointResult struct {
// FirstKeptIndex is the index of the first message retained after
// compaction; everything before it is summarized.
FirstKeptIndex int
// TurnStartIndex is the index of the user message that starts the turn the
// cut falls inside, or -1 when the cut lands on a clean turn boundary.
TurnStartIndex int
// IsSplitTurn reports whether the cut splits an in-progress assistant turn.
IsSplitTurn bool
}
// isValidCutPoint reports whether a message may serve as a cut point. A cut may
// land on a user or assistant message but never on a toolResult, because a
// toolResult must stay attached to the toolCall that produced it (pi semantics).
func isValidCutPoint(msg agentcore.Message) bool {
switch msg.Role() {
case agentcore.RoleUser, agentcore.RoleAssistant:
return true
default: // toolResult and any custom kinds are not cuttable.
return false
}
}
// findValidCutPoints returns the indices of all messages that may serve as cut
// points, in ascending order.
func findValidCutPoints(msgs []agentcore.Message) []int {
var pts []int
for i, m := range msgs {
if isValidCutPoint(m) {
pts = append(pts, i)
}
}
return pts
}
// findTurnStartIndex scans backwards from idx to find the user message that
// starts the turn containing idx, returning -1 if none is found.
func findTurnStartIndex(msgs []agentcore.Message, idx int) int {
for i := idx; i >= 0; i-- {
if msgs[i].Role() == agentcore.RoleUser {
return i
}
}
return -1
}
// FindCutPoint finds the compaction cut that keeps approximately
// keepRecentTokens worth of the most recent messages. It accumulates token
// estimates from the newest message backwards; once the retained budget is
// reached it snaps to the nearest valid cut point at or after that message,
// never splitting a toolCall from its toolResult. This mirrors pi's findCutPoint.
//
// When no valid cut point exists, it keeps everything (FirstKeptIndex 0).
func FindCutPoint(msgs []agentcore.Message, keepRecentTokens int) CutPointResult {
cutPoints := findValidCutPoints(msgs)
if len(cutPoints) == 0 {
return CutPointResult{FirstKeptIndex: 0, TurnStartIndex: -1, IsSplitTurn: false}
}
// Default to the earliest valid cut point (keep as much as possible) when
// the retained budget is never reached.
cutIndex := cutPoints[0]
accumulated := 0
for i := len(msgs) - 1; i >= 0; i-- {
accumulated += EstimateTokens(msgs[i])
if accumulated >= keepRecentTokens {
// Snap to the nearest valid cut point at or after i, so the kept
// window starts on a cuttable boundary.
for _, c := range cutPoints {
if c >= i {
cutIndex = c
break
}
}
break
}
}
cutOnUser := msgs[cutIndex].Role() == agentcore.RoleUser
turnStart := -1
if !cutOnUser {
turnStart = findTurnStartIndex(msgs, cutIndex)
}
return CutPointResult{
FirstKeptIndex: cutIndex,
TurnStartIndex: turnStart,
IsSplitTurn: !cutOnUser && turnStart != -1,
}
}
+119
View File
@@ -0,0 +1,119 @@
package compaction
import (
"strings"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
)
// bigUser builds a user message whose estimate is ~tokens tokens (4 chars each).
func bigUser(tokens int) agentcore.UserMessage {
return userMsg(strings.Repeat("x", tokens*4))
}
func assistantToolCall(id, name string) agentcore.AssistantMessage {
return agentcore.AssistantMessage{
RoleField: agentcore.RoleAssistant,
Content: agentcore.ContentList{agentcore.NewToolCallContent(id, name, nil)},
}
}
func toolResult(id string) agentcore.ToolResultMessage {
return agentcore.ToolResultMessage{
RoleField: agentcore.RoleToolResult,
ToolCallID: id,
Content: agentcore.ContentList{agentcore.NewTextContent("result")},
}
}
func TestFindCutPointNoValidCutPoint(t *testing.T) {
// Only toolResult messages -> no valid cut point.
msgs := []agentcore.Message{toolResult("a"), toolResult("b")}
got := FindCutPoint(msgs, 100)
if got.FirstKeptIndex != 0 || got.TurnStartIndex != -1 || got.IsSplitTurn {
t.Fatalf("no-cutpoint: got %+v, want {0 -1 false}", got)
}
}
func TestFindCutPointOnTurnBoundary(t *testing.T) {
// Three complete user/assistant turns, each user ~100 tokens.
// keepRecentTokens small so the cut lands on a clean user boundary.
msgs := []agentcore.Message{
bigUser(100), // 0
assistantMsg("a1", nil, ""), // 1
bigUser(100), // 2
assistantMsg("a2", nil, ""), // 3
bigUser(100), // 4
assistantMsg("a3", nil, ""), // 5
}
// keepRecentTokens=50: walking back, msg[5] tiny, msg[4]=100 >= 50 at i=4.
// nearest cut point >= 4 is index 4 (a user message) -> clean boundary.
got := FindCutPoint(msgs, 50)
if got.FirstKeptIndex != 4 {
t.Fatalf("FirstKeptIndex: got %d, want 4", got.FirstKeptIndex)
}
if got.IsSplitTurn {
t.Fatalf("IsSplitTurn: got true, want false (landed on user boundary)")
}
if got.TurnStartIndex != -1 {
t.Fatalf("TurnStartIndex: got %d, want -1", got.TurnStartIndex)
}
}
func TestFindCutPointSplitTurn(t *testing.T) {
// A turn starting with a user message, a big assistant reply, a toolCall and
// its toolResult. If the budget forces the cut onto the assistant message
// mid-turn, it's a split turn.
msgs := []agentcore.Message{
userMsg("older turn"), // 0
assistantMsg("older reply", nil, ""), // 1
userMsg("current turn start"), // 2 user
assistantMsg(strings.Repeat("y", 400), nil, ""), // 3 assistant ~100 tokens
assistantToolCall("t1", "read"), // 4 assistant (valid cut)
toolResult("t1"), // 5 toolResult (not cuttable)
}
// keepRecentTokens=50: from end, msg[5]=~2, msg[4]~1, msg[3]=100 >= 50 at i=3.
// nearest cut point >= 3 is index 3 (assistant) -> split turn, turn start=2.
got := FindCutPoint(msgs, 50)
if got.FirstKeptIndex != 3 {
t.Fatalf("FirstKeptIndex: got %d, want 3", got.FirstKeptIndex)
}
if !got.IsSplitTurn {
t.Fatalf("IsSplitTurn: got false, want true (cut on assistant mid-turn)")
}
if got.TurnStartIndex != 2 {
t.Fatalf("TurnStartIndex: got %d, want 2", got.TurnStartIndex)
}
}
func TestFindCutPointNeverCutsOnToolResult(t *testing.T) {
// Ensure a toolResult is never chosen even when it's the message where the
// budget is reached.
msgs := []agentcore.Message{
userMsg("u0"), // 0
assistantToolCall("t1", "grep"), // 1
toolResult("t1"), // 2 (budget could land here)
assistantMsg("done", nil, ""), // 3
}
got := FindCutPoint(msgs, 1) // tiny budget: reached at msg[3]
// cut must be a valid point (>=3 is index 3 assistant); never index 2.
if got.FirstKeptIndex == 2 {
t.Fatalf("cut landed on toolResult (index 2), which is illegal")
}
if msgs[got.FirstKeptIndex].Role() == agentcore.RoleToolResult {
t.Fatalf("FirstKeptIndex points at a toolResult: %d", got.FirstKeptIndex)
}
}
func TestFindCutPointBudgetNeverReachedKeepsEarliest(t *testing.T) {
msgs := []agentcore.Message{
userMsg("u0"), // 0
assistantMsg("a0", nil, ""), // 1
}
// Huge budget -> never reached -> keep from earliest valid cut point (0).
got := FindCutPoint(msgs, 1_000_000)
if got.FirstKeptIndex != 0 {
t.Fatalf("FirstKeptIndex: got %d, want 0 (earliest cut point)", got.FirstKeptIndex)
}
}
+367
View File
@@ -0,0 +1,367 @@
// This file (US-003) covers summarization: the structured prompts, the
// conversation serializer, file-operation extraction, and GenerateSummary,
// which drives a provider stream to turn compacted history into a structured
// checkpoint summary. It mirrors pi's harness/compaction/compaction.ts prompts
// and utils.ts helpers, adapted to pigo's flat message list and Provider stream.
package compaction
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/provider"
)
// SUMMARIZATION_SYSTEM_PROMPT instructs the model to only emit the structured
// summary and never continue the conversation. Verbatim from pi.
const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified.
Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`
// summarizationPrompt is the first-time summary template (pi's SUMMARIZATION_PROMPT).
const summarizationPrompt = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
Use this EXACT format:
## Goal
[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
## Constraints & Preferences
- [Any constraints, preferences, or requirements mentioned by user]
- [Or "(none)" if none were mentioned]
## Progress
### Done
- [x] [Completed tasks/changes]
### In Progress
- [ ] [Current work]
### Blocked
- [Issues preventing progress, if any]
## Key Decisions
- **[Decision]**: [Brief rationale]
## Next Steps
1. [Ordered list of what should happen next]
## Critical Context
- [Any data, examples, or references needed to continue]
- [Or "(none)" if not applicable]
Keep each section concise. Preserve exact file paths, function names, and error messages.`
// updateSummarizationPrompt incorporates new messages into an existing summary
// (pi's UPDATE_SUMMARIZATION_PROMPT).
const updateSummarizationPrompt = `The messages above are NEW conversation messages to incorporate into the existing summary provided in <previous-summary> tags.
Update the existing structured summary with new information. RULES:
- PRESERVE all existing information from the previous summary
- ADD new progress, decisions, and context from the new messages
- UPDATE the Progress section: move items from "In Progress" to "Done" when completed
- UPDATE "Next Steps" based on what was accomplished
- PRESERVE exact file paths, function names, and error messages
- If something is no longer relevant, you may remove it
Use this EXACT format:
## Goal
[Preserve existing goals, add new ones if the task expanded]
## Constraints & Preferences
- [Preserve existing, add new ones discovered]
## Progress
### Done
- [x] [Include previously done items AND newly completed items]
### In Progress
- [ ] [Current work - update based on progress]
### Blocked
- [Current blockers - remove if resolved]
## Key Decisions
- **[Decision]**: [Brief rationale] (preserve all previous, add new)
## Next Steps
1. [Update based on current state]
## Critical Context
- [Preserve important context, add new if needed]
Keep each section concise. Preserve exact file paths, function names, and error messages.`
// FileOperations accumulates the file paths a compaction range touched, split
// by access kind. Mirrors pi's FileOperations.
type FileOperations struct {
// Read holds files read but not necessarily modified.
Read map[string]struct{}
// Written holds files written by full-file write operations.
Written map[string]struct{}
// Edited holds files modified by edit operations.
Edited map[string]struct{}
}
// NewFileOps returns an empty file-operation accumulator.
func NewFileOps() FileOperations {
return FileOperations{
Read: map[string]struct{}{},
Written: map[string]struct{}{},
Edited: map[string]struct{}{},
}
}
// extractFileOpsFromMessage adds file operations from an assistant message's
// tool calls to the accumulator, keyed on the tool name (read/write/edit) and
// the string "path" argument. Non-assistant messages are ignored, matching pi.
func extractFileOpsFromMessage(msg agentcore.Message, ops FileOperations) {
a, ok := msg.(agentcore.AssistantMessage)
if !ok {
return
}
for _, call := range a.ToolCalls() {
path := toolCallPath(call.Arguments)
if path == "" {
continue
}
switch call.Name {
case "read":
ops.Read[path] = struct{}{}
case "write":
ops.Written[path] = struct{}{}
case "edit":
ops.Edited[path] = struct{}{}
}
}
}
// toolCallPath extracts a string "path" argument from a tool call's raw
// arguments, returning "" when absent or not a string.
func toolCallPath(args json.RawMessage) string {
if len(args) == 0 {
return ""
}
var decoded struct {
Path string `json:"path"`
}
if err := json.Unmarshal(args, &decoded); err != nil {
return ""
}
return decoded.Path
}
// computeFileLists derives sorted read-only and modified (editedwritten) file
// lists, excluding modified files from the read-only list. Mirrors pi.
func computeFileLists(ops FileOperations) (readFiles, modifiedFiles []string) {
modified := map[string]struct{}{}
for f := range ops.Edited {
modified[f] = struct{}{}
}
for f := range ops.Written {
modified[f] = struct{}{}
}
for f := range ops.Read {
if _, isMod := modified[f]; !isMod {
readFiles = append(readFiles, f)
}
}
for f := range modified {
modifiedFiles = append(modifiedFiles, f)
}
sort.Strings(readFiles)
sort.Strings(modifiedFiles)
return readFiles, modifiedFiles
}
// formatFileOperations renders the file lists as <read-files>/<modified-files>
// metadata blocks appended to a summary, or "" when both lists are empty.
func formatFileOperations(readFiles, modifiedFiles []string) string {
var sections []string
if len(readFiles) > 0 {
sections = append(sections, "<read-files>\n"+strings.Join(readFiles, "\n")+"\n</read-files>")
}
if len(modifiedFiles) > 0 {
sections = append(sections, "<modified-files>\n"+strings.Join(modifiedFiles, "\n")+"\n</modified-files>")
}
if len(sections) == 0 {
return ""
}
return "\n\n" + strings.Join(sections, "\n\n")
}
// toolResultMaxChars caps a tool result's serialized text in the summarization
// prompt, matching pi's TOOL_RESULT_MAX_CHARS.
const toolResultMaxChars = 2000
// truncateForSummary caps text at maxChars, appending a truncation marker.
func truncateForSummary(text string, maxChars int) string {
if len(text) <= maxChars {
return text
}
return fmt.Sprintf("%s\n\n[... %d more characters truncated]", text[:maxChars], len(text)-maxChars)
}
// textOf concatenates the text blocks of a content list.
func textOf(content agentcore.ContentList) string {
var b strings.Builder
for _, c := range content {
if t, ok := c.(agentcore.TextContent); ok {
b.WriteString(t.Text)
}
}
return b.String()
}
// serializeConversation renders messages as a plain-text transcript for the
// summarization prompt, mirroring pi's serializeConversation: user text,
// assistant thinking/text/tool-call lines, and truncated tool results.
func serializeConversation(msgs []agentcore.Message) string {
var parts []string
for _, msg := range msgs {
switch m := msg.(type) {
case agentcore.UserMessage:
if s := textOf(m.Content); s != "" {
parts = append(parts, "[User]: "+s)
}
case agentcore.AssistantMessage:
var textParts, thinkingParts, toolCalls []string
for _, block := range m.Content {
switch c := block.(type) {
case agentcore.TextContent:
textParts = append(textParts, c.Text)
case agentcore.ThinkingContent:
thinkingParts = append(thinkingParts, c.Thinking)
case agentcore.ToolCallContent:
toolCalls = append(toolCalls, formatToolCall(c))
}
}
if len(thinkingParts) > 0 {
parts = append(parts, "[Assistant thinking]: "+strings.Join(thinkingParts, "\n"))
}
if len(textParts) > 0 {
parts = append(parts, "[Assistant]: "+strings.Join(textParts, "\n"))
}
if len(toolCalls) > 0 {
parts = append(parts, "[Assistant tool calls]: "+strings.Join(toolCalls, "; "))
}
case agentcore.ToolResultMessage:
if s := textOf(m.Content); s != "" {
parts = append(parts, "[Tool result]: "+truncateForSummary(s, toolResultMaxChars))
}
}
}
return strings.Join(parts, "\n\n")
}
// formatToolCall renders a tool call as name(key=value, ...) with each value
// JSON-encoded, mirroring pi's serialization of tool-call arguments.
func formatToolCall(c agentcore.ToolCallContent) string {
if len(c.Arguments) == 0 {
return c.Name + "()"
}
var m map[string]json.RawMessage
if err := json.Unmarshal(c.Arguments, &m); err != nil {
// Not an object: fall back to the raw argument text.
return fmt.Sprintf("%s(%s)", c.Name, string(c.Arguments))
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys) // deterministic order (Go map iteration is random)
pairs := make([]string, 0, len(keys))
for _, k := range keys {
pairs = append(pairs, k+"="+string(m[k]))
}
return fmt.Sprintf("%s(%s)", c.Name, strings.Join(pairs, ", "))
}
// GenerateSummary drives a provider stream to summarize msgs into a structured
// checkpoint. When previousSummary is non-empty it uses the update template and
// embeds the prior summary in <previous-summary> tags; otherwise it uses the
// first-time template. maxTokens is bounded to min(0.8*reserveTokens,
// model.MaxOutputTokens) as in pi. The returned text is the concatenation of
// the assistant response's text blocks. A terminal error/aborted response is
// surfaced as an error.
func GenerateSummary(
ctx context.Context,
stream provider.StreamFn,
model provider.Model,
msgs []agentcore.Message,
reserveTokens int,
previousSummary string,
cfg provider.StreamConfig,
) (string, error) {
base := summarizationPrompt
if previousSummary != "" {
base = updateSummarizationPrompt
}
conversation := serializeConversation(msgs)
var b strings.Builder
b.WriteString("<conversation>\n")
b.WriteString(conversation)
b.WriteString("\n</conversation>\n\n")
if previousSummary != "" {
b.WriteString("<previous-summary>\n")
b.WriteString(previousSummary)
b.WriteString("\n</previous-summary>\n\n")
}
b.WriteString(base)
promptMsg := agentcore.UserMessage{
RoleField: agentcore.RoleUser,
Content: agentcore.ContentList{agentcore.NewTextContent(b.String())},
}
// Bound the summary output to min(0.8*reserveTokens, model max output).
maxTokens := (reserveTokens * 8) / 10
if model.MaxOutputTokens > 0 && model.MaxOutputTokens < maxTokens {
maxTokens = model.MaxOutputTokens
}
extra := map[string]any{}
for k, v := range cfg.Extra {
extra[k] = v
}
if maxTokens > 0 {
extra["max_tokens"] = maxTokens
}
cfg.Extra = extra
llm := provider.LlmContext{
SystemPrompt: SUMMARIZATION_SYSTEM_PROMPT,
Messages: agentcore.MessageList{promptMsg},
}
s, err := stream(ctx, model.ID, llm, cfg)
if err != nil {
return "", fmt.Errorf("compaction: build summary stream: %w", err)
}
// Drain events so the stream's result is populated, then read the final
// message. Failures ride the stream as a terminal message per the provider
// contract, so inspect StopReason rather than only the returned error.
for range s.Events() {
}
final, resErr := s.Result(ctx)
if resErr != nil {
return "", fmt.Errorf("compaction: summary stream: %w", resErr)
}
switch final.StopReason {
case agentcore.StopReasonAborted:
return "", fmt.Errorf("compaction: summarization aborted: %s", final.ErrorMessage)
case agentcore.StopReasonError:
return "", fmt.Errorf("compaction: summarization failed: %s", final.ErrorMessage)
}
summary := textOf(final.Content)
if strings.TrimSpace(summary) == "" {
return "", fmt.Errorf("compaction: summarization produced empty output")
}
return summary, nil
}
+274
View File
@@ -0,0 +1,274 @@
package compaction
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/provider"
)
func TestExtractFileOpsAndLists(t *testing.T) {
readArgs, _ := json.Marshal(map[string]string{"path": "a.go"})
writeArgs, _ := json.Marshal(map[string]string{"path": "b.go"})
editArgs, _ := json.Marshal(map[string]string{"path": "a.go"}) // a.go also edited -> modified wins
msgs := []agentcore.Message{
assistantToolCall("1", "read"),
assistantToolCall("2", "write"),
assistantToolCall("3", "edit"),
}
// Attach args by rebuilding with arguments.
msgs[0] = agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{agentcore.NewToolCallContent("1", "read", readArgs)}}
msgs[1] = agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{agentcore.NewToolCallContent("2", "write", writeArgs)}}
msgs[2] = agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{agentcore.NewToolCallContent("3", "edit", editArgs)}}
ops := NewFileOps()
for _, m := range msgs {
extractFileOpsFromMessage(m, ops)
}
read, modified := computeFileLists(ops)
// a.go was read AND edited -> only in modified; b.go written -> modified.
if len(read) != 0 {
t.Fatalf("readFiles: got %v, want []", read)
}
if strings.Join(modified, ",") != "a.go,b.go" {
t.Fatalf("modifiedFiles: got %v, want [a.go b.go]", modified)
}
}
func TestFormatFileOperations(t *testing.T) {
if got := formatFileOperations(nil, nil); got != "" {
t.Fatalf("empty: got %q, want empty", got)
}
got := formatFileOperations([]string{"r.go"}, []string{"m.go"})
if !strings.Contains(got, "<read-files>\nr.go\n</read-files>") {
t.Fatalf("missing read-files block: %q", got)
}
if !strings.Contains(got, "<modified-files>\nm.go\n</modified-files>") {
t.Fatalf("missing modified-files block: %q", got)
}
}
func TestSerializeConversation(t *testing.T) {
args, _ := json.Marshal(map[string]any{"path": "x.go", "n": 1})
msgs := []agentcore.Message{
userMsg("hello"),
agentcore.AssistantMessage{
RoleField: agentcore.RoleAssistant,
Content: agentcore.ContentList{
agentcore.NewThinkingContent("thinking hard"),
agentcore.NewTextContent("here goes"),
agentcore.NewToolCallContent("t1", "read", args),
},
},
toolResult("t1"),
}
got := serializeConversation(msgs)
for _, want := range []string{
"[User]: hello",
"[Assistant thinking]: thinking hard",
"[Assistant]: here goes",
"[Assistant tool calls]: read(",
"[Tool result]: result",
} {
if !strings.Contains(got, want) {
t.Fatalf("serialize missing %q in:\n%s", want, got)
}
}
}
func TestTruncateForSummary(t *testing.T) {
if got := truncateForSummary("short", 100); got != "short" {
t.Fatalf("no truncation expected: %q", got)
}
long := strings.Repeat("z", 2500)
got := truncateForSummary(long, toolResultMaxChars)
if !strings.Contains(got, "more characters truncated") {
t.Fatalf("expected truncation marker: %q", got[len(got)-60:])
}
}
// fakeStreamFn returns a StreamFn that yields a single done event with the
// given assistant message, capturing the LlmContext it was called with.
func fakeStreamFn(final agentcore.AssistantMessage, capture *provider.LlmContext) provider.StreamFn {
return func(ctx context.Context, model string, llm provider.LlmContext, cfg provider.StreamConfig) (*provider.AssistantMessageEventStream, error) {
if capture != nil {
*capture = llm
}
s := provider.NewAssistantMessageEventStream(4)
go func() {
_ = s.Emit(ctx, provider.StreamDoneEvent{Message: final})
s.SetResult(final)
s.Close()
}()
return s, nil
}
}
func assistantText(text string) agentcore.AssistantMessage {
return agentcore.AssistantMessage{
RoleField: agentcore.RoleAssistant,
Content: agentcore.ContentList{agentcore.NewTextContent(text)},
StopReason: agentcore.StopReasonEndTurn,
}
}
func TestGenerateSummaryFirstTime(t *testing.T) {
var captured provider.LlmContext
stream := fakeStreamFn(assistantText("## Goal\ndo the thing"), &captured)
model := provider.Model{ID: "m", MaxOutputTokens: 8000}
msgs := []agentcore.Message{userMsg("please do X")}
got, err := GenerateSummary(context.Background(), stream, model, msgs, 16384, "", provider.StreamConfig{})
if err != nil {
t.Fatalf("GenerateSummary: %v", err)
}
if !strings.Contains(got, "## Goal") {
t.Fatalf("summary text: %q", got)
}
// System prompt must be the summarization system prompt.
if captured.SystemPrompt != SUMMARIZATION_SYSTEM_PROMPT {
t.Fatalf("system prompt mismatch")
}
// First-time prompt uses the non-update template and wraps the conversation.
promptText := textOf(captured.Messages[0].(agentcore.UserMessage).Content)
if !strings.Contains(promptText, "<conversation>") || strings.Contains(promptText, "<previous-summary>") {
t.Fatalf("first-time prompt shape wrong:\n%s", promptText)
}
if !strings.Contains(promptText, "Create a structured context checkpoint") {
t.Fatalf("expected first-time template")
}
}
func TestGenerateSummaryUpdateUsesPrevious(t *testing.T) {
var captured provider.LlmContext
stream := fakeStreamFn(assistantText("updated summary"), &captured)
model := provider.Model{ID: "m"}
msgs := []agentcore.Message{userMsg("more work")}
_, err := GenerateSummary(context.Background(), stream, model, msgs, 16384, "PRIOR SUMMARY", provider.StreamConfig{})
if err != nil {
t.Fatalf("GenerateSummary: %v", err)
}
promptText := textOf(captured.Messages[0].(agentcore.UserMessage).Content)
if !strings.Contains(promptText, "<previous-summary>\nPRIOR SUMMARY") {
t.Fatalf("update prompt should embed previous summary:\n%s", promptText)
}
if !strings.Contains(promptText, "NEW conversation messages to incorporate") {
t.Fatalf("expected update template")
}
}
func TestGenerateSummaryMaxTokensCap(t *testing.T) {
var gotMax int
stream := func(ctx context.Context, model string, llm provider.LlmContext, cfg provider.StreamConfig) (*provider.AssistantMessageEventStream, error) {
if v, ok := cfg.Extra["max_tokens"].(int); ok {
gotMax = v
}
s := provider.NewAssistantMessageEventStream(2)
go func() {
m := assistantText("ok")
_ = s.Emit(ctx, provider.StreamDoneEvent{Message: m})
s.SetResult(m)
s.Close()
}()
return s, nil
}
// 0.8 * 16384 = 13107, but model max output is 5000 -> cap at 5000.
model := provider.Model{ID: "m", MaxOutputTokens: 5000}
_, err := GenerateSummary(context.Background(), stream, model, []agentcore.Message{userMsg("x")}, 16384, "", provider.StreamConfig{})
if err != nil {
t.Fatalf("GenerateSummary: %v", err)
}
if gotMax != 5000 {
t.Fatalf("max_tokens: got %d, want 5000", gotMax)
}
}
func TestGenerateSummaryErrorStopReason(t *testing.T) {
errMsg := agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, StopReason: agentcore.StopReasonError, ErrorMessage: "boom"}
stream := fakeStreamFn(errMsg, nil)
_, err := GenerateSummary(context.Background(), stream, provider.Model{ID: "m"}, []agentcore.Message{userMsg("x")}, 16384, "", provider.StreamConfig{})
if err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("expected error containing 'boom', got %v", err)
}
}
func TestCompactRebuildsContext(t *testing.T) {
readArgs, _ := json.Marshal(map[string]string{"path": "old.go"})
msgs := []agentcore.Message{
userMsg("turn one"), // 0
agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{agentcore.NewToolCallContent("t1", "read", readArgs)}}, // 1
toolResult("t1"), // 2
bigUser(100), // 3
assistantMsg("recent", nil, ""), // 4
}
stream := fakeStreamFn(assistantText("## Goal\nx"), nil)
// Small keepRecentTokens so the cut lands on the recent bigUser(100) turn,
// leaving the earlier read/toolResult prefix to be summarized.
settings := CompactionSettings{Enabled: true, ReserveTokens: 16384, KeepRecentTokens: 50}
res, err := Compact(context.Background(), stream, provider.Model{ID: "m"}, msgs, settings, -1, nil, "", provider.StreamConfig{})
if err != nil {
t.Fatalf("Compact: %v", err)
}
if res == nil {
t.Fatal("Compact returned nil result")
}
// old.go was read in the summarized prefix.
if strings.Join(res.Details.ReadFiles, ",") != "old.go" {
t.Fatalf("readFiles: got %v, want [old.go]", res.Details.ReadFiles)
}
if !strings.Contains(res.Summary, "<read-files>") {
t.Fatalf("summary should carry file metadata: %q", res.Summary)
}
rebuilt := res.RebuildContext(msgs, 123)
if rebuilt[0].Role() != agentcore.RoleCompaction {
t.Fatalf("first rebuilt message must be compaction, got %s", rebuilt[0].Role())
}
// Retained tail begins at FirstKeptIndex.
if len(rebuilt) != 1+(len(msgs)-res.FirstKeptIndex) {
t.Fatalf("rebuilt length: got %d", len(rebuilt))
}
}
func TestCompactNothingToSummarize(t *testing.T) {
// prevCompactionIndex already at/after the cut -> nil result.
msgs := []agentcore.Message{userMsg("a"), assistantMsg("b", nil, "")}
stream := fakeStreamFn(assistantText("unused"), nil)
res, err := Compact(context.Background(), stream, provider.Model{ID: "m"}, msgs, DefaultCompactionSettings, 5, nil, "", provider.StreamConfig{})
if err != nil {
t.Fatalf("Compact: %v", err)
}
if res != nil {
t.Fatalf("expected nil result when nothing to summarize, got %+v", res)
}
}
func TestCompactionMessageRoundTrip(t *testing.T) {
details, _ := json.Marshal(CompactionDetails{ReadFiles: []string{"a"}, ModifiedFiles: []string{"b"}})
cm := agentcore.CompactionMessage{
RoleField: agentcore.RoleCompaction,
Summary: "the summary",
TokensBefore: 42,
Details: details,
Timestamp: 7,
}
list := agentcore.MessageList{cm}
raw, err := json.Marshal(list)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var back agentcore.MessageList
if err := json.Unmarshal(raw, &back); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(back) != 1 || back[0].Role() != agentcore.RoleCompaction {
t.Fatalf("round-trip role: %+v", back)
}
got := back[0].(agentcore.CompactionMessage)
if got.Summary != "the summary" || got.TokensBefore != 42 {
t.Fatalf("round-trip fields: %+v", got)
}
}
+189
View File
@@ -0,0 +1,189 @@
// Package compaction implements context-window token accounting and the
// decision of when a long session must be compacted, mirroring pi's
// harness/compaction/compaction.ts.
//
// This file (US-001) covers the token side: estimating a message's token
// footprint from a character heuristic, deriving the current context-token
// usage (preferring provider-reported Usage over estimation), and the
// ShouldCompact threshold check. Cut-point finding (US-002) and summarization
// (US-003) live in sibling files.
package compaction
import (
"encoding/json"
"github.com/smallnest/pigo/internal/agentcore"
)
// CompactionSettings holds the thresholds and retention knobs for compaction,
// mirroring pi's CompactionSettings.
type CompactionSettings struct {
// Enabled gates automatic compaction decisions.
Enabled bool
// ReserveTokens is reserved for the summary prompt and its output; the
// effective usable window is contextWindow - ReserveTokens.
ReserveTokens int
// KeepRecentTokens is the approximate recent-context token budget to retain
// after compaction (consumed by FindCutPoint in US-002).
KeepRecentTokens int
}
// DefaultCompactionSettings matches pi's DEFAULT_COMPACTION_SETTINGS.
var DefaultCompactionSettings = CompactionSettings{
Enabled: true,
ReserveTokens: 16384,
KeepRecentTokens: 20000,
}
// estimatedImageChars is the fixed character budget attributed to an image
// block, matching pi's ESTIMATED_IMAGE_CHARS.
const estimatedImageChars = 4800
// charsPerToken is the conservative characters-per-token divisor pi uses.
const charsPerToken = 4
// ceilDiv returns ceil(a / b) for non-negative a and positive b.
func ceilDiv(a, b int) int {
if a <= 0 {
return 0
}
return (a + b - 1) / b
}
// contentListChars sums the character footprint of a content list, counting
// text/thinking/toolCall blocks by their text length and each image block as a
// fixed estimatedImageChars, mirroring pi's estimateTextAndImageContentChars
// plus its assistant-block handling.
func contentListChars(content agentcore.ContentList) int {
chars := 0
for _, block := range content {
switch c := block.(type) {
case agentcore.TextContent:
chars += len(c.Text)
case agentcore.ThinkingContent:
chars += len(c.Thinking)
case agentcore.ToolCallContent:
// name + serialized arguments, matching pi's toolCall accounting.
chars += len(c.Name)
if len(c.Arguments) > 0 {
chars += len(c.Arguments)
} else {
// nil/empty RawMessage serializes to "null" downstream.
b, _ := json.Marshal(json.RawMessage(c.Arguments))
chars += len(b)
}
case agentcore.ImageContent:
chars += estimatedImageChars
}
}
return chars
}
// EstimateTokens returns a conservative token estimate for one message using
// the same character heuristic as pi's estimateTokens (ceil(chars / 4)).
func EstimateTokens(msg agentcore.Message) int {
switch m := msg.(type) {
case agentcore.UserMessage:
return ceilDiv(contentListChars(m.Content), charsPerToken)
case agentcore.AssistantMessage:
return ceilDiv(contentListChars(m.Content), charsPerToken)
case agentcore.ToolResultMessage:
return ceilDiv(contentListChars(m.Content), charsPerToken)
case agentcore.CompactionMessage:
// A compaction checkpoint replays as its summary text; estimate from it.
return ceilDiv(len(m.Summary), charsPerToken)
default:
return 0
}
}
// calculateContextTokens derives total context tokens from a provider usage
// block. pigo's Usage only reports input/output, so we sum them (pi additionally
// folds cache read/write, which pigo does not track).
func calculateContextTokens(u agentcore.Usage) int {
return u.InputTokens + u.OutputTokens
}
// assistantUsage returns a usable Usage from an assistant message, skipping
// aborted/error responses and zero-token usage, mirroring pi's getAssistantUsage.
func assistantUsage(msg agentcore.Message) (agentcore.Usage, bool) {
a, ok := msg.(agentcore.AssistantMessage)
if !ok || a.Usage == nil {
return agentcore.Usage{}, false
}
if a.StopReason == agentcore.StopReasonAborted || a.StopReason == agentcore.StopReasonError {
return agentcore.Usage{}, false
}
if calculateContextTokens(*a.Usage) <= 0 {
return agentcore.Usage{}, false
}
return *a.Usage, true
}
// ContextUsageEstimate reports the derived context-token usage for a message
// list, mirroring pi's ContextUsageEstimate.
type ContextUsageEstimate struct {
// Tokens is the estimated total context tokens.
Tokens int
// UsageTokens is the tokens reported by the most recent assistant usage block.
UsageTokens int
// TrailingTokens is the estimated tokens after that usage block.
TrailingTokens int
// LastUsageIndex is the index of the message that provided usage, or -1 when
// none exists.
LastUsageIndex int
}
// EstimateContextTokens computes context-token usage for messages, preferring
// the most recent valid assistant Usage block and estimating only the messages
// that follow it. When no usage is available it estimates every message. This
// mirrors pi's estimateContextTokens.
func EstimateContextTokens(msgs []agentcore.Message) ContextUsageEstimate {
lastIdx := -1
var lastUsage agentcore.Usage
for i := len(msgs) - 1; i >= 0; i-- {
if u, ok := assistantUsage(msgs[i]); ok {
lastIdx = i
lastUsage = u
break
}
}
if lastIdx < 0 {
estimated := 0
for _, m := range msgs {
estimated += EstimateTokens(m)
}
return ContextUsageEstimate{
Tokens: estimated,
UsageTokens: 0,
TrailingTokens: estimated,
LastUsageIndex: -1,
}
}
usageTokens := calculateContextTokens(lastUsage)
trailing := 0
for i := lastIdx + 1; i < len(msgs); i++ {
trailing += EstimateTokens(msgs[i])
}
return ContextUsageEstimate{
Tokens: usageTokens + trailing,
UsageTokens: usageTokens,
TrailingTokens: trailing,
LastUsageIndex: lastIdx,
}
}
// ShouldCompact reports whether context usage has exceeded the usable window,
// matching pi: contextTokens > contextWindow - reserveTokens. Disabled settings
// or a non-positive contextWindow (unknown) never trigger compaction.
func ShouldCompact(contextTokens, contextWindow int, settings CompactionSettings) bool {
if !settings.Enabled {
return false
}
if contextWindow <= 0 {
return false
}
return contextTokens > contextWindow-settings.ReserveTokens
}
+189
View File
@@ -0,0 +1,189 @@
package compaction
import (
"encoding/json"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
)
func userMsg(text string) agentcore.UserMessage {
return agentcore.UserMessage{
RoleField: agentcore.RoleUser,
Content: agentcore.ContentList{agentcore.NewTextContent(text)},
}
}
func assistantMsg(text string, usage *agentcore.Usage, stop string) agentcore.AssistantMessage {
return agentcore.AssistantMessage{
RoleField: agentcore.RoleAssistant,
Content: agentcore.ContentList{agentcore.NewTextContent(text)},
Usage: usage,
StopReason: stop,
}
}
func TestEstimateTokensText(t *testing.T) {
// 8 chars / 4 = 2 tokens.
if got := EstimateTokens(userMsg("abcdefgh")); got != 2 {
t.Fatalf("text estimate: got %d, want 2", got)
}
// 9 chars -> ceil(9/4) = 3.
if got := EstimateTokens(userMsg("abcdefghi")); got != 3 {
t.Fatalf("ceil estimate: got %d, want 3", got)
}
// empty -> 0.
if got := EstimateTokens(userMsg("")); got != 0 {
t.Fatalf("empty estimate: got %d, want 0", got)
}
}
func TestEstimateTokensImage(t *testing.T) {
m := agentcore.UserMessage{
RoleField: agentcore.RoleUser,
Content: agentcore.ContentList{agentcore.NewImageContent("data", "image/png")},
}
// estimatedImageChars (4800) / 4 = 1200.
if got := EstimateTokens(m); got != 1200 {
t.Fatalf("image estimate: got %d, want 1200", got)
}
}
func TestEstimateTokensAssistantToolCall(t *testing.T) {
args := json.RawMessage(`{"path":"a.go"}`) // 15 chars
m := agentcore.AssistantMessage{
RoleField: agentcore.RoleAssistant,
Content: agentcore.ContentList{
agentcore.NewToolCallContent("id1", "read", args), // name "read" = 4 chars
},
}
// (4 + 15) / 4 = ceil(19/4) = 5.
if got := EstimateTokens(m); got != 5 {
t.Fatalf("toolcall estimate: got %d, want 5", got)
}
}
func TestEstimateTokensUnknownRoleZero(t *testing.T) {
if got := EstimateTokens(agentcore.ToolResultMessage{}); got != 0 {
t.Fatalf("empty tool result: got %d, want 0", got)
}
}
func TestEstimateContextTokensNoUsageFallsBackToEstimate(t *testing.T) {
msgs := []agentcore.Message{
userMsg("abcdefgh"), // 2
assistantMsg("abcd", nil, "end_turn"), // 1
}
est := EstimateContextTokens(msgs)
if est.LastUsageIndex != -1 {
t.Fatalf("LastUsageIndex: got %d, want -1", est.LastUsageIndex)
}
if est.Tokens != 3 {
t.Fatalf("Tokens: got %d, want 3", est.Tokens)
}
if est.TrailingTokens != 3 || est.UsageTokens != 0 {
t.Fatalf("trailing/usage: got %d/%d, want 3/0", est.TrailingTokens, est.UsageTokens)
}
}
func TestEstimateContextTokensPrefersUsage(t *testing.T) {
usage := &agentcore.Usage{InputTokens: 1000, OutputTokens: 200}
msgs := []agentcore.Message{
userMsg("first"),
assistantMsg("reply", usage, "end_turn"),
userMsg("abcdefgh"), // trailing: 2 tokens
}
est := EstimateContextTokens(msgs)
if est.LastUsageIndex != 1 {
t.Fatalf("LastUsageIndex: got %d, want 1", est.LastUsageIndex)
}
if est.UsageTokens != 1200 {
t.Fatalf("UsageTokens: got %d, want 1200", est.UsageTokens)
}
if est.TrailingTokens != 2 {
t.Fatalf("TrailingTokens: got %d, want 2", est.TrailingTokens)
}
if est.Tokens != 1202 {
t.Fatalf("Tokens: got %d, want 1202", est.Tokens)
}
}
func TestEstimateContextTokensSkipsAbortedAndErrorUsage(t *testing.T) {
good := &agentcore.Usage{InputTokens: 500, OutputTokens: 0}
bad := &agentcore.Usage{InputTokens: 9999, OutputTokens: 0}
msgs := []agentcore.Message{
assistantMsg("ok", good, "end_turn"),
assistantMsg("aborted", bad, agentcore.StopReasonAborted),
assistantMsg("errored", bad, agentcore.StopReasonError),
}
est := EstimateContextTokens(msgs)
if est.LastUsageIndex != 0 {
t.Fatalf("LastUsageIndex: got %d, want 0 (should skip aborted/error)", est.LastUsageIndex)
}
if est.UsageTokens != 500 {
t.Fatalf("UsageTokens: got %d, want 500", est.UsageTokens)
}
}
func TestEstimateContextTokensSkipsZeroUsage(t *testing.T) {
zero := &agentcore.Usage{InputTokens: 0, OutputTokens: 0}
msgs := []agentcore.Message{
userMsg("abcdefgh"), // 2
assistantMsg("reply", zero, "end_turn"),
}
est := EstimateContextTokens(msgs)
// zero usage is ignored, so falls back to full estimation.
if est.LastUsageIndex != -1 {
t.Fatalf("LastUsageIndex: got %d, want -1", est.LastUsageIndex)
}
}
func TestShouldCompactThresholdBoundaries(t *testing.T) {
s := CompactionSettings{Enabled: true, ReserveTokens: 16384}
window := 200000
usable := window - s.ReserveTokens // 183616
tests := []struct {
name string
contextTokens int
want bool
}{
{"far below", 1000, false},
{"equal to usable", usable, false}, // strictly greater required
{"one over usable", usable + 1, true},
{"far over", window * 2, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ShouldCompact(tt.contextTokens, window, s); got != tt.want {
t.Fatalf("ShouldCompact(%d): got %v, want %v", tt.contextTokens, got, tt.want)
}
})
}
}
func TestShouldCompactDisabled(t *testing.T) {
s := CompactionSettings{Enabled: false, ReserveTokens: 16384}
if ShouldCompact(1_000_000, 200000, s) {
t.Fatal("disabled settings must never compact")
}
}
func TestShouldCompactUnknownWindow(t *testing.T) {
s := CompactionSettings{Enabled: true, ReserveTokens: 16384}
if ShouldCompact(1_000_000, 0, s) {
t.Fatal("unknown (0) context window must never compact")
}
}
func TestDefaultCompactionSettings(t *testing.T) {
if DefaultCompactionSettings.ReserveTokens != 16384 {
t.Fatalf("ReserveTokens: got %d, want 16384", DefaultCompactionSettings.ReserveTokens)
}
if DefaultCompactionSettings.KeepRecentTokens != 20000 {
t.Fatalf("KeepRecentTokens: got %d, want 20000", DefaultCompactionSettings.KeepRecentTokens)
}
if !DefaultCompactionSettings.Enabled {
t.Fatal("default settings should be enabled")
}
}