mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 16:15:24 +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.
123 lines
3.9 KiB
Go
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")
|
|
}
|
|
}
|