mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-16 02:15:37 +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.
153 lines
5.5 KiB
Go
153 lines
5.5 KiB
Go
package caps
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
)
|
|
|
|
// stubGuardProvider records whether Chat was invoked and returns a canned reply.
|
|
type stubGuardProvider struct {
|
|
called bool
|
|
}
|
|
|
|
func (p *stubGuardProvider) Name() string { return "stubguard" }
|
|
func (p *stubGuardProvider) DefaultModel() string { return "claude-3-5-sonnet" }
|
|
func (p *stubGuardProvider) Chat(_ context.Context, _ providers.ChatRequest) (*providers.ChatResponse, error) {
|
|
p.called = true
|
|
return &providers.ChatResponse{Content: "ok"}, nil
|
|
}
|
|
func (p *stubGuardProvider) ChatStream(_ context.Context, _ providers.ChatRequest, _ func(providers.StreamChunk)) (*providers.ChatResponse, error) {
|
|
p.called = true
|
|
return &providers.ChatResponse{Content: "ok"}, nil
|
|
}
|
|
|
|
// bigMessage returns a user message whose complete-input count is at least
|
|
// target tokens, using the fixed model/provider-independent budget counter.
|
|
func bigMessage(t *testing.T, 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 := contextGuardCounter.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)}
|
|
}
|
|
|
|
// TestCapsChat_NonAgentCallSkipsWindowGuard proves a call with no AgentID and no
|
|
// agent budget is NOT forced against any model window: it passes straight to the
|
|
// provider even when huge. Model window is not a budget authority.
|
|
func TestCapsChat_NonAgentCallSkipsWindowGuard(t *testing.T) {
|
|
var svc *Service // nil service => Lite path
|
|
prov := &stubGuardProvider{}
|
|
// ~200k tokens: would blow any model window, but this is not agent-scoped.
|
|
msg := bigMessage(t, 200_000)
|
|
req := providers.ChatRequest{
|
|
Model: "gpt-4o",
|
|
Messages: []providers.Message{msg},
|
|
Options: map[string]any{providers.OptMaxTokens: 4096},
|
|
}
|
|
if _, err := svc.Chat(context.Background(), prov, req, ChatOptions{Purpose: "non-agent"}); err != nil {
|
|
t.Fatalf("non-agent call must not be window-guarded, got %v", err)
|
|
}
|
|
if !prov.called {
|
|
t.Fatal("provider.Chat should have been called for a non-agent call")
|
|
}
|
|
}
|
|
|
|
// TestCapsChat_AgentMissingBudgetUsesSafeDefault proves an agent-scoped call via
|
|
// Service.Chat (AgentID set) that reaches the gate WITHOUT a wired budget does
|
|
// NOT fail closed with a wiring error. These are background/utility calls (e.g.
|
|
// vault.classify) that run outside an agent turn; failing closed silently
|
|
// degrades enrichment. Instead a safe default budget is filled and the call
|
|
// reaches transport. NOTE: the agent-RUN tool path guards via GuardContextWindow
|
|
// directly (see internal/tools/usage_caps.go) and still fails closed — this
|
|
// relaxation is scoped to Service.Chat only.
|
|
func TestCapsChat_AgentMissingBudgetUsesSafeDefault(t *testing.T) {
|
|
var svc *Service
|
|
prov := &stubGuardProvider{}
|
|
req := providers.ChatRequest{
|
|
Model: "gpt-4o",
|
|
Messages: []providers.Message{{Role: "user", Content: "hello"}},
|
|
Options: map[string]any{providers.OptMaxTokens: 1024},
|
|
}
|
|
_, err := svc.Chat(context.Background(), prov, req, ChatOptions{
|
|
AgentID: uuid.New(),
|
|
Purpose: "vault.classify",
|
|
})
|
|
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("unexpected error from safe-default background call: %v", err)
|
|
}
|
|
if !prov.called {
|
|
t.Fatal("provider.Chat should have been called after filling a safe default budget")
|
|
}
|
|
}
|
|
|
|
// TestCapsChat_AgentBudgetSameRequestDiffersByWindow is the core agent-only
|
|
// contract test: the SAME request is blocked for a 128k agent and allowed for a
|
|
// 200k agent, decided only by the agent's configured window, never the model's.
|
|
func TestCapsChat_AgentBudgetSameRequestDiffersByWindow(t *testing.T) {
|
|
var svc *Service
|
|
agentID := uuid.New()
|
|
// ~140k tokens: fits a 200k window, exceeds a 128k window.
|
|
msg := bigMessage(t, 140_000)
|
|
req := providers.ChatRequest{
|
|
Model: "gpt-4o", // 128k model window — irrelevant to the decision
|
|
Messages: []providers.Message{msg},
|
|
Options: map[string]any{providers.OptMaxTokens: 4096},
|
|
}
|
|
|
|
// 128k agent: blocked.
|
|
prov128 := &stubGuardProvider{}
|
|
_, err := svc.Chat(context.Background(), prov128, req, ChatOptions{
|
|
AgentID: agentID,
|
|
Purpose: "cap-128k",
|
|
AgentContextWindow: 128_000,
|
|
AgentMaxTokens: 8_192,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("128k agent: expected abort for 140k request")
|
|
}
|
|
if prov128.called {
|
|
t.Fatal("128k agent: provider must NOT be called when the guard aborts (transport calls must be 0)")
|
|
}
|
|
var ctxErr *ContextWindowExceededError
|
|
if !errors.As(err, &ctxErr) {
|
|
t.Fatalf("expected *ContextWindowExceededError, got %T: %v", err, err)
|
|
}
|
|
if ctxErr.ContextWindow != 128_000 {
|
|
t.Fatalf("guard reported window %d, want 128000 (agent window, not model)", ctxErr.ContextWindow)
|
|
}
|
|
|
|
// 200k agent: allowed, provider reached.
|
|
prov200 := &stubGuardProvider{}
|
|
if _, err := svc.Chat(context.Background(), prov200, req, ChatOptions{
|
|
AgentID: agentID,
|
|
Purpose: "cap-200k",
|
|
AgentContextWindow: 200_000,
|
|
AgentMaxTokens: 8_192,
|
|
}); err != nil {
|
|
t.Fatalf("200k agent: expected allow for 140k request, got %v", err)
|
|
}
|
|
if !prov200.called {
|
|
t.Fatal("200k agent: provider should have been called")
|
|
}
|
|
}
|