Files
goclaw/internal/agent/loop_request_budget.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

100 lines
3.3 KiB
Go

package agent
import (
"fmt"
"log/slog"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
// RequestBudgetExceededError is returned before provider transport when the
// COMPLETE final request (messages + tools + output reserve) exceeds the
// receiving agent's configured context window. It is intentionally internal
// to the agent loop: callers abort/reduce instead of falling through to model
// fallback or sending an oversized payload.
type RequestBudgetExceededError struct {
AgentID string
Provider string
Model string
Attempt string
InputTokens int
OutputReserve int
ContextWindow int
}
func (e *RequestBudgetExceededError) Error() string {
return fmt.Sprintf(
"context budget exceeded: input=%d + output_reserve=%d > agent_window=%d (agent=%s provider=%s model=%s attempt=%s)",
e.InputTokens, e.OutputReserve, e.ContextWindow, e.AgentID, e.Provider, e.Model, e.Attempt,
)
}
// ContextBudgetExceeded marks this error as a request-level context-budget
// overflow. Package-neutral consumers (pipeline.ThinkStage) detect it via an
// interface check without importing the agent package, so they can re-enter
// reduction (prune -> compact -> shrink) and rebuild before aborting.
func (e *RequestBudgetExceededError) ContextBudgetExceeded() bool { return true }
// guardCompleteModelRequest is the mandatory final boundary check for EVERY
// model request emitted by an agent loop. Call it only after all messages,
// tools, retry instructions, model overrides and options have been finalized,
// and immediately before provider.Chat/ChatStream.
//
// Hard invariant:
//
// completeInputTokens + outputReserveTokens <= agent.ContextWindow
//
// The configured agent context window and configured max_tokens are the only
// budget authorities. Model and provider are diagnostic fields only.
func (l *Loop) guardCompleteModelRequest(request providers.ChatRequest, providerName, model, attempt string) error {
// Bare Loop literals used by unrelated unit tests opt out; NewLoop always
// wires the fixed local budget counter.
if l.budgetCounter == nil {
return nil
}
contextWindow := l.resolveEffectiveContextWindow()
if contextWindow <= 0 {
return fmt.Errorf("context_window_unresolved: agent=%s provider=%s model=%s attempt=%s", l.id, providerName, model, attempt)
}
inputTokens, err := l.budgetCounter.CountRequest(request)
if err != nil {
return fmt.Errorf("count complete request: %w", err)
}
outputReserve := l.effectiveMaxTokens()
if inputTokens+outputReserve <= contextWindow {
slog.Debug("context_budget.guard",
"agent", l.id,
"provider", providerName,
"model", model,
"attempt", attempt,
"input_tokens", inputTokens,
"output_reserve_tokens", outputReserve,
"context_window", contextWindow,
"action", "allow")
return nil
}
slog.Info("context_budget.guard",
"agent", l.id,
"provider", providerName,
"model", model,
"attempt", attempt,
"input_tokens", inputTokens,
"output_reserve_tokens", outputReserve,
"context_window", contextWindow,
"action", "abort",
"reason", "context_budget_exceeded")
return &RequestBudgetExceededError{
AgentID: l.id,
Provider: providerName,
Model: model,
Attempt: attempt,
InputTokens: inputTokens,
OutputReserve: outputReserve,
ContextWindow: contextWindow,
}
}