From 083c194b57fce41420a399f0d9de22220af63b0e Mon Sep 17 00:00:00 2001 From: PratikDhanave Date: Fri, 24 Jul 2026 07:00:04 +0530 Subject: [PATCH] Honor an explicit MinimumPreserved=0 in compaction strategies The four compaction strategies computed their preserve floor with cmp.Or(max(field, 0), default), which folds unset, explicit 0, and negative values all into the type default (truncation 32, sliding window 1, summarization 8, tool result 16). A caller who set MinimumPreserved* to 0 to preserve nothing silently got the default instead, and there was no way to express the preserve-none config. Change each MinimumPreserved* field to *int so an unset (nil) config uses the default while an explicit value is honored as-is, clamping a negative to 0. This mirrors the .NET port, which uses a nullable int and EnsureNonNegative(value) => Math.Max(0, value). --- agent/compaction/compaction_test.go | 66 +++++++++++++++---- agent/compaction/contextwindow.go | 5 +- agent/compaction/index_test.go | 4 +- agent/compaction/slidingwindow.go | 11 +++- agent/compaction/summarization.go | 10 ++- agent/compaction/toolresult.go | 11 +++- agent/compaction/truncation.go | 11 +++- .../agents/step18_compaction_pipeline/main.go | 10 +-- 8 files changed, 98 insertions(+), 30 deletions(-) diff --git a/agent/compaction/compaction_test.go b/agent/compaction/compaction_test.go index c1e4ee75..1c363ff4 100644 --- a/agent/compaction/compaction_test.go +++ b/agent/compaction/compaction_test.go @@ -115,7 +115,7 @@ func TestTruncationStrategy_ExcludesOldestGroups(t *testing.T) { index := compaction.CreateMessageIndex(turnMessages(3), nil) strategy := &compaction.TruncationStrategy{ Trigger: compaction.GroupsExceed(2), - MinimumPreservedGroups: 2, + MinimumPreservedGroups: ptr(2), } compacted, err := strategy.Compact(t.Context(), index) @@ -142,7 +142,7 @@ func TestTruncationStrategy_SkipsPreExcludedAndSystemGroups(t *testing.T) { }, nil) index.Groups[1].IsExcluded = true strategy := &compaction.TruncationStrategy{ - MinimumPreservedGroups: 1, + MinimumPreservedGroups: ptr(1), } compacted, err := strategy.Compact(t.Context(), index) @@ -187,7 +187,7 @@ func TestSlidingWindowStrategy_ExcludesOldestTurns(t *testing.T) { index := compaction.CreateMessageIndex(turnMessages(3), nil) strategy := &compaction.SlidingWindowStrategy{ Trigger: compaction.TurnsExceed(1), - MinimumPreservedTurns: 1, + MinimumPreservedTurns: ptr(1), } compacted, err := strategy.Compact(t.Context(), index) @@ -215,7 +215,7 @@ func TestSlidingWindowStrategy_PreservesTurnZeroGroups(t *testing.T) { }, nil) strategy := &compaction.SlidingWindowStrategy{ Trigger: compaction.TurnsExceed(1), - MinimumPreservedTurns: 1, + MinimumPreservedTurns: ptr(1), } compacted, err := strategy.Compact(t.Context(), index) @@ -233,6 +233,48 @@ func TestSlidingWindowStrategy_PreservesTurnZeroGroups(t *testing.T) { } } +func TestTruncationStrategy_ExplicitZeroPreservesNone(t *testing.T) { + index := compaction.CreateMessageIndex([]*message.Message{ + textMessage(message.RoleSystem, "system"), + textMessage(message.RoleAssistant, "g1"), + textMessage(message.RoleAssistant, "g2"), + textMessage(message.RoleAssistant, "g3"), + textMessage(message.RoleAssistant, "g4"), + textMessage(message.RoleAssistant, "g5"), + }, nil) + strategy := &compaction.TruncationStrategy{MinimumPreservedGroups: ptr(0)} + + compacted, err := strategy.Compact(t.Context(), index) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !compacted { + t.Fatal("expected compaction with an explicit zero floor") + } + if got := index.IncludedNonSystemGroupCount(); got != 0 { + t.Fatalf("expected all removable non-system groups excluded, got %d preserved", got) + } + if index.Groups[0].IsExcluded { + t.Fatal("expected system group to be preserved") + } +} + +func TestSlidingWindowStrategy_NegativeMinimumClampsToZero(t *testing.T) { + index := compaction.CreateMessageIndex(turnMessages(3), nil) + strategy := &compaction.SlidingWindowStrategy{MinimumPreservedTurns: ptr(-5)} + + compacted, err := strategy.Compact(t.Context(), index) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !compacted { + t.Fatal("expected compaction when the floor clamps to zero") + } + if got := index.IncludedTurnCount(); got != 0 { + t.Fatalf("expected a negative floor to clamp to zero and exclude all turns, got %d turns preserved", got) + } +} + func TestSlidingWindowStrategy_ZeroValueUsesDefaults(t *testing.T) { index := compaction.CreateMessageIndex(turnMessages(3), nil) strategy := &compaction.SlidingWindowStrategy{} @@ -273,7 +315,7 @@ func TestToolResultStrategy_CollapsesOldToolGroups(t *testing.T) { index := compaction.CreateMessageIndex(messages, nil) strategy := &compaction.ToolResultStrategy{ Trigger: compaction.HasToolCalls(), - MinimumPreservedGroups: 2, + MinimumPreservedGroups: ptr(2), } compacted, err := strategy.Compact(t.Context(), index) @@ -345,7 +387,7 @@ func TestSummarizationStrategy_InsertsSummaryAndPreservesRecentGroups(t *testing strategy := &compaction.SummarizationStrategy{ Trigger: compaction.GroupsExceed(2), Summarizer: summarizer, - MinimumPreservedGroups: 2, + MinimumPreservedGroups: ptr(2), SummarizationPrompt: "summarize", } @@ -417,7 +459,7 @@ func TestSummarizationStrategy_RestoresGroupsWhenSummarizerFails(t *testing.T) { strategy := &compaction.SummarizationStrategy{ Trigger: compaction.GroupsExceed(2), Summarizer: compaction.SummarizerFunc(func(context.Context, []*message.Message) (string, error) { return "", expected }), - MinimumPreservedGroups: 1, + MinimumPreservedGroups: ptr(1), } compacted, err := strategy.Compact(t.Context(), index) @@ -437,7 +479,7 @@ func TestSummarizationStrategy_PropagatesCancellation(t *testing.T) { strategy := &compaction.SummarizationStrategy{ Trigger: compaction.GroupsExceed(2), Summarizer: compaction.SummarizerFunc(func(context.Context, []*message.Message) (string, error) { return "", context.Canceled }), - MinimumPreservedGroups: 1, + MinimumPreservedGroups: ptr(1), } compacted, err := strategy.Compact(t.Context(), index) @@ -457,7 +499,7 @@ func TestNewProvider_CompactsAndPersistsIndex(t *testing.T) { provider := compaction.NewContextProvider(compaction.ContextProviderConfig{ Strategy: &compaction.TruncationStrategy{ Trigger: compaction.GroupsExceed(2), - MinimumPreservedGroups: 2, + MinimumPreservedGroups: ptr(2), }, SourceID: "compaction-test", }) @@ -494,7 +536,7 @@ func TestNewProvider_SourceStampsGeneratedMessages(t *testing.T) { Strategy: &compaction.SummarizationStrategy{ Trigger: compaction.GroupsExceed(2), Summarizer: compaction.SummarizerFunc(func(context.Context, []*message.Message) (string, error) { return "older context", nil }), - MinimumPreservedGroups: 2, + MinimumPreservedGroups: ptr(2), }, SourceID: "compaction-test", }) @@ -518,7 +560,7 @@ func TestNewProvider_CompactsWithoutSession(t *testing.T) { provider := compaction.NewContextProvider(compaction.ContextProviderConfig{ Strategy: &compaction.TruncationStrategy{ Trigger: compaction.GroupsExceed(2), - MinimumPreservedGroups: 2, + MinimumPreservedGroups: ptr(2), }, }) @@ -583,3 +625,5 @@ func messageTexts(messages []*message.Message) []string { } return texts } + +func ptr[T any](value T) *T { return &value } diff --git a/agent/compaction/contextwindow.go b/agent/compaction/contextwindow.go index ce51f37d..75879346 100644 --- a/agent/compaction/contextwindow.go +++ b/agent/compaction/contextwindow.go @@ -78,15 +78,16 @@ func (s *ContextWindowStrategy) Compact(ctx context.Context, index *MessageIndex toolEvictionTokens := int(float64(inputBudget) * toolEviction) truncationTokens := int(float64(inputBudget) * truncation) + minimumPreservedGroups := 2 pipeline := &PipelineStrategy{ Strategies: []Strategy{ &ToolResultStrategy{ Trigger: TokensExceed(toolEvictionTokens), - MinimumPreservedGroups: 2, + MinimumPreservedGroups: &minimumPreservedGroups, }, &TruncationStrategy{ Trigger: TokensExceed(truncationTokens), - MinimumPreservedGroups: 2, + MinimumPreservedGroups: &minimumPreservedGroups, }, }, } diff --git a/agent/compaction/index_test.go b/agent/compaction/index_test.go index 02345f7e..64175c00 100644 --- a/agent/compaction/index_test.go +++ b/agent/compaction/index_test.go @@ -84,7 +84,7 @@ func TestMessageIndex_ClassifiesStrategySummaryMessages(t *testing.T) { strategy := &compaction.SummarizationStrategy{ Trigger: compaction.GroupsExceed(2), Summarizer: compaction.SummarizerFunc(func(context.Context, []*message.Message) (string, error) { return "older context", nil }), - MinimumPreservedGroups: 1, + MinimumPreservedGroups: ptr(1), } compacted, err := strategy.Compact(t.Context(), index) @@ -183,7 +183,7 @@ func TestMessageIndex_UpdatePreservesStateFromCompactedProjection(t *testing.T) strategy := &compaction.SummarizationStrategy{ Trigger: compaction.GroupsExceed(2), Summarizer: compaction.SummarizerFunc(func(context.Context, []*message.Message) (string, error) { return "older context", nil }), - MinimumPreservedGroups: 2, + MinimumPreservedGroups: ptr(2), SummarizationPrompt: "summarize", } diff --git a/agent/compaction/slidingwindow.go b/agent/compaction/slidingwindow.go index 08533353..cb780ced 100644 --- a/agent/compaction/slidingwindow.go +++ b/agent/compaction/slidingwindow.go @@ -3,7 +3,6 @@ package compaction import ( - "cmp" "context" "slices" ) @@ -25,7 +24,10 @@ type SlidingWindowStrategy struct { // MinimumPreservedTurns is the minimum number of most-recent user turns to preserve. // Groups with nil or non-positive turn indexes are preserved independently of this value. - MinimumPreservedTurns int + // + // When nil, a default floor is used. An explicit value is honored as-is, so a pointer to 0 + // disables the floor entirely; a negative value is clamped to 0. + MinimumPreservedTurns *int } // Compact compacts index in place. @@ -35,7 +37,10 @@ func (strategy *SlidingWindowStrategy) Compact(_ context.Context, index *Message return false, nil } - minimumPreservedTurns := cmp.Or(max(strategy.MinimumPreservedTurns, 0), defaultMinimumPreservedSlidingWindowTurns) + minimumPreservedTurns := defaultMinimumPreservedSlidingWindowTurns + if strategy.MinimumPreservedTurns != nil { + minimumPreservedTurns = max(*strategy.MinimumPreservedTurns, 0) + } turnGroups := make(map[int][]int) var turnOrder []int for i, group := range index.Groups { diff --git a/agent/compaction/summarization.go b/agent/compaction/summarization.go index 5d3abcd1..9538d547 100644 --- a/agent/compaction/summarization.go +++ b/agent/compaction/summarization.go @@ -54,7 +54,10 @@ type SummarizationStrategy struct { // MinimumPreservedGroups is the minimum number of most-recent non-system groups to preserve. // This is a hard floor; summarization will not summarize groups within this protected window. - MinimumPreservedGroups int + // + // When nil, a default floor is used. An explicit value is honored as-is, so a pointer to 0 + // disables the floor entirely; a negative value is clamped to 0. + MinimumPreservedGroups *int // SummarizationPrompt is the system prompt prepended to messages sent to Summarizer. // When empty, a default prompt is used. @@ -75,7 +78,10 @@ func (strategy *SummarizationStrategy) Compact(ctx context.Context, index *Messa if strategy.Summarizer == nil { return false, nil } - minimumPreservedGroups := cmp.Or(max(strategy.MinimumPreservedGroups, 0), defaultMinimumPreservedSummarizationGroups) + minimumPreservedGroups := defaultMinimumPreservedSummarizationGroups + if strategy.MinimumPreservedGroups != nil { + minimumPreservedGroups = max(*strategy.MinimumPreservedGroups, 0) + } summarizationPrompt := cmp.Or(strategy.SummarizationPrompt, defaultSummarizationPrompt) summaryUnavailableMessage := cmp.Or(strategy.SummaryUnavailableMessage, "[Summary unavailable]") diff --git a/agent/compaction/toolresult.go b/agent/compaction/toolresult.go index c29d4eb1..2ae9a6ec 100644 --- a/agent/compaction/toolresult.go +++ b/agent/compaction/toolresult.go @@ -3,7 +3,6 @@ package compaction import ( - "cmp" "context" "fmt" "slices" @@ -29,7 +28,10 @@ type ToolResultStrategy struct { // MinimumPreservedGroups is the minimum number of most-recent non-system groups to preserve. // This is a hard floor; tool-call groups within this protected window are not collapsed. - MinimumPreservedGroups int + // + // When nil, a default floor is used. An explicit value is honored as-is, so a pointer to 0 + // disables the floor entirely; a negative value is clamped to 0. + MinimumPreservedGroups *int // ToolCallFormatter formats a tool-call group as a compact summary string. // When nil, DefaultToolCallFormatter is used, which produces a YAML-like block listing @@ -44,7 +46,10 @@ func (strategy *ToolResultStrategy) Compact(_ context.Context, index *MessageInd return false, nil } - minimumPreservedGroups := cmp.Or(max(strategy.MinimumPreservedGroups, 0), defaultMinimumPreservedToolResultGroups) + minimumPreservedGroups := defaultMinimumPreservedToolResultGroups + if strategy.MinimumPreservedGroups != nil { + minimumPreservedGroups = max(*strategy.MinimumPreservedGroups, 0) + } var nonSystemIncludedIndices []int for i, group := range index.Groups { if !group.IsExcluded && group.Kind != GroupKindSystem { diff --git a/agent/compaction/truncation.go b/agent/compaction/truncation.go index dbed94a3..7009f579 100644 --- a/agent/compaction/truncation.go +++ b/agent/compaction/truncation.go @@ -3,7 +3,6 @@ package compaction import ( - "cmp" "context" ) @@ -24,7 +23,10 @@ type TruncationStrategy struct { // MinimumPreservedGroups is the minimum number of most-recent non-system groups to preserve. // This is a hard floor; truncation will not remove groups beyond this limit. - MinimumPreservedGroups int + // + // When nil, a default floor is used. An explicit value is honored as-is, so a pointer to 0 + // disables the floor entirely; a negative value is clamped to 0. + MinimumPreservedGroups *int } // Compact compacts index in place. @@ -34,7 +36,10 @@ func (strategy *TruncationStrategy) Compact(_ context.Context, index *MessageInd return false, nil } - minimumPreservedGroups := cmp.Or(max(strategy.MinimumPreservedGroups, 0), defaultMinimumPreservedTruncationGroups) + minimumPreservedGroups := defaultMinimumPreservedTruncationGroups + if strategy.MinimumPreservedGroups != nil { + minimumPreservedGroups = max(*strategy.MinimumPreservedGroups, 0) + } removableCount := index.IncludedNonSystemGroupCount() maxRemovable := removableCount - minimumPreservedGroups if maxRemovable <= 0 { diff --git a/examples/02-agents/agents/step18_compaction_pipeline/main.go b/examples/02-agents/agents/step18_compaction_pipeline/main.go index 391f424f..1c6514ed 100644 --- a/examples/02-agents/agents/step18_compaction_pipeline/main.go +++ b/examples/02-agents/agents/step18_compaction_pipeline/main.go @@ -58,23 +58,23 @@ func main() { // 1. Gentle: collapse old tool-call groups into short summaries. &compaction.ToolResultStrategy{ Trigger: compaction.MessagesExceed(7), - MinimumPreservedGroups: 4, + MinimumPreservedGroups: ptr(4), }, // 2. Moderate: use an LLM to summarize older conversation spans into a concise message. &compaction.SummarizationStrategy{ Trigger: compaction.TokensExceed(0x500), Summarizer: summarizer, - MinimumPreservedGroups: 4, + MinimumPreservedGroups: ptr(4), }, // 3. Aggressive: keep only the last N user turns and their responses. &compaction.SlidingWindowStrategy{ Trigger: compaction.TurnsExceed(4), - MinimumPreservedTurns: 4, + MinimumPreservedTurns: ptr(4), }, // 4. Emergency: drop oldest groups until under the token budget. &compaction.TruncationStrategy{ Trigger: compaction.TokensExceed(0x8000), - MinimumPreservedGroups: 8, + MinimumPreservedGroups: ptr(8), }, }, } @@ -126,6 +126,8 @@ When responding, be extra descriptive and use as many words as possible without } } +func ptr[T any](value T) *T { return &value } + func lookupPrice(_ context.Context, productName string) (string, error) { switch strings.ToUpper(productName) { case "LAPTOP":