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:
viettranx committed 2026-03-29 07:50:00 +07:00
1 parent f8278cfaff
commit 635870acd2
12 files changed
+345 -174

No files matched your search

+41 -13
View File
@@ -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)
}
}
+39 -2
View File
@@ -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) {
+116
View File
@@ -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
+36 -2
View File
@@ -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
}
+5 -2
View File
@@ -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
+2 -1
View File
@@ -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 {
-26
View File
@@ -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.
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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>
);
}