fix: prevent premature memory threshold by tuning summarization params
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
This commit is contained in:
parent
8c2fa468d2
commit
42be3dec37
1 changed files with 32 additions and 36 deletions
|
|
@ -728,56 +728,48 @@ func (al *AgentLoop) updateToolContexts(channel, chatID string) {
|
||||||
func (al *AgentLoop) maybeSummarize(sessionKey, channel, chatID string) {
|
func (al *AgentLoop) maybeSummarize(sessionKey, channel, chatID string) {
|
||||||
newHistory := al.sessions.GetHistory(sessionKey)
|
newHistory := al.sessions.GetHistory(sessionKey)
|
||||||
tokenEstimate := al.estimateTokens(newHistory)
|
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 {
|
if _, loading := al.summarizing.LoadOrStore(sessionKey, true); !loading {
|
||||||
go func() {
|
go func() {
|
||||||
defer al.summarizing.Delete(sessionKey)
|
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)
|
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.
|
// forceCompression reduces context when the limit is hit.
|
||||||
// It drops the oldest 50% of messages (keeping system prompt and last user message).
|
// It drops the oldest 35% of messages (keeping system prompt, recent conversation, and last user message).
|
||||||
func (al *AgentLoop) forceCompression(sessionKey string) {
|
func (al *AgentLoop) forceCompression(sessionKey string) {
|
||||||
history := al.sessions.GetHistory(sessionKey)
|
history := al.sessions.GetHistory(sessionKey)
|
||||||
if len(history) <= 4 {
|
if len(history) <= 6 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep system prompt (usually [0]) and the very last message (user's trigger)
|
// Keep system prompt (usually [0]) and the very last message (user's trigger)
|
||||||
// We want to drop the oldest half of the *conversation*
|
// Drop the oldest 35% of conversation to preserve more recent context.
|
||||||
// Assuming [0] is system, [1:] is conversation
|
|
||||||
conversation := history[1 : len(history)-1]
|
conversation := history[1 : len(history)-1]
|
||||||
if len(conversation) == 0 {
|
if len(conversation) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to find the mid-point of the conversation
|
// Drop the oldest 35% of conversation
|
||||||
mid := len(conversation) / 2
|
dropCount := len(conversation) * 35 / 100
|
||||||
|
if dropCount < 1 {
|
||||||
|
dropCount = 1
|
||||||
|
}
|
||||||
|
|
||||||
// New history structure:
|
droppedCount := dropCount
|
||||||
// 1. System Prompt
|
keptConversation := conversation[dropCount:]
|
||||||
// 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:]
|
|
||||||
|
|
||||||
newHistory := make([]providers.Message, 0)
|
newHistory := make([]providers.Message, 0)
|
||||||
newHistory = append(newHistory, history[0]) // System prompt
|
newHistory = append(newHistory, history[0]) // System prompt
|
||||||
|
|
@ -884,12 +876,13 @@ func (al *AgentLoop) summarizeSession(sessionKey string) {
|
||||||
history := al.sessions.GetHistory(sessionKey)
|
history := al.sessions.GetHistory(sessionKey)
|
||||||
summary := al.sessions.GetSummary(sessionKey)
|
summary := al.sessions.GetSummary(sessionKey)
|
||||||
|
|
||||||
// Keep last 4 messages for continuity
|
// Keep last 8 messages for continuity (provides ~2-3 recent interactions of context)
|
||||||
if len(history) <= 4 {
|
keepCount := 8
|
||||||
|
if len(history) <= keepCount {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
toSummarize := history[:len(history)-4]
|
toSummarize := history[:len(history)-keepCount]
|
||||||
|
|
||||||
// Oversized Message Guard
|
// Oversized Message Guard
|
||||||
// Skip messages larger than 50% of context window to prevent summarizer overflow
|
// Skip messages larger than 50% of context window to prevent summarizer overflow
|
||||||
|
|
@ -946,7 +939,7 @@ func (al *AgentLoop) summarizeSession(sessionKey string) {
|
||||||
|
|
||||||
if finalSummary != "" {
|
if finalSummary != "" {
|
||||||
al.sessions.SetSummary(sessionKey, finalSummary)
|
al.sessions.SetSummary(sessionKey, finalSummary)
|
||||||
al.sessions.TruncateHistory(sessionKey, 4)
|
al.sessions.TruncateHistory(sessionKey, 8)
|
||||||
al.sessions.Save(sessionKey)
|
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.
|
// 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
|
// Uses ~3.5 characters per token as a balanced heuristic:
|
||||||
// overheads better than the previous 3 chars/token.
|
// - 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 {
|
func (al *AgentLoop) estimateTokens(messages []providers.Message) int {
|
||||||
totalChars := 0
|
totalChars := 0
|
||||||
for _, m := range messages {
|
for _, m := range messages {
|
||||||
totalChars += utf8.RuneCountInString(m.Content)
|
totalChars += utf8.RuneCountInString(m.Content)
|
||||||
}
|
}
|
||||||
// 2.5 chars per token = totalChars * 2 / 5
|
// 3.5 chars per token = totalChars * 2 / 7
|
||||||
return totalChars * 2 / 5
|
return totalChars * 2 / 7
|
||||||
}
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) {
|
func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue