diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 6b1d2169..204f343d 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -271,7 +271,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) } }) } else { - resp, err = l.provider.Chat(callCtx, chatReq) + resp, err = provider.Chat(callCtx, chatReq) } if err != nil { @@ -308,24 +308,51 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) rs.totalUsage.ThinkingTokens += resp.Usage.ThinkingTokens } - // Mid-loop compaction: same threshold as maybeSummarize (contextWindow * historyShare) - // but applied to in-memory messages during the run. Prevents context overflow for - // long-running agents (e.g. delegated research tasks that accumulate many tool results). + // Mid-loop context management: uses history-only token count (excludes overhead + // from system prompt, tool definitions, context files) for threshold comparison. + // Two-phase approach: prune old tool results first, then compact only if still over budget. if !rs.midLoopCompacted && l.contextWindow > 0 { historyShare := config.DefaultHistoryShare if l.compactionCfg != nil && l.compactionCfg.MaxHistoryShare > 0 { historyShare = l.compactionCfg.MaxHistoryShare } - threshold := int(float64(l.contextWindow) * historyShare) + historyBudget := int(float64(l.contextWindow) * historyShare) - promptTokens := 0 - if resp.Usage != nil && resp.Usage.PromptTokens > 0 { - promptTokens = resp.Usage.PromptTokens - } else { - promptTokens = EstimateTokens(messages) + // Calibrate overhead on first LLM response with usage data. + if !rs.overheadCalibrated && resp.Usage != nil && resp.Usage.PromptTokens > 0 { + historyEst := EstimateHistoryTokens(messages) + rs.overheadTokens = resp.Usage.PromptTokens - historyEst + if rs.overheadTokens < 0 { + rs.overheadTokens = 0 + } + rs.overheadCalibrated = true } - if promptTokens >= threshold { + // Compute history-only token count (excludes system prompt/tools overhead). + historyTokens := 0 + if resp.Usage != nil && resp.Usage.PromptTokens > 0 && rs.overheadCalibrated { + historyTokens = resp.Usage.PromptTokens - rs.overheadTokens + } else { + historyTokens = EstimateHistoryTokens(messages) + } + + // Phase 1: Prune old tool results before resorting to full compaction (at 70% of budget). + if historyTokens >= int(float64(historyBudget)*0.7) && !rs.midLoopPruned { + rs.midLoopPruned = true + pruned := pruneContextMessages(messages, l.contextWindow, l.contextPruningCfg) + if len(pruned) > 0 { + messages = pruned + historyTokens = EstimateHistoryTokens(messages) + } + slog.Info("mid_loop_pruning", + "agent", l.id, + "history_tokens", historyTokens, + "budget", historyBudget, + "overhead", rs.overheadTokens) + } + + // Phase 2: Full compaction only if still over budget after pruning. + if historyTokens >= historyBudget { rs.midLoopCompacted = true emitRun(AgentEvent{ Type: protocol.AgentEventActivity, @@ -338,8 +365,9 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) } slog.Info("mid_loop_compaction", "agent", l.id, - "prompt_tokens", promptTokens, - "threshold", threshold, + "history_tokens", historyTokens, + "budget", historyBudget, + "overhead", rs.overheadTokens, "context_window", l.contextWindow) } } diff --git a/internal/agent/loop_history.go b/internal/agent/loop_history.go index aa112831..aa61e2c6 100644 --- a/internal/agent/loop_history.go +++ b/internal/agent/loop_history.go @@ -543,9 +543,15 @@ func sanitizeHistory(msgs []providers.Message) ([]providers.Message, int) { func (l *Loop) maybeSummarize(ctx context.Context, sessionKey string) { history := l.sessions.GetHistory(ctx, sessionKey) - // Use calibrated token estimation when available. + // Use calibrated token estimation, adjusted for overhead. + // lastPromptTokens includes everything (system prompt, tools, context files, history). + // We subtract estimated overhead so the threshold comparison is history-only. lastPT, lastMC := l.sessions.GetLastPromptTokens(ctx, sessionKey) - tokenEstimate := EstimateTokensWithCalibration(history, lastPT, lastMC) + adjustedLastPT := lastPT - l.estimateOverhead(history, lastPT, lastMC) + if adjustedLastPT < 0 { + adjustedLastPT = 0 + } + tokenEstimate := EstimateTokensWithCalibration(history, adjustedLastPT, lastMC) // Resolve compaction thresholds from config with sensible defaults. historyShare := config.DefaultHistoryShare @@ -657,6 +663,37 @@ func (l *Loop) maybeSummarize(ctx context.Context, sessionKey string) { }() } +// estimateOverhead derives the non-history token overhead (system prompt + tool definitions + +// context files) from calibration data. Used by maybeSummarize to compare history-only tokens +// against the compaction threshold. +func (l *Loop) estimateOverhead(history []providers.Message, lastPromptTokens, lastMsgCount int) int { + if lastPromptTokens <= 0 || lastMsgCount <= 0 { + // No calibration data — use conservative default (20% of context, capped at 40k). + fallback := int(float64(l.contextWindow) * 0.2) + if fallback > 40000 { + fallback = 40000 + } + return fallback + } + + // Overhead = total prompt tokens - estimated history tokens at calibration time. + count := lastMsgCount + if count > len(history) { + count = len(history) + } + historyEstAtCalibration := EstimateTokens(history[:count]) + overhead := lastPromptTokens - historyEstAtCalibration + if overhead < 0 { + overhead = 0 + } + // Clamp: overhead shouldn't exceed 40% of context window. + maxOverhead := int(float64(l.contextWindow) * 0.4) + if overhead > maxOverhead { + overhead = maxOverhead + } + return overhead +} + // buildGroupWriterPrompt builds the system prompt section for group file writer restrictions. // For non-writers: injects refusal instructions + removes SOUL.md/AGENTS.md from context files. func (l *Loop) buildGroupWriterPrompt(ctx context.Context, groupID, senderID string, files []bootstrap.ContextFile) (string, []bootstrap.ContextFile) { diff --git a/internal/agent/loop_history_test.go b/internal/agent/loop_history_test.go index 189a452b..e95daa25 100644 --- a/internal/agent/loop_history_test.go +++ b/internal/agent/loop_history_test.go @@ -3,6 +3,7 @@ package agent import ( "testing" + "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/providers" ) @@ -394,6 +395,121 @@ func TestEstimateTokens(t *testing.T) { } } +func TestEstimateTokensIncludesToolCalls(t *testing.T) { + // Message with tool calls should estimate higher than content-only. + contentOnly := []providers.Message{ + {Role: "assistant", Content: "Let me search for that."}, + } + withToolCalls := []providers.Message{ + {Role: "assistant", Content: "Let me search for that.", ToolCalls: []providers.ToolCall{ + {ID: "tc_1", Name: "web_fetch", Arguments: map[string]any{ + "url": "https://example.com/very-long-path/to/some/resource?query=test", + }}, + }}, + } + + contentTokens := EstimateTokens(contentOnly) + toolTokens := EstimateTokens(withToolCalls) + + if toolTokens <= contentTokens { + t.Errorf("expected tool call tokens (%d) > content-only tokens (%d)", toolTokens, contentTokens) + } +} + +func TestEstimateHistoryTokensSkipsSystem(t *testing.T) { + msgs := []providers.Message{ + {Role: "system", Content: "You are a helpful assistant with a very long system prompt that includes tool definitions and context files..."}, + {Role: "user", Content: "Hello"}, + {Role: "assistant", Content: "Hi there!"}, + } + + allTokens := EstimateTokens(msgs) + historyTokens := EstimateHistoryTokens(msgs) + + if historyTokens >= allTokens { + t.Errorf("history tokens (%d) should be less than all tokens (%d)", historyTokens, allTokens) + } + + // History should only count user + assistant messages. + expectedHistory := EstimateTokens(msgs[1:]) + if historyTokens != expectedHistory { + t.Errorf("history tokens (%d) != expected (%d)", historyTokens, expectedHistory) + } +} + +func TestEstimateOverhead(t *testing.T) { + loop := &Loop{contextWindow: 200000} + + t.Run("no_calibration_data", func(t *testing.T) { + history := []providers.Message{{Role: "user", Content: "Hello"}} + overhead := loop.estimateOverhead(history, 0, 0) + // Should use fallback: 20% of 200k = 40000 + if overhead != 40000 { + t.Errorf("expected fallback 40000, got %d", overhead) + } + }) + + t.Run("with_calibration", func(t *testing.T) { + history := []providers.Message{ + {Role: "user", Content: "Hello world"}, + {Role: "assistant", Content: "Hi there!"}, + } + // Simulate: LLM reported 50k prompt tokens for 2 messages. + // History estimate for those 2 messages is small (~7 tokens). + // So overhead should be ~49993. + overhead := loop.estimateOverhead(history, 50000, 2) + if overhead <= 0 { + t.Errorf("expected positive overhead, got %d", overhead) + } + // Should be clamped to 40% of context = 80000 + maxOverhead := int(float64(200000) * 0.4) + if overhead > maxOverhead { + t.Errorf("overhead %d exceeds max %d", overhead, maxOverhead) + } + }) +} + +func TestPruneContextMessagesDefaultEnabled(t *testing.T) { + // With nil config, pruning should run (not return early). + // Create messages with a large tool result that exceeds soft trim threshold. + largeContent := make([]byte, 10000) + for i := range largeContent { + largeContent[i] = 'x' + } + msgs := []providers.Message{ + {Role: "user", Content: "Search for info"}, + {Role: "assistant", Content: "I'll search.", ToolCalls: []providers.ToolCall{{ID: "tc1", Name: "web_fetch"}}}, + {Role: "tool", Content: string(largeContent), ToolCallID: "tc1"}, + {Role: "assistant", Content: "Found results."}, + {Role: "user", Content: "Thanks"}, + {Role: "assistant", Content: "You're welcome."}, + {Role: "user", Content: "More info"}, + {Role: "assistant", Content: "Sure, here you go."}, + } + + // With nil config and small context window (to trigger soft trim ratio > 0.3), + // pruning should trim the large tool result. + result := pruneContextMessages(msgs, 5000, nil) + + // The large tool result should have been trimmed. + toolMsg := result[2] + if len(toolMsg.Content) >= 10000 { + t.Error("expected large tool result to be trimmed, but it was not") + } +} + +func TestPruneContextMessagesExplicitOff(t *testing.T) { + msgs := []providers.Message{ + {Role: "user", Content: "Hello"}, + } + cfg := &config.ContextPruningConfig{Mode: "off"} + result := pruneContextMessages(msgs, 200000, cfg) + // Should return original messages unchanged. + if len(result) != len(msgs) { + t.Errorf("expected %d messages, got %d", len(msgs), len(result)) + } +} + func TestTruncateStr(t *testing.T) { tests := []struct { name string diff --git a/internal/agent/loop_tracing.go b/internal/agent/loop_tracing.go index 1b466586..e4f6b401 100644 --- a/internal/agent/loop_tracing.go +++ b/internal/agent/loop_tracing.go @@ -373,12 +373,46 @@ func truncateStr(s string, maxLen int) string { return "..." + s[start:] } +// estimateMessageTokens returns a rough token estimate for a single message, +// including content text and tool call arguments. +func estimateMessageTokens(m providers.Message) int { + tokens := utf8.RuneCountInString(m.Content) / 3 + for _, tc := range m.ToolCalls { + tokens += len(tc.ID)/3 + len(tc.Name)/3 + for k, v := range tc.Arguments { + tokens += len(k) / 3 + switch val := v.(type) { + case string: + tokens += len(val) / 3 + default: + tokens += 10 // small fixed estimate for non-string args (numbers, booleans, etc.) + } + } + } + return tokens +} + // EstimateTokens returns a rough token estimate for a slice of messages. +// Includes content text and tool call arguments (JSON overhead). // Used internally for summarization thresholds and externally for adaptive throttle. func EstimateTokens(messages []providers.Message) int { total := 0 for _, m := range messages { - total += utf8.RuneCountInString(m.Content) / 3 + total += estimateMessageTokens(m) + } + return total +} + +// EstimateHistoryTokens estimates tokens for history messages only, +// excluding system messages (which are overhead: system prompt, tool defs, context files). +// Used for compaction threshold checks where we need history-only token count. +func EstimateHistoryTokens(messages []providers.Message) int { + total := 0 + for _, m := range messages { + if m.Role == "system" { + continue + } + total += estimateMessageTokens(m) } return total } @@ -402,7 +436,7 @@ func EstimateTokensWithCalibration(messages []providers.Message, lastPromptToken // Estimate only the new messages with the heuristic and add to base. delta := 0 for _, m := range messages[lastMsgCount:] { - delta += utf8.RuneCountInString(m.Content) / 3 + delta += estimateMessageTokens(m) } return lastPromptTokens + delta } diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index 5eeb54da..f77d833c 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -502,8 +502,11 @@ type runState struct { // Crash safety checkpointFlushedMsgs int - // Mid-loop compaction - midLoopCompacted bool + // Mid-loop compaction and overhead calibration + midLoopCompacted bool + midLoopPruned bool + overheadTokens int // non-history token overhead (system prompt + tools + context files) + overheadCalibrated bool // Bootstrap detection bootstrapWriteDetected bool diff --git a/internal/agent/pruning.go b/internal/agent/pruning.go index 0aec34fb..ef1e7137 100644 --- a/internal/agent/pruning.go +++ b/internal/agent/pruning.go @@ -99,7 +99,8 @@ func resolvePruningSettings(cfg *config.ContextPruningConfig) *effectivePruningS // Only tool results older than keepLastAssistants are eligible for pruning. // Returns a new slice if any changes were made, otherwise the original. func pruneContextMessages(msgs []providers.Message, contextWindowTokens int, cfg *config.ContextPruningConfig) []providers.Message { - if cfg == nil || cfg.Mode != "cache-ttl" { + // Pruning runs by default for all providers. Only skip when explicitly disabled. + if cfg != nil && cfg.Mode == "off" { return msgs } if contextWindowTokens <= 0 || len(msgs) == 0 { diff --git a/internal/config/config_load.go b/internal/config/config_load.go index 2f2b06d8..6fc5a343 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -71,7 +71,6 @@ func Load(path string) (*Config, error) { if err != nil { if os.IsNotExist(err) { cfg.applyEnvOverrides() - cfg.applyContextPruningDefaults() return cfg, nil } return nil, fmt.Errorf("read config: %w", err) @@ -82,7 +81,6 @@ func Load(path string) (*Config, error) { } cfg.applyEnvOverrides() - cfg.applyContextPruningDefaults() return cfg, nil } @@ -273,29 +271,6 @@ func (c *Config) applyEnvOverrides() { } } -// applyContextPruningDefaults auto-enables context pruning when the Anthropic -// provider is configured, matching TS applyContextPruningDefaults() in -// src/config/defaults.ts. -// -// Go port does not have OAuth vs API-key distinction — we always treat it as -// API-key mode. -func (c *Config) applyContextPruningDefaults() { - // Only apply when Anthropic is configured. - if c.Providers.Anthropic.APIKey == "" { - return - } - - defaults := &c.Agents.Defaults - - // Auto-enable context pruning if mode not explicitly set. - if defaults.ContextPruning == nil { - defaults.ContextPruning = &ContextPruningConfig{ - Mode: "cache-ttl", - } - } else if defaults.ContextPruning.Mode == "" { - defaults.ContextPruning.Mode = "cache-ttl" - } -} // Save writes the config to a JSON file. func Save(path string, cfg *Config) error { @@ -419,7 +394,6 @@ func (c *Config) ResolveDisplayName(agentID string) string { // Call this after modifying config to restore runtime secrets from env vars. func (c *Config) ApplyEnvOverrides() { c.applyEnvOverrides() - c.applyContextPruningDefaults() } // ExpandHome replaces leading ~ with the user home directory. diff --git a/ui/web/src/i18n/locales/en/agents.json b/ui/web/src/i18n/locales/en/agents.json index 0098a907..7ca70acd 100644 --- a/ui/web/src/i18n/locales/en/agents.json +++ b/ui/web/src/i18n/locales/en/agents.json @@ -601,6 +601,7 @@ "contextPruning": { "title": "Context Pruning", "description": "Trim old tool results to save context window", + "descriptionSimple": "Automatically trims old tool results to prevent context overflow. Enabled by default.", "mode": "Mode", "keepLastAssistants": "Keep Last Assistants", "softTrimRatio": "Soft Trim Ratio (0-1)", @@ -612,7 +613,8 @@ "tailChars": "Tail Chars", "hardClear": "Hard Clear", "enabled": "Enabled", - "placeholderText": "Placeholder Text" + "placeholderText": "Placeholder Text", + "advanced": "Advanced" }, "sandbox": { "title": "Sandbox", diff --git a/ui/web/src/i18n/locales/vi/agents.json b/ui/web/src/i18n/locales/vi/agents.json index 925d1f47..4b862d48 100644 --- a/ui/web/src/i18n/locales/vi/agents.json +++ b/ui/web/src/i18n/locales/vi/agents.json @@ -612,7 +612,9 @@ "tailChars": "Ký tự cuối", "hardClear": "Xóa cứng", "enabled": "Đã bật", - "placeholderText": "Văn bản thay thế" + "placeholderText": "Văn bản thay thế", + "descriptionSimple": "Tự động cắt bớt kết quả công cụ cũ để tránh tràn ngữ cảnh. Bật mặc định.", + "advanced": "Nâng cao" }, "sandbox": { "title": "Hộp cát", diff --git a/ui/web/src/i18n/locales/zh/agents.json b/ui/web/src/i18n/locales/zh/agents.json index b8c9a43a..ca7b44e5 100644 --- a/ui/web/src/i18n/locales/zh/agents.json +++ b/ui/web/src/i18n/locales/zh/agents.json @@ -612,7 +612,9 @@ "tailChars": "尾部字符数", "hardClear": "硬清除", "enabled": "已启用", - "placeholderText": "占位符文本" + "placeholderText": "占位符文本", + "descriptionSimple": "自动裁剪旧工具结果以防止上下文溢出。默认启用。", + "advanced": "高级" }, "sandbox": { "title": "沙箱", diff --git a/ui/web/src/pages/agents/agent-detail/agent-advanced-dialog.tsx b/ui/web/src/pages/agents/agent-detail/agent-advanced-dialog.tsx index ab28a17b..84cbf581 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-advanced-dialog.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-advanced-dialog.tsx @@ -110,7 +110,7 @@ export function AgentAdvancedDialog({ open, onOpenChange, agent, onUpdate }: Age } await onUpdate({ compaction_config: comp, - context_pruning: pruneEnabled ? prune : null, + context_pruning: pruneEnabled ? (Object.keys(prune).length > 0 ? prune : null) : { mode: "off" }, sandbox_config: sbEnabled ? sb : null, other_config: otherBase, }); diff --git a/ui/web/src/pages/agents/agent-detail/config-sections/context-pruning-section.tsx b/ui/web/src/pages/agents/agent-detail/config-sections/context-pruning-section.tsx index 4cfb0cf8..3b846063 100644 --- a/ui/web/src/pages/agents/agent-detail/config-sections/context-pruning-section.tsx +++ b/ui/web/src/pages/agents/agent-detail/config-sections/context-pruning-section.tsx @@ -1,13 +1,8 @@ +import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { ChevronRight } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import type { ContextPruningConfig } from "@/types/agent"; import { ConfigSection, InfoLabel, numOrUndef } from "./config-section"; @@ -21,138 +16,115 @@ interface ContextPruningSectionProps { export function ContextPruningSection({ enabled, value, onToggle, onChange }: ContextPruningSectionProps) { const { t } = useTranslation("agents"); const s = "configSections.contextPruning"; + const [showAdvanced, setShowAdvanced] = useState(false); + return ( -
-
- {t(`${s}.mode`)} - -
-
- {t(`${s}.keepLastAssistants`)} - - onChange({ ...value, keepLastAssistants: numOrUndef(e.target.value) }) - } - /> -
-
-
-
- {t(`${s}.softTrimRatio`)} - onChange({ ...value, softTrimRatio: numOrUndef(e.target.value) })} - /> -
-
- {t(`${s}.hardClearRatio`)} - onChange({ ...value, hardClearRatio: numOrUndef(e.target.value) })} - /> -
-
-
- {t(`${s}.minPrunableToolChars`)} + {/* Primary setting */} +
+ {t(`${s}.keepLastAssistants`)} - onChange({ ...value, minPrunableToolChars: numOrUndef(e.target.value) }) + onChange({ ...value, keepLastAssistants: numOrUndef(e.target.value) }) } />
- {/* Soft Trim */} -
-

