mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 06:16:10 +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.
83 lines
3.1 KiB
Go
83 lines
3.1 KiB
Go
package consolidation
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// budgetAgentStore overrides only GetByIDUnscoped; the embedded nil interface
|
|
// panics if any other method is called, which keeps the mock honest.
|
|
type budgetAgentStore struct {
|
|
store.AgentCRUDStore
|
|
agent *store.AgentData
|
|
err error
|
|
}
|
|
|
|
func (m *budgetAgentStore) GetByIDUnscoped(_ context.Context, _ uuid.UUID) (*store.AgentData, error) {
|
|
if m.err != nil {
|
|
return nil, m.err
|
|
}
|
|
return m.agent, nil
|
|
}
|
|
|
|
// The whole point of the fix: background workers must carry the agent's
|
|
// configured request budget into ctx, or the agent-only preflight guard fails
|
|
// closed with AgentBudgetWiringError and silently kills memory consolidation.
|
|
func TestWithAgentRequestBudget_WiresConfiguredAgentBudget(t *testing.T) {
|
|
id := uuid.New()
|
|
agents := &budgetAgentStore{agent: &store.AgentData{ContextWindow: 128000, MaxTokens: 4096}}
|
|
|
|
ctx := withAgentRequestBudget(context.Background(), agents, id, "episodic-summary")
|
|
|
|
if got := store.AgentContextWindowFromContext(ctx); got != 128000 {
|
|
t.Fatalf("context window: want 128000, got %d", got)
|
|
}
|
|
if got := store.AgentMaxTokensFromContext(ctx); got != 4096 {
|
|
t.Fatalf("max tokens: want 4096, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestWithAgentRequestBudget_NilStoreFallsBackToDefaults(t *testing.T) {
|
|
ctx := withAgentRequestBudget(context.Background(), nil, uuid.New(), "dreaming-synthesis")
|
|
|
|
if got := store.AgentContextWindowFromContext(ctx); got != config.DefaultContextWindow {
|
|
t.Fatalf("context window: want default %d, got %d", config.DefaultContextWindow, got)
|
|
}
|
|
if got := store.AgentMaxTokensFromContext(ctx); got != config.DefaultMaxTokens {
|
|
t.Fatalf("max tokens: want default %d, got %d", config.DefaultMaxTokens, got)
|
|
}
|
|
}
|
|
|
|
func TestWithAgentRequestBudget_LookupErrorFallsBackToDefaults(t *testing.T) {
|
|
agents := &budgetAgentStore{err: errors.New("db down")}
|
|
|
|
ctx := withAgentRequestBudget(context.Background(), agents, uuid.New(), "episodic-summary")
|
|
|
|
if got := store.AgentContextWindowFromContext(ctx); got != config.DefaultContextWindow {
|
|
t.Fatalf("context window: want default %d, got %d", config.DefaultContextWindow, got)
|
|
}
|
|
if got := store.AgentMaxTokensFromContext(ctx); got != config.DefaultMaxTokens {
|
|
t.Fatalf("max tokens: want default %d, got %d", config.DefaultMaxTokens, got)
|
|
}
|
|
}
|
|
|
|
// A zero/partial agent row must not zero out the budget — the setters ignore
|
|
// non-positive values, and the helper must supply defaults for the missing half.
|
|
func TestWithAgentRequestBudget_ZeroFieldsUseDefaults(t *testing.T) {
|
|
agents := &budgetAgentStore{agent: &store.AgentData{ContextWindow: 0, MaxTokens: 0}}
|
|
|
|
ctx := withAgentRequestBudget(context.Background(), agents, uuid.New(), "episodic-summary")
|
|
|
|
if got := store.AgentContextWindowFromContext(ctx); got != config.DefaultContextWindow {
|
|
t.Fatalf("context window: want default %d, got %d", config.DefaultContextWindow, got)
|
|
}
|
|
if got := store.AgentMaxTokensFromContext(ctx); got != config.DefaultMaxTokens {
|
|
t.Fatalf("max tokens: want default %d, got %d", config.DefaultMaxTokens, got)
|
|
}
|
|
}
|