diff --git a/internal/agent/loop_compact.go b/internal/agent/loop_compact.go index c6c3175371..906a9eef65 100644 --- a/internal/agent/loop_compact.go +++ b/internal/agent/loop_compact.go @@ -37,6 +37,8 @@ Conversation to summarize: ` +const defaultCompactionTimeout = 120 * time.Second + // compactMessagesInPlace summarizes the first ~70% of messages into a condensed // summary, keeping the last ~30% intact. Operates purely on the local messages // slice — no session state touched, no locks needed. @@ -84,7 +86,7 @@ func (l *Loop) compactMessagesInPlace(ctx context.Context, messages []providers. } } - sctx, cancel := context.WithTimeout(ctx, 30*time.Second) + sctx, cancel := context.WithTimeout(ctx, l.compactionTimeout()) defer cancel() inTokens := l.estimateSummaryInputTokens(toSummarize) @@ -132,6 +134,13 @@ func (l *Loop) compactMessagesInPlace(ctx context.Context, messages []providers. return result } +func (l *Loop) compactionTimeout() time.Duration { + if l.compactionCfg != nil && l.compactionCfg.TimeoutSeconds > 0 { + return time.Duration(l.compactionCfg.TimeoutSeconds) * time.Second + } + return defaultCompactionTimeout +} + // dynamicSummaryMax returns the output-token budget for a compaction or // summarization call, scaled to input size. Formula: in/25 (~4% compression), // clamped to [1024, 8192]. Floor keeps short summaries coherent; cap prevents diff --git a/internal/agent/loop_compact_timeout_test.go b/internal/agent/loop_compact_timeout_test.go new file mode 100644 index 0000000000..2e0f991e86 --- /dev/null +++ b/internal/agent/loop_compact_timeout_test.go @@ -0,0 +1,34 @@ +package agent + +import ( + "testing" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/config" +) + +func TestLoopCompactionTimeoutDefaultsToTwoMinutes(t *testing.T) { + loop := &Loop{} + + if got := loop.compactionTimeout(); got != 120*time.Second { + t.Fatalf("compactionTimeout() = %v, want %v", got, 120*time.Second) + } +} + +func TestLoopCompactionTimeoutIgnoresNonPositiveConfig(t *testing.T) { + for _, timeoutSeconds := range []int{0, -1} { + loop := &Loop{compactionCfg: &config.CompactionConfig{TimeoutSeconds: timeoutSeconds}} + + if got := loop.compactionTimeout(); got != 120*time.Second { + t.Fatalf("compactionTimeout() with timeoutSeconds=%d = %v, want %v", timeoutSeconds, got, 120*time.Second) + } + } +} + +func TestLoopCompactionTimeoutUsesConfiguredSeconds(t *testing.T) { + loop := &Loop{compactionCfg: &config.CompactionConfig{TimeoutSeconds: 45}} + + if got := loop.compactionTimeout(); got != 45*time.Second { + t.Fatalf("compactionTimeout() = %v, want %v", got, 45*time.Second) + } +} diff --git a/internal/channels/discord/handler.go b/internal/channels/discord/handler.go index f54e86d146..3482acefe4 100644 --- a/internal/channels/discord/handler.go +++ b/internal/channels/discord/handler.go @@ -44,12 +44,29 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate peerKind = "direct" } + // Pre-compute mention flag for groups so policy gating can suppress + // pairing replies when the bot was not addressed. + mentioned := false + if !isDM { + for _, u := range m.Mentions { + if u.ID == c.botUserID { + mentioned = true + break + } + } + if !mentioned && m.ReferencedMessage != nil && + m.ReferencedMessage.Author != nil && + m.ReferencedMessage.Author.ID == c.botUserID { + mentioned = true + } + } + if isDM { if !c.checkDMPolicy(ctx, senderID, channelID) { return } } else { - if !c.checkGroupPolicy(ctx, senderID, channelID) { + if !c.checkGroupPolicy(ctx, senderID, channelID, mentioned) { slog.Debug("discord group message rejected by policy", "user_id", senderID, "username", senderName, @@ -167,20 +184,8 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate // Mention gating: in groups, only respond when bot is @mentioned (default true). // When not mentioned, record message to pending history for later context. + // `mentioned` was pre-computed above for policy gating. if peerKind == "group" && c.RequireMention() { - mentioned := false - for _, u := range m.Mentions { - if u.ID == c.botUserID { - mentioned = true - break - } - } - // Reply to bot's message counts as implicit mention. - if !mentioned && m.ReferencedMessage != nil && - m.ReferencedMessage.Author != nil && - m.ReferencedMessage.Author.ID == c.botUserID { - mentioned = true - } if !mentioned { // Collect media file paths for group history context. var mediaPaths []string @@ -318,12 +323,17 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate } // checkGroupPolicy evaluates the group policy for a sender, with pairing support. -func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, channelID string) bool { +// When RequireMention is enabled, pairing replies only fire if the bot was +// explicitly addressed — otherwise the bot stays silent in the channel. +func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, channelID string, mentioned bool) bool { result := c.CheckGroupPolicy(ctx, senderID, channelID, c.config.GroupPolicy) switch result { case channels.PolicyAllow: return true case channels.PolicyNeedsPairing: + if c.RequireMention() && !mentioned { + return false + } groupSenderID := fmt.Sprintf("group:%s", channelID) c.sendPairingReply(ctx, groupSenderID, channelID) return false diff --git a/internal/config/config.go b/internal/config/config.go index 80ca1722be..f9c3067cbd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -156,6 +156,7 @@ type CompactionConfig struct { ReserveTokensFloor int `json:"reserveTokensFloor,omitempty"` // min reserve tokens (default 20000) MaxHistoryShare float64 `json:"maxHistoryShare,omitempty"` // max share of context for history (default 0.85) KeepLastMessages int `json:"keepLastMessages,omitempty"` // messages to keep after compaction (default 4) + TimeoutSeconds int `json:"timeoutSeconds,omitempty"` // per-compaction LLM timeout (default 120) MemoryFlush *MemoryFlushConfig `json:"memoryFlush,omitempty"` // pre-compaction flush } diff --git a/ui/web/src/i18n/locales/en/agents.json b/ui/web/src/i18n/locales/en/agents.json index f30678fe72..41b2e4a0b8 100644 --- a/ui/web/src/i18n/locales/en/agents.json +++ b/ui/web/src/i18n/locales/en/agents.json @@ -795,6 +795,8 @@ "maxHistoryShareTip": "Maximum fraction of context window for conversation history (e.g. 0.85 = 85%). Compaction triggers when exceeded.", "keepLastMessages": "Keep Last Messages", "keepLastMessagesTip": "Recent messages kept after compaction. Older messages are replaced by a summary.", + "timeoutSeconds": "Compaction Timeout (seconds)", + "timeoutSecondsTip": "Maximum time to wait for the compaction summarization call. Leave empty to use the default 120 seconds.", "memoryFlush": "Memory Flush", "memoryFlushTip": "Before compaction, the agent gets a turn to save important context to memory files. Also triggers Knowledge Graph extraction." }, diff --git a/ui/web/src/i18n/locales/en/config.json b/ui/web/src/i18n/locales/en/config.json index 105e02b955..ce11cbcd6e 100644 --- a/ui/web/src/i18n/locales/en/config.json +++ b/ui/web/src/i18n/locales/en/config.json @@ -100,6 +100,8 @@ "agents.compaction.reserveTokensFloorTip": "Minimum token buffer to reserve for new responses before compacting.", "agents.compaction.maxHistoryShare": "Max History Share (0-1)", "agents.compaction.maxHistoryShareTip": "Fraction of the context window that conversation history can occupy.", + "agents.compaction.timeoutSeconds": "Compaction Timeout (seconds)", + "agents.compaction.timeoutSecondsTip": "Maximum time to wait for compaction summarization. Leave empty to use the default 120 seconds.", "agents.pruning.title": "Context Pruning", "agents.pruning.desc": "Trim old tool results to free context space", diff --git a/ui/web/src/i18n/locales/vi/agents.json b/ui/web/src/i18n/locales/vi/agents.json index ea8d07f797..65e593b1cb 100644 --- a/ui/web/src/i18n/locales/vi/agents.json +++ b/ui/web/src/i18n/locales/vi/agents.json @@ -780,6 +780,8 @@ "maxHistoryShareTip": "Tỷ lệ tối đa của cửa sổ ngữ cảnh dành cho lịch sử hội thoại (vd: 0.85 = 85%). Nén kích hoạt khi vượt quá.", "keepLastMessages": "Giữ tin nhắn cuối", "keepLastMessagesTip": "Số tin nhắn gần nhất giữ lại sau khi nén. Các tin nhắn cũ hơn được thay thế bằng bản tóm tắt.", + "timeoutSeconds": "Timeout nén (giây)", + "timeoutSecondsTip": "Thời gian tối đa chờ lượt tóm tắt nén. Để trống để dùng mặc định 120 giây.", "memoryFlush": "Ghi nhớ trước nén", "memoryFlushTip": "Trước khi nén, agent được một lượt để lưu ngữ cảnh quan trọng vào file bộ nhớ. Cũng kích hoạt trích xuất Knowledge Graph." }, diff --git a/ui/web/src/i18n/locales/vi/config.json b/ui/web/src/i18n/locales/vi/config.json index dabd554c75..4091afc6df 100644 --- a/ui/web/src/i18n/locales/vi/config.json +++ b/ui/web/src/i18n/locales/vi/config.json @@ -100,6 +100,8 @@ "agents.compaction.reserveTokensFloorTip": "Bộ đệm token tối thiểu cần dự trữ cho phản hồi mới trước khi nén.", "agents.compaction.maxHistoryShare": "Phần lịch sử tối đa (0-1)", "agents.compaction.maxHistoryShareTip": "Tỷ lệ cửa sổ ngữ cảnh mà lịch sử cuộc trò chuyện có thể chiếm.", + "agents.compaction.timeoutSeconds": "Timeout nén (giây)", + "agents.compaction.timeoutSecondsTip": "Thời gian tối đa chờ tóm tắt nén. Để trống để dùng mặc định 120 giây.", "agents.pruning.title": "Cắt bớt ngữ cảnh", "agents.pruning.desc": "Cắt bỏ kết quả công cụ cũ để giải phóng không gian ngữ cảnh", diff --git a/ui/web/src/i18n/locales/zh/agents.json b/ui/web/src/i18n/locales/zh/agents.json index ac99513e2f..e0aa74142a 100644 --- a/ui/web/src/i18n/locales/zh/agents.json +++ b/ui/web/src/i18n/locales/zh/agents.json @@ -780,6 +780,8 @@ "maxHistoryShareTip": "上下文窗口用于对话历史的最大比例(如0.85 = 85%)。超过时触发压缩。", "keepLastMessages": "保留最后消息数", "keepLastMessagesTip": "压缩后保留的最近消息数。旧消息被摘要替换。", + "timeoutSeconds": "压缩超时(秒)", + "timeoutSecondsTip": "等待压缩摘要调用的最长时间。留空则使用默认 120 秒。", "memoryFlush": "压缩前记忆", "memoryFlushTip": "压缩前,Agent可以将重要上下文保存到记忆文件。同时触发知识图谱提取。" }, diff --git a/ui/web/src/i18n/locales/zh/config.json b/ui/web/src/i18n/locales/zh/config.json index 9ededd06ab..dffbf0300c 100644 --- a/ui/web/src/i18n/locales/zh/config.json +++ b/ui/web/src/i18n/locales/zh/config.json @@ -100,6 +100,8 @@ "agents.compaction.reserveTokensFloorTip": "压缩前为新响应保留的最小 Token 缓冲区。", "agents.compaction.maxHistoryShare": "最大历史占比(0-1)", "agents.compaction.maxHistoryShareTip": "对话历史可占用的上下文窗口比例。", + "agents.compaction.timeoutSeconds": "压缩超时(秒)", + "agents.compaction.timeoutSecondsTip": "等待压缩摘要的最长时间。留空则使用默认 120 秒。", "agents.pruning.title": "上下文裁剪", "agents.pruning.desc": "裁剪旧工具结果以释放上下文空间", diff --git a/ui/web/src/pages/agents/agent-detail/config-sections/compaction-section.tsx b/ui/web/src/pages/agents/agent-detail/config-sections/compaction-section.tsx index fd138ee7fa..9e3b9ea6e0 100644 --- a/ui/web/src/pages/agents/agent-detail/config-sections/compaction-section.tsx +++ b/ui/web/src/pages/agents/agent-detail/config-sections/compaction-section.tsx @@ -39,6 +39,16 @@ export function CompactionSection({ value, onChange }: CompactionSectionProps) { onChange={(e) => onChange({ ...value, keepLastMessages: numOrUndef(e.target.value) })} /> +