{t(`${s}.softTrim`)}

-
-
- {t(`${s}.maxChars`)} - - onChange({ ...value, softTrim: { ...value.softTrim, maxChars: numOrUndef(e.target.value) } }) - } - /> -
-
- {t(`${s}.headChars`)} - - onChange({ ...value, softTrim: { ...value.softTrim, headChars: numOrUndef(e.target.value) } }) - } - /> -
-
- {t(`${s}.tailChars`)} - - onChange({ ...value, softTrim: { ...value.softTrim, tailChars: numOrUndef(e.target.value) } }) - } - /> -
-
-
+ {/* Advanced toggle */} + - {/* Hard Clear */} -
-

{t(`${s}.hardClear`)}

-
- - onChange({ ...value, hardClear: { ...value.hardClear, enabled: v } }) - } - /> - {t(`${s}.enabled`)} + {showAdvanced && ( +
+ {/* Trim ratios */} +
+
+ {t(`${s}.softTrimRatio`)} + onChange({ ...value, softTrimRatio: numOrUndef(e.target.value) })} + /> +
+
+ {t(`${s}.hardClearRatio`)} + onChange({ ...value, hardClearRatio: numOrUndef(e.target.value) })} + /> +
+
+ + {/* Soft Trim chars */} +
+
+ {t(`${s}.maxChars`)} + + onChange({ ...value, softTrim: { ...value.softTrim, maxChars: numOrUndef(e.target.value) } }) + } + /> +
+
+ {t(`${s}.headChars`)} + + onChange({ ...value, softTrim: { ...value.softTrim, headChars: numOrUndef(e.target.value) } }) + } + /> +
+
+ {t(`${s}.tailChars`)} + + onChange({ ...value, softTrim: { ...value.softTrim, tailChars: numOrUndef(e.target.value) } }) + } + /> +
+
+ + {/* Hard Clear */} +
+
+ + onChange({ ...value, hardClear: { ...value.hardClear, enabled: v } }) + } + /> + {t(`${s}.hardClear`)} +
+
-
- {t(`${s}.placeholderText`)} - - onChange({ ...value, hardClear: { ...value.hardClear, placeholder: e.target.value || undefined } }) - } - /> -
-
+ )} ); }