mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 08:15:29 +00:00
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:
1 parent
8d9a7c127a
commit
48335d7797
26 files changed
+730
-115
No files matched your search
@@ -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
@@ -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.) |
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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] + "..."
|
||||
}
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user