feat(compaction): configurable provider, model & max_tokens for pending message compaction

Add Provider, Model, MaxTokens to PendingCompactionConfig so users can
override the LLM used for pending message summarization via the config
UI. Falls back to agent's provider/model when not set. Increase default
max_tokens from 512 to 4096. Add allowEmpty prop to ProviderModelSelect
to prevent auto-selecting first provider when empty means "use default".
This commit is contained in:
viettranx committed 2026-03-10 14:27:15 +07:00
1 parent 004f6055ff
commit 1ddd2e547a
10 files changed
+167 -35

No files matched your search

+10 -1
View File
@@ -14,6 +14,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/cache"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/channels/discord"
"github.com/nextlevelbuilder/goclaw/internal/channels/feishu"
@@ -618,6 +619,8 @@ func runGateway() {
if pendingMessagesH != nil {
if pc := cfg.Channels.PendingCompaction; pc != nil {
pendingMessagesH.SetKeepRecent(pc.KeepRecent)
pendingMessagesH.SetMaxTokens(pc.MaxTokens)
pendingMessagesH.SetProviderModel(pc.Provider, pc.Model)
}
server.SetPendingMessagesHandler(pendingMessagesH)
}
@@ -852,7 +855,13 @@ func runGateway() {
webFetchTool.UpdatePolicy(updatedCfg.Tools.WebFetch.Policy, updatedCfg.Tools.WebFetch.AllowedDomains, updatedCfg.Tools.WebFetch.BlockedDomains)
})
go consumeInboundMessages(ctx, msgBus, agentRouter, cfg, sched, channelMgr, consumerTeamStore, quotaChecker, delegateMgr, pgStores.Sessions, pgStores.Agents)
// Contact collector: auto-collect user info from channels with in-memory dedup cache.
var contactCollector *store.ContactCollector
if pgStores.Contacts != nil {
contactCollector = store.NewContactCollector(pgStores.Contacts, cache.NewInMemoryCache[bool]())
}
go consumeInboundMessages(ctx, msgBus, agentRouter, cfg, sched, channelMgr, consumerTeamStore, quotaChecker, delegateMgr, pgStores.Sessions, pgStores.Agents, contactCollector)
go func() {
sig := <-sigCh
+7 -3
View File
@@ -17,6 +17,7 @@ import (
type CompactionConfig struct {
Threshold int // trigger compaction when entries exceed this (default 50)
KeepRecent int // keep this many recent raw messages (default 15)
MaxTokens int // max output tokens for summarization (default 4096)
Provider providers.Provider // LLM provider for summarization
Model string // model to use for summarization
}
@@ -44,10 +45,13 @@ func (ph *PendingHistory) MaybeCompact(historyKey string, currentCount int, cfg
// CompactGroup performs LLM-based compaction on a pending message group.
// Reused by both auto-compact (channel) and HTTP compact endpoint.
// Returns the number of entries remaining after compaction.
func CompactGroup(ctx context.Context, s store.PendingMessageStore, channelName, historyKey string, provider providers.Provider, model string, keepRecent int) (int, error) {
func CompactGroup(ctx context.Context, s store.PendingMessageStore, channelName, historyKey string, provider providers.Provider, model string, keepRecent, maxTokens int) (int, error) {
if keepRecent <= 0 {
keepRecent = 15
}
if maxTokens <= 0 {
maxTokens = 4096
}
// Step 1: Read entries from DB
entries, err := s.ListByKey(ctx, channelName, historyKey)
@@ -86,7 +90,7 @@ func CompactGroup(ctx context.Context, s store.PendingMessageStore, channelName,
Content: "Summarize these group chat messages concisely, preserving key topics, decisions, names, and important context:\n\n" + sb.String(),
}},
Model: model,
Options: map[string]any{"max_tokens": 512, "temperature": 0.3},
Options: map[string]any{"max_tokens": maxTokens, "temperature": 0.3},
})
if err != nil {
return 0, fmt.Errorf("llm summarize: %w", err)
@@ -140,7 +144,7 @@ func (ph *PendingHistory) runCompaction(historyKey string, cfg *CompactionConfig
return
}
_, err = CompactGroup(ctx, ph.store, ph.channelName, historyKey, cfg.Provider, cfg.Model, cfg.KeepRecent)
_, err = CompactGroup(ctx, ph.store, ph.channelName, historyKey, cfg.Provider, cfg.Model, cfg.KeepRecent, cfg.MaxTokens)
if err != nil {
slog.Warn("compaction.failed", "channel", ph.channelName, "key", historyKey, "error", err)
return
+38 -19
View File
@@ -235,28 +235,47 @@ func (l *InstanceLoader) loadInstance(ctx context.Context, inst store.ChannelIns
base.SetType(inst.ChannelType)
}
// Wire pending message auto-compaction using the agent's configured provider+model.
if pc, ok := ch.(PendingCompactable); ok && ag != nil && l.providerReg != nil && ag.Provider != "" {
if p, err := l.providerReg.Get(ag.Provider); err == nil {
model := ag.Model
if model == "" {
model = p.DefaultModel()
}
if model != "" {
cc := &CompactionConfig{
Provider: p,
Model: model,
// Wire pending message auto-compaction.
// Priority: config provider/model > agent's provider/model > fallback.
if pc, ok := ch.(PendingCompactable); ok && l.providerReg != nil {
var p providers.Provider
var model string
// Try config-level provider/model first.
if l.pendingCompactCfg != nil && l.pendingCompactCfg.Provider != "" {
if cp, err := l.providerReg.Get(l.pendingCompactCfg.Provider); err == nil {
p = cp
model = l.pendingCompactCfg.Model
if model == "" {
model = cp.DefaultModel()
}
// Apply global threshold/keepRecent from config if set.
if l.pendingCompactCfg != nil {
cc.Threshold = l.pendingCompactCfg.Threshold
cc.KeepRecent = l.pendingCompactCfg.KeepRecent
}
pc.SetPendingCompaction(cc)
slog.Debug("pending compaction configured", "channel", inst.Name, "provider", ag.Provider, "model", model,
"threshold", cc.Threshold, "keep_recent", cc.KeepRecent)
}
}
// Fallback: agent's provider/model.
if p == nil && ag != nil && ag.Provider != "" {
if ap, err := l.providerReg.Get(ag.Provider); err == nil {
p = ap
model = ag.Model
if model == "" {
model = ap.DefaultModel()
}
}
}
if p != nil && model != "" {
cc := &CompactionConfig{
Provider: p,
Model: model,
}
if l.pendingCompactCfg != nil {
cc.Threshold = l.pendingCompactCfg.Threshold
cc.KeepRecent = l.pendingCompactCfg.KeepRecent
cc.MaxTokens = l.pendingCompactCfg.MaxTokens
}
pc.SetPendingCompaction(cc)
slog.Debug("pending compaction configured", "channel", inst.Name, "provider", p.Name(), "model", model,
"threshold", cc.Threshold, "keep_recent", cc.KeepRecent, "max_tokens", cc.MaxTokens)
}
}
l.manager.RegisterChannel(inst.Name, ch)
l.loaded[inst.Name] = struct{}{}
+5 -2
View File
@@ -4,8 +4,11 @@ package config
// When a group accumulates more than Threshold pending messages, older messages are
// summarized by an LLM and replaced with a compact summary, keeping KeepRecent raw messages.
type PendingCompactionConfig struct {
Threshold int `json:"threshold,omitempty"` // trigger compaction when entries exceed this (default 50)
KeepRecent int `json:"keep_recent,omitempty"` // keep this many recent raw messages after compaction (default 15)
Threshold int `json:"threshold,omitempty"` // trigger compaction when entries exceed this (default 50)
KeepRecent int `json:"keep_recent,omitempty"` // keep this many recent raw messages after compaction (default 15)
MaxTokens int `json:"max_tokens,omitempty"` // max output tokens for LLM summarization (default 4096)
Provider string `json:"provider,omitempty"` // LLM provider name (e.g. "openai"); empty = use agent's provider
Model string `json:"model,omitempty"` // model for summarization; empty = use agent's model
}
// ChannelsConfig contains per-channel configuration.
+29 -5
View File
@@ -19,7 +19,10 @@ type PendingMessagesHandler struct {
agentStore store.AgentStore
token string
providerReg *providers.Registry
keepRecent int // global keepRecent from config (0 = use default 15)
keepRecent int // global keepRecent from config (0 = use default 15)
maxTokens int // max output tokens for LLM summarization (0 = use default)
cfgProvider string // config-level provider override (empty = resolve from agent)
cfgModel string // config-level model override (empty = resolve from agent)
}
func NewPendingMessagesHandler(s store.PendingMessageStore, agentStore store.AgentStore, token string, providerReg *providers.Registry) *PendingMessagesHandler {
@@ -29,6 +32,15 @@ func NewPendingMessagesHandler(s store.PendingMessageStore, agentStore store.Age
// SetKeepRecent sets the global keepRecent value from config.
func (h *PendingMessagesHandler) SetKeepRecent(n int) { h.keepRecent = n }
// SetMaxTokens sets the global maxTokens value from config.
func (h *PendingMessagesHandler) SetMaxTokens(n int) { h.maxTokens = n }
// SetProviderModel sets explicit provider/model from config.
func (h *PendingMessagesHandler) SetProviderModel(provider, model string) {
h.cfgProvider = provider
h.cfgModel = model
}
func (h *PendingMessagesHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/pending-messages", h.authMiddleware(h.handleListGroups))
mux.HandleFunc("GET /v1/pending-messages/messages", h.authMiddleware(h.handleListMessages))
@@ -146,7 +158,7 @@ func (h *PendingMessagesHandler) handleCompact(w http.ResponseWriter, r *http.Re
if keepRecent <= 0 {
keepRecent = 15
}
remaining, err := channels.CompactGroup(ctx, h.store, req.ChannelName, req.HistoryKey, provider, model, keepRecent)
remaining, err := channels.CompactGroup(ctx, h.store, req.ChannelName, req.HistoryKey, provider, model, keepRecent, h.maxTokens)
if err != nil {
slog.Warn("compact.failed", "channel", req.ChannelName, "key", req.HistoryKey, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -156,14 +168,26 @@ func (h *PendingMessagesHandler) handleCompact(w http.ResponseWriter, r *http.Re
}
// resolveProviderAndModel resolves the LLM provider+model for pending message compaction.
// Uses the default agent's configured provider and model so compaction uses the same
// LLM as the agent that processes these messages.
// Priority: config provider/model > default agent's provider/model > first available provider.
func (h *PendingMessagesHandler) resolveProviderAndModel() (providers.Provider, string) {
if h.providerReg == nil {
return nil, ""
}
// Use the default agent's provider+model
// Config-level provider/model override.
if h.cfgProvider != "" {
if p, err := h.providerReg.Get(h.cfgProvider); err == nil {
model := h.cfgModel
if model == "" {
model = p.DefaultModel()
}
if model != "" {
return p, model
}
}
}
// Fallback: default agent's provider+model.
if h.agentStore != nil {
if ag, err := h.agentStore.GetDefault(context.Background()); err == nil && ag.Provider != "" {
if p, err := h.providerReg.Get(ag.Provider); err == nil {
@@ -34,6 +34,8 @@ interface ProviderModelSelectProps {
savedModel?: string;
/** Called when verification status changes. True = save should be blocked (changed but not verified). */
onSaveBlockedChange?: (blocked: boolean) => void;
/** When true, skip auto-selecting the first provider when none is set. Useful when empty means "use default". */
allowEmpty?: boolean;
}
export function ProviderModelSelect({
@@ -51,17 +53,18 @@ export function ProviderModelSelect({
savedProvider,
savedModel,
onSaveBlockedChange,
allowEmpty,
}: ProviderModelSelectProps) {
const { t } = useTranslation("common");
const { providers } = useProviders();
const enabledProviders = providers.filter((p) => p.enabled);
// Auto-select first enabled provider when none is set
// Auto-select first enabled provider when none is set (unless allowEmpty)
useEffect(() => {
if (!provider && enabledProviders.length > 0) {
if (!allowEmpty && !provider && enabledProviders.length > 0) {
onProviderChange(enabledProviders[0]!.name);
}
}, [provider, enabledProviders, onProviderChange]);
}, [allowEmpty, provider, enabledProviders, onProviderChange]);
const selectedProvider = useMemo(
() => enabledProviders.find((p) => p.name === provider),
+8
View File
@@ -130,6 +130,14 @@
"behavior.pendingCompactionThresholdTip": "Trigger compaction when a group accumulates more than this many pending messages. Set to 0 to use the default (50).",
"behavior.pendingCompactionKeepRecent": "Keep Recent",
"behavior.pendingCompactionKeepRecentTip": "Number of recent raw messages to keep after compaction. Older messages are replaced with an LLM summary. Default is 15.",
"behavior.pendingCompactionMaxTokens": "Max Tokens",
"behavior.pendingCompactionMaxTokensTip": "Maximum output tokens for LLM summarization. Default is 4096.",
"behavior.pendingCompactionProvider": "Provider",
"behavior.pendingCompactionProviderTip": "LLM provider for summarization. Leave empty to use the channel agent's provider.",
"behavior.pendingCompactionProviderPlaceholder": "Use agent's provider",
"behavior.pendingCompactionModel": "Model",
"behavior.pendingCompactionModelTip": "Model for summarization. Leave empty to use the channel agent's model.",
"behavior.pendingCompactionModelPlaceholder": "Use agent's model",
"behavior.pendingCompactionInfo": "When a group chat exceeds the threshold, the LLM automatically summarizes older messages into a single compact entry, keeping the most recent ones intact for context.",
"behavior.rateLimitTitle": "Rate Limiting",
"behavior.rateLimitDescription": "Inbound message size limits and per-user request throttling",
+8
View File
@@ -130,6 +130,14 @@
"behavior.pendingCompactionThresholdTip": "Kích hoạt nén khi nhóm tích lũy nhiều hơn số tin nhắn chờ này. Đặt 0 để dùng mặc định (50).",
"behavior.pendingCompactionKeepRecent": "Giữ gần đây",
"behavior.pendingCompactionKeepRecentTip": "Số tin nhắn gần đây giữ lại sau khi nén. Tin cũ hơn được thay bằng bản tóm tắt LLM. Mặc định là 15.",
"behavior.pendingCompactionMaxTokens": "Token tối đa",
"behavior.pendingCompactionMaxTokensTip": "Số token tối đa cho kết quả tóm tắt LLM. Mặc định 4096.",
"behavior.pendingCompactionProvider": "Nhà cung cấp",
"behavior.pendingCompactionProviderTip": "Nhà cung cấp LLM cho tóm tắt. Để trống sẽ dùng provider của agent kênh.",
"behavior.pendingCompactionProviderPlaceholder": "Dùng provider của agent",
"behavior.pendingCompactionModel": "Mô hình",
"behavior.pendingCompactionModelTip": "Mô hình LLM cho tóm tắt. Để trống sẽ dùng model của agent kênh.",
"behavior.pendingCompactionModelPlaceholder": "Dùng model của agent",
"behavior.pendingCompactionInfo": "Khi nhóm chat vượt ngưỡng, LLM tự động tóm tắt các tin nhắn cũ thành một mục rút gọn, giữ nguyên các tin gần đây nhất làm ngữ cảnh.",
"behavior.rateLimitTitle": "Giới hạn tốc độ",
"behavior.rateLimitDescription": "Giới hạn kích thước tin nhắn đến và kiểm soát tốc độ yêu cầu mỗi người dùng",
+8
View File
@@ -130,6 +130,14 @@
"behavior.pendingCompactionThresholdTip": "当群组累积超过此数量的待处理消息时触发压缩。设为 0 使用默认值(50)。",
"behavior.pendingCompactionKeepRecent": "保留最近",
"behavior.pendingCompactionKeepRecentTip": "压缩后保留的最近原始消息数量。较旧的消息将替换为 LLM 摘要。默认为 15。",
"behavior.pendingCompactionMaxTokens": "最大 Token 数",
"behavior.pendingCompactionMaxTokensTip": "LLM 摘要的最大输出 token 数。默认 4096。",
"behavior.pendingCompactionProvider": "提供商",
"behavior.pendingCompactionProviderTip": "用于摘要的 LLM 提供商。留空将使用频道 Agent 的提供商。",
"behavior.pendingCompactionProviderPlaceholder": "使用 Agent 的提供商",
"behavior.pendingCompactionModel": "模型",
"behavior.pendingCompactionModelTip": "用于摘要的模型。留空将使用频道 Agent 的模型。",
"behavior.pendingCompactionModelPlaceholder": "使用 Agent 的模型",
"behavior.pendingCompactionInfo": "当群聊超过阈值时,LLM 自动将旧消息总结为一条精简条目,保留最近的消息作为上下文。",
"behavior.rateLimitTitle": "速率限制",
"behavior.rateLimitDescription": "入站消息大小限制和每用户请求限流",
@@ -1,4 +1,4 @@
import { Archive, Clock, Info } from "lucide-react";
import { Archive, Clock, Hash, Info } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -9,10 +9,14 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ProviderModelSelect } from "@/components/shared/provider-model-select";
export interface PendingCompactionValues {
threshold?: number;
keep_recent?: number;
max_tokens?: number;
provider?: string;
model?: string;
}
interface Props {
@@ -38,6 +42,23 @@ export function BehaviorPendingCompactionCard({ value, onChange }: Props) {
</CardDescription>
</CardHeader>
<CardContent className="space-y-0">
{/* Provider & Model */}
<div className="border-b py-4">
<ProviderModelSelect
provider={value.provider ?? ""}
onProviderChange={(v) => update({ provider: v })}
model={value.model ?? ""}
onModelChange={(v) => update({ model: v })}
providerLabel={t("behavior.pendingCompactionProvider")}
modelLabel={t("behavior.pendingCompactionModel")}
providerTip={t("behavior.pendingCompactionProviderTip")}
modelTip={t("behavior.pendingCompactionModelTip")}
providerPlaceholder={t("behavior.pendingCompactionProviderPlaceholder")}
modelPlaceholder={t("behavior.pendingCompactionModelPlaceholder")}
allowEmpty
/>
</div>
{/* Threshold */}
<div className="border-b py-4">
<div className="flex items-start justify-between gap-4">
@@ -64,7 +85,7 @@ export function BehaviorPendingCompactionCard({ value, onChange }: Props) {
</div>
{/* Keep Recent */}
<div className="py-4">
<div className="border-b py-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<Clock className="mt-0.5 h-4 w-4 shrink-0 text-blue-500" />
@@ -88,6 +109,31 @@ export function BehaviorPendingCompactionCard({ value, onChange }: Props) {
</div>
</div>
{/* Max Tokens */}
<div className="py-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<Hash className="mt-0.5 h-4 w-4 shrink-0 text-purple-500" />
<div className="space-y-1">
<Label className="text-sm font-medium">
{t("behavior.pendingCompactionMaxTokens")}
</Label>
<p className="text-xs text-muted-foreground">
{t("behavior.pendingCompactionMaxTokensTip")}
</p>
</div>
</div>
<Input
type="number"
value={value.max_tokens ?? ""}
onChange={(e) => update({ max_tokens: Number(e.target.value) })}
placeholder="4096"
min={256}
className="w-24 shrink-0"
/>
</div>
</div>
{/* Info banner */}
<div className="flex items-start gap-2 rounded-md border border-orange-200 bg-orange-50 px-3 py-2 text-xs text-orange-700 dark:border-orange-800 dark:bg-orange-950/30 dark:text-orange-300">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />