mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 14:15:00 +00:00
The v3 pipeline compacts session history mid-loop (prune_stage + final-request guard) but only mutates the run's message buffer, never the session store. Each turn reloads full history and re-compacts from scratch: message_tokens climb 129k->156k across turns while every turn compacts back down to ~60k. The lossy compaction differs per run, degrading the agent. The same missing persistence stalls episodic memory: the cumulative compaction count never advances, so the episodic worker's idempotency key (sessionKey:count) is pinned and every cycle after the first is skipped. Observed on live traffic: 8 run.completed since deploy, 0 new episodic. Fixes, all reusing existing machinery (no new store methods, no migrations): - Bug A: emitSessionCompleted reads cumulative GetCompactionCount (matching the legacy v2 path) instead of the per-run counter that resets to 0. - Bug B/anti-loop: finalize passes state.Prune.MidLoopCompacted into maybeSummarize; under pressure it lowers the trigger to a unit-aligned threshold (compactionInputCap - overhead, same MaxRequestShare the guard uses) so the compaction is PERSISTED via the existing TruncateHistory + IncrementCompaction path. Defensive floor prevents over-compaction on pathological config; tool-result-only bloat still skips (history-only). - Bug C: SourceID embeds the count (sessionKey:count) so the eventbus dedup key advances per compaction cycle instead of swallowing rapid same-session turns within the 5m TTL. Tests: episodic compaction, maybe_summarize pressure, request budget. go build (PG + sqliteonly), go vet, go test -race all green.
140 lines
5.8 KiB
Go
140 lines
5.8 KiB
Go
package caps
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// A background/utility LLM call (carries an agent ID but no wired per-agent
|
|
// budget — e.g. vault.classify / vault.batch_summarize) must NOT fail closed
|
|
// with an AgentBudgetWiringError. It should fall through to a safe default
|
|
// budget and reach the provider. Regression guard for the vault-enrichment
|
|
// break introduced when the agent-only guard was added.
|
|
func TestServiceChat_BackgroundBudgetFallback_ReachesProvider(t *testing.T) {
|
|
// nil store service: falls back to a direct provider.Chat after the guard,
|
|
// isolating the budget-wiring behaviour from usage-cap policy plumbing.
|
|
var svc *Service
|
|
provider := &fakeChatProvider{name: "openrouter", model: "token/model"}
|
|
|
|
ctx := store.WithAgentID(context.Background(), uuid.New()) // agent-scoped, but NO window/max_tokens wired
|
|
_, err := svc.Chat(ctx, provider, providers.ChatRequest{
|
|
Model: "token/model",
|
|
Messages: []providers.Message{{Role: "user", Content: "classify this short doc"}},
|
|
Options: map[string]any{providers.OptMaxTokens: 4096},
|
|
}, ChatOptions{
|
|
AgentID: uuid.New(),
|
|
ProviderName: "openrouter",
|
|
Purpose: "vault.classify",
|
|
MaxOutputTokens: 4096,
|
|
})
|
|
if err != nil {
|
|
var wiringErr *AgentBudgetWiringError
|
|
if errors.As(err, &wiringErr) {
|
|
t.Fatalf("background call must not fail closed with a wiring error: %v", err)
|
|
}
|
|
t.Fatalf("background call returned unexpected error: %v", err)
|
|
}
|
|
if provider.calls != 1 {
|
|
t.Fatalf("provider calls = %d, want 1 (background call must reach transport)", provider.calls)
|
|
}
|
|
}
|
|
|
|
// The fallback is a SAFE default, not a bypass: a background call whose real
|
|
// input exceeds the default window is still aborted before transport by the
|
|
// window guard. Proves fallback != "guess a model window and wave it through".
|
|
func TestServiceChat_BackgroundBudgetFallback_StillGuardsOversizedInput(t *testing.T) {
|
|
var svc *Service
|
|
provider := &fakeChatProvider{name: "openrouter", model: "token/model"}
|
|
|
|
// Force a tiny window via opts so the guard has a small ceiling; leave
|
|
// max_tokens unwired so the fallback fills only that half.
|
|
huge := strings.Repeat("token ", 60_000) // well over a small window once counted
|
|
ctx := store.WithAgentID(context.Background(), uuid.New())
|
|
_, err := svc.Chat(ctx, provider, providers.ChatRequest{
|
|
Model: "token/model",
|
|
Messages: []providers.Message{{Role: "user", Content: huge}},
|
|
Options: map[string]any{providers.OptMaxTokens: 1024},
|
|
}, ChatOptions{
|
|
AgentID: uuid.New(),
|
|
ProviderName: "openrouter",
|
|
Purpose: "vault.batch_summarize",
|
|
AgentContextWindow: 8_000, // operator-provided window is preserved by the fallback
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected the window guard to abort an oversized background request")
|
|
}
|
|
var ctxErr *ContextWindowExceededError
|
|
if !errors.As(err, &ctxErr) {
|
|
t.Fatalf("expected *ContextWindowExceededError, got %T: %v", err, err)
|
|
}
|
|
if ctxErr.ContextWindow != 8_000 {
|
|
t.Fatalf("guard used window %d, want 8000 (operator value preserved, not defaulted)", ctxErr.ContextWindow)
|
|
}
|
|
if provider.calls != 0 {
|
|
t.Fatalf("provider calls = %d, want 0 (oversized request must not reach transport)", provider.calls)
|
|
}
|
|
}
|
|
|
|
// A non-agent-scoped call (no agent ID anywhere) must be untouched by the
|
|
// fallback path — it is neither guarded nor defaulted, matching prior behaviour.
|
|
func TestServiceChat_NonAgentScoped_NoFallbackNoGuard(t *testing.T) {
|
|
var svc *Service
|
|
provider := &fakeChatProvider{name: "openrouter", model: "token/model"}
|
|
|
|
huge := strings.Repeat("token ", 60_000)
|
|
_, err := svc.Chat(context.Background(), provider, providers.ChatRequest{
|
|
Model: "token/model",
|
|
Messages: []providers.Message{{Role: "user", Content: huge}},
|
|
Options: map[string]any{providers.OptMaxTokens: 1024},
|
|
}, ChatOptions{
|
|
ProviderName: "openrouter",
|
|
Purpose: "system.utility",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("non-agent-scoped call must pass through untouched, got %v", err)
|
|
}
|
|
if provider.calls != 1 {
|
|
t.Fatalf("provider calls = %d, want 1", provider.calls)
|
|
}
|
|
}
|
|
|
|
// backgroundBudgetFallback fills only the missing halves and preserves any
|
|
// operator-provided value; it never lets the reserve meet/exceed the window.
|
|
func TestBackgroundBudgetFallback_FillsOnlyMissing(t *testing.T) {
|
|
// Both missing: window defaulted, max_tokens taken from opts.MaxOutputTokens.
|
|
got := backgroundBudgetFallback("vault.classify", AgentBudget{}, ChatOptions{MaxOutputTokens: 4096})
|
|
if got.ContextWindow != defaultBackgroundContextWindow {
|
|
t.Errorf("ContextWindow = %d, want %d", got.ContextWindow, defaultBackgroundContextWindow)
|
|
}
|
|
if got.MaxTokens != 4096 {
|
|
t.Errorf("MaxTokens = %d, want 4096 (from opts.MaxOutputTokens)", got.MaxTokens)
|
|
}
|
|
|
|
// opts has no max_tokens either: falls back to the package default.
|
|
got = backgroundBudgetFallback("vault.classify", AgentBudget{}, ChatOptions{})
|
|
if got.MaxTokens != defaultBackgroundMaxTokens {
|
|
t.Errorf("MaxTokens = %d, want %d (package default)", got.MaxTokens, defaultBackgroundMaxTokens)
|
|
}
|
|
|
|
// Operator window preserved; only max_tokens defaulted.
|
|
got = backgroundBudgetFallback("vault.classify", AgentBudget{ContextWindow: 32_000}, ChatOptions{MaxOutputTokens: 2048})
|
|
if got.ContextWindow != 32_000 {
|
|
t.Errorf("ContextWindow = %d, want 32000 (preserved)", got.ContextWindow)
|
|
}
|
|
if got.MaxTokens != 2048 {
|
|
t.Errorf("MaxTokens = %d, want 2048", got.MaxTokens)
|
|
}
|
|
|
|
// Reserve must never meet/exceed the window.
|
|
got = backgroundBudgetFallback("vault.classify", AgentBudget{ContextWindow: 1000, MaxTokens: 4000}, ChatOptions{})
|
|
if got.MaxTokens >= got.ContextWindow {
|
|
t.Errorf("MaxTokens %d must be < ContextWindow %d", got.MaxTokens, got.ContextWindow)
|
|
}
|
|
}
|