diff --git a/internal/compaction/compaction.go b/internal/compaction/compaction.go index 1c9d263f..e8a3f38b 100644 --- a/internal/compaction/compaction.go +++ b/internal/compaction/compaction.go @@ -1,10 +1,12 @@ // Package compaction provides context window management with token estimation, // compaction triggers, and LLM-based conversation summarization. // -// The algorithm preserves a token budget of recent -// messages (KeepRecentTokens, default 20 000) rather than a fixed message -// count. Auto-compaction fires when estimated context usage exceeds -// contextWindow − ReserveTokens. +// The algorithm preserves a token budget of recent messages +// (KeepRecentTokens) rather than a fixed message count. Auto-compaction +// fires when estimated context usage exceeds contextWindow − ReserveTokens. +// Both budgets adapt to the model's context/output limits when known (see +// AdaptiveReserveTokens and AdaptiveKeepRecentTokens); explicit values in +// CompactionOptions override the adaptive computation. // // Features modelled after pi's compaction system: // - Tool result truncation (2000 char max) during serialisation @@ -12,6 +14,8 @@ // the turn prefix is summarised separately and merged // - Cumulative file tracking: read and modified files extracted from // tool calls and carried forward across compactions +// - Anchored summaries: on re-compaction the previous summary is fed +// back into the prompt and updated incrementally package compaction import ( @@ -33,7 +37,7 @@ func estimateTokens(text string) int { } // EstimateMessageTokens estimates total tokens across a slice of fantasy -// messages by summing the estimated tokens for every text part. +// messages by summing the estimated tokens for every message part. func EstimateMessageTokens(messages []fantasy.Message) int { total := 0 for _, msg := range messages { @@ -43,17 +47,48 @@ func EstimateMessageTokens(messages []fantasy.Message) int { } // estimateSingleMessageTokens returns the estimated token count for one -// message. +// message. All part types contribute: text, reasoning, tool-call names and +// JSON arguments, tool results, and file attachments. Tool traffic often +// dominates agentic sessions, so counting only text parts systematically +// undercounts context usage and triggers compaction too late (issue #83). func estimateSingleMessageTokens(msg fantasy.Message) int { total := 0 for _, part := range msg.Content { - if tp, ok := part.(fantasy.TextPart); ok { - total += estimateTokens(tp.Text) + switch p := part.(type) { + case fantasy.TextPart: + total += estimateTokens(p.Text) + case fantasy.ReasoningPart: + total += estimateTokens(p.Text) + case fantasy.ToolCallPart: + total += estimateTokens(p.ToolName) + estimateTokens(p.Input) + case fantasy.ToolResultPart: + total += estimateToolResultTokens(p) + case fantasy.FilePart: + // Rough estimate for file/image attachments: base64 + // encoding expands bytes by ~4/3 and providers tokenise + // the encoded payload, so ~3 bytes per token. + total += estimateTokens(p.Filename) + len(p.Data)/3 } } return total } +// estimateToolResultTokens returns the estimated token count for a tool +// result part, covering text, error, and media output variants. +func estimateToolResultTokens(p fantasy.ToolResultPart) int { + switch out := p.Output.(type) { + case fantasy.ToolResultOutputContentText: + return estimateTokens(out.Text) + case fantasy.ToolResultOutputContentError: + if out.Error != nil { + return estimateTokens(out.Error.Error()) + } + case fantasy.ToolResultOutputContentMedia: + return estimateTokens(out.Text) + len(out.Data)/4 + } + return 0 +} + // --------------------------------------------------------------------------- // Auto-compact trigger // --------------------------------------------------------------------------- @@ -84,24 +119,78 @@ type CompactionResult struct { } // CompactionOptions configures compaction behaviour. Token-based defaults -// are applied for zero-value fields. +// are applied for zero-value fields; when model limits (ContextWindow, +// MaxOutputTokens) are known, the defaults adapt to them. type CompactionOptions struct { - ContextWindow int // Model's context window size (tokens) - ReserveTokens int // Tokens to reserve for LLM response, default 16384 - KeepRecentTokens int // Recent tokens to preserve (not summarised), default 20000 + ContextWindow int // Model's context window size (tokens), 0 = unknown + MaxOutputTokens int // Model's max output tokens, 0 = unknown; scales the ReserveTokens default + ReserveTokens int // Tokens to reserve for LLM response, 0 = adaptive (see AdaptiveReserveTokens) + KeepRecentTokens int // Recent tokens to preserve (not summarised), 0 = adaptive (see AdaptiveKeepRecentTokens) SummaryPrompt string // Custom summary prompt (empty = use default) } -// defaults fills zero-value fields with sensible defaults. +// Budget defaults. Explicit values in CompactionOptions always override the +// adaptive computation. +const ( + // DefaultReserveTokens is the fallback response reserve when the + // model's output limit is unknown, and the ceiling when it is known. + DefaultReserveTokens = 16384 + // DefaultKeepRecentTokens is the fallback keep-recent budget when the + // model's context window is unknown. + DefaultKeepRecentTokens = 20000 + // minKeepRecentTokens is the floor for the adaptive keep-recent + // budget so at least a couple of turns always survive compaction. + minKeepRecentTokens = 2000 +) + +// AdaptiveReserveTokens returns the response reserve budget for a model with +// the given maximum output token limit: min(DefaultReserveTokens, +// maxOutputTokens). A model can never produce more than its output limit in +// one response, so reserving more than that wastes usable context on +// small-output models. Returns DefaultReserveTokens when the limit is +// unknown (<= 0). +func AdaptiveReserveTokens(maxOutputTokens int) int { + if maxOutputTokens <= 0 { + return DefaultReserveTokens + } + return min(DefaultReserveTokens, maxOutputTokens) +} + +// AdaptiveKeepRecentTokens returns the keep-recent budget scaled to the +// model's usable context (contextWindow − reserveTokens): a quarter of the +// usable context, floored at 2000 tokens. Fixed budgets are wasteful on +// small-context models (a 20k keep budget can exceed the whole window) and +// stingy on large-context ones. Returns DefaultKeepRecentTokens when the +// context window is unknown (<= 0). +func AdaptiveKeepRecentTokens(contextWindow, reserveTokens int) int { + if contextWindow <= 0 { + return DefaultKeepRecentTokens + } + usable := contextWindow - reserveTokens + if usable <= 0 { + return minKeepRecentTokens + } + return max(minKeepRecentTokens, usable/4) +} + +// defaults fills zero-value fields with sensible defaults, adapting to the +// model's context/output limits when they are known. func (o *CompactionOptions) defaults() { if o.ReserveTokens <= 0 { - o.ReserveTokens = 16384 + o.ReserveTokens = AdaptiveReserveTokens(o.MaxOutputTokens) } if o.KeepRecentTokens <= 0 { - o.KeepRecentTokens = 20000 + o.KeepRecentTokens = AdaptiveKeepRecentTokens(o.ContextWindow, o.ReserveTokens) } } +// ApplyDefaults fills zero-value fields with sensible defaults, adapting to +// the model's context/output limits (ContextWindow, MaxOutputTokens) when +// they are known. Explicit non-zero values are preserved. +func (o *CompactionOptions) ApplyDefaults() { + o.defaults() +} + // defaultSystemPrompt is the system prompt sent to the summarisation LLM. const defaultSystemPrompt = `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. @@ -151,6 +240,13 @@ Use this EXACT format: Keep each section concise. Preserve exact file paths, function names, and error messages.` +// anchoredSummaryInstructions is appended to the summary prompt when a +// previous compaction's summary is available, so the LLM updates the +// anchored summary incrementally instead of re-summarising from scratch. +const anchoredSummaryInstructions = ` + +An anchored summary from a previous compaction is provided at the very top inside tags. Update that anchored summary using the conversation above: preserve still-true details, remove stale details, and merge in the new facts. Output the complete updated summary in the format specified.` + // --------------------------------------------------------------------------- // Tool result truncation // --------------------------------------------------------------------------- @@ -207,7 +303,7 @@ func FindCutPoint(messages []fantasy.Message, keepRecentTokens int) int { return 0 } if keepRecentTokens <= 0 { - keepRecentTokens = 20000 + keepRecentTokens = DefaultKeepRecentTokens } accumulated := 0 @@ -445,9 +541,13 @@ func serializeMessages(messages []fantasy.Message) string { // Compact // --------------------------------------------------------------------------- -// PreviousCompaction carries file tracking state from a prior compaction so -// that file operations accumulate across multiple compactions. +// PreviousCompaction carries state from a prior compaction: file tracking so +// that file operations accumulate across multiple compactions, and the prior +// summary text so re-compaction updates it incrementally (anchored summary) +// instead of re-summarising from scratch and losing detail across +// generations. type PreviousCompaction struct { + Summary string // Prior summary text, fed back into the summarisation prompt ReadFiles []string ModifiedFiles []string } @@ -512,15 +612,22 @@ func Compact( recentOps := extractFileOps(recentMessages) ops.merge(recentOps) + // Anchored summary: feed the previous compaction's summary back into + // the summarisation prompt so it is updated incrementally. + previousSummary := "" + if prev != nil { + previousSummary = prev.Summary + } + // Handle split turns: when the cut lands mid-turn, summarise the turn // prefix separately and merge with the history summary. var summaryText string var err error if IsSplitTurn(messages, cutPoint) { - summaryText, err = compactSplitTurn(ctx, model, oldMessages, messages, cutPoint, opts, customInstructions, onChunk) + summaryText, err = compactSplitTurn(ctx, model, oldMessages, messages, cutPoint, opts, customInstructions, previousSummary, onChunk) } else { - summaryText, err = compactNormal(ctx, model, oldMessages, opts, customInstructions, onChunk) + summaryText, err = compactNormal(ctx, model, oldMessages, opts, customInstructions, previousSummary, onChunk) } if err != nil { return nil, nil, err @@ -573,10 +680,11 @@ func compactNormal( oldMessages []fantasy.Message, opts CompactionOptions, customInstructions string, + previousSummary string, onChunk StreamCallback, ) (string, error) { conversationText := serializeMessages(oldMessages) - return generateSummary(ctx, model, conversationText, opts, customInstructions, onChunk) + return generateSummary(ctx, model, conversationText, opts, customInstructions, previousSummary, onChunk) } // compactSplitTurn handles the case where the cut point lands mid-turn. @@ -596,6 +704,7 @@ func compactSplitTurn( cutPoint int, opts CompactionOptions, customInstructions string, + previousSummary string, onChunk StreamCallback, ) (string, error) { // Find where the split turn starts. @@ -613,13 +722,17 @@ func compactSplitTurn( var historySummary string var err error - // Generate history summary if there are complete turns before the split. + // Generate history summary if there are complete turns before the + // split. The anchored previous summary belongs with the history + // portion; when there is no history call it is threaded into the + // turn-prefix call below instead. if len(historyMessages) >= 2 { historySummary, err = generateSummary(ctx, model, - serializeMessages(historyMessages), opts, "", onChunk) + serializeMessages(historyMessages), opts, "", previousSummary, onChunk) if err != nil { return "", fmt.Errorf("split turn history summary failed: %w", err) } + previousSummary = "" } // Stream the separator between history and turn prefix summaries. @@ -638,7 +751,7 @@ func compactSplitTurn( turnPrefixPrompt += "\n\nAdditional instructions: " + customInstructions } - turnPrefixSummary, err := generateSummary(ctx, model, turnPrefixText, opts, turnPrefixPrompt, onChunk) + turnPrefixSummary, err := generateSummary(ctx, model, turnPrefixText, opts, turnPrefixPrompt, previousSummary, onChunk) if err != nil { return "", fmt.Errorf("split turn prefix summary failed: %w", err) } @@ -653,7 +766,32 @@ func compactSplitTurn( return historySummary, nil } +// buildSummaryPrompt assembles the full prompt sent to the summarisation +// LLM: an optional anchored previous summary, the serialised conversation, +// and the summary instructions (with anchored-update and custom-instruction +// addenda when applicable). +func buildSummaryPrompt(conversationText string, opts CompactionOptions, customInstructions, previousSummary string) string { + userPrompt := opts.SummaryPrompt + if userPrompt == "" { + userPrompt = defaultSummaryPrompt + } + if previousSummary != "" { + userPrompt += anchoredSummaryInstructions + } + if customInstructions != "" { + userPrompt += "\n\nAdditional instructions: " + customInstructions + } + + prompt := conversationText + "\n\n" + userPrompt + if previousSummary != "" { + prompt = "\n" + previousSummary + "\n\n\n" + prompt + } + return prompt +} + // generateSummary calls the LLM to produce a structured summary. +// If previousSummary is non-empty it is included as an anchored summary that +// the LLM updates with the new conversation instead of starting from scratch. // If onChunk is provided, the summary is streamed using Agent.Stream(). func generateSummary( ctx context.Context, @@ -661,22 +799,15 @@ func generateSummary( conversationText string, opts CompactionOptions, customInstructions string, + previousSummary string, onChunk StreamCallback, ) (string, error) { - userPrompt := opts.SummaryPrompt - if userPrompt == "" { - userPrompt = defaultSummaryPrompt - } - if customInstructions != "" { - userPrompt += "\n\nAdditional instructions: " + customInstructions - } + prompt := buildSummaryPrompt(conversationText, opts, customInstructions, previousSummary) summaryAgent := fantasy.NewAgent(model, fantasy.WithSystemPrompt(defaultSystemPrompt), ) - prompt := conversationText + "\n\n" + userPrompt - // Use streaming if onChunk is provided. if onChunk != nil { var fullText strings.Builder diff --git a/internal/compaction/compaction_test.go b/internal/compaction/compaction_test.go index 2783b435..8bbd1aa9 100644 --- a/internal/compaction/compaction_test.go +++ b/internal/compaction/compaction_test.go @@ -2,6 +2,7 @@ package compaction import ( "context" + "errors" "strings" "testing" @@ -62,6 +63,96 @@ func TestEstimateMessageTokens_Empty(t *testing.T) { } } +func TestEstimateMessageTokens_AllPartTypes(t *testing.T) { + // Tool-heavy message traffic must be counted (issue #83): tool-call + // JSON arguments, tool results, reasoning, and file parts all + // contribute to the estimate. + toolInput := `{"path":"` + strings.Repeat("a", 394) + `"}` // 404 chars + msgs := []fantasy.Message{ + { + Role: fantasy.MessageRoleAssistant, + Content: []fantasy.MessagePart{ + fantasy.ReasoningPart{Text: strings.Repeat("r", 400)}, // 100 tokens + fantasy.ToolCallPart{ + ToolCallID: "1", + ToolName: "read", // 4 chars → 1 token + Input: toolInput, // 404 chars → 101 tokens + }, + }, + }, + { + Role: fantasy.MessageRoleTool, + Content: []fantasy.MessagePart{ + fantasy.ToolResultPart{ + ToolCallID: "1", + Output: fantasy.ToolResultOutputContentText{Text: strings.Repeat("o", 800)}, // 200 tokens + }, + }, + }, + } + + got := EstimateMessageTokens(msgs) + want := 100 + 1 + 101 + 200 + if got != want { + t.Errorf("EstimateMessageTokens = %d, want %d", got, want) + } +} + +func TestEstimateMessageTokens_ToolResultVariants(t *testing.T) { + errMsg := fantasy.Message{ + Role: fantasy.MessageRoleTool, + Content: []fantasy.MessagePart{ + fantasy.ToolResultPart{ + Output: fantasy.ToolResultOutputContentError{Error: errors.New(strings.Repeat("e", 400))}, + }, + }, + } + if got := EstimateMessageTokens([]fantasy.Message{errMsg}); got != 100 { + t.Errorf("error tool result tokens = %d, want 100", got) + } + + mediaMsg := fantasy.Message{ + Role: fantasy.MessageRoleTool, + Content: []fantasy.MessagePart{ + fantasy.ToolResultPart{ + Output: fantasy.ToolResultOutputContentMedia{ + Data: strings.Repeat("D", 400), // base64 payload → 100 tokens + Text: strings.Repeat("t", 40), // 10 tokens + }, + }, + }, + } + if got := EstimateMessageTokens([]fantasy.Message{mediaMsg}); got != 110 { + t.Errorf("media tool result tokens = %d, want 110", got) + } + + nilErrMsg := fantasy.Message{ + Role: fantasy.MessageRoleTool, + Content: []fantasy.MessagePart{ + fantasy.ToolResultPart{Output: fantasy.ToolResultOutputContentError{}}, + }, + } + if got := EstimateMessageTokens([]fantasy.Message{nilErrMsg}); got != 0 { + t.Errorf("nil error tool result tokens = %d, want 0", got) + } +} + +func TestEstimateMessageTokens_FilePart(t *testing.T) { + msg := fantasy.Message{ + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.FilePart{ + Filename: strings.Repeat("f", 8), // 2 tokens + Data: make([]byte, 300), // 300/3 = 100 tokens + MediaType: "image/png", + }, + }, + } + if got := EstimateMessageTokens([]fantasy.Message{msg}); got != 102 { + t.Errorf("file part tokens = %d, want 102", got) + } +} + // --------------------------------------------------------------------------- // ShouldCompact (contextTokens > contextWindow - reserveTokens) // --------------------------------------------------------------------------- @@ -234,6 +325,66 @@ func TestCompactionOptions_DefaultsPreservesExisting(t *testing.T) { } } +func TestCompactionOptions_AdaptiveDefaults(t *testing.T) { + // Small-context, small-output model: both budgets scale down. + opts := CompactionOptions{ContextWindow: 32768, MaxOutputTokens: 4096} + opts.ApplyDefaults() + + if opts.ReserveTokens != 4096 { + t.Errorf("ReserveTokens = %d, want 4096 (min(16384, maxOutput))", opts.ReserveTokens) + } + wantKeep := (32768 - 4096) / 4 // 7168 + if opts.KeepRecentTokens != wantKeep { + t.Errorf("KeepRecentTokens = %d, want %d (usable/4)", opts.KeepRecentTokens, wantKeep) + } +} + +func TestAdaptiveReserveTokens(t *testing.T) { + tests := []struct { + name string + maxOutput int + want int + }{ + {"unknown limit", 0, DefaultReserveTokens}, + {"negative limit", -1, DefaultReserveTokens}, + {"small output model", 4096, 4096}, + {"large output model", 65536, DefaultReserveTokens}, + {"exactly at default", 16384, 16384}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := AdaptiveReserveTokens(tt.maxOutput); got != tt.want { + t.Errorf("AdaptiveReserveTokens(%d) = %d, want %d", tt.maxOutput, got, tt.want) + } + }) + } +} + +func TestAdaptiveKeepRecentTokens(t *testing.T) { + tests := []struct { + name string + contextWindow int + reserveTokens int + want int + }{ + {"unknown window", 0, 16384, DefaultKeepRecentTokens}, + {"reserve swallows window", 8192, 16384, minKeepRecentTokens}, + {"tiny model floors at minimum", 8192, 4096, minKeepRecentTokens}, // usable/4 = 1024 < 2000 + {"small model", 32768, 4096, (32768 - 4096) / 4}, // 7168 + {"200k model", 200000, 16384, (200000 - 16384) / 4}, // 45904 + {"1M model scales up", 1000000, 16384, (1000000 - 16384) / 4}, // 245904 + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := AdaptiveKeepRecentTokens(tt.contextWindow, tt.reserveTokens) + if got != tt.want { + t.Errorf("AdaptiveKeepRecentTokens(%d, %d) = %d, want %d", + tt.contextWindow, tt.reserveTokens, got, tt.want) + } + }) + } +} + // --------------------------------------------------------------------------- // Compact (integration — too few messages) // --------------------------------------------------------------------------- @@ -440,6 +591,46 @@ func TestSortedKeys_Empty(t *testing.T) { } } +// --------------------------------------------------------------------------- +// Anchored summary prompt (issue #83) +// --------------------------------------------------------------------------- + +func TestBuildSummaryPrompt_NoAnchor(t *testing.T) { + prompt := buildSummaryPrompt("[User]:\nhello\n\n", CompactionOptions{}, "", "") + if strings.Contains(prompt, "") { + t.Error("anchored-summary block present without a previous summary") + } + if !strings.Contains(prompt, "## Goal") { + t.Error("default summary prompt missing") + } +} + +func TestBuildSummaryPrompt_WithAnchor(t *testing.T) { + prev := "## Goal\nShip the widget feature." + prompt := buildSummaryPrompt("[User]:\nhello\n\n", CompactionOptions{}, "", prev) + + if !strings.HasPrefix(prompt, "\n"+prev+"\n") { + t.Error("previous summary not anchored at the top of the prompt") + } + if !strings.Contains(prompt, "Update that anchored summary") { + t.Error("anchored update instructions missing") + } + // The anchor must come before the conversation text. + if strings.Index(prompt, "") > strings.Index(prompt, "[User]:") { + t.Error("anchored summary should precede the conversation text") + } +} + +func TestBuildSummaryPrompt_WithAnchorAndCustomInstructions(t *testing.T) { + prompt := buildSummaryPrompt("convo", CompactionOptions{}, "Focus on API design", "prior summary") + if !strings.Contains(prompt, "Additional instructions: Focus on API design") { + t.Error("custom instructions missing") + } + if !strings.Contains(prompt, "") { + t.Error("anchored summary missing") + } +} + // --------------------------------------------------------------------------- // Skill-content protection (issue #65, gap #7) // --------------------------------------------------------------------------- diff --git a/pkg/kit/compaction.go b/pkg/kit/compaction.go index bfe3c133..e3ac29ea 100644 --- a/pkg/kit/compaction.go +++ b/pkg/kit/compaction.go @@ -16,10 +16,6 @@ type ContextStats struct { MessageCount int // Number of messages in the conversation } -// defaultReserveTokens is the number of tokens to keep free in the context -// window as a safety margin during compaction checks. -const defaultReserveTokens = 16384 - // EstimateContextTokens returns the estimated token count of the current // conversation based on session messages. func (m *Kit) EstimateContextTokens() int { @@ -27,6 +23,20 @@ func (m *Kit) EstimateContextTokens() int { return compaction.EstimateMessageTokens(messages) } +// reserveTokensForModel returns the response reserve budget: an explicit +// CompactionOptions override when set, otherwise a value adapted to the +// model's output limit (min(16384, maxOutput)). +func (m *Kit) reserveTokensForModel(info *ModelInfo) int { + if m.compactionOpts != nil && m.compactionOpts.ReserveTokens > 0 { + return m.compactionOpts.ReserveTokens + } + maxOutput := 0 + if info != nil { + maxOutput = info.Limit.Output + } + return compaction.AdaptiveReserveTokens(maxOutput) +} + // ShouldCompact reports whether the conversation is near the model's context // limit and should be compacted. // Formula: contextTokens > contextWindow − reserveTokens. @@ -45,10 +55,7 @@ func (m *Kit) ShouldCompact() bool { return false } - reserveTokens := defaultReserveTokens - if m.compactionOpts != nil && m.compactionOpts.ReserveTokens > 0 { - reserveTokens = m.compactionOpts.ReserveTokens - } + reserveTokens := m.reserveTokensForModel(info) // Prefer the real API-reported token count when available. m.lastInputTokensMu.RLock() @@ -129,19 +136,25 @@ func (m *Kit) compactInternal(ctx context.Context, opts *CompactionOptions, cust // compactImpl performs the actual compaction work. On success it emits a // CompactionEvent via persistAndEmitCompaction. func (m *Kit) compactImpl(ctx context.Context, opts *CompactionOptions, customInstructions string, isAutomatic bool) (*CompactionResult, error) { - if opts == nil { - if m.compactionOpts != nil { - opts = m.compactionOpts - } else { - opts = &CompactionOptions{} - } + // Work on a copy so auto-populated model limits never mutate the + // caller's (or the instance's shared) options. + var optsCopy CompactionOptions + if opts != nil { + optsCopy = *opts + } else if m.compactionOpts != nil { + optsCopy = *m.compactionOpts } + opts = &optsCopy - // Auto-populate context window from model info if not set. - if opts.ContextWindow <= 0 { - if info := m.GetModelInfo(); info != nil { + // Auto-populate model limits if not set; compaction.defaults() adapts + // the reserve/keep budgets to them (issue #83). + if info := m.GetModelInfo(); info != nil { + if opts.ContextWindow <= 0 { opts.ContextWindow = info.Limit.Context } + if opts.MaxOutputTokens <= 0 { + opts.MaxOutputTokens = info.Limit.Output + } } messages := m.session.GetMessages() @@ -173,10 +186,12 @@ func (m *Kit) compactImpl(ctx context.Context, opts *CompactionOptions, customIn } } - // Carry forward file tracking from previous compaction. + // Carry forward file tracking and the prior summary (for anchored, + // incremental re-summarisation) from the previous compaction. var prev *compaction.PreviousCompaction if lastCompaction := m.session.GetLastCompaction(); lastCompaction != nil { prev = &compaction.PreviousCompaction{ + Summary: lastCompaction.Summary, ReadFiles: lastCompaction.ReadFiles, ModifiedFiles: lastCompaction.ModifiedFiles, } @@ -215,12 +230,16 @@ func (m *Kit) compactImpl(ctx context.Context, opts *CompactionOptions, customIn } // applyCustomCompaction handles compaction when an extension provides a -// custom summary. It still determines the cut point and persists a +// custom summary. It still determines the cut point (using the same +// adaptive budget defaults as regular compaction) and persists a // CompactionEntry. func (m *Kit) applyCustomCompaction(summary string, messages []LLMMessage, opts *CompactionOptions) (*CompactionResult, error) { originalTokens := compaction.EstimateMessageTokens(convertToLLMMessages(messages)) - cutPoint := compaction.FindCutPoint(convertToLLMMessages(messages), opts.KeepRecentTokens) + resolved := *opts + resolved.ApplyDefaults() + + cutPoint := compaction.FindCutPoint(convertToLLMMessages(messages), resolved.KeepRecentTokens) if cutPoint == 0 { cutPoint = len(messages) - 1 if cutPoint < 1 { diff --git a/www/pages/sdk/options.md b/www/pages/sdk/options.md index 8a28c056..d9187a29 100644 --- a/www/pages/sdk/options.md +++ b/www/pages/sdk/options.md @@ -223,7 +223,7 @@ context files at runtime (e.g. per user or per session), use the | Field | Type | Default | Description | |-------|------|---------|-------------| | `AutoCompact` | `bool` | `false` | Auto-compact when near context limit | -| `CompactionOptions` | `*CompactionOptions` | — | Configuration for auto-compaction | +| `CompactionOptions` | `*CompactionOptions` | — | Configuration for compaction; zero-value budget fields adapt to the model's limits. See [CompactionOptions](#compactionoptions) below. | | `MCPAuthHandler` | `MCPAuthHandler` | — | OAuth handler for remote MCP servers. `nil` disables OAuth (servers returning 401 fail with the authorization-required error). See [MCP OAuth](#mcp-oauth-authorization) below. | | `MCPTokenStoreFactory` | `func` | — | Custom OAuth token storage for MCP servers (default: JSON file in `$XDG_CONFIG_HOME/.kit/mcp_tokens.json`). | | `InProcessMCPServers` | `map[string]*MCPServer` | — | In-process mcp-go servers (no subprocess) | @@ -234,6 +234,24 @@ context files at runtime (e.g. per user or per session), use the | `MCPTaskMaxPollInterval` | `time.Duration` | `5s` | Cap on the polling interval (a server-supplied `pollInterval` can otherwise grow without bound). | | `MCPTaskProgress` | `MCPTaskProgressHandler` | — | Optional callback invoked once when a task is accepted and on every observed status transition. The final invocation always carries a terminal status. | +### CompactionOptions + +`CompactionOptions` controls how conversations are summarized. The token +budgets are adaptive: leave a field at zero and it scales to the model's +limits, or set an explicit value to override. + +| Field | Type | Zero-value behaviour | Description | +|-------|------|----------------------|-------------| +| `ContextWindow` | `int` | auto-populated from the model registry | Model's context window size in tokens | +| `MaxOutputTokens` | `int` | auto-populated from the model registry | Model's max output tokens; scales the adaptive `ReserveTokens` | +| `ReserveTokens` | `int` | `min(16384, MaxOutputTokens)` | Tokens reserved for the LLM response | +| `KeepRecentTokens` | `int` | a quarter of the usable context (`ContextWindow − ReserveTokens`), floored at 2000 | Recent tokens preserved verbatim (not summarized) | +| `SummaryPrompt` | `string` | built-in structured checkpoint prompt | Custom summarization prompt | + +When a session compacts more than once, the previous summary is fed back into +the summarization prompt and updated incrementally (anchored summaries), so +detail is preserved across compaction generations. + ## MCP OAuth Authorization When a remote MCP server (SSE or Streamable HTTP) requires OAuth, Kit runs diff --git a/www/pages/sdk/overview.md b/www/pages/sdk/overview.md index 56edb075..af99877f 100644 --- a/www/pages/sdk/overview.md +++ b/www/pages/sdk/overview.md @@ -550,6 +550,13 @@ if host.ShouldCompact() { } ``` +Token estimates count every message part (tool-call arguments, tool results, +reasoning, file attachments), and after the first turn the real API-reported +token count is preferred over the heuristic. Compaction budgets adapt to the +model's context and output limits when left at their zero values; pass a +`*kit.CompactionOptions` as the second argument to `Compact` to override +them — see [SDK options → CompactionOptions](/sdk/options#compactionoptions). + ## Provider error classification Provider failures are wrapped with exported sentinels so you can branch on the diff --git a/www/pages/sessions.md b/www/pages/sessions.md index f0329ab4..ee9fd9ae 100644 --- a/www/pages/sessions.md +++ b/www/pages/sessions.md @@ -24,6 +24,9 @@ Each line in the session file is a JSON entry representing a message, tool call, When conversations grow long, Kit can compact them to free up context window space. The compaction system: - **Non-destructive**: Old messages remain on disk for history; only the LLM context is summarized +- **Full-context token estimation**: Estimates count every message part — tool-call arguments, tool results, reasoning, and file attachments — not just plain text, so tool-heavy sessions trigger compaction on time +- **Adaptive budgets**: The response reserve and keep-recent budgets scale with the model's context window and output limit instead of using fixed constants +- **Anchored summaries**: When a session compacts more than once, the previous summary is fed back to the LLM and updated incrementally instead of being regenerated from scratch - **File tracking**: Tracks which files were read and modified across compactions - **Split-turn handling**: Can summarize large single turns by splitting them - **Tool result truncation**: Caps tool output during serialization to stay within token budgets