diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index dfbef9fbc..4ce9ce5de 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -88,6 +88,12 @@ func NewAgentInstance( temperature = *defaults.Temperature } + // Resolve ContextWindow: use from config if set, otherwise use maxTokens as fallback + contextWindow := defaults.ContextWindow + if contextWindow == 0 { + contextWindow = maxTokens + } + // Resolve fallback candidates modelCfg := providers.ModelConfig{ Primary: model, @@ -104,7 +110,7 @@ func NewAgentInstance( MaxIterations: maxIter, MaxTokens: maxTokens, Temperature: temperature, - ContextWindow: maxTokens, + ContextWindow: contextWindow, Provider: provider, Sessions: sessionsManager, ContextBuilder: contextBuilder, diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index bf229ad74..604d758fd 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -29,6 +29,11 @@ import ( "github.com/sipeed/picoclaw/pkg/utils" ) +const ( + DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR = 20000 + DEFAULT_COMPACTION_KEEP_RECENT_TOKENS = 20000 +) + type AgentLoop struct { bus *bus.MessageBus cfg *config.Config @@ -748,12 +753,27 @@ func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID st } // maybeSummarize triggers summarization if the session history exceeds thresholds. +// Uses token-oriented approach instead of message count to better handle large context windows. func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) { newHistory := agent.Sessions.GetHistory(sessionKey) tokenEstimate := al.estimateTokens(newHistory) - threshold := agent.ContextWindow * 75 / 100 - if len(newHistory) > 20 || tokenEstimate > threshold { + // Get compaction settings from defaults (use configured values or defaults) + reserveTokensFloor := DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR + keepRecentTokens := DEFAULT_COMPACTION_KEEP_RECENT_TOKENS + + if al.cfg != nil && al.cfg.Agents.Defaults.Compaction.ReserveTokensFloor > 0 { + reserveTokensFloor = al.cfg.Agents.Defaults.Compaction.ReserveTokensFloor + } + if al.cfg != nil && al.cfg.Agents.Defaults.Compaction.KeepRecentTokens > 0 { + keepRecentTokens = al.cfg.Agents.Defaults.Compaction.KeepRecentTokens + } + + // Trigger compaction when we're close to the context window limit + // Threshold: ContextWindow - ReserveTokensFloor (e.g., for 200k context: trigger at 180k) + triggerThreshold := agent.ContextWindow - reserveTokensFloor + + if tokenEstimate > triggerThreshold { summarizeKey := agent.ID + ":" + sessionKey if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading { go func() { @@ -765,7 +785,7 @@ func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, c Content: "Memory threshold reached. Optimizing conversation history...", }) } - al.summarizeSession(agent, sessionKey) + al.summarizeSessionWithTokenLimit(agent, sessionKey, keepRecentTokens) }() } } @@ -1020,6 +1040,106 @@ func (al *AgentLoop) summarizeBatch( return response.Content, nil } +// summarizeSessionWithTokenLimit summarizes conversation history using a token-oriented approach. +// Keeps the last keepRecentTokens tokens instead of a fixed number of messages. +func (al *AgentLoop) summarizeSessionWithTokenLimit(agent *AgentInstance, sessionKey string, keepRecentTokens int) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + history := agent.Sessions.GetHistory(sessionKey) + summary := agent.Sessions.GetSummary(sessionKey) + + // Find the split point based on token limit + // We iterate from the end, accumulating tokens until we hit the limit + keepStartIdx := len(history) + currentTokens := 0 + + for i := len(history) - 1; i >= 0; i-- { + msgTokens := al.estimateTokens([]providers.Message{history[i]}) + if currentTokens+msgTokens > keepRecentTokens { + break + } + currentTokens += msgTokens + keepStartIdx = i + } + + // If there's not enough to summarize, return + if keepStartIdx <= 1 { + return + } + + toSummarize := history[:keepStartIdx] + if len(toSummarize) == 0 { + return + } + + // Oversized Message Guard + maxMessageTokens := agent.ContextWindow / 2 + validMessages := make([]providers.Message, 0) + omitted := false + + for _, m := range toSummarize { + if m.Role != "user" && m.Role != "assistant" { + continue + } + msgTokens := len(m.Content) / 2 + if msgTokens > maxMessageTokens { + omitted = true + continue + } + validMessages = append(validMessages, m) + } + + if len(validMessages) == 0 { + return + } + + // Multi-Part Summarization + var finalSummary string + if len(validMessages) > 10 { + mid := len(validMessages) / 2 + part1 := validMessages[:mid] + part2 := validMessages[mid:] + + s1, _ := al.summarizeBatch(ctx, agent, part1, "") + s2, _ := al.summarizeBatch(ctx, agent, part2, "") + + mergePrompt := fmt.Sprintf( + "Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", + s1, + s2, + ) + resp, err := agent.Provider.Chat( + ctx, + []providers.Message{{Role: "user", Content: mergePrompt}}, + nil, + agent.Model, + map[string]any{ + "max_tokens": 1024, + "temperature": 0.3, + }, + ) + if err == nil { + finalSummary = resp.Content + } else { + finalSummary = s1 + " " + s2 + } + } else { + finalSummary, _ = al.summarizeBatch(ctx, agent, validMessages, summary) + } + + if omitted && finalSummary != "" { + finalSummary += "\n[Note: Some oversized messages were omitted from this summary for efficiency.]" + } + + if finalSummary != "" { + agent.Sessions.SetSummary(sessionKey, finalSummary) + // Keep only the messages that weren't summarized + agent.Sessions.SetHistory(sessionKey, history[keepStartIdx:]) + agent.Sessions.Save(sessionKey) + } +} + // estimateTokens estimates the number of tokens in a message list. // Uses a safe heuristic of 2.5 characters per token to account for CJK and other // overheads better than the previous 3 chars/token. diff --git a/pkg/config/config.go b/pkg/config/config.go index 036021e49..4160caa31 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -167,16 +167,24 @@ type SessionConfig struct { } type AgentDefaults struct { - Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` - RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` - Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` - Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` - ModelFallbacks []string `json:"model_fallbacks,omitempty"` - ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` - ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` - MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` - Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` - MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` + Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` + RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` + Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` + Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` + ModelFallbacks []string `json:"model_fallbacks,omitempty"` + ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` + ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` + MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` + ContextWindow int `json:"context_window,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_WINDOW"` + Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` + MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` + Compaction CompactionConfig `json:"compaction,omitempty"` +} + +type CompactionConfig struct { + ReserveTokens int `json:"reserve_tokens,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_COMPACTION_RESERVE_TOKENS"` + ReserveTokensFloor int `json:"reserve_tokens_floor,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_COMPACTION_RESERVE_TOKENS_FLOOR"` + KeepRecentTokens int `json:"keep_recent_tokens,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_COMPACTION_KEEP_RECENT_TOKENS"` } type ChannelsConfig struct {