feat(pruning)!: faithful port of TS context pruning + backfill migration

Port goclaw context pruning to match upstream TS design in
openclaw/src/agents/pi-hooks/context-pruning/:

- Opt-in default: prune only when mode="cache-ttl" (was opt-out)
- Remove Pass 0 per-result 30% guard (duplicated Pass 1 with different
  suffix, caused wobble)
- Dedupe double prune call per iteration: PruneStage owns the single
  entry point; loop_history only runs limitHistoryTurns + sanitizeHistory
- Add cache-TTL gate for Anthropic prompt cache: skip prune while cache
  is live, scoped per-session via sync.Map
- Add context.pruned event emission for observability
- Configurable TTL as Go duration string ("5m", "30s")

BREAKING CHANGE: context pruning now opt-in. Add
contextPruning.mode: "cache-ttl" to config.agents.defaults to restore.

Migration 51 / SQLite v19 backfills mode="cache-ttl" for agents with
existing custom context_pruning config missing the mode field, so
previously-configured agents keep pruning after the opt-in flip.
NULL configs stay NULL (new opt-in default applies).

Web UI adds Cache TTL input + toggle wiring mode to cache-ttl/off.
This commit is contained in:
viettranx committed 2026-04-15 11:24:57 +07:00
1 parent 8d9a7c127a
commit 48335d7797
26 files changed
+730 -115

No files matched your search

+29
View File
@@ -2,6 +2,35 @@
All notable changes to GoClaw are documented here. For full documentation, see [docs.goclaw.sh](https://docs.goclaw.sh).
## Unreleased
### Breaking Changes
- **Context pruning now opt-in.** Previously tool-result trimming ran by default
for all providers; now requires explicit `contextPruning.mode: "cache-ttl"` in
`config.agents.defaults` to enable. Matches upstream TS design and prevents
silent prompt-cache invalidation on Anthropic.
Migration — add to `config.json5`:
```json5
agents: {
defaults: {
contextPruning: { mode: "cache-ttl" }
}
}
```
### Improvements
- **Context pruning cleanup.** Removed redundant Pass 0 (per-result 30% guard),
deduplicated double prune call per iteration, added SanitizeHistory to
PruneStage for broken tool_use/tool_result pair cleanup.
- **Context pruning config backfill (migration).** Agents with existing custom
`context_pruning` config (e.g., `softTrimRatio`, `keepLastAssistants`) but
missing a `mode` field get auto-backfilled with `mode: "cache-ttl"` to
preserve their intent after the opt-in flip. Rows with NULL config stay
NULL (new opt-in default applies). PG migration 51; SQLite schema v19.
## Project Status
### Implemented & Tested in Production
+29 -18
View File
@@ -59,15 +59,16 @@ Finalize (runs once, uses background context if cancelled)
- Resolve workspace + context files dynamically
- Build system prompt (15+ sections)
- Inject conversation summary if exists
- Run history pipeline (limitHistoryTurns → pruneContextMessages → sanitizeHistory)
- Run history pipeline (limitHistoryTurns → sanitizeHistory)
- Filter tools through PolicyEngine (RBAC)
- Call LLM, record span with token counts
- Emit `chunk` events (streaming) or single response
**PruneStage**
**PruneStage** (opt-in via `contextPruning.mode: "cache-ttl"`)
- Estimate token ratio vs context window
- If >= 30%, run soft trim pass (keep first/last 1500 chars, replace middle with "...")
- If >= 25%, run soft trim pass (keep first/last 3000 chars, replace middle with "...")
- If >= 50%, run hard clear pass (replace with placeholder)
- Run sanitizeHistory to fix broken tool_use/tool_result pairs after prune
- Trigger memory flush (synchronous) if compaction threshold exceeded
**ToolStage**
@@ -183,7 +184,7 @@ flowchart TD
subgraph PH3["Phase 3: Build Messages"]
P3A[Build system prompt - 15+ sections] --> P3B[Inject conversation summary if present]
P3B --> P3C["History pipeline: limitHistoryTurns --> pruneContextMessages --> sanitizeHistory"]
P3B --> P3C["History pipeline: limitHistoryTurns --> sanitizeHistory"]
P3C --> P3D[Append current user message]
P3D --> P3E[Buffer user message locally - deferred write]
end
@@ -398,25 +399,20 @@ All security events use the `slog.Warn("security.injection_detected")` conventio
## 5. History Pipeline
The history pipeline prepares conversation history before sending it to the LLM. It runs in three sequential stages.
The history pipeline prepares conversation history before sending it to the LLM. It runs in two sequential stages. Context pruning is handled separately by PruneStage (opt-in via `contextPruning.mode: "cache-ttl"`).
```mermaid
flowchart TD
RAW[Raw Session History] --> S1
S1["Stage 1: limitHistoryTurns<br/>Keep the last N user turns<br/>plus their associated assistant/tool messages"] --> S2
S2["Stage 2: pruneContextMessages<br/>2-pass tool result trimming<br/>(see Section 6)"] --> S3
S3["Stage 3: sanitizeHistory<br/>Repair broken tool_use / tool_result pairing<br/>after truncation"] --> OUT[Cleaned History]
S2["Stage 2: sanitizeHistory<br/>Repair broken tool_use / tool_result pairing<br/>after truncation"] --> OUT[Cleaned History]
```
### Stage 1: limitHistoryTurns
Takes the raw session history and a `historyLimit` parameter. Keeps only the last N user turns along with all associated assistant and tool messages that belong to those turns. Earlier messages are discarded.
### Stage 2: pruneContextMessages
Applies the 2-pass context pruning algorithm described in Section 6.
### Stage 3: sanitizeHistory
### Stage 2: sanitizeHistory
Repairs tool message pairing that may have been broken by truncation or compaction:
@@ -429,15 +425,17 @@ Repairs tool message pairing that may have been broken by truncation or compacti
## 6. Context Pruning
Context pruning reduces oversized tool results using a 2-pass algorithm. It only activates when the estimated token-to-context-window ratio crosses a threshold.
Context pruning reduces oversized tool results using a 2-pass algorithm. **It is opt-in** — configure `contextPruning.mode: "cache-ttl"` to enable. When disabled (default), zero overhead. Owned by PruneStage in the agent pipeline.
```mermaid
flowchart TD
START[Estimate token ratio vs context window] --> CHECK{Ratio >= softTrimRatio 0.3?}
START[Check mode == cache-ttl?] --> GATE{Mode enabled?}
GATE -->|No| SKIP[No pruning - zero overhead]
GATE -->|Yes| CHECK{Ratio >= softTrimRatio 0.25?}
CHECK -->|No| DONE[No pruning needed]
CHECK -->|Yes| PASS1
PASS1["Pass 1: Soft Trim<br/>For each eligible tool result > 4000 chars:<br/>Keep first 1500 chars + last 1500 chars<br/>Replace middle with '...'"]
PASS1["Pass 1: Soft Trim<br/>For each eligible tool result > 6000 chars:<br/>Keep first 3000 chars + last 3000 chars<br/>Replace middle with '...'"]
PASS1 --> CHECK2{"Ratio >= hardClearRatio 0.5?"}
CHECK2 -->|No| DONE
CHECK2 -->|Yes| PASS2
@@ -446,12 +444,25 @@ flowchart TD
PASS2 --> DONE
```
### Configuration
Enable pruning by setting `contextPruning.mode` in agent defaults:
```json5
agents: {
defaults: {
contextPruning: { mode: "cache-ttl" }
}
}
```
### Defaults
| Parameter | Default | Description |
|-----------|---------|-------------|
| `mode` | `""` (disabled) | `""` or `"off"` = disabled; `"cache-ttl"` = enabled |
| `keepLastAssistants` | 3 | Number of recent assistant messages protected from pruning |
| `softTrimRatio` | 0.3 | Token ratio threshold to trigger Pass 1 |
| `softTrimRatio` | 0.25 | Token ratio threshold to trigger Pass 1 |
| `hardClearRatio` | 0.5 | Token ratio threshold to trigger Pass 2 |
| `minPrunableToolChars` | 50,000 | Minimum tool result length eligible for hard clear |
@@ -719,8 +730,8 @@ Enabled via the `GOCLAW_TRACE_VERBOSE=1` environment variable.
| `internal/agent/loop_run.go` | Run() entry point: dual-mode gate (v2 vs v3), trace creation, span management |
| `internal/agent/loop_pipeline_adapter.go` | Bridge v2 Loop to v3 Pipeline: state conversion, dependency injection, callback wiring |
| `internal/agent/loop.go` | runLoop() core loop: LLM iteration, tool execution, message buffering (v2 path) |
| `internal/agent/loop_history.go` | History pipeline: limitHistoryTurns, pruneContextMessages, sanitizeHistory, summary injection |
| `internal/agent/pruning.go` | Context pruning: 2-pass soft trim and hard clear algorithm |
| `internal/agent/loop_history.go` | History pipeline: limitHistoryTurns, sanitizeHistory, summary injection |
| `internal/agent/pruning.go` | Context pruning: 2-pass soft trim and hard clear algorithm (opt-in via PruneStage) |
| `internal/agent/loop_compact.go` | Mid-loop compaction: in-memory message summarization during iterations |
| `internal/agent/systemprompt.go` | System prompt assembly (19+ sections), PromptFull and PromptMinimal modes |
| `internal/agent/systemprompt_sections.go` | Individual section builders (tooling, workspace, sandbox, skills, MCP, etc.) |
+34
View File
@@ -6,6 +6,40 @@ All notable changes to GoClaw Gateway are documented here. Format follows [Keep
## [Unreleased] — 2026-04-15
#### ElevenLabs Audio Manager Refactor — Phase 6 (2026-04-15)
Desktop UI parity: voice picker + STT admin form ported from web to Wails desktop frontend. Completes 6-phase plan; all audio surfaces (TTS/STT/Music/SFX) now manageable from both web and desktop Lite editions.
### Added
- **Desktop voice picker**: `ui/desktop/frontend/src/components/agents/voice-picker.tsx` — uses desktop's native Combobox primitive; embedded in `AgentDetailPanel` with `other_config.tts_voice_id` persistence via spread-merge pattern
- **Desktop voice preview button**: `ui/desktop/frontend/src/components/agents/voice-preview-button.tsx` — singleton `<audio>` (module-level `currentAudio` ref); hidden when `preview_url` absent (resolves P6-L)
- **Desktop STT admin form**: `ui/desktop/frontend/src/components/builtin-tools/stt-provider-form.tsx` — mirrors web fields + `whatsapp_enabled` toggle + privacy banner; routed via `ToolSettingsDialog` when `tool.name === 'stt'`
- **Desktop voices service + hook**: `services/voices.ts` (`listVoices`, `refreshVoices`) + `hooks/use-voices.ts` (plain useState/useEffect — no React Query on desktop; mirrors web hook shape)
- **Desktop Vitest infrastructure**: `vitest.config.ts` + `test/setup.ts` (jsdom + jest-dom + HTMLMediaElement mock) — resolves P6-H3 (desktop tests previously impossible)
- **Desktop i18n `tts` namespace**: `i18n/locales/{en,vi,zh}/tts.json` (15 → 16 total namespaces registered in `i18n/index.ts`) + `tools.json` extended with `builtin.sttForm.*` keys
- **Desktop component tests**: voice picker (4 tests) + STT form (3 tests) — parity subset covering render, save, preview, privacy banner
### Changed
- **Desktop `AgentDetailPanel`**: Added `ttsVoiceId` local state + `onSaveWithVoice` wrapper that preserves existing `other_config` fields via spread-merge before overwriting `tts_voice_id`
- **Desktop `ToolSettingsDialog`**: Added `tool.name === 'stt'` routing branch before JsonSettingsForm fallback; existing `web_fetch` + MEDIA_TOOLS routing unchanged
- **Desktop `package.json`**: Added devDeps (`vitest`, `@testing-library/react`, `@testing-library/jest-dom`, `jsdom`, `@vitejs/plugin-react`) + `test` / `test:watch` npm scripts
### Resolved
- **Audit finding P6-B1**: Gate G5 verified — Phase 5 merged green before Phase 6 work began
- **Audit finding P6-B2**: Desktop path rewrite confirmed — no `pages/` dir used; component composition via `AgentDetailPanel` + `ToolSettingsDialog`
- **Audit finding P6-B3**: `tts.json` namespace created + registered in desktop `i18n/index.ts` (web-desktop i18n parity)
- **Audit finding P6-H1**: `tools.json` extended with STT labels; `tts.json` created per Decision 4
- **Audit finding P6-H2**: SttProviderForm integration point identified (`ToolSettingsDialog.tsx` name-routing) + wired
- **Audit finding P6-H3**: Vitest installed + configured in desktop frontend (was previously absent)
- **Audit finding P6-L**: Voice preview button hidden (not disabled) when `preview_url` null/empty
- **Audit finding P6-M** (codec): MP3 + Opus codec smoke test remains on manual verification checklist (user to confirm on macOS WKWebView + Windows WebView2)
- **Cross-phase XP-5**: Desktop paths confirmed — `components/agents/`, `components/builtin-tools/`, `services/`, `hooks/` (no `pages/`)
### Deferred
- **Manual smoke test**: `wails dev -tags sqliteonly` voice picker save + STT form submit + codec playback requires user verification (cannot run headless in automation)
---
#### ElevenLabs Audio Manager Refactor — Phase 5 (2026-04-15)
Channel STT migration (Telegram/Feishu/Discord) + WhatsApp voice transcription with tenant opt-in (default disabled due to E2E encryption trade-off, resolves privacy red-team finding).
+3 -3
View File
@@ -256,10 +256,10 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s
})
}
// History pipeline matching TS: limitHistoryTurns → pruneContext → sanitizeHistory.
// History pipeline: limitHistoryTurns → sanitizeHistory.
// Pruning is owned by PruneStage in the pipeline (single entry point).
trimmed := limitHistoryTurns(history, historyLimit)
pruned := pruneContextMessages(trimmed, l.contextWindow, l.contextPruningCfg, l.tokenCounter, l.model)
sanitized, droppedCount := sanitizeHistory(pruned)
sanitized, droppedCount := sanitizeHistory(trimmed)
messages = append(messages, sanitized...)
// If orphaned messages were found and dropped, persist the cleaned history
+5 -4
View File
@@ -602,7 +602,7 @@ func TestEstimateOverhead(t *testing.T) {
}
func TestPruneContextMessagesDefaultEnabled(t *testing.T) {
// With nil config, pruning should run (not return early).
// Phase 04: nil config → no pruning (opt-in). To enable, use Mode: "cache-ttl".
// Create messages with a large tool result that exceeds soft trim threshold.
largeContent := make([]byte, 10000)
for i := range largeContent {
@@ -619,9 +619,10 @@ func TestPruneContextMessagesDefaultEnabled(t *testing.T) {
{Role: "assistant", Content: "Sure, here you go."},
}
// With nil config and small context window (to trigger soft trim ratio > 0.3),
// With cache-ttl mode and small context window (to trigger soft trim ratio > 0.3),
// pruning should trim the large tool result.
result := pruneContextMessages(msgs, 5000, nil, nil, "")
cfg := &config.ContextPruningConfig{Mode: "cache-ttl"}
result := pruneContextMessages(msgs, 5000, cfg, nil, "", nil)
// The large tool result should have been trimmed.
toolMsg := result[2]
@@ -635,7 +636,7 @@ func TestPruneContextMessagesExplicitOff(t *testing.T) {
{Role: "user", Content: "Hello"},
}
cfg := &config.ContextPruningConfig{Mode: "off"}
result := pruneContextMessages(msgs, 200000, cfg, nil, "")
result := pruneContextMessages(msgs, 200000, cfg, nil, "", nil)
// Should return original messages unchanged.
if len(result) != len(msgs) {
t.Errorf("expected %d messages, got %d", len(msgs), len(result))
+15
View File
@@ -3,6 +3,7 @@ package agent
import (
"context"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
"github.com/nextlevelbuilder/goclaw/internal/memory"
"github.com/nextlevelbuilder/goclaw/internal/pipeline"
@@ -111,8 +112,22 @@ func (l *Loop) buildPipelineDeps(req *RunRequest, bridgeRS *runState) pipeline.P
// Prune callbacks
PruneMessages: cb.pruneMessages,
SanitizeHistory: cb.sanitizeHistory,
CompactMessages: cb.compactMessages,
// Cache-TTL gate callbacks (Phase 06)
GetProviderCaps: func() providers.ProviderCapabilities {
if ca, ok := l.provider.(providers.CapabilitiesAware); ok {
return ca.Capabilities()
}
return providers.ProviderCapabilities{}
},
GetPruningConfig: func() *config.ContextPruningConfig {
return l.contextPruningCfg
},
GetCacheTouch: l.cacheTouchAt,
MarkCacheTouched: l.markCacheTouched,
// Memory flush
RunMemoryFlush: cb.runMemoryFlush,
+23 -4
View File
@@ -47,6 +47,7 @@ func (l *Loop) pipelineCallbacks(req *RunRequest, bridgeRS *runState) pipelineCa
buildFilteredTools: l.makeBuildFilteredTools(req),
callLLM: l.makeCallLLM(req, emitRun),
pruneMessages: l.makePruneMessages(),
sanitizeHistory: sanitizeHistory,
compactMessages: l.makeCompactMessages(req),
runMemoryFlush: l.makeRunMemoryFlush(),
executeToolCall: l.makeExecuteToolCall(req, bridgeRS),
@@ -73,7 +74,8 @@ type pipelineCallbackSet struct {
injectReminders func(ctx context.Context, input *pipeline.RunInput, msgs []providers.Message) []providers.Message
buildFilteredTools func(state *pipeline.RunState) ([]providers.ToolDefinition, error)
callLLM func(ctx context.Context, state *pipeline.RunState, req providers.ChatRequest) (*providers.ChatResponse, error)
pruneMessages func(msgs []providers.Message, budget int) []providers.Message
pruneMessages func(msgs []providers.Message, budget int) ([]providers.Message, pipeline.PruneStats)
sanitizeHistory func(msgs []providers.Message) ([]providers.Message, int)
compactMessages func(ctx context.Context, msgs []providers.Message, model string) ([]providers.Message, error)
runMemoryFlush func(ctx context.Context, state *pipeline.RunState) error
executeToolCall func(ctx context.Context, state *pipeline.RunState, tc providers.ToolCall) ([]providers.Message, error)
@@ -312,9 +314,11 @@ func (l *Loop) makeCallLLM(req *RunRequest, emitRun func(AgentEvent)) func(ctx c
}
}
func (l *Loop) makePruneMessages() func(msgs []providers.Message, budget int) []providers.Message {
return func(msgs []providers.Message, budget int) []providers.Message {
return pruneContextMessages(msgs, budget, l.contextPruningCfg, l.tokenCounter, l.model)
func (l *Loop) makePruneMessages() func(msgs []providers.Message, budget int) ([]providers.Message, pipeline.PruneStats) {
return func(msgs []providers.Message, budget int) ([]providers.Message, pipeline.PruneStats) {
var stats pipeline.PruneStats
pruned := pruneContextMessages(msgs, budget, l.contextPruningCfg, l.tokenCounter, l.model, &stats)
return pruned, stats
}
}
@@ -342,6 +346,21 @@ func (l *Loop) makeCompactMessages(req *RunRequest) func(ctx context.Context, ms
// duplicating the string.
const SessionMetaKeyLastCompactionAt = "last_compaction_at"
// cacheTouchAt returns the last prune-mutation timestamp for a session.
// Returns zero time if no touch recorded yet.
func (l *Loop) cacheTouchAt(sessionKey string) time.Time {
if v, ok := l.cacheTouchBySession.Load(sessionKey); ok {
return v.(time.Time)
}
return time.Time{}
}
// markCacheTouched records the current time as the last prune-mutation timestamp
// for the given session. Called only after pruning actually mutates messages.
func (l *Loop) markCacheTouched(sessionKey string) {
l.cacheTouchBySession.Store(sessionKey, time.Now())
}
func (l *Loop) makeRunMemoryFlush() func(ctx context.Context, state *pipeline.RunState) error {
return func(ctx context.Context, state *pipeline.RunState) error {
settings := ResolveMemoryFlushSettings(l.compactionCfg)
+8
View File
@@ -241,6 +241,14 @@ type Loop struct {
// User identity resolver: maps channel contacts to merged tenant users for credential lookups.
userResolver UserIdentityResolver
// Per-session cache-touch timestamps for the cache-TTL pruning gate (Phase 06).
// Key: sessionKey (string), Value: time.Time of last prune mutation.
// sync.Map zero value is ready to use — no init required.
// Grows with distinct sessions; typical gateway has bounded session count.
// Note: in-memory only — timestamps reset on process restart (one extra prune
// per session on restart, then steady-state resumes).
cacheTouchBySession sync.Map
}
// AgentEvent is emitted during agent execution for WS broadcasting.
+16 -44
View File
@@ -2,9 +2,11 @@ package agent
import (
"fmt"
"log/slog"
"unicode/utf8"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/pipeline"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/tokencount"
)
@@ -134,9 +136,13 @@ func resolvePruningSettings(cfg *config.ContextPruningConfig) *effectivePruningS
// When tc is non-nil, token counting uses tiktoken for accuracy (especially
// for non-ASCII content like Vietnamese/Chinese). When nil, falls back to the
// legacy rune_count/charsPerTokenEstimate heuristic so existing tests pass.
func pruneContextMessages(msgs []providers.Message, contextWindowTokens int, cfg *config.ContextPruningConfig, tc tokencount.TokenCounter, model string) []providers.Message {
// Pruning runs by default for all providers. Only skip when explicitly disabled.
if cfg != nil && cfg.Mode == "off" {
func pruneContextMessages(msgs []providers.Message, contextWindowTokens int, cfg *config.ContextPruningConfig, tc tokencount.TokenCounter, model string, stats *pipeline.PruneStats) []providers.Message {
// Opt-in: require explicit mode. Matches TS computeEffectiveSettings in settings.ts.
if cfg == nil || cfg.Mode == "" || cfg.Mode == "off" {
return msgs
}
if cfg.Mode != "cache-ttl" {
slog.Warn("context_pruning: unknown mode, disabled", "mode", cfg.Mode)
return msgs
}
if contextWindowTokens <= 0 || len(msgs) == 0 {
@@ -201,51 +207,11 @@ func pruneContextMessages(msgs []providers.Message, contextWindowTokens int, cfg
return msgs
}
// Pass 0: Per-result context guard — force-trim any single tool result
// exceeding 30% of the context window. Catches outlier outputs even
// when overall context ratio is low.
maxSingleResultTokens := tokenWindow * 3 / 10
// Char budget for actual string slicing (always char-based regardless of estimator).
maxSingleResultChars := maxSingleResultTokens
if tc != nil {
maxSingleResultChars = maxSingleResultTokens * charsPerTokenEstimate
}
var result []providers.Message
for _, idx := range prunableIndexes {
msgTokens := est.estimateTokens(msgs[idx].Content)
if msgTokens > maxSingleResultTokens {
if result == nil {
result = make([]providers.Message, len(msgs))
copy(result, msgs)
}
msg := msgs[idx]
head := takeHead(msg.Content, maxSingleResultChars*7/10)
tail := takeTail(msg.Content, maxSingleResultChars*3/10)
msgChars := utf8.RuneCountInString(msg.Content)
trimmed := fmt.Sprintf("%s\n\n⚠️ [... middle content omitted ...]\n\n%s\n\n[Single tool result trimmed: %d chars exceeded per-result limit of %d chars.]",
head, tail, msgChars, maxSingleResultChars)
result[idx] = providers.Message{
Role: msg.Role,
Content: trimmed,
ToolCallID: msg.ToolCallID,
}
totalTokens += est.estimateTokens(trimmed) - msgTokens
}
}
if result != nil {
msgs = result
result = nil
// Re-check ratio after per-result guard.
ratio = float64(totalTokens) / float64(tokenWindow)
if ratio < settings.softTrimRatio {
return msgs
}
}
// Build tool call name map for media tool detection in soft trim.
toolCallNames := buildToolCallNameMap(msgs)
// Pass 1: Soft trim long tool results.
var result []providers.Message
for i := range prunableIndexes {
idx := prunableIndexes[i]
msg := msgs[idx]
@@ -295,6 +261,9 @@ func pruneContextMessages(msgs []providers.Message, contextWindowTokens int, cfg
ToolCallID: msg.ToolCallID,
}
totalTokens += est.estimateTokens(trimmed) - msgTokens
if stats != nil {
stats.ResultsTrimmed++
}
}
output := msgs
@@ -347,6 +316,9 @@ func pruneContextMessages(msgs []providers.Message, contextWindowTokens int, cfg
afterTokens := est.estimateTokens(settings.hardClearPlaceholder)
totalTokens += afterTokens - beforeTokens
ratio = float64(totalTokens) / float64(tokenWindow)
if stats != nil {
stats.ResultsCleared++
}
}
return output
@@ -0,0 +1,62 @@
//go:build !sqliteonly
package agent
import (
"sync/atomic"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/pipeline"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
// TestPruneStage_SingleEntryPoint verifies that after Phase 03 refactoring:
// - loop_history.go no longer calls pruneContextMessages
// - PruneStage owns the single entry point for pruning
// - SanitizeHistory is called after PruneMessages in PruneStage
//
// Full PruneStage tests are in internal/pipeline/stages_test.go.
func TestPruneStage_SingleEntryPoint(t *testing.T) {
// Verify loop_history's sanitizeHistory works on trimmed input directly.
// This proves pruneContextMessages is no longer called in that path.
history := []providers.Message{
{Role: "user", Content: "q1"},
{Role: "assistant", Content: "", ToolCalls: []providers.ToolCall{{ID: "tc1", Name: "read"}}},
{Role: "tool", ToolCallID: "tc1", Content: "result1"},
// Orphan tool result (no matching tool_use) - sanitizeHistory should drop it
{Role: "tool", ToolCallID: "tc_orphan", Content: "orphan result"},
{Role: "assistant", Content: "a1"},
}
// limitHistoryTurns doesn't drop the orphan, but sanitizeHistory does
trimmed := limitHistoryTurns(history, 100)
if len(trimmed) != len(history) {
t.Fatalf("limitHistoryTurns altered message count: got %d, want %d", len(trimmed), len(history))
}
sanitized, droppedCount := sanitizeHistory(trimmed)
if droppedCount != 1 {
t.Errorf("sanitizeHistory should drop 1 orphan, got droppedCount=%d", droppedCount)
}
if len(sanitized) != 4 {
t.Errorf("expected 4 messages after sanitize, got %d", len(sanitized))
}
}
// TestPruneCallback_CountsOnce demonstrates the callback structure supports counting.
// Actual single-call verification is in pipeline/stages_test.go.
func TestPruneCallback_CountsOnce(t *testing.T) {
var count int32
fakePrune := func(msgs []providers.Message, budget int) ([]providers.Message, pipeline.PruneStats) {
atomic.AddInt32(&count, 1)
return msgs, pipeline.PruneStats{}
}
// Simulate what PruneStage does: call PruneMessages once
msgs := []providers.Message{{Role: "user", Content: "test"}}
_, _ = fakePrune(msgs, 10000)
if got := atomic.LoadInt32(&count); got != 1 {
t.Errorf("expected prune called exactly once, got %d", got)
}
}
+143 -11
View File
@@ -5,6 +5,7 @@ import (
"testing"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/pipeline"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
@@ -191,7 +192,7 @@ func TestPruneContextMessages_ModeOff_ReturnsOriginal(t *testing.T) {
{Role: "assistant", Content: "hello"},
}
cfg := &config.ContextPruningConfig{Mode: "off"}
got := pruneContextMessages(msgs, 100000, cfg, nil, "")
got := pruneContextMessages(msgs, 100000, cfg, nil, "", nil)
if len(got) != len(msgs) {
t.Error("mode=off should return original slice")
}
@@ -199,7 +200,7 @@ func TestPruneContextMessages_ModeOff_ReturnsOriginal(t *testing.T) {
func TestPruneContextMessages_ZeroWindow_ReturnsOriginal(t *testing.T) {
msgs := []providers.Message{{Role: "user", Content: "hi"}}
got := pruneContextMessages(msgs, 0, nil, nil, "")
got := pruneContextMessages(msgs, 0, nil, nil, "", nil)
if len(got) != len(msgs) {
t.Error("zero context window should return original")
}
@@ -214,7 +215,7 @@ func TestPruneContextMessages_SmallContext_NoChange(t *testing.T) {
{Role: "assistant", Content: "done"},
}
// Very large window → ratio tiny → nothing pruned.
got := pruneContextMessages(msgs, 1_000_000, nil, nil, "")
got := pruneContextMessages(msgs, 1_000_000, nil, nil, "", nil)
if len(got) != len(msgs) {
t.Errorf("small context: len=%d, want %d", len(got), len(msgs))
}
@@ -238,8 +239,9 @@ func TestPruneContextMessages_SoftTrim_LongToolResult(t *testing.T) {
}
cfg := &config.ContextPruningConfig{
SoftTrimRatio: 0.01, // threshold very low → prune immediately
HardClearRatio: 0.99, // don't hard clear
Mode: "cache-ttl", // enable pruning
SoftTrimRatio: 0.01, // threshold very low → prune immediately
HardClearRatio: 0.99, // don't hard clear
SoftTrim: &config.ContextPruningSoftTrim{
MaxChars: 100,
HeadChars: 50,
@@ -247,7 +249,7 @@ func TestPruneContextMessages_SoftTrim_LongToolResult(t *testing.T) {
},
}
// Use a context window that makes ratio exceed threshold.
got := pruneContextMessages(msgs, 100, cfg, nil, "")
got := pruneContextMessages(msgs, 100, cfg, nil, "", nil)
// Tool result should be trimmed — shorter than original.
for _, m := range got {
@@ -279,6 +281,7 @@ func TestPruneContextMessages_HardClear_WhenRatioVeryHigh(t *testing.T) {
enabled := true
cfg := &config.ContextPruningConfig{
Mode: "cache-ttl", // enable pruning
SoftTrimRatio: 0.001,
HardClearRatio: 0.001,
MinPrunableToolChars: 1, // very low threshold
@@ -293,7 +296,7 @@ func TestPruneContextMessages_HardClear_WhenRatioVeryHigh(t *testing.T) {
},
}
got := pruneContextMessages(msgs, 10, cfg, nil, "")
got := pruneContextMessages(msgs, 10, cfg, nil, "", nil)
// Tool result should be replaced by placeholder or heavily trimmed.
for _, m := range got {
@@ -331,13 +334,14 @@ func TestPruneContextMessages_MediaToolHigherBudget(t *testing.T) {
}
cfg := &config.ContextPruningConfig{
SoftTrimRatio: 0.01, // force prune
HardClearRatio: 0.999, // don't hard clear
Mode: "cache-ttl", // enable pruning
SoftTrimRatio: 0.01, // force prune
HardClearRatio: 0.999, // don't hard clear
}
// Use 7000 tokens (28K chars) so per-result guard (30% = 8.4K) doesn't
// trigger on 7K-char results, isolating the soft trim behavior.
got := pruneContextMessages(msgs, 7000, cfg, nil, "")
got := pruneContextMessages(msgs, 7000, cfg, nil, "", nil)
// read_image result (7000 chars < 8000 media budget) → NOT trimmed
imgResult := got[2]
@@ -373,6 +377,7 @@ func TestPruneContextMessages_MediaToolSkipsHardClear(t *testing.T) {
enabled := true
cfg := &config.ContextPruningConfig{
Mode: "cache-ttl", // enable pruning
SoftTrimRatio: 0.001,
HardClearRatio: 0.001,
MinPrunableToolChars: 1,
@@ -382,7 +387,7 @@ func TestPruneContextMessages_MediaToolSkipsHardClear(t *testing.T) {
},
}
got := pruneContextMessages(msgs, 5000, cfg, nil, "")
got := pruneContextMessages(msgs, 5000, cfg, nil, "", nil)
// read_image result should survive hard clear (only soft-trimmed at most)
imgResult := got[2]
@@ -438,3 +443,130 @@ func TestHasImportantTail_ShortContentNoMatch(t *testing.T) {
t.Error("expected no important tail")
}
}
// ─── Characterization tests (Phase 01) ────────────────────────────────────
// Lock current (buggy-vs-TS) behavior before refactoring. Subsequent phases
// update these tests to new expected values.
// makeLargeHistoryFixture creates a history with a large tool result for opt-in tests.
// contextWindow=5000 → charWindow=20000, total ~8030 chars → ratio ~40% ≥ 25%.
func makeLargeHistoryFixture() []providers.Message {
return []providers.Message{
{Role: "user", Content: "q"},
{Role: "assistant", Content: "", ToolCalls: []providers.ToolCall{{ID: "c1", Name: "read_file"}}},
{Role: "tool", ToolCallID: "c1", Content: strings.Repeat("x", 8000)},
{Role: "assistant", Content: "a1"},
{Role: "assistant", Content: "a2"},
{Role: "assistant", Content: "a3"},
}
}
func TestPruneContextMessages_NilCfg_NoOp(t *testing.T) {
// Phase 04: nil cfg → no pruning (opt-in). Matches TS design.
msgs := makeLargeHistoryFixture()
got := pruneContextMessages(msgs, 5000, nil, nil, "", nil)
if estimateMessageChars(got[2]) != 8000 {
t.Errorf("nil cfg should be no-op (opt-in). Got %d chars", estimateMessageChars(got[2]))
}
}
func TestPruneContextMessages_EmptyMode_NoOp(t *testing.T) {
cfg := &config.ContextPruningConfig{Mode: ""}
msgs := makeLargeHistoryFixture()
got := pruneContextMessages(msgs, 5000, cfg, nil, "", nil)
if estimateMessageChars(got[2]) != 8000 {
t.Errorf("empty mode should be no-op. Got %d chars", estimateMessageChars(got[2]))
}
}
func TestPruneContextMessages_UnknownMode_NoOp(t *testing.T) {
cfg := &config.ContextPruningConfig{Mode: "bogus"}
msgs := makeLargeHistoryFixture()
got := pruneContextMessages(msgs, 5000, cfg, nil, "", nil)
if estimateMessageChars(got[2]) != 8000 {
t.Errorf("unknown mode should be no-op. Got %d chars", estimateMessageChars(got[2]))
}
}
func TestPruneContextMessages_CacheTtlMode_Prunes(t *testing.T) {
cfg := &config.ContextPruningConfig{Mode: "cache-ttl"}
msgs := makeLargeHistoryFixture()
got := pruneContextMessages(msgs, 5000, cfg, nil, "", nil)
if estimateMessageChars(got[2]) >= 8000 {
t.Errorf("cache-ttl mode should prune. Got unchanged content.")
}
}
func TestPruneContextMessages_Pass0_RemovedSuffixAbsent(t *testing.T) {
// Phase 02: Pass 0 removed. This test asserts the distinctive suffix is GONE.
// Pass 1 still trims via ratio gate, but with different suffix format.
//
// contextWindow=10000 → charWindow=40000; msg=15000 chars = 37.5% ≥ 25% softTrimRatio.
// Pass 1 trims the large result.
msgs := []providers.Message{
{Role: "user", Content: "q"},
{Role: "assistant", Content: "", ToolCalls: []providers.ToolCall{{ID: "c1", Name: "read_file"}}},
{Role: "tool", ToolCallID: "c1", Content: strings.Repeat("x", 15000)},
{Role: "assistant", Content: "a1"},
{Role: "assistant", Content: "a2"},
{Role: "assistant", Content: "a3"},
}
cfg := &config.ContextPruningConfig{Mode: "cache-ttl"}
got := pruneContextMessages(msgs, 10000, cfg, nil, "", nil)
content := got[2].Content
// Pass 0 suffix markers must be GONE after Phase 02 deletion.
if strings.Contains(content, "Single tool result trimmed:") {
t.Errorf("Pass 0 suffix should be removed; got: %q", testTruncate(content, 200))
}
if strings.Contains(content, "⚠️ [... middle content omitted ...]") {
t.Errorf("Pass 0 middle-omitted marker should be removed; got: %q", testTruncate(content, 200))
}
// Must still be trimmed by Pass 1 (smaller than original).
if estimateMessageChars(got[2]) >= 15000 {
t.Errorf("Pass 1 should still trim the large result via ratio gate; got %d chars", estimateMessageChars(got[2]))
}
}
// ─── PruneStats (Phase 05) ────────────────────────────────────────────────
func TestPruneContextMessages_Stats_TrimmedPopulated(t *testing.T) {
// Large tool result that will be soft-trimmed.
msgs := []providers.Message{
{Role: "user", Content: "q"},
{Role: "assistant", Content: "", ToolCalls: []providers.ToolCall{{ID: "c1", Name: "read_file"}}},
{Role: "tool", ToolCallID: "c1", Content: strings.Repeat("x", 15000)},
{Role: "assistant", Content: "a1"},
{Role: "assistant", Content: "a2"},
{Role: "assistant", Content: "a3"},
}
cfg := &config.ContextPruningConfig{Mode: "cache-ttl"}
var stats pipeline.PruneStats
pruneContextMessages(msgs, 5000, cfg, nil, "", &stats)
if stats.ResultsTrimmed == 0 {
t.Error("expected at least one ResultsTrimmed, got 0")
}
}
func TestPruneContextMessages_Stats_NilSafe(t *testing.T) {
// Passing nil stats must not panic.
msgs := []providers.Message{
{Role: "user", Content: "q"},
{Role: "assistant", Content: "", ToolCalls: []providers.ToolCall{{ID: "c1", Name: "read_file"}}},
{Role: "tool", ToolCallID: "c1", Content: strings.Repeat("x", 15000)},
{Role: "assistant", Content: "a1"},
{Role: "assistant", Content: "a2"},
{Role: "assistant", Content: "a3"},
}
cfg := &config.ContextPruningConfig{Mode: "cache-ttl"}
_ = pruneContextMessages(msgs, 5000, cfg, nil, "", nil) // must not panic
}
// ─── parseTTL (Phase 06) ─────────────────────────────────────────────────
func testTruncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
+8 -3
View File
@@ -151,10 +151,15 @@ type MemoryFlushConfig struct {
}
// ContextPruningConfig configures in-memory context pruning of old tool results.
// Matching TS src/agents/pi-extensions/context-pruning/settings.ts.
// Mode "cache-ttl": prune when context exceeds softTrimRatio of context window.
// Matches TS openclaw/src/agents/pi-hooks/context-pruning/settings.ts.
//
// Mode "" (default) or "off" → pruning disabled, zero overhead.
// Mode "cache-ttl" → prune eligible tool results when ratio exceeds softTrimRatio,
//
// gated by provider prompt-cache TTL (see PruneStage).
type ContextPruningConfig struct {
Mode string `json:"mode,omitempty"` // "off" (default), "cache-ttl"
Mode string `json:"mode,omitempty"` // "" (default off), "off", "cache-ttl"
TTL string `json:"ttl,omitempty"` // cache TTL gate duration (default "5m"), Go duration string e.g. "5m", "30s"
KeepLastAssistants int `json:"keepLastAssistants,omitempty"` // protect last N assistant msgs (default 3)
SoftTrimRatio float64 `json:"softTrimRatio,omitempty"` // start soft trim at this % of window (default 0.3)
HardClearRatio float64 `json:"hardClearRatio,omitempty"` // start hard clear at this % (default 0.5)
+16
View File
@@ -16,6 +16,9 @@ const (
EventRunCompleted EventType = "run.completed"
EventToolExecuted EventType = "tool.executed"
// Context pruning observability (Phase 05)
EventContextPruned EventType = "context.pruned"
// Vault events (v3 enrichment pipeline)
EventVaultDocUpserted EventType = "vault.doc_upserted"
@@ -111,6 +114,19 @@ type DelegateFailedPayload struct {
Error string
}
// ContextPrunedPayload is emitted when pruning mutates context messages.
// Payload intentionally excludes raw message content (counts + tokens only).
type ContextPrunedPayload struct {
SessionKey string
TokensBefore int
TokensAfter int
Budget int
ResultsTrimmed int // soft-trimmed count
ResultsCleared int // hard-cleared count
Compacted bool
Trigger string // "soft" | "hard" | "compact"
}
// VaultDocUpsertedPayload is emitted after a vault document is registered/updated.
type VaultDocUpsertedPayload struct {
DocID string // vault_documents.id (UUID)
+17 -1
View File
@@ -2,6 +2,7 @@ package pipeline
import (
"context"
"time"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/config"
@@ -11,6 +12,14 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/workspace"
)
// PruneStats holds counts from a single pruneContextMessages invocation.
// Populated by the pruning function; read by PruneStage for event emission.
type PruneStats struct {
ResultsTrimmed int // Pass 1: soft-trimmed count
ResultsCleared int // Pass 2: hard-cleared count
Compacted bool // LLM compaction ran this cycle
}
// PipelineDeps bundles all external dependencies stages need.
// Passed to NewDefaultPipeline; individual stages receive what they need via closure or direct field access.
type PipelineDeps struct {
@@ -55,9 +64,16 @@ type PipelineDeps struct {
EmitBlockReply func(content string) // emit block.reply for intermediate assistant content
// Prune callbacks (PruneStage)
PruneMessages func(msgs []providers.Message, budget int) []providers.Message
PruneMessages func(msgs []providers.Message, budget int) ([]providers.Message, PruneStats)
SanitizeHistory func(msgs []providers.Message) ([]providers.Message, int)
CompactMessages func(ctx context.Context, msgs []providers.Message, model string) ([]providers.Message, error)
// Cache-TTL gate callbacks (Phase 06). All optional (nil = feature disabled).
GetProviderCaps func() providers.ProviderCapabilities // provider capabilities for cache detection
GetPruningConfig func() *config.ContextPruningConfig // pruning config (TTL field)
GetCacheTouch func(sessionKey string) time.Time // per-session last prune-mutation timestamp
MarkCacheTouched func(sessionKey string) // record mutation timestamp AFTER prune mutates
// Memory flush callbacks (MemoryFlushStage, invoked by PruneStage)
RunMemoryFlush func(ctx context.Context, state *RunState) error
+104 -6
View File
@@ -4,7 +4,9 @@ import (
"context"
"fmt"
"log/slog"
"time"
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
@@ -27,6 +29,23 @@ func NewPruneStage(deps *PipelineDeps, memFlush *MemoryFlushStage) *PruneStage {
func (s *PruneStage) Name() string { return "prune" }
func (s *PruneStage) Result() StageResult { return s.result }
// defaultCachePruneTTL is used when cfg.TTL is empty or invalid.
const defaultCachePruneTTL = 5 * time.Minute
// parseTTL parses a Go duration string (e.g. "5m"). Falls back to 5m default on
// empty/invalid input with a warning log. Negative durations treated as invalid.
func parseTTL(s string) time.Duration {
if s == "" {
return defaultCachePruneTTL
}
d, err := time.ParseDuration(s)
if err != nil || d <= 0 {
slog.Warn("context_pruning: invalid ttl, using 5m default", "ttl", s, "err", err)
return defaultCachePruneTTL
}
return d
}
// Execute checks history tokens against budget, prunes/compacts as needed.
func (s *PruneStage) Execute(ctx context.Context, state *RunState) error {
s.result = Continue
@@ -52,18 +71,97 @@ func (s *PruneStage) Execute(ctx context.Context, state *RunState) error {
// Count current history tokens
historyTokens := s.countHistory(state)
state.Prune.HistoryTokens = historyTokens
tokensBefore := historyTokens
softThreshold := budget * 70 / 100
if historyTokens <= softThreshold {
return nil // under budget, no action needed
}
// Phase 1: soft prune at 70% budget
if s.deps.PruneMessages != nil {
pruned := s.deps.PruneMessages(state.Messages.History(), budget)
state.Messages.SetHistory(pruned)
historyTokens = s.countHistory(state)
state.Prune.HistoryTokens = historyTokens
sessionKey := state.Input.SessionKey
skipSoftPrune := false
// Cache-TTL gate (provider-aware, per-session).
// Only activates when all 4 callbacks are wired and mode == "cache-ttl".
if s.deps.GetPruningConfig != nil && s.deps.GetProviderCaps != nil && s.deps.GetCacheTouch != nil {
cfg := s.deps.GetPruningConfig()
if cfg != nil && cfg.Mode == "cache-ttl" {
caps := s.deps.GetProviderCaps()
if caps.CacheControl {
ttl := parseTTL(cfg.TTL)
touch := s.deps.GetCacheTouch(sessionKey)
if !touch.IsZero() && time.Since(touch) < ttl {
if historyTokens <= budget {
return nil // cache still live, below hard budget — preserve prefix
}
// Over hard budget despite live cache — safety valve: skip soft prune,
// fall through to compaction only.
slog.Info("context.cache_ttl_overridden",
"session_key", sessionKey,
"tokens", historyTokens,
"budget", budget,
)
skipSoftPrune = true
}
}
}
}
// Phase 1: soft prune at 70% budget (unless TTL gate routed to compact-only).
var pruneStats PruneStats
if !skipSoftPrune && s.deps.PruneMessages != nil {
var pruned []providers.Message
pruned, pruneStats = s.deps.PruneMessages(state.Messages.History(), budget)
mutated := pruneStats.ResultsTrimmed > 0 || pruneStats.ResultsCleared > 0
if mutated {
// Sanitize after prune to fix broken tool_use/tool_result pairs.
// Applied in-memory only — prune-induced orphans are ephemeral.
if s.deps.SanitizeHistory != nil {
pruned, _ = s.deps.SanitizeHistory(pruned)
}
state.Messages.SetHistory(pruned)
historyTokens = s.countHistory(state)
state.Prune.HistoryTokens = historyTokens
// Match TS semantics: touch recorded ONLY after prune mutates messages.
if s.deps.MarkCacheTouched != nil && sessionKey != "" {
s.deps.MarkCacheTouched(sessionKey)
}
}
}
// Emit observability event + slog when pruning actually mutated messages.
if pruneStats.ResultsTrimmed > 0 || pruneStats.ResultsCleared > 0 || pruneStats.Compacted {
trigger := "soft"
if pruneStats.Compacted {
trigger = "compact"
} else if pruneStats.ResultsCleared > 0 {
trigger = "hard"
}
if s.deps.EventBus != nil {
s.deps.EventBus.Publish(eventbus.DomainEvent{
Type: eventbus.EventContextPruned,
SourceID: sessionKey,
Payload: &eventbus.ContextPrunedPayload{
SessionKey: sessionKey,
TokensBefore: tokensBefore,
TokensAfter: historyTokens,
Budget: budget,
ResultsTrimmed: pruneStats.ResultsTrimmed,
ResultsCleared: pruneStats.ResultsCleared,
Compacted: pruneStats.Compacted,
Trigger: trigger,
},
})
}
slog.Info("context.pruned",
"session_key", sessionKey,
"tokens_before", tokensBefore,
"tokens_after", historyTokens,
"trimmed", pruneStats.ResultsTrimmed,
"cleared", pruneStats.ResultsCleared,
"compacted", pruneStats.Compacted,
"trigger", trigger,
)
}
if historyTokens <= budget {
+166 -14
View File
@@ -7,9 +7,12 @@ import (
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/workspace"
)
@@ -344,9 +347,9 @@ func TestPruneStage_UnderBudget_NoOp(t *testing.T) {
MaxTokens: 1000,
},
TokenCounter: &mockTokenCounter{countPerMessage: 1}, // tiny counts
PruneMessages: func(msgs []providers.Message, _ int) []providers.Message {
PruneMessages: func(msgs []providers.Message, _ int) ([]providers.Message, PruneStats) {
pruneCallCount++
return msgs
return msgs, PruneStats{}
},
}
stage := NewPruneStage(deps, nil)
@@ -380,10 +383,10 @@ func TestPruneStage_Over70Percent_CallsPruneMessages(t *testing.T) {
MaxTokens: 1000,
},
TokenCounter: &mockTokenCounter{countPerMessage: 100},
PruneMessages: func(msgs []providers.Message, budget int) []providers.Message {
PruneMessages: func(msgs []providers.Message, budget int) ([]providers.Message, PruneStats) {
pruneCallCount++
// return trimmed history that's under budget
return msgs[:1]
return msgs[:1], PruneStats{ResultsTrimmed: 1}
},
}
stage := NewPruneStage(deps, nil)
@@ -416,9 +419,9 @@ func TestPruneStage_Over100Percent_CallsCompact(t *testing.T) {
MaxTokens: 100,
},
TokenCounter: &mockTokenCounter{countPerMessage: 100},
PruneMessages: func(msgs []providers.Message, _ int) []providers.Message {
PruneMessages: func(msgs []providers.Message, _ int) ([]providers.Message, PruneStats) {
// return same size — still over budget
return msgs
return msgs, PruneStats{}
},
CompactMessages: func(_ context.Context, msgs []providers.Message, _ string) ([]providers.Message, error) {
compactCallCount++
@@ -455,8 +458,8 @@ func TestPruneStage_StillOverAfterCompaction_ReturnsAbortRun(t *testing.T) {
MaxTokens: 100,
},
TokenCounter: &mockTokenCounter{countPerMessage: 100},
PruneMessages: func(msgs []providers.Message, _ int) []providers.Message {
return msgs // no reduction
PruneMessages: func(msgs []providers.Message, _ int) ([]providers.Message, PruneStats) {
return msgs, PruneStats{} // no reduction
},
CompactMessages: func(_ context.Context, msgs []providers.Message, _ string) ([]providers.Message, error) {
// compaction still returns too many messages
@@ -520,9 +523,9 @@ func TestPruneStage_EffectiveContextWindow_OverridesConfig(t *testing.T) {
MaxTokens: 1_000,
},
TokenCounter: &mockTokenCounter{countPerMessage: 600},
PruneMessages: func(msgs []providers.Message, _ int) []providers.Message {
PruneMessages: func(msgs []providers.Message, _ int) ([]providers.Message, PruneStats) {
pruneCallCount++
return msgs
return msgs, PruneStats{}
},
}
stage := NewPruneStage(deps, nil)
@@ -632,9 +635,9 @@ func TestPruneStage_ReserveTokens_BuffersBudget(t *testing.T) {
ReserveTokens: 2_000,
},
TokenCounter: &mockTokenCounter{countPerMessage: 100},
PruneMessages: func(msgs []providers.Message, _ int) []providers.Message {
PruneMessages: func(msgs []providers.Message, _ int) ([]providers.Message, PruneStats) {
pruneCallCount++
return msgs[:1]
return msgs[:1], PruneStats{ResultsTrimmed: 1}
},
}
stage := NewPruneStage(deps, nil)
@@ -666,9 +669,9 @@ func TestPruneStage_EffectiveContextWindow_ZeroFallsBackToConfig(t *testing.T) {
MaxTokens: 1_000,
},
TokenCounter: &mockTokenCounter{countPerMessage: 600},
PruneMessages: func(msgs []providers.Message, _ int) []providers.Message {
PruneMessages: func(msgs []providers.Message, _ int) ([]providers.Message, PruneStats) {
pruneCallCount++
return msgs[:1]
return msgs[:1], PruneStats{ResultsTrimmed: 1}
},
}
stage := NewPruneStage(deps, nil)
@@ -1768,3 +1771,152 @@ func TestFinalizeStage_DeduplicatesMedia_WithRealFile(t *testing.T) {
t.Error("Size should be populated from real file")
}
}
// ─── parseTTL (Phase 06) ─────────────────────────────────────────────────
func TestParseTTL_ValidInputs(t *testing.T) {
cases := []struct {
in string
want time.Duration
}{
{"", 5 * time.Minute},
{"5m", 5 * time.Minute},
{"30s", 30 * time.Second},
{"1h30m", 90 * time.Minute},
{"bogus", 5 * time.Minute}, // invalid → fallback
{"-1m", 5 * time.Minute}, // negative → fallback
}
for _, tc := range cases {
got := parseTTL(tc.in)
if got != tc.want {
t.Errorf("parseTTL(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}
// ─── Cache-TTL gate (Phase 06) ───────────────────────────────────────────
// makePruneHistory builds history that exceeds the 70% soft threshold.
// budget = 10000-0-1000=9000, soft=6300. 100 msgs * 100 = 10000 > 6300.
func makePruneHistory(n int) []providers.Message {
h := make([]providers.Message, n)
for i := range h {
h[i] = providers.Message{Role: "tool", ToolCallID: "c1", Content: "x"}
}
return h
}
func TestPruneStage_CacheTtlGate_SkipsWithinTTL(t *testing.T) {
t.Parallel()
// cache live (1 min ago), history above soft threshold but below hard budget.
var pruneCalled int32
deps := &PipelineDeps{
Config: PipelineConfig{ContextWindow: 10000, MaxTokens: 1000},
TokenCounter: &mockTokenCounter{countPerMessage: 100},
PruneMessages: func(msgs []providers.Message, _ int) ([]providers.Message, PruneStats) {
atomic.AddInt32(&pruneCalled, 1)
return msgs, PruneStats{}
},
GetProviderCaps: func() providers.ProviderCapabilities { return providers.ProviderCapabilities{CacheControl: true} },
GetPruningConfig: func() *config.ContextPruningConfig { return &config.ContextPruningConfig{Mode: "cache-ttl", TTL: "5m"} },
GetCacheTouch: func(string) time.Time { return time.Now().Add(-1 * time.Minute) }, // 1m ago, within 5m TTL
MarkCacheTouched: func(string) {},
}
stage := NewPruneStage(deps, nil)
state := defaultState()
state.Messages.SetHistory(makePruneHistory(50)) // 50*100=5000 tokens; soft=6300, hard=9000; below hard
if err := stage.Execute(context.Background(), state); err != nil {
t.Fatalf("Execute() error: %v", err)
}
if atomic.LoadInt32(&pruneCalled) != 0 {
t.Error("prune should be skipped when cache is live and below hard budget")
}
}
func TestPruneStage_CacheTtlGate_PrunesAfterTTL(t *testing.T) {
t.Parallel()
// touch = 6 minutes ago → TTL expired → gate allows prune
var pruneCalled int32
var touchCalled int32
deps := &PipelineDeps{
Config: PipelineConfig{ContextWindow: 10000, MaxTokens: 1000},
TokenCounter: &mockTokenCounter{countPerMessage: 100},
PruneMessages: func(msgs []providers.Message, _ int) ([]providers.Message, PruneStats) {
atomic.AddInt32(&pruneCalled, 1)
return msgs[:1], PruneStats{ResultsTrimmed: 1}
},
GetProviderCaps: func() providers.ProviderCapabilities { return providers.ProviderCapabilities{CacheControl: true} },
GetPruningConfig: func() *config.ContextPruningConfig { return &config.ContextPruningConfig{Mode: "cache-ttl", TTL: "5m"} },
GetCacheTouch: func(string) time.Time { return time.Now().Add(-6 * time.Minute) }, // 6m ago, expired
MarkCacheTouched: func(string) { atomic.AddInt32(&touchCalled, 1) },
}
stage := NewPruneStage(deps, nil)
state := defaultState()
state.Messages.SetHistory(makePruneHistory(100)) // 100*100=10000; above soft threshold
if err := stage.Execute(context.Background(), state); err != nil {
t.Fatalf("Execute() error: %v", err)
}
if atomic.LoadInt32(&pruneCalled) == 0 {
t.Error("prune should fire when TTL expired")
}
if atomic.LoadInt32(&touchCalled) == 0 {
t.Error("MarkCacheTouched should be called after mutation")
}
}
func TestPruneStage_CacheTtlGate_NoCacheSupport_Prunes(t *testing.T) {
t.Parallel()
// CacheControl=false → gate is no-op → normal prune runs
var pruneCalled int32
deps := &PipelineDeps{
Config: PipelineConfig{ContextWindow: 10000, MaxTokens: 1000},
TokenCounter: &mockTokenCounter{countPerMessage: 100},
PruneMessages: func(msgs []providers.Message, _ int) ([]providers.Message, PruneStats) {
atomic.AddInt32(&pruneCalled, 1)
return msgs[:1], PruneStats{ResultsTrimmed: 1}
},
GetProviderCaps: func() providers.ProviderCapabilities { return providers.ProviderCapabilities{CacheControl: false} },
GetPruningConfig: func() *config.ContextPruningConfig { return &config.ContextPruningConfig{Mode: "cache-ttl", TTL: "5m"} },
GetCacheTouch: func(string) time.Time { return time.Now().Add(-1 * time.Minute) },
MarkCacheTouched: func(string) {},
}
stage := NewPruneStage(deps, nil)
state := defaultState()
state.Messages.SetHistory(makePruneHistory(100))
if err := stage.Execute(context.Background(), state); err != nil {
t.Fatalf("Execute() error: %v", err)
}
if atomic.LoadInt32(&pruneCalled) == 0 {
t.Error("prune should fire when provider has no cache support")
}
}
func TestPruneStage_CacheTtlGate_MarkTouchedOnlyOnMutation(t *testing.T) {
t.Parallel()
// PruneMessages returns zero stats (no mutation) → MarkCacheTouched must NOT be called.
var touchCalled int32
deps := &PipelineDeps{
Config: PipelineConfig{ContextWindow: 10000, MaxTokens: 1000},
TokenCounter: &mockTokenCounter{countPerMessage: 100},
PruneMessages: func(msgs []providers.Message, _ int) ([]providers.Message, PruneStats) {
return msgs, PruneStats{} // no mutation
},
GetProviderCaps: func() providers.ProviderCapabilities { return providers.ProviderCapabilities{CacheControl: true} },
GetPruningConfig: func() *config.ContextPruningConfig { return &config.ContextPruningConfig{Mode: "cache-ttl", TTL: "5m"} },
GetCacheTouch: func(string) time.Time { return time.Time{} }, // cold
MarkCacheTouched: func(string) { atomic.AddInt32(&touchCalled, 1) },
}
stage := NewPruneStage(deps, nil)
state := defaultState()
state.Messages.SetHistory(makePruneHistory(100))
if err := stage.Execute(context.Background(), state); err != nil {
t.Fatalf("Execute() error: %v", err)
}
if atomic.LoadInt32(&touchCalled) != 0 {
t.Error("MarkCacheTouched should NOT be called when prune returns no mutation")
}
}
+13 -1
View File
@@ -16,7 +16,7 @@ var schemaSQL string
// SchemaVersion is the current SQLite schema version.
// Bump this when adding new migration steps below.
const SchemaVersion = 18
const SchemaVersion = 19
// migrations maps version → SQL to apply when upgrading FROM that version.
// schema.sql always represents the LATEST full schema (for fresh DBs).
@@ -443,6 +443,18 @@ CREATE INDEX IF NOT EXISTS idx_vault_docs_delegation
17: `INSERT INTO builtin_tools (name, display_name, description, category, enabled, settings)
VALUES ('stt', 'Speech-to-Text', 'Transcribe voice/audio messages to text using ElevenLabs Scribe or a proxy service', 'media', 1, '{}')
ON CONFLICT (name) DO NOTHING;`,
// Version 18 → 19: backfill mode: "cache-ttl" for agents with custom
// context_pruning config missing the mode field. Mirrors PG migration 51.
// Preserves user intent after the opt-in default flip. NULL rows stay NULL.
18: `UPDATE agents
SET context_pruning = json_set(context_pruning, '$.mode', 'cache-ttl')
WHERE context_pruning IS NOT NULL
AND context_pruning <> ''
AND context_pruning <> '{}'
AND json_valid(context_pruning)
AND json_type(context_pruning) = 'object'
AND json_extract(context_pruning, '$.mode') IS NULL;`,
}
// backfillV16 populates base_name / path_basename for rows that existed
+1 -1
View File
@@ -2,4 +2,4 @@ package upgrade
// RequiredSchemaVersion is the schema migration version this binary requires.
// Bump this whenever adding a new SQL migration file.
const RequiredSchemaVersion uint = 50
const RequiredSchemaVersion uint = 51
@@ -0,0 +1,4 @@
-- No-op: cannot distinguish rows backfilled by the up migration from rows
-- where the user explicitly set mode = "cache-ttl". Rollback requires a DB
-- backup taken before the up migration ran.
SELECT 1;
@@ -0,0 +1,12 @@
-- Backfill mode: "cache-ttl" for agents that have custom context_pruning config
-- but are missing the "mode" field. This preserves user intent after the
-- opt-in default flip (faithful port of TS behavior) — see CHANGELOG.
--
-- Rows matched: context_pruning is a non-empty JSON object without a "mode" key.
-- Rows skipped: NULL, empty object, non-object values, or already has "mode".
UPDATE agents
SET context_pruning = jsonb_set(context_pruning, '{mode}', '"cache-ttl"'::jsonb)
WHERE context_pruning IS NOT NULL
AND jsonb_typeof(context_pruning) = 'object'
AND context_pruning <> '{}'::jsonb
AND NOT (context_pruning ? 'mode');
+2 -1
View File
@@ -796,7 +796,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.",
"descriptionSimple": "Automatically trims old tool results to prevent context overflow. Set mode to 'cache-ttl' to enable.",
"mode": "Mode",
"keepLastAssistants": "Keep Last Assistants",
"softTrimRatio": "Soft Trim Ratio (0-1)",
@@ -809,6 +809,7 @@
"hardClear": "Hard Clear",
"enabled": "Enabled",
"placeholderText": "Placeholder Text",
"cacheTtl": "Cache TTL",
"advanced": "Advanced"
},
"sandbox": {
+2 -1
View File
@@ -792,7 +792,8 @@
"hardClear": "Xóa cứng",
"enabled": "Đã bật",
"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.",
"cacheTtl": "Cache TTL",
"descriptionSimple": "Tự động cắt bớt kết quả công cụ cũ để tránh tràn ngữ cảnh. Đặt chế độ 'cache-ttl' để bật.",
"advanced": "Nâng cao"
},
"sandbox": {
+2 -1
View File
@@ -792,7 +792,8 @@
"hardClear": "硬清除",
"enabled": "已启用",
"placeholderText": "占位符文本",
"descriptionSimple": "自动裁剪旧工具结果以防止上下文溢出。默认启用。",
"cacheTtl": "缓存 TTL",
"descriptionSimple": "自动裁剪旧工具结果以防止上下文溢出。设置模式为 'cache-ttl' 以启用。",
"advanced": "高级"
},
"sandbox": {
@@ -97,7 +97,7 @@ export function deriveState(
{}
) as WorkspaceSharingConfig,
comp: agent.compaction_config ?? {},
pruneEnabled: agent.context_pruning?.mode !== "off",
pruneEnabled: agent.context_pruning?.mode === "cache-ttl",
prune: agent.context_pruning ?? {},
sbEnabled: agent.sandbox_config != null,
sb: agent.sandbox_config ?? {},
@@ -146,7 +146,7 @@ export function buildAdvancedUpdatePayload(
const updates: Record<string, unknown> = {
compaction_config: comp,
context_pruning: pruneEnabled
? (Object.keys(prune).length > 0 ? prune : null)
? { mode: "cache-ttl", ...prune }
: { mode: "off" },
sandbox_config: sbEnabled ? sb : null,
...routingPayload,
@@ -38,6 +38,19 @@ export function ContextPruningSection({ enabled, value, onToggle, onChange }: Co
/>
</div>
{/* Cache TTL — always shown when pruning is enabled (opt-in requires cache-ttl mode) */}
{enabled && (
<div className="max-w-xs space-y-2">
<InfoLabel tip='Prompt cache TTL. Pruning is skipped while the cache is live, preserving cache hits. Use Go duration strings like "5m", "30s". Default: 5m.'>{t(`${s}.cacheTtl`, "Cache TTL")}</InfoLabel>
<Input
type="text"
placeholder="5m"
value={value.ttl ?? ""}
onChange={(e) => onChange({ ...value, ttl: e.target.value || undefined })}
/>
</div>
)}
{/* Advanced toggle */}
<button
type="button"
+1
View File
@@ -31,6 +31,7 @@ export interface CompactionConfig {
export interface ContextPruningConfig {
mode?: "off" | "cache-ttl";
ttl?: string; // cache TTL gate duration, e.g. "5m" (default). Only used when mode="cache-ttl"
keepLastAssistants?: number;
softTrimRatio?: number;
hardClearRatio?: number;