Files
Enterprise-Knowledge-Base/internal/agent/memory/consolidator.go
T
wizardchen a8924e301e feat(agent): enhance message consolidation and context management
- Improved the message consolidation logic to preserve the current turn's user query and all subsequent assistant/tool messages, ensuring better context retention.
- Updated the CompressContext function to reflect the new consolidation strategy, maintaining the system prompt and relevant recent messages.
- Refactored the context manager to support optional message repository for improved context rebuilding from persistent storage.
- Added comprehensive tests to validate the new consolidation behavior and ensure correct message handling across various scenarios.
2026-03-25 22:08:29 +08:00

336 lines
10 KiB
Go

// Package memory provides LLM-powered memory consolidation for agent conversations.
// When the context window grows too large, older messages are summarized by the LLM
// into a compact memory block that preserves key facts and tool results.
package memory
import (
"context"
"fmt"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/agent/token"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/models/chat"
)
const (
// DefaultConsolidationThreshold is the ratio of context window that triggers consolidation.
// When token count exceeds MaxContextTokens * threshold, consolidation is triggered.
DefaultConsolidationThreshold = 0.5
// maxConsolidationAttempts is the maximum number of LLM calls for consolidation.
// After this many failures, fall back to raw archiving.
maxConsolidationAttempts = 3
// consolidationTimeout is the timeout for the consolidation LLM call.
consolidationTimeout = 60 * time.Second
)
// Consolidator compresses agent conversation history using LLM summarization.
// When the message history grows too large for the context window, it summarizes
// older messages into a compact system message that preserves key facts.
type Consolidator struct {
chatModel chat.Chat
estimator *token.Estimator
maxTokens int // context window size
threshold float64 // trigger ratio (default 0.5)
}
// NewConsolidator creates a memory consolidator.
// maxContextTokens is the total context window budget.
// threshold is the ratio (0-1) at which consolidation triggers (0 = use default 0.5).
func NewConsolidator(
chatModel chat.Chat,
estimator *token.Estimator,
maxContextTokens int,
threshold float64,
) *Consolidator {
if threshold <= 0 || threshold >= 1 {
threshold = DefaultConsolidationThreshold
}
return &Consolidator{
chatModel: chatModel,
estimator: estimator,
maxTokens: maxContextTokens,
threshold: threshold,
}
}
// ShouldConsolidate checks if consolidation is needed based on the caller-provided
// token estimate. The estimate should come from the model API's Usage when available.
func (c *Consolidator) ShouldConsolidate(currentTokens int) bool {
if c.maxTokens <= 0 {
return false
}
triggerAt := int(float64(c.maxTokens) * c.threshold)
return currentTokens > triggerAt
}
// Consolidate summarizes older messages and returns a compressed message array.
// It preserves:
// - The system prompt (first message)
// - The current turn: user query (last user message) and all subsequent
// assistant/tool messages belonging to the same turn
// - Recent history messages that fit within the token budget
//
// Older history messages are replaced with a summary system message.
// On LLM failure after maxConsolidationAttempts, falls back to raw text archiving.
func (c *Consolidator) Consolidate(
ctx context.Context,
messages []chat.Message,
) ([]chat.Message, error) {
if len(messages) <= 3 {
return messages, nil
}
systemMsg := messages[0]
// Find the current user query — the last message with role "user".
// Everything from this point onward (user query + assistant tool_calls +
// tool results) is the active turn and must be preserved intact.
lastUserIdx := 0
for i := len(messages) - 1; i >= 1; i-- {
if messages[i].Role == "user" {
lastUserIdx = i
break
}
}
if lastUserIdx <= 1 {
return messages, nil
}
history := messages[1:lastUserIdx]
tail := messages[lastUserIdx:]
if len(history) < 2 {
return messages, nil
}
targetTokens := int(float64(c.maxTokens) * c.threshold * 0.6) // aim for 60% of threshold
tailTokens := 0
for i := range tail {
tailTokens += c.estimator.EstimateMessage(&tail[i])
}
keepFromEnd := c.findKeepBoundary(history, targetTokens, &systemMsg, tailTokens)
if keepFromEnd >= len(history) {
return messages, nil
}
toConsolidate := history[:len(history)-keepFromEnd]
toKeep := history[len(history)-keepFromEnd:]
summary, err := c.summarizeWithRetry(ctx, toConsolidate)
if err != nil {
logger.Warnf(ctx, "[MemoryConsolidator] LLM summarization failed after retries, "+
"falling back to raw archive: %v", err)
summary = c.rawArchive(toConsolidate)
}
summaryMsg := chat.Message{
Role: "system",
Content: fmt.Sprintf(
"[Memory Summary - %d earlier messages consolidated]\n\n%s",
len(toConsolidate), summary,
),
}
result := make([]chat.Message, 0, 2+len(toKeep)+len(tail))
result = append(result, systemMsg)
result = append(result, summaryMsg)
result = append(result, toKeep...)
result = append(result, tail...)
logger.Infof(ctx, "[MemoryConsolidator] Consolidated %d messages → summary (%d chars), "+
"keeping %d history + %d current-turn messages",
len(toConsolidate), len(summary), len(toKeep), len(tail))
return result, nil
}
// findKeepBoundary determines how many messages from the end of history to keep.
// Returns the count of messages to keep (from the end), respecting tool_call/tool_result boundaries.
// tailTokens is the token cost of the current-turn tail that is always preserved.
func (c *Consolidator) findKeepBoundary(
history []chat.Message,
targetTokens int,
systemMsg *chat.Message,
tailTokens int,
) int {
budget := targetTokens -
c.estimator.EstimateMessage(systemMsg) -
tailTokens -
500 // reserve for summary message
if budget <= 0 {
return 0
}
tokens := 0
keepCount := 0
i := len(history) - 1
for i >= 0 {
msg := history[i]
msgTokens := c.estimator.EstimateMessage(&msg)
if msg.Role == "tool" {
groupTokens := msgTokens
groupSize := 1
j := i - 1
for j >= 0 && history[j].Role == "tool" {
groupTokens += c.estimator.EstimateMessage(&history[j])
groupSize++
j--
}
if j >= 0 && history[j].Role == "assistant" {
groupTokens += c.estimator.EstimateMessage(&history[j])
groupSize++
}
if tokens+groupTokens > budget {
break
}
tokens += groupTokens
keepCount += groupSize
i -= groupSize
} else {
if tokens+msgTokens > budget {
break
}
tokens += msgTokens
keepCount++
i--
}
}
return keepCount
}
// summarizeWithRetry attempts LLM summarization with retries.
func (c *Consolidator) summarizeWithRetry(
ctx context.Context,
messages []chat.Message,
) (string, error) {
prompt := c.buildConsolidationPrompt(messages)
var lastErr error
for attempt := 1; attempt <= maxConsolidationAttempts; attempt++ {
summarizeCtx, cancel := context.WithTimeout(ctx, consolidationTimeout)
resp, err := c.chatModel.Chat(summarizeCtx, []chat.Message{
{Role: "system", Content: consolidationSystemPrompt},
{Role: "user", Content: prompt},
}, &chat.ChatOptions{
Temperature: 0.3, // low temperature for factual summarization
MaxTokens: 2000,
})
cancel()
if err != nil {
lastErr = err
logger.Warnf(ctx, "[MemoryConsolidator] Summarization attempt %d/%d failed: %v",
attempt, maxConsolidationAttempts, err)
continue
}
if resp != nil && resp.Content != "" {
return resp.Content, nil
}
lastErr = fmt.Errorf("empty response from LLM")
}
return "", fmt.Errorf("summarization failed after %d attempts: %w",
maxConsolidationAttempts, lastErr)
}
// buildConsolidationPrompt creates the prompt for LLM to summarize messages.
func (c *Consolidator) buildConsolidationPrompt(messages []chat.Message) string {
var sb strings.Builder
sb.WriteString("Summarize the following conversation history, preserving:\n")
sb.WriteString("1. Key facts and decisions made\n")
sb.WriteString("2. Tool execution results and their outcomes\n")
sb.WriteString("3. User's original intent and requirements\n")
sb.WriteString("4. Any errors encountered and how they were resolved\n\n")
sb.WriteString("Conversation to summarize:\n\n")
for _, msg := range messages {
switch msg.Role {
case "user":
sb.WriteString(fmt.Sprintf("**User**: %s\n\n", truncateForPrompt(msg.Content, 2000)))
case "assistant":
if len(msg.ToolCalls) > 0 {
names := make([]string, len(msg.ToolCalls))
for i, tc := range msg.ToolCalls {
names[i] = tc.Function.Name
}
sb.WriteString(fmt.Sprintf("**Assistant** [called tools: %s]: %s\n\n",
strings.Join(names, ", "), truncateForPrompt(msg.Content, 1000)))
} else {
sb.WriteString(fmt.Sprintf("**Assistant**: %s\n\n",
truncateForPrompt(msg.Content, 2000)))
}
case "tool":
sb.WriteString(fmt.Sprintf("**Tool [%s]**: %s\n\n",
msg.Name, truncateForPrompt(msg.Content, 1000)))
}
}
return sb.String()
}
// rawArchive creates a simple text dump of messages as fallback when LLM fails.
func (c *Consolidator) rawArchive(messages []chat.Message) string {
var sb strings.Builder
sb.WriteString("Raw conversation archive (LLM summarization unavailable):\n\n")
for _, msg := range messages {
content := truncateForPrompt(msg.Content, 500)
switch msg.Role {
case "user":
sb.WriteString(fmt.Sprintf("- User: %s\n", content))
case "assistant":
if len(msg.ToolCalls) > 0 {
names := make([]string, len(msg.ToolCalls))
for j, tc := range msg.ToolCalls {
names[j] = tc.Function.Name
}
sb.WriteString(fmt.Sprintf("- Assistant [tools: %s]: %s\n",
strings.Join(names, ","), content))
} else {
sb.WriteString(fmt.Sprintf("- Assistant: %s\n", content))
}
case "tool":
sb.WriteString(fmt.Sprintf("- Tool[%s]: %s\n", msg.Name, content))
}
}
return sb.String()
}
// truncateForPrompt truncates a string to maxLen characters for use in prompts.
func truncateForPrompt(s string, maxLen int) string {
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
return string(runes[:maxLen]) + "..."
}
//nolint:lll // raw string literal used for prompt readability
const consolidationSystemPrompt = "" +
"You are a conversation summarizer. " +
"Your task is to create a concise but comprehensive summary " +
"of a conversation between a user and an AI assistant.\n\n" +
"The summary should:\n" +
"- Be written in the same language as the original conversation\n" +
"- Preserve all key facts, numbers, and specific details\n" +
"- Include the outcomes of any tool executions\n" +
"- Note any errors or issues encountered\n" +
"- Be structured with clear sections if the conversation covered multiple topics\n" +
"- Be concise — aim for 30% or less of the original length\n\n" +
"Output only the summary, no preamble or explanation."