mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 10:15:48 +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.
259 lines
9.8 KiB
Go
259 lines
9.8 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
)
|
|
|
|
// Fixture math for these tests (contextWindow=200000, maxTokens=8192, nil cfg):
|
|
//
|
|
// effectiveMaxTokens = 8192
|
|
// compactionInputCap = min(200000-8192, int(200000*0.85)-8192) = min(191808, 161808) = 161808
|
|
// baseline threshold = int(200000*0.85) = 170000
|
|
// overhead (no calib) = min(int(200000*0.2), 40000) = 40000
|
|
// pressureThreshold = max(161808-40000, 0) = 121808
|
|
// defensive floor = 170000/2 = 85000 (< 121808, so unused)
|
|
//
|
|
// Token estimate (no calibration) = sum(runes(content)/3) over all history messages.
|
|
// Bands used below:
|
|
//
|
|
// ~100k tokens → below BOTH thresholds → skip even under mid-loop pressure
|
|
// ~150k tokens → between pressure(121808) and baseline(170000) → persist ONLY under pressure
|
|
const (
|
|
pressureContextWindow = 200000
|
|
pressureMaxTokens = 8192
|
|
)
|
|
|
|
// buildTokenHistory returns n alternating user/assistant messages whose combined
|
|
// rune/3 token estimate is approximately targetTokens. Each message carries
|
|
// targetTokens/n * 3 runes so EstimateTokens(history) ≈ targetTokens.
|
|
func buildTokenHistory(n, targetTokens int) []providers.Message {
|
|
perMsgTokens := targetTokens / n
|
|
perMsgRunes := perMsgTokens * 3
|
|
content := makeLongString(perMsgRunes)
|
|
history := make([]providers.Message, n)
|
|
for i := range history {
|
|
if i%2 == 0 {
|
|
history[i] = providers.Message{Role: "user", Content: content}
|
|
} else {
|
|
history[i] = providers.Message{Role: "assistant", Content: content}
|
|
}
|
|
}
|
|
return history
|
|
}
|
|
|
|
// waitForCount polls get() until it reaches want or the deadline elapses.
|
|
func waitForCount(t *testing.T, get func() int, want int) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if get() >= want {
|
|
return
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
t.Fatalf("timed out waiting for count to reach %d, last=%d", want, get())
|
|
}
|
|
|
|
func newPressureLoop(sessions *nopSessionStore, provider providers.Provider) *Loop {
|
|
return &Loop{
|
|
id: "test-agent",
|
|
provider: provider,
|
|
model: "claude-3-5-sonnet",
|
|
contextWindow: pressureContextWindow,
|
|
maxTokens: pressureMaxTokens,
|
|
sessions: sessions,
|
|
hasMemory: false,
|
|
compactionCfg: nil,
|
|
}
|
|
}
|
|
|
|
// V1-B / V2 core: a run that compacted mid-loop lowers the threshold to
|
|
// pressureThreshold (121808). History at ~150k tokens is BELOW the baseline
|
|
// (170000) but ABOVE the pressure threshold → summarize must run and PERSIST
|
|
// (TruncateHistory + IncrementCompaction each exactly once).
|
|
func TestMaybeSummarize_MidLoopPressurePersists(t *testing.T) {
|
|
sessions := &nopSessionStore{
|
|
history: buildTokenHistory(20, 150000), // ~150k tokens
|
|
}
|
|
loop := newPressureLoop(sessions, &capturingProvider{response: "compaction summary"})
|
|
|
|
loop.maybeSummarize(context.Background(), "sess-1", true)
|
|
|
|
waitForCount(t, sessions.incrementCount, 1)
|
|
|
|
if got := sessions.incrementCount(); got != 1 {
|
|
t.Errorf("IncrementCompaction called %d times, want 1", got)
|
|
}
|
|
if got := sessions.truncateCount(); got != 1 {
|
|
t.Errorf("TruncateHistory called %d times, want 1", got)
|
|
}
|
|
sessions.mu.Lock()
|
|
keepLast := sessions.truncateKeepLast
|
|
setSummary := sessions.setSummaryCalls
|
|
sessions.mu.Unlock()
|
|
if keepLast != 4 {
|
|
t.Errorf("TruncateHistory keepLast = %d, want 4", keepLast)
|
|
}
|
|
if setSummary != 1 {
|
|
t.Errorf("SetSummary called %d times, want 1", setSummary)
|
|
}
|
|
}
|
|
|
|
// V2 no-over-compaction (baseline case): the SAME ~150k history without mid-loop
|
|
// pressure must SKIP (150000 < 170000 baseline). No summarize, no persist.
|
|
func TestMaybeSummarize_NonMidLoopSkipsUnderBaseline(t *testing.T) {
|
|
provider := &capturingProvider{response: "should not be called"}
|
|
sessions := &nopSessionStore{
|
|
history: buildTokenHistory(20, 150000), // ~150k tokens
|
|
}
|
|
loop := newPressureLoop(sessions, provider)
|
|
|
|
loop.maybeSummarize(context.Background(), "sess-1", false)
|
|
|
|
// Skip returns synchronously before spawning the goroutine; give any
|
|
// (erroneously spawned) goroutine a brief window to prove it didn't run.
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
if got := len(provider.captured); got != 0 {
|
|
t.Errorf("provider.Chat called %d times, want 0 (under baseline, no pressure)", got)
|
|
}
|
|
if got := sessions.incrementCount(); got != 0 {
|
|
t.Errorf("IncrementCompaction called %d times, want 0", got)
|
|
}
|
|
if got := sessions.truncateCount(); got != 0 {
|
|
t.Errorf("TruncateHistory called %d times, want 0", got)
|
|
}
|
|
}
|
|
|
|
// V2 no-over-compaction (the KEY case that distinguishes "lower threshold" from
|
|
// "skip threshold"): mid-loop pressure is set, but the real history is small
|
|
// (~100k tokens, below pressureThreshold=121808). The mid-loop compaction was
|
|
// driven by transient tool-result bloat, not history — so we must STILL SKIP and
|
|
// NOT truncate the session. This proves we lowered the threshold rather than
|
|
// bypassing it.
|
|
func TestMaybeSummarize_MidLoopSkipsWhenHistorySmall(t *testing.T) {
|
|
provider := &capturingProvider{response: "should not be called"}
|
|
sessions := &nopSessionStore{
|
|
history: buildTokenHistory(20, 100000), // ~100k tokens < pressureThreshold
|
|
}
|
|
loop := newPressureLoop(sessions, provider)
|
|
|
|
loop.maybeSummarize(context.Background(), "sess-1", true)
|
|
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
if got := len(provider.captured); got != 0 {
|
|
t.Errorf("provider.Chat called %d times, want 0 (history below pressure threshold)", got)
|
|
}
|
|
if got := sessions.incrementCount(); got != 0 {
|
|
t.Errorf("IncrementCompaction called %d times, want 0 (no over-compaction)", got)
|
|
}
|
|
if got := sessions.truncateCount(); got != 0 {
|
|
t.Errorf("TruncateHistory called %d times, want 0 (no over-compaction)", got)
|
|
}
|
|
}
|
|
|
|
// V2 threshold-sync boundary: with one fixed history between the pressure
|
|
// threshold (121808) and the baseline (170000), the ONLY thing that flips the
|
|
// decision is the mid-loop flag. Persist when true, skip when false — proving
|
|
// the two thresholds bracket this history exactly as designed.
|
|
func TestMaybeSummarize_ThresholdSyncBoundary(t *testing.T) {
|
|
history := buildTokenHistory(20, 150000) // between 121808 and 170000
|
|
|
|
// mid-loop = false → skip
|
|
skipProvider := &capturingProvider{response: "unused"}
|
|
skipSessions := &nopSessionStore{history: cloneMessages(history)}
|
|
newPressureLoop(skipSessions, skipProvider).maybeSummarize(context.Background(), "sess-1", false)
|
|
time.Sleep(50 * time.Millisecond)
|
|
if got := skipSessions.incrementCount(); got != 0 {
|
|
t.Errorf("non-mid-loop: IncrementCompaction = %d, want 0 (skip above pressure, below baseline)", got)
|
|
}
|
|
|
|
// mid-loop = true → persist
|
|
triggerSessions := &nopSessionStore{history: cloneMessages(history)}
|
|
newPressureLoop(triggerSessions, &capturingProvider{response: "compaction summary"}).
|
|
maybeSummarize(context.Background(), "sess-1", true)
|
|
waitForCount(t, triggerSessions.incrementCount, 1)
|
|
if got := triggerSessions.incrementCount(); got != 1 {
|
|
t.Errorf("mid-loop: IncrementCompaction = %d, want 1 (persist under pressure)", got)
|
|
}
|
|
}
|
|
|
|
// V2 anti-loop (characterization): turn 1 persists the mid-loop compaction,
|
|
// shrinking the session history. Turn 2 loads the now-small history, so even
|
|
// with mid-loop pressure still set it stays below the pressure threshold and
|
|
// does NOT re-compact — the unbounded re-compaction loop is broken.
|
|
func TestMaybeSummarize_AntiLoop_SecondTurnDoesNotRecompact(t *testing.T) {
|
|
sessions := &nopSessionStore{
|
|
history: buildTokenHistory(20, 150000),
|
|
}
|
|
loop := newPressureLoop(sessions, &capturingProvider{response: "compaction summary"})
|
|
|
|
// Turn 1: pressure present, history large → persist (truncate to keepLast=4).
|
|
loop.maybeSummarize(context.Background(), "sess-1", true)
|
|
waitForCount(t, sessions.incrementCount, 1)
|
|
|
|
sessions.mu.Lock()
|
|
remaining := len(sessions.history)
|
|
sessions.mu.Unlock()
|
|
if remaining > 4 {
|
|
t.Fatalf("after turn 1 persist, history len = %d, want <= 4 (keepLast)", remaining)
|
|
}
|
|
|
|
// Turn 2: history is now tiny (4 messages). Even with pressure set, the
|
|
// history-only estimate is far below pressureThreshold → skip.
|
|
loop.maybeSummarize(context.Background(), "sess-1", true)
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
if got := sessions.incrementCount(); got != 1 {
|
|
t.Errorf("after turn 2, IncrementCompaction total = %d, want 1 (no re-compaction)", got)
|
|
}
|
|
if got := sessions.truncateCount(); got != 1 {
|
|
t.Errorf("after turn 2, TruncateHistory total = %d, want 1 (no re-compaction)", got)
|
|
}
|
|
}
|
|
|
|
// Media preservation regression: MediaRefs on messages about to be truncated
|
|
// must be carried onto the first kept message so the agent doesn't lose track of
|
|
// shared files across a pressure-driven compaction.
|
|
func TestMaybeSummarize_MidLoopPreservesMediaRefs(t *testing.T) {
|
|
history := buildTokenHistory(20, 150000)
|
|
// Attach a media ref to an early message (will be in the to-summarize slice).
|
|
history[0].MediaRefs = []providers.MediaRef{
|
|
{ID: "img-1", MimeType: "image/png", Kind: "image", Path: "/tmp/img-1.png"},
|
|
}
|
|
sessions := &nopSessionStore{history: history}
|
|
loop := newPressureLoop(sessions, &capturingProvider{response: "compaction summary"})
|
|
|
|
loop.maybeSummarize(context.Background(), "sess-1", true)
|
|
waitForCount(t, sessions.incrementCount, 1)
|
|
|
|
sessions.mu.Lock()
|
|
defer sessions.mu.Unlock()
|
|
if len(sessions.history) == 0 {
|
|
t.Fatal("history empty after truncation")
|
|
}
|
|
var found bool
|
|
for _, ref := range sessions.history[0].MediaRefs {
|
|
if ref.ID == "img-1" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("preserved media ref img-1 not found on first kept message; refs=%+v", sessions.history[0].MediaRefs)
|
|
}
|
|
}
|
|
|
|
// cloneMessages returns a shallow copy of a message slice so two Loops don't
|
|
// share the same backing array (TruncateHistory mutates it in the mock).
|
|
func cloneMessages(msgs []providers.Message) []providers.Message {
|
|
out := make([]providers.Message, len(msgs))
|
|
copy(out, msgs)
|
|
return out
|
|
}
|