Files
goclaw/internal/agent/loop_request_budget_test.go
Clark Cant 5ca433b8ac fix: persist mid-loop compaction to stop re-compaction loop and revive episodic
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.
2026-07-25 00:39:02 +07:00

123 lines
3.9 KiB
Go

package agent
import (
"errors"
"strings"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/tokencount"
)
func newBudgetTestLoop(window, maxTokens int) *Loop {
return &Loop{
id: "budget-test",
contextWindow: window,
maxTokens: maxTokens,
budgetCounter: tokencount.NewBudgetCounter(),
}
}
func budgetMessageAtLeast(t *testing.T, counter tokencount.BudgetCounter, target int) providers.Message {
t.Helper()
low, high := 1, target*8
for low < high {
mid := low + (high-low)/2
msg := providers.Message{Role: "user", Content: strings.Repeat("word ", mid)}
count, err := counter.CountMessages([]providers.Message{msg})
if err != nil {
t.Fatal(err)
}
if count < target {
low = mid + 1
} else {
high = mid
}
}
return providers.Message{Role: "user", Content: strings.Repeat("word ", low)}
}
func TestGuardCompleteModelRequest_AgentWindowOnly(t *testing.T) {
counter := tokencount.NewBudgetCounter()
msg := budgetMessageAtLeast(t, counter, 130_000)
req := providers.ChatRequest{Model: "model-a", Messages: []providers.Message{msg}}
loop128 := newBudgetTestLoop(128_000, 8_192)
err := loop128.guardCompleteModelRequest(req, "provider-a", "model-a", "initial")
if err == nil {
t.Fatal("128k agent: expected request to be blocked")
}
var budgetErr *RequestBudgetExceededError
if !errors.As(err, &budgetErr) {
t.Fatalf("expected RequestBudgetExceededError, got %T: %v", err, err)
}
loop200 := newBudgetTestLoop(200_000, 8_192)
if err := loop200.guardCompleteModelRequest(req, "provider-a", "model-a", "initial"); err != nil {
t.Fatalf("200k agent: expected request to fit, got %v", err)
}
}
func TestGuardCompleteModelRequest_ModelProviderIndependent(t *testing.T) {
loop := newBudgetTestLoop(128_000, 8_192)
base := providers.ChatRequest{
Model: "model-a",
Messages: []providers.Message{{
Role: "assistant",
Content: "same content",
Thinking: "same thinking",
ToolCalls: []providers.ToolCall{{
ID: "call-1",
Name: "read_file",
Arguments: map[string]any{"path": "/tmp/a"},
Metadata: map[string]string{"thought_signature": "sig"},
}},
}},
}
other := base
other.Model = "totally-different-model"
first, err := loop.budgetCounter.CountRequest(base)
if err != nil {
t.Fatal(err)
}
second, err := loop.budgetCounter.CountRequest(other)
if err != nil {
t.Fatal(err)
}
if first != second {
t.Fatalf("completeInput changed with model: %d != %d", first, second)
}
if errA, errB := loop.guardCompleteModelRequest(base, "provider-a", base.Model, "a"), loop.guardCompleteModelRequest(other, "provider-b", other.Model, "b"); (errA == nil) != (errB == nil) {
t.Fatalf("allow/abort changed with model/provider: %v vs %v", errA, errB)
}
}
func TestGuardCompleteModelRequest_MaxTokensChangesHardCapExactly(t *testing.T) {
counter := tokencount.NewBudgetCounter()
msg := budgetMessageAtLeast(t, counter, 7_500)
req := providers.ChatRequest{Messages: []providers.Message{msg}}
input, err := counter.CountRequest(req)
if err != nil {
t.Fatal(err)
}
window := input + 2_500
allow := newBudgetTestLoop(window, 2_500)
if err := allow.guardCompleteModelRequest(req, "p", "m", "allow"); err != nil {
t.Fatalf("input + 2500 should fit exactly: input=%d window=%d err=%v", input, window, err)
}
block := newBudgetTestLoop(window, 2_501)
if err := block.guardCompleteModelRequest(req, "p", "m", "block"); err == nil {
t.Fatalf("input + 2501 must exceed by one: input=%d window=%d", input, window)
}
}
func TestGuardCompleteModelRequest_FailsClosedOnUnresolvedAgentWindow(t *testing.T) {
loop := newBudgetTestLoop(0, 8_192)
req := providers.ChatRequest{Messages: []providers.Message{{Role: "user", Content: "hi"}}}
if err := loop.guardCompleteModelRequest(req, "provider", "model", "initial"); err == nil {
t.Fatal("expected wiring failure without configured agent window")
}
}