mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 06:13:57 +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.
100 lines
3.3 KiB
Go
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,
|
|
}
|
|
}
|