mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 20:17:20 +00:00
fix(agent): prevent premature compaction by separating overhead from history tokens
Root cause: compaction threshold compared total prompt tokens (including system prompt, tool definitions, context files) against budget. Sessions with only 50-60k of conversation could trigger compaction when overhead pushed total to 120k+. Changes: - Enable context pruning by default for all providers (was Anthropic-only) - Add EstimateHistoryTokens() that excludes system messages from count - Fix EstimateTokens() to include ToolCall argument sizes - Calibrate overhead on first LLM response, subtract from threshold checks - Mid-loop: prune old tool results first (at 70% budget), compact only if still over budget after pruning - maybeSummarize: subtract overhead from calibration base - Remove applyContextPruningDefaults() (Anthropic-only auto-enable) - Simplify context pruning UI: show only Keep Last Assistants by default, hide advanced params behind collapsible, remove Mode selector
This commit is contained in:
1 parent
f8278cfaff
commit
635870acd2
12 files changed
+345
-174
No files matched your search
+41
-13
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -612,7 +612,9 @@
|
||||
"tailChars": "尾部字符数",
|
||||
"hardClear": "硬清除",
|
||||
"enabled": "已启用",
|
||||
"placeholderText": "占位符文本"
|
||||
"placeholderText": "占位符文本",
|
||||
"descriptionSimple": "自动裁剪旧工具结果以防止上下文溢出。默认启用。",
|
||||
"advanced": "高级"
|
||||
},
|
||||
"sandbox": {
|
||||
"title": "沙箱",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<ConfigSection
|
||||
title={t(`${s}.title`)}
|
||||
description={t(`${s}.description`)}
|
||||
description={t(`${s}.descriptionSimple`, "Automatically trims old tool results to prevent context overflow. Enabled by default.")}
|
||||
enabled={enabled}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip="Pruning strategy. 'cache-ttl' trims old tool results based on their position in the conversation.">{t(`${s}.mode`)}</InfoLabel>
|
||||
<Select
|
||||
value={value.mode ?? ""}
|
||||
onValueChange={(v) =>
|
||||
onChange({ ...value, mode: v as ContextPruningConfig["mode"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="off" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="off">off</SelectItem>
|
||||
<SelectItem value="cache-ttl">cache-ttl</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip="Number of recent assistant turns whose tool results are always kept intact, never pruned.">{t(`${s}.keepLastAssistants`)}</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="3"
|
||||
value={value.keepLastAssistants ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...value, keepLastAssistants: numOrUndef(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip="Context usage ratio (0-1) at which soft trimming begins. E.g. 0.3 means trimming starts when context is 30% full.">{t(`${s}.softTrimRatio`)}</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.05"
|
||||
placeholder="0.3"
|
||||
value={value.softTrimRatio ?? ""}
|
||||
onChange={(e) => onChange({ ...value, softTrimRatio: numOrUndef(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip="Context usage ratio (0-1) at which hard clearing kicks in. E.g. 0.5 means full clearing at 50% context usage.">{t(`${s}.hardClearRatio`)}</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.05"
|
||||
placeholder="0.5"
|
||||
value={value.hardClearRatio ?? ""}
|
||||
onChange={(e) => onChange({ ...value, hardClearRatio: numOrUndef(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip="Only tool results with at least this many characters are eligible for pruning. Shorter results are left untouched.">{t(`${s}.minPrunableToolChars`)}</InfoLabel>
|
||||
{/* Primary setting */}
|
||||
<div className="max-w-xs space-y-2">
|
||||
<InfoLabel tip="Number of recent assistant turns whose tool results are always kept intact, never pruned.">{t(`${s}.keepLastAssistants`)}</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="50000"
|
||||
value={value.minPrunableToolChars ?? ""}
|
||||
placeholder="3"
|
||||
value={value.keepLastAssistants ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...value, minPrunableToolChars: numOrUndef(e.target.value) })
|
||||
onChange({ ...value, keepLastAssistants: numOrUndef(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Soft Trim */}
|
||||
<div className="space-y-3 rounded-md border border-dashed p-3">
|
||||
<h4 className="text-xs font-medium text-muted-foreground">{t(`${s}.softTrim`)}</h4>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip="Tool results longer than this will be soft-trimmed, keeping only head and tail portions.">{t(`${s}.maxChars`)}</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="4000"
|
||||
value={value.softTrim?.maxChars ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...value, softTrim: { ...value.softTrim, maxChars: numOrUndef(e.target.value) } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip="Number of characters to keep from the beginning of a trimmed tool result.">{t(`${s}.headChars`)}</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="1500"
|
||||
value={value.softTrim?.headChars ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...value, softTrim: { ...value.softTrim, headChars: numOrUndef(e.target.value) } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip="Number of characters to keep from the end of a trimmed tool result.">{t(`${s}.tailChars`)}</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="1500"
|
||||
value={value.softTrim?.tailChars ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...value, softTrim: { ...value.softTrim, tailChars: numOrUndef(e.target.value) } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Advanced toggle */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
>
|
||||
<ChevronRight className={`h-3 w-3 transition-transform ${showAdvanced ? "rotate-90" : ""}`} />
|
||||
{t(`${s}.advanced`, "Advanced")}
|
||||
</button>
|
||||
|
||||
{/* Hard Clear */}
|
||||
<div className="space-y-3 rounded-md border border-dashed p-3">
|
||||
<h4 className="text-xs font-medium text-muted-foreground">{t(`${s}.hardClear`)}</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={value.hardClear?.enabled ?? true}
|
||||
onCheckedChange={(v) =>
|
||||
onChange({ ...value, hardClear: { ...value.hardClear, enabled: v } })
|
||||
}
|
||||
/>
|
||||
<InfoLabel tip="When enabled, old tool results beyond the hard clear ratio are replaced entirely with placeholder text.">{t(`${s}.enabled`)}</InfoLabel>
|
||||
{showAdvanced && (
|
||||
<div className="space-y-4 pl-4 border-l-2 border-muted">
|
||||
{/* Trim ratios */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip="Context usage ratio (0-1) at which soft trimming begins. E.g. 0.3 means trimming starts when context is 30% full.">{t(`${s}.softTrimRatio`)}</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.05"
|
||||
placeholder="0.3"
|
||||
value={value.softTrimRatio ?? ""}
|
||||
onChange={(e) => onChange({ ...value, softTrimRatio: numOrUndef(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip="Context usage ratio (0-1) at which hard clearing kicks in. E.g. 0.5 means full clearing at 50% context usage.">{t(`${s}.hardClearRatio`)}</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.05"
|
||||
placeholder="0.5"
|
||||
value={value.hardClearRatio ?? ""}
|
||||
onChange={(e) => onChange({ ...value, hardClearRatio: numOrUndef(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Soft Trim chars */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip="Tool results longer than this will be soft-trimmed, keeping only head and tail portions.">{t(`${s}.maxChars`)}</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="4000"
|
||||
value={value.softTrim?.maxChars ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...value, softTrim: { ...value.softTrim, maxChars: numOrUndef(e.target.value) } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip="Number of characters to keep from the beginning of a trimmed tool result.">{t(`${s}.headChars`)}</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="1500"
|
||||
value={value.softTrim?.headChars ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...value, softTrim: { ...value.softTrim, headChars: numOrUndef(e.target.value) } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip="Number of characters to keep from the end of a trimmed tool result.">{t(`${s}.tailChars`)}</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="1500"
|
||||
value={value.softTrim?.tailChars ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...value, softTrim: { ...value.softTrim, tailChars: numOrUndef(e.target.value) } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hard Clear */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={value.hardClear?.enabled ?? true}
|
||||
onCheckedChange={(v) =>
|
||||
onChange({ ...value, hardClear: { ...value.hardClear, enabled: v } })
|
||||
}
|
||||
/>
|
||||
<InfoLabel tip="When enabled, old tool results beyond the hard clear ratio are replaced entirely with placeholder text.">{t(`${s}.hardClear`)}</InfoLabel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip="Text that replaces cleared tool results. Helps the agent understand content was removed.">{t(`${s}.placeholderText`)}</InfoLabel>
|
||||
<Input
|
||||
placeholder="[Old tool result content cleared]"
|
||||
value={value.hardClear?.placeholder ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...value, hardClear: { ...value.hardClear, placeholder: e.target.value || undefined } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ConfigSection>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user