feat: add compaction ratio setting to control partial context compaction

Allow users to configure what percentage of older messages to compact,
keeping the most recent portion intact. Default ratio is 80%, meaning
the oldest 80% of uncompacted messages are summarized while the newest
20% remain as-is for full-fidelity context.
This commit is contained in:
Acbox
2026-03-29 19:14:43 +08:00
parent fc1ef4ddb3
commit 0e646625bf
23 changed files with 181 additions and 37 deletions
+4 -3
View File
@@ -114,10 +114,11 @@ func makeBotRow(botID, ownerUserID pgtype.UUID) *fakeRow {
*dest[15].(*string) = "" // HeartbeatPrompt
*dest[16].(*bool) = false // CompactionEnabled
*dest[17].(*int32) = 100000 // CompactionThreshold
*dest[18].(*pgtype.UUID) = pgtype.UUID{} // CompactionModelID
*dest[19].(*[]byte) = []byte(`{}`)
*dest[20].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
*dest[18].(*int32) = 80 // CompactionRatio
*dest[19].(*pgtype.UUID) = pgtype.UUID{} // CompactionModelID
*dest[20].(*[]byte) = []byte(`{}`)
*dest[21].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
*dest[22].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
return nil
},
}
+4 -3
View File
@@ -71,10 +71,11 @@ func makeBotRow(botID, ownerUserID pgtype.UUID) *fakeRow {
*dest[15].(*string) = "" // HeartbeatPrompt
*dest[16].(*bool) = false // CompactionEnabled
*dest[17].(*int32) = 100000 // CompactionThreshold
*dest[18].(*pgtype.UUID) = pgtype.UUID{} // CompactionModelID
*dest[19].(*[]byte) = []byte(`{}`)
*dest[20].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
*dest[18].(*int32) = 80 // CompactionRatio
*dest[19].(*pgtype.UUID) = pgtype.UUID{} // CompactionModelID
*dest[20].(*[]byte) = []byte(`{}`)
*dest[21].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
*dest[22].(*pgtype.Timestamptz) = pgtype.Timestamptz{}
return nil
},
}
+55 -3
View File
@@ -79,6 +79,12 @@ func (s *Service) doCompaction(ctx context.Context, logID pgtype.UUID, sessionUU
return nil
}
toCompact := splitByRatio(messages, cfg.TotalInputTokens, cfg.Ratio)
if len(toCompact) == 0 {
s.completeLog(ctx, logID, "ok", "", "", nil, pgtype.UUID{})
return nil
}
priorLogs, err := s.queries.ListCompactionLogsBySession(ctx, sessionUUID)
if err != nil {
return err
@@ -90,9 +96,9 @@ func (s *Service) doCompaction(ctx context.Context, logID pgtype.UUID, sessionUU
}
}
entries := make([]messageEntry, 0, len(messages))
messageIDs := make([]pgtype.UUID, 0, len(messages))
for _, m := range messages {
entries := make([]messageEntry, 0, len(toCompact))
messageIDs := make([]pgtype.UUID, 0, len(toCompact))
for _, m := range toCompact {
entries = append(entries, messageEntry{
Role: m.Role,
Content: extractTextContent(m.Content),
@@ -258,3 +264,49 @@ func extractTextContent(content []byte) string {
func joinTexts(parts []string) string {
return strings.Join(parts, " ")
}
// splitByRatio splits messages so that roughly the first ratio% (by token weight)
// are returned for compaction, and the rest are kept as-is.
// When ratio >= 100 or totalInputTokens <= 0, all messages are returned.
func splitByRatio(messages []sqlc.ListUncompactedMessagesBySessionRow, totalInputTokens, ratio int) []sqlc.ListUncompactedMessagesBySessionRow {
if ratio >= 100 || ratio <= 0 || totalInputTokens <= 0 || len(messages) == 0 {
return messages
}
keepTokens := totalInputTokens * (100 - ratio) / 100
if keepTokens <= 0 {
return messages
}
accumulated := 0
cutoff := len(messages)
for i := len(messages) - 1; i >= 0; i-- {
accumulated += estimateRowTokens(messages[i])
if accumulated >= keepTokens {
cutoff = i + 1
break
}
}
if cutoff <= 0 {
return nil
}
if cutoff >= len(messages) {
return messages
}
return messages[:cutoff]
}
type usagePayload struct {
OutputTokens *int `json:"output_tokens"`
}
func estimateRowTokens(m sqlc.ListUncompactedMessagesBySessionRow) int {
if len(m.Usage) > 0 {
var u usagePayload
if json.Unmarshal(m.Usage, &u) == nil && u.OutputTokens != nil && *u.OutputTokens > 0 {
return *u.OutputTokens
}
}
return len(m.Content) / 4
}
+10 -8
View File
@@ -28,12 +28,14 @@ type ListLogsResponse struct {
// TriggerConfig holds the parameters needed to trigger a compaction.
type TriggerConfig struct {
BotID string
SessionID string
ModelID string
ClientType string
APIKey string //nolint:gosec // runtime credential, not a hardcoded secret
CodexAccountID string
BaseURL string
HTTPClient *http.Client
BotID string
SessionID string
ModelID string
ClientType string
APIKey string //nolint:gosec // runtime credential, not a hardcoded secret
CodexAccountID string
BaseURL string
HTTPClient *http.Client
Ratio int
TotalInputTokens int
}
@@ -31,9 +31,16 @@ func (r *Resolver) maybeCompact(ctx context.Context, req conversation.ChatReques
modelID = rc.model.ID
}
ratio := settings.CompactionRatio
if ratio <= 0 || ratio > 100 {
ratio = 80
}
cfg := compaction.TriggerConfig{
BotID: req.BotID,
SessionID: req.SessionID,
BotID: req.BotID,
SessionID: req.SessionID,
Ratio: ratio,
TotalInputTokens: inputTokens,
}
model, err := r.modelsService.GetByID(ctx, modelID)
+3 -1
View File
@@ -94,7 +94,7 @@ func (q *Queries) DeleteBotByID(ctx context.Context, id pgtype.UUID) error {
}
const getBotByID = `-- name: GetBotByID :one
SELECT id, owner_user_id, display_name, avatar_url, timezone, is_active, status, language, reasoning_enabled, reasoning_effort, chat_model_id, search_provider_id, memory_provider_id, heartbeat_enabled, heartbeat_interval, heartbeat_prompt, compaction_enabled, compaction_threshold, compaction_model_id, metadata, created_at, updated_at
SELECT id, owner_user_id, display_name, avatar_url, timezone, is_active, status, language, reasoning_enabled, reasoning_effort, chat_model_id, search_provider_id, memory_provider_id, heartbeat_enabled, heartbeat_interval, heartbeat_prompt, compaction_enabled, compaction_threshold, compaction_ratio, compaction_model_id, metadata, created_at, updated_at
FROM bots
WHERE id = $1
`
@@ -118,6 +118,7 @@ type GetBotByIDRow struct {
HeartbeatPrompt string `json:"heartbeat_prompt"`
CompactionEnabled bool `json:"compaction_enabled"`
CompactionThreshold int32 `json:"compaction_threshold"`
CompactionRatio int32 `json:"compaction_ratio"`
CompactionModelID pgtype.UUID `json:"compaction_model_id"`
Metadata []byte `json:"metadata"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
@@ -146,6 +147,7 @@ func (q *Queries) GetBotByID(ctx context.Context, id pgtype.UUID) (GetBotByIDRow
&i.HeartbeatPrompt,
&i.CompactionEnabled,
&i.CompactionThreshold,
&i.CompactionRatio,
&i.CompactionModelID,
&i.Metadata,
&i.CreatedAt,
+1 -1
View File
@@ -511,7 +511,7 @@ WITH updated AS (
SET display_name = $1,
updated_at = now()
WHERE bots.id = $2
RETURNING id, owner_user_id, display_name, avatar_url, timezone, is_active, status, language, reasoning_enabled, reasoning_effort, chat_model_id, search_provider_id, memory_provider_id, heartbeat_enabled, heartbeat_interval, heartbeat_prompt, heartbeat_model_id, compaction_enabled, compaction_threshold, compaction_model_id, title_model_id, tts_model_id, browser_context_id, metadata, created_at, updated_at, acl_default_effect
RETURNING id, owner_user_id, display_name, avatar_url, timezone, is_active, status, language, reasoning_enabled, reasoning_effort, chat_model_id, search_provider_id, memory_provider_id, heartbeat_enabled, heartbeat_interval, heartbeat_prompt, heartbeat_model_id, compaction_enabled, compaction_threshold, compaction_ratio, compaction_model_id, title_model_id, tts_model_id, browser_context_id, metadata, created_at, updated_at, acl_default_effect
)
SELECT
updated.id AS id,
+1
View File
@@ -28,6 +28,7 @@ type Bot struct {
HeartbeatModelID pgtype.UUID `json:"heartbeat_model_id"`
CompactionEnabled bool `json:"compaction_enabled"`
CompactionThreshold int32 `json:"compaction_threshold"`
CompactionRatio int32 `json:"compaction_ratio"`
CompactionModelID pgtype.UUID `json:"compaction_model_id"`
TitleModelID pgtype.UUID `json:"title_model_id"`
TtsModelID pgtype.UUID `json:"tts_model_id"`
+20 -10
View File
@@ -21,6 +21,7 @@ SET language = 'auto',
heartbeat_prompt = '',
compaction_enabled = false,
compaction_threshold = 100000,
compaction_ratio = 80,
chat_model_id = NULL,
heartbeat_model_id = NULL,
compaction_model_id = NULL,
@@ -49,6 +50,7 @@ SELECT
bots.heartbeat_prompt,
bots.compaction_enabled,
bots.compaction_threshold,
bots.compaction_ratio,
chat_models.id AS chat_model_id,
heartbeat_models.id AS heartbeat_model_id,
compaction_models.id AS compaction_model_id,
@@ -79,6 +81,7 @@ type GetSettingsByBotIDRow struct {
HeartbeatPrompt string `json:"heartbeat_prompt"`
CompactionEnabled bool `json:"compaction_enabled"`
CompactionThreshold int32 `json:"compaction_threshold"`
CompactionRatio int32 `json:"compaction_ratio"`
ChatModelID pgtype.UUID `json:"chat_model_id"`
HeartbeatModelID pgtype.UUID `json:"heartbeat_model_id"`
CompactionModelID pgtype.UUID `json:"compaction_model_id"`
@@ -102,6 +105,7 @@ func (q *Queries) GetSettingsByBotID(ctx context.Context, id pgtype.UUID) (GetSe
&i.HeartbeatPrompt,
&i.CompactionEnabled,
&i.CompactionThreshold,
&i.CompactionRatio,
&i.ChatModelID,
&i.HeartbeatModelID,
&i.CompactionModelID,
@@ -125,17 +129,18 @@ WITH updated AS (
heartbeat_prompt = $6,
compaction_enabled = $7,
compaction_threshold = $8,
chat_model_id = COALESCE($9::uuid, bots.chat_model_id),
heartbeat_model_id = COALESCE($10::uuid, bots.heartbeat_model_id),
compaction_model_id = COALESCE($11::uuid, bots.compaction_model_id),
title_model_id = COALESCE($12::uuid, bots.title_model_id),
search_provider_id = COALESCE($13::uuid, bots.search_provider_id),
memory_provider_id = COALESCE($14::uuid, bots.memory_provider_id),
tts_model_id = COALESCE($15::uuid, bots.tts_model_id),
browser_context_id = COALESCE($16::uuid, bots.browser_context_id),
compaction_ratio = $9,
chat_model_id = COALESCE($10::uuid, bots.chat_model_id),
heartbeat_model_id = COALESCE($11::uuid, bots.heartbeat_model_id),
compaction_model_id = COALESCE($12::uuid, bots.compaction_model_id),
title_model_id = COALESCE($13::uuid, bots.title_model_id),
search_provider_id = COALESCE($14::uuid, bots.search_provider_id),
memory_provider_id = COALESCE($15::uuid, bots.memory_provider_id),
tts_model_id = COALESCE($16::uuid, bots.tts_model_id),
browser_context_id = COALESCE($17::uuid, bots.browser_context_id),
updated_at = now()
WHERE bots.id = $17
RETURNING bots.id, bots.language, bots.reasoning_enabled, bots.reasoning_effort, bots.heartbeat_enabled, bots.heartbeat_interval, bots.heartbeat_prompt, bots.compaction_enabled, bots.compaction_threshold, bots.chat_model_id, bots.heartbeat_model_id, bots.compaction_model_id, bots.title_model_id, bots.search_provider_id, bots.memory_provider_id, bots.tts_model_id, bots.browser_context_id
WHERE bots.id = $18
RETURNING bots.id, bots.language, bots.reasoning_enabled, bots.reasoning_effort, bots.heartbeat_enabled, bots.heartbeat_interval, bots.heartbeat_prompt, bots.compaction_enabled, bots.compaction_threshold, bots.compaction_ratio, bots.chat_model_id, bots.heartbeat_model_id, bots.compaction_model_id, bots.title_model_id, bots.search_provider_id, bots.memory_provider_id, bots.tts_model_id, bots.browser_context_id
)
SELECT
updated.id AS bot_id,
@@ -147,6 +152,7 @@ SELECT
updated.heartbeat_prompt,
updated.compaction_enabled,
updated.compaction_threshold,
updated.compaction_ratio,
chat_models.id AS chat_model_id,
heartbeat_models.id AS heartbeat_model_id,
compaction_models.id AS compaction_model_id,
@@ -175,6 +181,7 @@ type UpsertBotSettingsParams struct {
HeartbeatPrompt string `json:"heartbeat_prompt"`
CompactionEnabled bool `json:"compaction_enabled"`
CompactionThreshold int32 `json:"compaction_threshold"`
CompactionRatio int32 `json:"compaction_ratio"`
ChatModelID pgtype.UUID `json:"chat_model_id"`
HeartbeatModelID pgtype.UUID `json:"heartbeat_model_id"`
CompactionModelID pgtype.UUID `json:"compaction_model_id"`
@@ -196,6 +203,7 @@ type UpsertBotSettingsRow struct {
HeartbeatPrompt string `json:"heartbeat_prompt"`
CompactionEnabled bool `json:"compaction_enabled"`
CompactionThreshold int32 `json:"compaction_threshold"`
CompactionRatio int32 `json:"compaction_ratio"`
ChatModelID pgtype.UUID `json:"chat_model_id"`
HeartbeatModelID pgtype.UUID `json:"heartbeat_model_id"`
CompactionModelID pgtype.UUID `json:"compaction_model_id"`
@@ -216,6 +224,7 @@ func (q *Queries) UpsertBotSettings(ctx context.Context, arg UpsertBotSettingsPa
arg.HeartbeatPrompt,
arg.CompactionEnabled,
arg.CompactionThreshold,
arg.CompactionRatio,
arg.ChatModelID,
arg.HeartbeatModelID,
arg.CompactionModelID,
@@ -237,6 +246,7 @@ func (q *Queries) UpsertBotSettings(ctx context.Context, arg UpsertBotSettingsPa
&i.HeartbeatPrompt,
&i.CompactionEnabled,
&i.CompactionThreshold,
&i.CompactionRatio,
&i.ChatModelID,
&i.HeartbeatModelID,
&i.CompactionModelID,
+14 -3
View File
@@ -69,7 +69,7 @@ func (s *Service) UpsertBot(ctx context.Context, botID string, req UpsertRequest
if err != nil {
return Settings{}, err
}
current := normalizeBotSetting(botRow.Language, aclDefaultEffect, botRow.ReasoningEnabled, botRow.ReasoningEffort, botRow.HeartbeatEnabled, botRow.HeartbeatInterval, botRow.CompactionEnabled, botRow.CompactionThreshold)
current := normalizeBotSetting(botRow.Language, aclDefaultEffect, botRow.ReasoningEnabled, botRow.ReasoningEffort, botRow.HeartbeatEnabled, botRow.HeartbeatInterval, botRow.CompactionEnabled, botRow.CompactionThreshold, botRow.CompactionRatio)
if strings.TrimSpace(req.Language) != "" {
current.Language = strings.TrimSpace(req.Language)
}
@@ -94,6 +94,9 @@ func (s *Service) UpsertBot(ctx context.Context, botID string, req UpsertRequest
if req.CompactionThreshold != nil && *req.CompactionThreshold >= 0 {
current.CompactionThreshold = *req.CompactionThreshold
}
if req.CompactionRatio != nil && *req.CompactionRatio >= 1 && *req.CompactionRatio <= 100 {
current.CompactionRatio = *req.CompactionRatio
}
chatModelUUID := pgtype.UUID{}
if value := strings.TrimSpace(req.ChatModelID); value != "" {
modelID, err := s.resolveModelUUID(ctx, value)
@@ -170,6 +173,7 @@ func (s *Service) UpsertBot(ctx context.Context, botID string, req UpsertRequest
HeartbeatPrompt: "",
CompactionEnabled: current.CompactionEnabled,
CompactionThreshold: int32(current.CompactionThreshold), //nolint:gosec // bounded by non-negative setter above
CompactionRatio: int32(current.CompactionRatio), //nolint:gosec // bounded 1-100 above
ChatModelID: chatModelUUID,
HeartbeatModelID: heartbeatModelUUID,
CompactionModelID: compactionModelUUID,
@@ -209,7 +213,7 @@ func (s *Service) Delete(ctx context.Context, botID string) error {
return nil
}
func normalizeBotSetting(language string, aclDefaultEffect string, reasoningEnabled bool, reasoningEffort string, heartbeatEnabled bool, heartbeatInterval int32, compactionEnabled bool, compactionThreshold int32) Settings {
func normalizeBotSetting(language string, aclDefaultEffect string, reasoningEnabled bool, reasoningEffort string, heartbeatEnabled bool, heartbeatInterval int32, compactionEnabled bool, compactionThreshold int32, compactionRatio int32) Settings {
settings := Settings{
Language: strings.TrimSpace(language),
AclDefaultEffect: strings.TrimSpace(aclDefaultEffect),
@@ -219,6 +223,7 @@ func normalizeBotSetting(language string, aclDefaultEffect string, reasoningEnab
HeartbeatInterval: int(heartbeatInterval),
CompactionEnabled: compactionEnabled,
CompactionThreshold: int(compactionThreshold),
CompactionRatio: int(compactionRatio),
}
if settings.Language == "" {
settings.Language = DefaultLanguage
@@ -235,6 +240,9 @@ func normalizeBotSetting(language string, aclDefaultEffect string, reasoningEnab
if settings.CompactionThreshold < 0 {
settings.CompactionThreshold = 0
}
if settings.CompactionRatio < 1 || settings.CompactionRatio > 100 {
settings.CompactionRatio = 80
}
return settings
}
@@ -256,6 +264,7 @@ func normalizeBotSettingsReadRow(row sqlc.GetSettingsByBotIDRow) Settings {
row.HeartbeatInterval,
row.CompactionEnabled,
row.CompactionThreshold,
row.CompactionRatio,
row.ChatModelID,
row.HeartbeatModelID,
row.CompactionModelID,
@@ -276,6 +285,7 @@ func normalizeBotSettingsWriteRow(row sqlc.UpsertBotSettingsRow) Settings {
row.HeartbeatInterval,
row.CompactionEnabled,
row.CompactionThreshold,
row.CompactionRatio,
row.ChatModelID,
row.HeartbeatModelID,
row.CompactionModelID,
@@ -295,6 +305,7 @@ func normalizeBotSettingsFields(
heartbeatInterval int32,
compactionEnabled bool,
compactionThreshold int32,
compactionRatio int32,
chatModelID pgtype.UUID,
heartbeatModelID pgtype.UUID,
compactionModelID pgtype.UUID,
@@ -304,7 +315,7 @@ func normalizeBotSettingsFields(
ttsModelID pgtype.UUID,
browserContextID pgtype.UUID,
) Settings {
settings := normalizeBotSetting(language, "", reasoningEnabled, reasoningEffort, heartbeatEnabled, heartbeatInterval, compactionEnabled, compactionThreshold)
settings := normalizeBotSetting(language, "", reasoningEnabled, reasoningEffort, heartbeatEnabled, heartbeatInterval, compactionEnabled, compactionThreshold, compactionRatio)
if chatModelID.Valid {
settings.ChatModelID = uuid.UUID(chatModelID.Bytes).String()
}
+2
View File
@@ -22,6 +22,7 @@ type Settings struct {
TitleModelID string `json:"title_model_id"`
CompactionEnabled bool `json:"compaction_enabled"`
CompactionThreshold int `json:"compaction_threshold"`
CompactionRatio int `json:"compaction_ratio"`
CompactionModelID string `json:"compaction_model_id,omitempty"`
}
@@ -41,5 +42,6 @@ type UpsertRequest struct {
TitleModelID string `json:"title_model_id,omitempty"`
CompactionEnabled *bool `json:"compaction_enabled,omitempty"`
CompactionThreshold *int `json:"compaction_threshold,omitempty"`
CompactionRatio *int `json:"compaction_ratio,omitempty"`
CompactionModelID *string `json:"compaction_model_id,omitempty"`
}