From 42be3dec37e3581983e10523b1537ca5f9c66cb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Feb 2026 22:41:35 +0000 Subject: [PATCH] fix: prevent premature memory threshold by tuning summarization params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Memory threshold reached" notification was triggering too early, often after just 5 user interactions. Root causes: - Message threshold was 20 (only ~5 interactions with tool calls) → raised to 40 - Token threshold was 75% of context window → raised to 85% - Token estimation used 2.5 chars/token (too aggressive) → changed to 3.5 chars/token - Post-summarization only kept 4 messages → now keeps 8 - Force compression dropped 50% of messages → now drops 35% - Removed the user-facing "⚠️ Memory threshold" notification (now silent background operation) https://claude.ai/code/session_01MYemTMPtHrcgidWs8UdjcG --- pkg/agent/loop.go | 68 ++++++++++++++++++++++------------------------- 1 file changed, 32 insertions(+), 36 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index d48549805..3a2952dcb 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -728,56 +728,48 @@ func (al *AgentLoop) updateToolContexts(channel, chatID string) { func (al *AgentLoop) maybeSummarize(sessionKey, channel, chatID string) { newHistory := al.sessions.GetHistory(sessionKey) tokenEstimate := al.estimateTokens(newHistory) - threshold := al.contextWindow * 75 / 100 + threshold := al.contextWindow * 85 / 100 - if len(newHistory) > 20 || tokenEstimate > threshold { + // Use message count threshold of 40 (tool calls mean ~3-5 messages per user interaction, + // so 40 messages ≈ 8-13 real interactions before summarization triggers). + if len(newHistory) > 40 || tokenEstimate > threshold { if _, loading := al.summarizing.LoadOrStore(sessionKey, true); !loading { go func() { defer al.summarizing.Delete(sessionKey) - // Notify user about optimization if not an internal channel - if !constants.IsInternalChannel(channel) { - al.bus.PublishOutbound(bus.OutboundMessage{ - Channel: channel, - ChatID: chatID, - Content: "⚠️ Memory threshold reached. Optimizing conversation history...", - }) - } al.summarizeSession(sessionKey) + logger.InfoCF("agent", "Session summarized in background", map[string]interface{}{ + "session_key": sessionKey, + "history_count": len(newHistory), + "token_estimate": tokenEstimate, + }) }() } } } -// forceCompression aggressively reduces context when the limit is hit. -// It drops the oldest 50% of messages (keeping system prompt and last user message). +// forceCompression reduces context when the limit is hit. +// It drops the oldest 35% of messages (keeping system prompt, recent conversation, and last user message). func (al *AgentLoop) forceCompression(sessionKey string) { history := al.sessions.GetHistory(sessionKey) - if len(history) <= 4 { + if len(history) <= 6 { return } // Keep system prompt (usually [0]) and the very last message (user's trigger) - // We want to drop the oldest half of the *conversation* - // Assuming [0] is system, [1:] is conversation + // Drop the oldest 35% of conversation to preserve more recent context. conversation := history[1 : len(history)-1] if len(conversation) == 0 { return } - // Helper to find the mid-point of the conversation - mid := len(conversation) / 2 + // Drop the oldest 35% of conversation + dropCount := len(conversation) * 35 / 100 + if dropCount < 1 { + dropCount = 1 + } - // New history structure: - // 1. System Prompt - // 2. [Summary of dropped part] - synthesized - // 3. Second half of conversation - // 4. Last message - - // Simplified approach for emergency: Drop first half of conversation - // and rely on existing summary if present, or create a placeholder. - - droppedCount := mid - keptConversation := conversation[mid:] + droppedCount := dropCount + keptConversation := conversation[dropCount:] newHistory := make([]providers.Message, 0) newHistory = append(newHistory, history[0]) // System prompt @@ -884,12 +876,13 @@ func (al *AgentLoop) summarizeSession(sessionKey string) { history := al.sessions.GetHistory(sessionKey) summary := al.sessions.GetSummary(sessionKey) - // Keep last 4 messages for continuity - if len(history) <= 4 { + // Keep last 8 messages for continuity (provides ~2-3 recent interactions of context) + keepCount := 8 + if len(history) <= keepCount { return } - toSummarize := history[:len(history)-4] + toSummarize := history[:len(history)-keepCount] // Oversized Message Guard // Skip messages larger than 50% of context window to prevent summarizer overflow @@ -946,7 +939,7 @@ func (al *AgentLoop) summarizeSession(sessionKey string) { if finalSummary != "" { al.sessions.SetSummary(sessionKey, finalSummary) - al.sessions.TruncateHistory(sessionKey, 4) + al.sessions.TruncateHistory(sessionKey, 8) al.sessions.Save(sessionKey) } } @@ -973,15 +966,18 @@ func (al *AgentLoop) summarizeBatch(ctx context.Context, batch []providers.Messa } // estimateTokens estimates the number of tokens in a message list. -// Uses a safe heuristic of 2.5 characters per token to account for CJK and other -// overheads better than the previous 3 chars/token. +// Uses ~3.5 characters per token as a balanced heuristic: +// - English text averages ~4 chars/token +// - CJK text averages ~1.5-2 chars/token +// - Mixed content (code, URLs, tool results) averages ~3-4 chars/token +// The previous 2.5 ratio was too aggressive and triggered summarization too early. func (al *AgentLoop) estimateTokens(messages []providers.Message) int { totalChars := 0 for _, m := range messages { totalChars += utf8.RuneCountInString(m.Content) } - // 2.5 chars per token = totalChars * 2 / 5 - return totalChars * 2 / 5 + // 3.5 chars per token = totalChars * 2 / 7 + return totalChars * 2 / 7 } func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) {