feat: add context compaction to automatically summarize old messages (#compaction) (#276)

When input tokens exceed a configurable threshold after a conversation round,
the system asynchronously compacts older messages into a summary. Cascading
compactions reference prior summaries via <prior_context> tags to maintain
conversational continuity without duplicating content.

- Add bot_history_message_compacts table and compact_id on messages
- Add compaction_enabled, compaction_threshold, compaction_model_id to bots
- Implement compaction service (internal/compaction) with LLM summarization
- Integrate into conversation flow: replace compacted messages with summaries
  wrapped in <summary> tags during context loading
- Add REST API endpoints (GET/DELETE /bots/:bot_id/compaction/logs)
- Add frontend Compaction tab with settings and log viewer
- Wire compaction service into both dev (cmd/agent) and prod (cmd/memoh) entry points
- Update test mocks to include new GetBotByID columns
This commit is contained in:
Acbox Liu
2026-03-22 14:26:00 +08:00
committed by GitHub
parent 91e5e44509
commit de62f94315
40 changed files with 2375 additions and 197 deletions
+22
View File
@@ -172,6 +172,9 @@ CREATE TABLE IF NOT EXISTS bots (
heartbeat_interval INTEGER NOT NULL DEFAULT 30,
heartbeat_prompt TEXT NOT NULL DEFAULT '',
heartbeat_model_id UUID REFERENCES models(id) ON DELETE SET NULL,
compaction_enabled BOOLEAN NOT NULL DEFAULT false,
compaction_threshold INTEGER NOT NULL DEFAULT 100000,
compaction_model_id UUID REFERENCES models(id) ON DELETE SET NULL,
title_model_id UUID REFERENCES models(id) ON DELETE SET NULL,
tts_model_id UUID REFERENCES tts_models(id) ON DELETE SET NULL,
browser_context_id UUID REFERENCES browser_contexts(id) ON DELETE SET NULL,
@@ -373,10 +376,12 @@ CREATE TABLE IF NOT EXISTS bot_history_messages (
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
usage JSONB,
model_id UUID REFERENCES models(id) ON DELETE SET NULL,
compact_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_bot_history_messages_bot_created ON bot_history_messages(bot_id, created_at);
CREATE INDEX IF NOT EXISTS idx_bot_history_messages_compact ON bot_history_messages(compact_id);
CREATE INDEX IF NOT EXISTS idx_bot_history_messages_session
ON bot_history_messages(session_id, created_at);
CREATE INDEX IF NOT EXISTS idx_bot_history_messages_session_source
@@ -539,6 +544,23 @@ CREATE TABLE IF NOT EXISTS bot_heartbeat_logs (
CREATE INDEX IF NOT EXISTS idx_heartbeat_logs_bot_started ON bot_heartbeat_logs(bot_id, started_at DESC);
CREATE TABLE IF NOT EXISTS bot_history_message_compacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
session_id UUID REFERENCES bot_sessions(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'ok', 'error')),
summary TEXT NOT NULL DEFAULT '',
message_count INTEGER NOT NULL DEFAULT 0,
error_message TEXT NOT NULL DEFAULT '',
usage JSONB,
model_id UUID REFERENCES models(id) ON DELETE SET NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_compacts_bot_session ON bot_history_message_compacts(bot_id, session_id, started_at DESC);
ALTER TABLE bot_history_messages ADD CONSTRAINT fk_compact_id FOREIGN KEY (compact_id) REFERENCES bot_history_message_compacts(id) ON DELETE SET NULL;
-- schedule_logs: structured execution records for scheduled tasks.
CREATE TABLE IF NOT EXISTS schedule_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+12
View File
@@ -0,0 +1,12 @@
-- 0040_compaction (down)
-- Revert context compaction support.
ALTER TABLE bots DROP COLUMN IF EXISTS compaction_model_id;
ALTER TABLE bots DROP COLUMN IF EXISTS compaction_threshold;
ALTER TABLE bots DROP COLUMN IF EXISTS compaction_enabled;
DROP INDEX IF EXISTS idx_bot_history_messages_compact;
ALTER TABLE bot_history_messages DROP COLUMN IF EXISTS compact_id;
DROP INDEX IF EXISTS idx_compacts_bot_session;
DROP TABLE IF EXISTS bot_history_message_compacts;
+28
View File
@@ -0,0 +1,28 @@
-- 0040_compaction
-- Add context compaction support: compaction logs table, compact_id on messages, bot settings columns.
-- bot_history_message_compacts: stores compaction records and summaries.
CREATE TABLE IF NOT EXISTS bot_history_message_compacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
session_id UUID REFERENCES bot_sessions(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'ok', 'error')),
summary TEXT NOT NULL DEFAULT '',
message_count INTEGER NOT NULL DEFAULT 0,
error_message TEXT NOT NULL DEFAULT '',
usage JSONB,
model_id UUID REFERENCES models(id) ON DELETE SET NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_compacts_bot_session ON bot_history_message_compacts(bot_id, session_id, started_at DESC);
-- Add compact_id to messages to track which compaction a message belongs to.
ALTER TABLE bot_history_messages ADD COLUMN IF NOT EXISTS compact_id UUID REFERENCES bot_history_message_compacts(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_bot_history_messages_compact ON bot_history_messages(compact_id);
-- Bot-level compaction settings.
ALTER TABLE bots ADD COLUMN IF NOT EXISTS compaction_enabled BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE bots ADD COLUMN IF NOT EXISTS compaction_threshold INTEGER NOT NULL DEFAULT 100000;
ALTER TABLE bots ADD COLUMN IF NOT EXISTS compaction_model_id UUID REFERENCES models(id) ON DELETE SET NULL;
+1 -1
View File
@@ -4,7 +4,7 @@ VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, owner_user_id, display_name, avatar_url, is_active, status, max_context_load_time, max_context_tokens, language, reasoning_enabled, reasoning_effort, chat_model_id, search_provider_id, memory_provider_id, heartbeat_enabled, heartbeat_interval, heartbeat_prompt, metadata, created_at, updated_at;
-- name: GetBotByID :one
SELECT id, owner_user_id, display_name, avatar_url, is_active, status, max_context_load_time, max_context_tokens, language, reasoning_enabled, reasoning_effort, chat_model_id, search_provider_id, memory_provider_id, heartbeat_enabled, heartbeat_interval, heartbeat_prompt, metadata, created_at, updated_at
SELECT id, owner_user_id, display_name, avatar_url, is_active, status, max_context_load_time, max_context_tokens, 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
FROM bots
WHERE id = $1;
+38
View File
@@ -0,0 +1,38 @@
-- name: CreateCompactionLog :one
INSERT INTO bot_history_message_compacts (bot_id, session_id)
VALUES ($1, $2)
RETURNING id, bot_id, session_id, status, summary, message_count, error_message, usage, model_id, started_at, completed_at;
-- name: CompleteCompactionLog :one
UPDATE bot_history_message_compacts
SET status = $2,
summary = $3,
message_count = $4,
error_message = $5,
usage = $6,
model_id = $7,
completed_at = now()
WHERE id = $1
RETURNING id, bot_id, session_id, status, summary, message_count, error_message, usage, model_id, started_at, completed_at;
-- name: GetCompactionLogByID :one
SELECT id, bot_id, session_id, status, summary, message_count, error_message, usage, model_id, started_at, completed_at
FROM bot_history_message_compacts
WHERE id = $1;
-- name: ListCompactionLogsByBot :many
SELECT id, bot_id, session_id, status, summary, message_count, error_message, usage, model_id, started_at, completed_at
FROM bot_history_message_compacts
WHERE bot_id = $1
AND ($2::timestamptz IS NULL OR started_at < $2)
ORDER BY started_at DESC
LIMIT $3;
-- name: ListCompactionLogsBySession :many
SELECT id, bot_id, session_id, status, summary, message_count, error_message, usage, model_id, started_at, completed_at
FROM bot_history_message_compacts
WHERE session_id = $1
ORDER BY started_at ASC;
-- name: DeleteCompactionLogsByBot :exec
DELETE FROM bot_history_message_compacts WHERE bot_id = $1;
+15
View File
@@ -148,6 +148,7 @@ SELECT
m.content,
m.metadata,
m.usage,
m.compact_id,
m.created_at,
ci.display_name AS sender_display_name,
ci.avatar_url AS sender_avatar_url,
@@ -173,6 +174,7 @@ SELECT
m.content,
m.metadata,
m.usage,
m.compact_id,
m.created_at,
ci.display_name AS sender_display_name,
ci.avatar_url AS sender_avatar_url,
@@ -360,3 +362,16 @@ WHERE m.bot_id = sqlc.arg(bot_id)
) ILIKE '%' || sqlc.narg(keyword)::text || '%')
ORDER BY m.created_at DESC
LIMIT sqlc.arg(max_count);
-- name: MarkMessagesCompacted :exec
UPDATE bot_history_messages
SET compact_id = $1
WHERE id = ANY($2::uuid[]);
-- name: ListUncompactedMessagesBySession :many
SELECT id, chat_id, session_id, role, content, usage, platform, external_message_id, sender_channel_identity_id, compact_id, created_at
FROM bot_history_messages
WHERE session_id = $1
AND compact_id IS NULL
AND is_active = true
ORDER BY created_at ASC;
+15 -1
View File
@@ -9,8 +9,11 @@ SELECT
bots.heartbeat_enabled,
bots.heartbeat_interval,
bots.heartbeat_prompt,
bots.compaction_enabled,
bots.compaction_threshold,
chat_models.id AS chat_model_id,
heartbeat_models.id AS heartbeat_model_id,
compaction_models.id AS compaction_model_id,
title_models.id AS title_model_id,
search_providers.id AS search_provider_id,
memory_providers.id AS memory_provider_id,
@@ -19,6 +22,7 @@ SELECT
FROM bots
LEFT JOIN models AS chat_models ON chat_models.id = bots.chat_model_id
LEFT JOIN models AS heartbeat_models ON heartbeat_models.id = bots.heartbeat_model_id
LEFT JOIN models AS compaction_models ON compaction_models.id = bots.compaction_model_id
LEFT JOIN models AS title_models ON title_models.id = bots.title_model_id
LEFT JOIN search_providers ON search_providers.id = bots.search_provider_id
LEFT JOIN memory_providers ON memory_providers.id = bots.memory_provider_id
@@ -37,8 +41,11 @@ WITH updated AS (
heartbeat_enabled = sqlc.arg(heartbeat_enabled),
heartbeat_interval = sqlc.arg(heartbeat_interval),
heartbeat_prompt = sqlc.arg(heartbeat_prompt),
compaction_enabled = sqlc.arg(compaction_enabled),
compaction_threshold = sqlc.arg(compaction_threshold),
chat_model_id = COALESCE(sqlc.narg(chat_model_id)::uuid, bots.chat_model_id),
heartbeat_model_id = COALESCE(sqlc.narg(heartbeat_model_id)::uuid, bots.heartbeat_model_id),
compaction_model_id = COALESCE(sqlc.narg(compaction_model_id)::uuid, bots.compaction_model_id),
title_model_id = COALESCE(sqlc.narg(title_model_id)::uuid, bots.title_model_id),
search_provider_id = COALESCE(sqlc.narg(search_provider_id)::uuid, bots.search_provider_id),
memory_provider_id = COALESCE(sqlc.narg(memory_provider_id)::uuid, bots.memory_provider_id),
@@ -46,7 +53,7 @@ WITH updated AS (
browser_context_id = COALESCE(sqlc.narg(browser_context_id)::uuid, bots.browser_context_id),
updated_at = now()
WHERE bots.id = sqlc.arg(id)
RETURNING bots.id, bots.max_context_load_time, bots.max_context_tokens, bots.language, bots.reasoning_enabled, bots.reasoning_effort, bots.heartbeat_enabled, bots.heartbeat_interval, bots.heartbeat_prompt, bots.chat_model_id, bots.heartbeat_model_id, bots.title_model_id, bots.search_provider_id, bots.memory_provider_id, bots.tts_model_id, bots.browser_context_id
RETURNING bots.id, bots.max_context_load_time, bots.max_context_tokens, 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
)
SELECT
updated.id AS bot_id,
@@ -58,8 +65,11 @@ SELECT
updated.heartbeat_enabled,
updated.heartbeat_interval,
updated.heartbeat_prompt,
updated.compaction_enabled,
updated.compaction_threshold,
chat_models.id AS chat_model_id,
heartbeat_models.id AS heartbeat_model_id,
compaction_models.id AS compaction_model_id,
title_models.id AS title_model_id,
search_providers.id AS search_provider_id,
memory_providers.id AS memory_provider_id,
@@ -68,6 +78,7 @@ SELECT
FROM updated
LEFT JOIN models AS chat_models ON chat_models.id = updated.chat_model_id
LEFT JOIN models AS heartbeat_models ON heartbeat_models.id = updated.heartbeat_model_id
LEFT JOIN models AS compaction_models ON compaction_models.id = updated.compaction_model_id
LEFT JOIN models AS title_models ON title_models.id = updated.title_model_id
LEFT JOIN search_providers ON search_providers.id = updated.search_provider_id
LEFT JOIN memory_providers ON memory_providers.id = updated.memory_provider_id
@@ -84,8 +95,11 @@ SET max_context_load_time = 1440,
heartbeat_enabled = false,
heartbeat_interval = 30,
heartbeat_prompt = '',
compaction_enabled = false,
compaction_threshold = 100000,
chat_model_id = NULL,
heartbeat_model_id = NULL,
compaction_model_id = NULL,
title_model_id = NULL,
search_provider_id = NULL,
memory_provider_id = NULL,