Files
DemoCoder/context_compaction.go
2026-04-21 13:19:17 +03:00

337 lines
8.8 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"gocoder/agents"
"gocoder/db"
)
const (
defaultContextCompactionTriggerRatio = 0.80
defaultContextCompactionTargetRatio = 0.70
contextCompactionSummaryFloor = 256
contextCompactionSummaryCeiling = 1024
contextCompactionSummaryDivisor = 20
)
func configuredCompactionTriggerRatio() float64 {
cfg := currentConfig()
if cfg.LLM.ContextCompactionTriggerRatio > 0 && cfg.LLM.ContextCompactionTriggerRatio < 1 {
return cfg.LLM.ContextCompactionTriggerRatio
}
return defaultContextCompactionTriggerRatio
}
func configuredCompactionTargetRatio() float64 {
trigger := configuredCompactionTriggerRatio()
ratio := defaultContextCompactionTargetRatio
cfg := currentConfig()
if cfg.LLM.ContextCompactionTargetRatio > 0 && cfg.LLM.ContextCompactionTargetRatio < 1 {
ratio = cfg.LLM.ContextCompactionTargetRatio
}
if ratio >= trigger {
ratio = trigger * 0.875
if ratio <= 0 {
ratio = defaultContextCompactionTargetRatio
}
}
return ratio
}
func effectiveContextTokenLimit() int {
cfg := currentConfig()
if cfg.LLM.ContextTokens > 0 {
return cfg.LLM.ContextTokens
}
if cfg.LLM.MaxTokens > 0 {
return cfg.LLM.MaxTokens
}
return 0
}
func estimateTextTokens(text string) int {
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return 0
}
tokens := len(trimmed)/4 + 1
if tokens < 1 {
tokens = 1
}
return tokens
}
func estimateMessageTokens(msg db.Message) int {
tokens := estimateTextTokens(msg.Content)
if tokens == 0 {
return 0
}
return tokens + 4
}
func estimatePromptTokens(messages []db.Message) int {
total := 0
for _, msg := range messages {
total += estimateMessageTokens(msg)
}
return total
}
func formatConversationSummary(summary string, summaryCutoffOrder int) string {
trimmed := strings.TrimSpace(summary)
if trimmed == "" {
return ""
}
if summaryCutoffOrder > 0 {
return fmt.Sprintf("Conversation memory summary up to message %d:\n%s", summaryCutoffOrder, trimmed)
}
return "Conversation memory summary:\n" + trimmed
}
func buildPromptHistory(history []db.Message, summary string, summaryCutoffOrder int) []db.Message {
promptHistory := make([]db.Message, 0, len(history)+1)
if summaryText := formatConversationSummary(summary, summaryCutoffOrder); summaryText != "" {
promptHistory = append(promptHistory, db.Message{
Role: "system",
Kind: "message",
Content: summaryText,
})
}
for _, msg := range history {
if summaryCutoffOrder > 0 && msg.Order <= summaryCutoffOrder {
continue
}
promptHistory = append(promptHistory, msg)
}
return promptHistory
}
func filterMessagesAfterOrder(history []db.Message, order int) []db.Message {
if order <= 0 {
return append([]db.Message(nil), history...)
}
filtered := make([]db.Message, 0, len(history))
for _, msg := range history {
if msg.Order > order {
filtered = append(filtered, msg)
}
}
return filtered
}
func selectMessagesThroughOrder(history []db.Message, startExclusive, endInclusive int) []db.Message {
if endInclusive <= startExclusive {
return nil
}
selected := make([]db.Message, 0)
for _, msg := range history {
if msg.Order <= startExclusive {
continue
}
if msg.Order > endInclusive {
break
}
selected = append(selected, msg)
}
return selected
}
func estimateSummaryReserveTokens(limit int) int {
reserve := limit / contextCompactionSummaryDivisor
if reserve < contextCompactionSummaryFloor {
reserve = contextCompactionSummaryFloor
}
if reserve > contextCompactionSummaryCeiling {
reserve = contextCompactionSummaryCeiling
}
return reserve
}
func selectCompactionCutoff(systemMsgs []db.Message, history []db.Message, summary string, summaryCutoffOrder int, userMessage string, contextLimit int) (int, bool) {
if contextLimit <= 0 || len(history) == 0 {
return 0, false
}
trigger := int(float64(contextLimit) * configuredCompactionTriggerRatio())
target := int(float64(contextLimit) * configuredCompactionTargetRatio())
if target < 1 {
target = 1
}
currentEstimate := estimatePromptTokens(buildPromptMessages(systemMsgs, buildPromptHistory(history, summary, summaryCutoffOrder), userMessage))
if currentEstimate <= trigger {
return 0, false
}
summaryReserve := estimateSummaryReserveTokens(contextLimit)
for _, msg := range history {
if msg.Order <= summaryCutoffOrder {
continue
}
tailHistory := filterMessagesAfterOrder(history, msg.Order)
estimate := estimatePromptTokens(buildPromptMessages(systemMsgs, buildPromptHistory(tailHistory, "", 0), userMessage)) + summaryReserve
if estimate <= target {
return msg.Order, true
}
}
lastOrder := 0
for i := len(history) - 1; i >= 0; i-- {
if history[i].Order > summaryCutoffOrder {
lastOrder = history[i].Order
break
}
}
if lastOrder > summaryCutoffOrder {
return lastOrder, true
}
return 0, false
}
func renderCompactionTranscript(messages []db.Message) string {
if len(messages) == 0 {
return ""
}
var b strings.Builder
for i, msg := range messages {
if i > 0 {
b.WriteString("\n")
}
fmt.Fprintf(&b, "Order %d | role=%s | kind=%s", msg.Order, strings.TrimSpace(msg.Role), strings.TrimSpace(msg.Kind))
if name := strings.TrimSpace(msg.Name); name != "" {
fmt.Fprintf(&b, " | name=%s", name)
}
if agent := strings.TrimSpace(msg.Agent); agent != "" {
fmt.Fprintf(&b, " | agent=%s", agent)
}
b.WriteString("\n")
content := strings.TrimSpace(msg.Content)
if content == "" {
content = "[empty]"
}
b.WriteString(content)
b.WriteString("\n")
}
return strings.TrimSpace(b.String())
}
func callLMStudioCompletion(ctx context.Context, messages []db.Message, maxTokens int, temperature float64) (string, error) {
requestCtx := ctx
if requestCtx == nil {
requestCtx = context.Background()
}
cfg := currentConfig()
reqBody := buildLMStudioRequestWithLimits(messages, false, cfg.LLM, maxTokens, temperature)
jsonData, err := json.Marshal(reqBody)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(requestCtx, http.MethodPost, cfg.LLM.BaseURL+"/chat/completions", bytes.NewBuffer(jsonData))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
_, _ = io.ReadAll(resp.Body)
return "", fmt.Errorf("LMStudio API error: status=%d", resp.StatusCode)
}
var lmResp LMStudioResponse
if err := json.NewDecoder(resp.Body).Decode(&lmResp); err != nil {
return "", err
}
if len(lmResp.Choices) == 0 {
return "", fmt.Errorf("empty response from LLM")
}
content := strings.TrimSpace(lmResp.Choices[0].Message.Content)
if content == "" {
return "", fmt.Errorf("empty response from LLM")
}
return content, nil
}
func summarizeConversationChunk(ctx context.Context, existingSummary string, messages []db.Message, summaryReserveTokens int) (string, error) {
messagesToSend := []db.Message{
{
Role: "system",
Content: agents.ContextCompactorSystemMessage(),
},
}
if trimmed := strings.TrimSpace(existingSummary); trimmed != "" {
messagesToSend = append(messagesToSend, db.Message{
Role: "system",
Content: "Existing memory to preserve and compress:\n" + trimmed,
})
}
transcript := renderCompactionTranscript(messages)
if transcript != "" {
messagesToSend = append(messagesToSend, db.Message{
Role: "user",
Content: "Compress the following conversation chunk into durable memory:\n\n" + transcript,
})
}
return callLMStudioCompletion(ctx, messagesToSend, summaryReserveTokens, 0.1)
}
func maybeCompactConversationContext(ctx context.Context, conv *db.Conversation, history []db.Message, systemMsgs []db.Message, userMessage string) bool {
if conv == nil || store == nil {
return false
}
contextLimit := effectiveContextTokenLimit()
cutoff, ok := selectCompactionCutoff(systemMsgs, history, conv.ContextSummary, conv.ContextSummaryOrder, userMessage, contextLimit)
if !ok || cutoff <= conv.ContextSummaryOrder {
return false
}
chunk := selectMessagesThroughOrder(history, conv.ContextSummaryOrder, cutoff)
if len(chunk) == 0 {
return false
}
summaryReserveTokens := estimateSummaryReserveTokens(contextLimit)
nextSummary, err := summarizeConversationChunk(ctx, conv.ContextSummary, chunk, summaryReserveTokens)
if err != nil {
log.Printf("failed to compact conversation %s through order %d: %v", conv.ID, cutoff, err)
return false
}
if err := store.UpdateConversationContextSummary(conv.ID, nextSummary, cutoff); err != nil {
log.Printf("failed to store compacted conversation memory for %s: %v", conv.ID, err)
return false
}
conv.ContextSummary = nextSummary
conv.ContextSummaryOrder = cutoff
log.Printf("compacted conversation %s context through message %d", conv.ID, cutoff)
return true
}