Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion internal/agent/loop_compact.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions internal/agent/loop_compact_timeout_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
40 changes: 25 additions & 15 deletions internal/channels/discord/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
2 changes: 2 additions & 0 deletions ui/web/src/i18n/locales/en/agents.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
},
Expand Down
2 changes: 2 additions & 0 deletions ui/web/src/i18n/locales/en/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions ui/web/src/i18n/locales/vi/agents.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
},
Expand Down
2 changes: 2 additions & 0 deletions ui/web/src/i18n/locales/vi/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions ui/web/src/i18n/locales/zh/agents.json
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,8 @@
"maxHistoryShareTip": "上下文窗口用于对话历史的最大比例(如0.85 = 85%)。超过时触发压缩。",
"keepLastMessages": "保留最后消息数",
"keepLastMessagesTip": "压缩后保留的最近消息数。旧消息被摘要替换。",
"timeoutSeconds": "压缩超时(秒)",
"timeoutSecondsTip": "等待压缩摘要调用的最长时间。留空则使用默认 120 秒。",
"memoryFlush": "压缩前记忆",
"memoryFlushTip": "压缩前,Agent可以将重要上下文保存到记忆文件。同时触发知识图谱提取。"
},
Expand Down
2 changes: 2 additions & 0 deletions ui/web/src/i18n/locales/zh/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "裁剪旧工具结果以释放上下文空间",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ export function CompactionSection({ value, onChange }: CompactionSectionProps) {
onChange={(e) => onChange({ ...value, keepLastMessages: numOrUndef(e.target.value) })}
/>
</div>
<div className="space-y-2">
<InfoLabel tip={t(`${s}.timeoutSecondsTip`)}>{t(`${s}.timeoutSeconds`)}</InfoLabel>
<Input
type="number"
placeholder="120"
min={1}
value={value.timeoutSeconds ?? ""}
onChange={(e) => onChange({ ...value, timeoutSeconds: numOrUndef(e.target.value) })}
/>
</div>
</div>
<div className="flex items-center gap-2">
<Switch
Expand Down
2 changes: 1 addition & 1 deletion ui/web/src/pages/config/sections/ai-defaults-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ export function AiDefaultsSection({ data, onSave, saving }: Props) {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label={t("agents.compaction.reserveTokensFloor")} tip={t("agents.compaction.reserveTokensFloorTip")} type="number" value={compaction.reserveTokensFloor} onChange={(v) => updateNested("compaction", { reserveTokensFloor: Number(v) })} placeholder="20000" />
<Field label={t("agents.compaction.maxHistoryShare")} tip={t("agents.compaction.maxHistoryShareTip")} type="number" step="0.05" value={compaction.maxHistoryShare} onChange={(v) => updateNested("compaction", { maxHistoryShare: Number(v) })} placeholder="0.75" />
<Field label={t("agents.compaction.timeoutSeconds")} tip={t("agents.compaction.timeoutSecondsTip")} type="number" value={compaction.timeoutSeconds} onChange={(v) => updateNested("compaction", { timeoutSeconds: Number(v) })} placeholder="120" />
</div>
</SubSection>

Expand Down Expand Up @@ -263,4 +264,3 @@ export function AiDefaultsSection({ data, onSave, saving }: Props) {
</Card>
);
}

1 change: 1 addition & 0 deletions ui/web/src/types/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface CompactionConfig {
reserveTokensFloor?: number;
maxHistoryShare?: number;
keepLastMessages?: number;
timeoutSeconds?: number;
memoryFlush?: {
enabled?: boolean;
softThresholdTokens?: number;
Expand Down