mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 18:18: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.
292 lines
9.5 KiB
Go
292 lines
9.5 KiB
Go
package pipeline
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
)
|
|
|
|
// FinalRequestEstimate describes the complete pre-call context budget for the
|
|
// request that will actually be sent to the model.
|
|
type FinalRequestEstimate struct {
|
|
MessageTokens int
|
|
ToolTokens int
|
|
InputTokens int
|
|
OutputReserveTokens int
|
|
HardInputCapTokens int
|
|
CompactTargetTokens int
|
|
ContextWindow int
|
|
MaxRequestShare float64
|
|
}
|
|
|
|
const defaultMaxRequestShare = 0.85
|
|
|
|
func effectiveMaxRequestShare(cfg *config.CompactionConfig) float64 {
|
|
if cfg != nil && cfg.MaxRequestShare > 0 && cfg.MaxRequestShare <= 1 {
|
|
return cfg.MaxRequestShare
|
|
}
|
|
return defaultMaxRequestShare
|
|
}
|
|
|
|
func (s *ThinkStage) buildChatRequest(state *RunState, toolDefs []providers.ToolDefinition) providers.ChatRequest {
|
|
options := map[string]any{
|
|
providers.OptMaxTokens: s.deps.Config.MaxTokens,
|
|
}
|
|
return providers.ChatRequest{
|
|
Messages: state.Messages.All(),
|
|
Tools: toolDefs,
|
|
Model: state.Model,
|
|
Options: options,
|
|
}
|
|
}
|
|
|
|
func (s *ThinkStage) finalRequestEstimate(state *RunState, req providers.ChatRequest) (FinalRequestEstimate, error) {
|
|
contextWindow := state.Context.EffectiveContextWindow
|
|
if contextWindow == 0 {
|
|
contextWindow = s.deps.Config.ContextWindow
|
|
}
|
|
if contextWindow <= 0 {
|
|
return FinalRequestEstimate{}, nil
|
|
}
|
|
|
|
messageTokens, toolTokens, err := countBudgetInput(s.deps, state.Model, req)
|
|
if err != nil {
|
|
return FinalRequestEstimate{}, err
|
|
}
|
|
outputReserve := s.deps.Config.MaxTokens
|
|
if outputReserve < 0 {
|
|
outputReserve = 0
|
|
}
|
|
share := effectiveMaxRequestShare(s.deps.Config.Compaction)
|
|
inputTokens := messageTokens + toolTokens
|
|
hardInputCap := contextWindow - outputReserve
|
|
shareTarget := int(float64(contextWindow)*share) - outputReserve
|
|
compactTarget := min(hardInputCap, shareTarget)
|
|
return FinalRequestEstimate{
|
|
MessageTokens: messageTokens,
|
|
ToolTokens: toolTokens,
|
|
InputTokens: inputTokens,
|
|
OutputReserveTokens: outputReserve,
|
|
HardInputCapTokens: hardInputCap,
|
|
CompactTargetTokens: compactTarget,
|
|
ContextWindow: contextWindow,
|
|
MaxRequestShare: share,
|
|
}, nil
|
|
}
|
|
|
|
func countBudgetInput(deps *PipelineDeps, model string, req providers.ChatRequest) (int, int, error) {
|
|
if deps != nil && deps.BudgetCounter != nil {
|
|
messages, err := deps.BudgetCounter.CountMessages(req.Messages)
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf("count request messages: %w", err)
|
|
}
|
|
tools, err := deps.BudgetCounter.CountToolSchemas(req.Tools)
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf("count request tools: %w", err)
|
|
}
|
|
return messages, tools, nil
|
|
}
|
|
// Isolated pipeline tests may still wire only the legacy counter. Runtime
|
|
// always provides BudgetCounter.
|
|
return countRequestMessages(deps, model, req.Messages), countRequestTools(deps, model, req.Tools), nil
|
|
}
|
|
|
|
func countRequestMessages(deps *PipelineDeps, model string, messages []providers.Message) int {
|
|
if deps != nil && deps.TokenCounter != nil {
|
|
return deps.TokenCounter.CountMessages(model, messages)
|
|
}
|
|
total := 0
|
|
for _, msg := range messages {
|
|
total += utf8.RuneCountInString(msg.Content)/3 + 4
|
|
for _, tc := range msg.ToolCalls {
|
|
total += utf8.RuneCountInString(tc.ID)/3 + utf8.RuneCountInString(tc.Name)/3
|
|
}
|
|
}
|
|
return total
|
|
}
|
|
|
|
func countRequestTools(deps *PipelineDeps, model string, tools []providers.ToolDefinition) int {
|
|
if len(tools) == 0 {
|
|
return 0
|
|
}
|
|
if deps != nil && deps.TokenCounter != nil {
|
|
return deps.TokenCounter.CountToolSchemas(model, tools)
|
|
}
|
|
blob, err := json.Marshal(tools)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return utf8.RuneCountInString(string(blob)) / 3
|
|
}
|
|
|
|
func (e FinalRequestEstimate) withinLimit() bool {
|
|
return e.ContextWindow > 0 &&
|
|
e.HardInputCapTokens > 0 &&
|
|
e.CompactTargetTokens > 0 &&
|
|
e.InputTokens <= e.HardInputCapTokens &&
|
|
e.InputTokens <= e.CompactTargetTokens
|
|
}
|
|
|
|
func (s *ThinkStage) prepareFinalRequest(ctx context.Context, state *RunState, toolDefs []providers.ToolDefinition) (providers.ChatRequest, FinalRequestEstimate, error) {
|
|
req := s.buildChatRequest(state, toolDefs)
|
|
estimate, err := s.finalRequestEstimate(state, req)
|
|
if err != nil {
|
|
return req, estimate, err
|
|
}
|
|
if estimate.ContextWindow <= 0 {
|
|
// A nil counter means this lightweight pipeline instance did not opt into
|
|
// request budgeting (primarily isolated stage tests). Runtime wiring always
|
|
// provides a counter, so an unresolved runtime window still fails closed.
|
|
if s.deps.TokenCounter == nil {
|
|
return req, estimate, nil
|
|
}
|
|
return req, estimate, fmt.Errorf("context_window_unresolved: no configured agent context window")
|
|
}
|
|
if estimate.HardInputCapTokens <= 0 || estimate.CompactTargetTokens <= 0 {
|
|
s.logFinalRequestGuard(state, estimate, "abort", "invalid_budget")
|
|
return req, estimate, fmt.Errorf("final request context budget unavailable: context_window=%d output_reserve=%d hard_input_cap=%d compact_target=%d",
|
|
estimate.ContextWindow, estimate.OutputReserveTokens, estimate.HardInputCapTokens, estimate.CompactTargetTokens)
|
|
}
|
|
if estimate.withinLimit() {
|
|
s.logFinalRequestGuard(state, estimate, "allow", "initial")
|
|
return req, estimate, nil
|
|
}
|
|
|
|
s.logFinalRequestGuard(state, estimate, "reduce", "initial")
|
|
steps := []string{"prune_history", "compact_history", "shrink_memory"}
|
|
for _, step := range steps {
|
|
changed, err := s.reduceFinalRequestContext(ctx, state, estimate, step)
|
|
if err != nil {
|
|
return req, estimate, err
|
|
}
|
|
if !changed {
|
|
continue
|
|
}
|
|
req = s.buildChatRequest(state, toolDefs)
|
|
estimate, err = s.finalRequestEstimate(state, req)
|
|
if err != nil {
|
|
return req, estimate, err
|
|
}
|
|
if estimate.withinLimit() {
|
|
s.logFinalRequestGuard(state, estimate, "allow", step)
|
|
return req, estimate, nil
|
|
}
|
|
s.logFinalRequestGuard(state, estimate, "reduce", step)
|
|
}
|
|
|
|
s.logFinalRequestGuard(state, estimate, "abort", "exhausted")
|
|
return req, estimate, fmt.Errorf("final request context budget exceeded: estimated_input=%d compact_target=%d hard_input_cap=%d context_window=%d max_request_share=%.2f",
|
|
estimate.InputTokens, estimate.CompactTargetTokens, estimate.HardInputCapTokens, estimate.ContextWindow, estimate.MaxRequestShare)
|
|
}
|
|
|
|
func (s *ThinkStage) reduceFinalRequestContext(ctx context.Context, state *RunState, estimate FinalRequestEstimate, step string) (bool, error) {
|
|
switch step {
|
|
case "prune_history":
|
|
return s.pruneForFinalRequestBudget(state, estimate), nil
|
|
case "compact_history":
|
|
return s.compactForFinalRequestBudget(ctx, state)
|
|
case "shrink_memory":
|
|
return s.shrinkMemoryForFinalRequestBudget(state), nil
|
|
default:
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
func (s *ThinkStage) pruneForFinalRequestBudget(state *RunState, estimate FinalRequestEstimate) bool {
|
|
if s.deps.PruneMessages == nil {
|
|
return false
|
|
}
|
|
history := state.Messages.History()
|
|
if len(history) == 0 {
|
|
return false
|
|
}
|
|
fixedMessages := []providers.Message{state.Messages.System()}
|
|
fixedMessages = append(fixedMessages, state.Messages.Pending()...)
|
|
fixedMessageTokens := countRequestMessages(s.deps, state.Model, fixedMessages)
|
|
budget := estimate.CompactTargetTokens - estimate.ToolTokens - fixedMessageTokens
|
|
if budget <= 0 {
|
|
budget = 1
|
|
}
|
|
pruned, stats := s.deps.PruneMessages(history, budget)
|
|
changed := stats.ResultsTrimmed > 0 || stats.ResultsCleared > 0 || stats.Compacted || len(pruned) != len(history)
|
|
if !changed {
|
|
return false
|
|
}
|
|
if s.deps.SanitizeHistory != nil {
|
|
pruned, _ = s.deps.SanitizeHistory(pruned)
|
|
}
|
|
state.Messages.SetHistory(pruned)
|
|
return true
|
|
}
|
|
|
|
func (s *ThinkStage) compactForFinalRequestBudget(ctx context.Context, state *RunState) (bool, error) {
|
|
if s.deps.CompactMessages == nil {
|
|
return false, nil
|
|
}
|
|
history := state.Messages.History()
|
|
if len(history) == 0 {
|
|
return false, nil
|
|
}
|
|
savedPending := state.Messages.Pending()
|
|
compacted, err := s.deps.CompactMessages(ctx, history, state.Model)
|
|
if err != nil {
|
|
return false, fmt.Errorf("compact final request context: %w", err)
|
|
}
|
|
state.Messages.ReplaceHistory(compacted)
|
|
for _, msg := range savedPending {
|
|
state.Messages.AppendPending(msg)
|
|
}
|
|
state.Prune.MidLoopCompacted = true
|
|
state.Compact.CompactionCount++
|
|
return true, nil
|
|
}
|
|
|
|
func (s *ThinkStage) shrinkMemoryForFinalRequestBudget(state *RunState) bool {
|
|
section := strings.TrimSpace(state.Context.MemorySection)
|
|
if section == "" {
|
|
return false
|
|
}
|
|
sys := state.Messages.System()
|
|
content := sys.Content
|
|
candidates := []string{"\n\n" + state.Context.MemorySection, state.Context.MemorySection, section}
|
|
for _, candidate := range candidates {
|
|
if candidate == "" {
|
|
continue
|
|
}
|
|
if strings.Contains(content, candidate) {
|
|
sys.Content = strings.Replace(content, candidate, "", 1)
|
|
state.Messages.SetSystem(sys)
|
|
state.Context.MemorySection = ""
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *ThinkStage) logFinalRequestGuard(state *RunState, estimate FinalRequestEstimate, action, step string) {
|
|
if estimate.ContextWindow <= 0 {
|
|
return
|
|
}
|
|
slog.Info("final_context.guard",
|
|
"session_key", state.Input.SessionKey,
|
|
"run_id", state.RunID,
|
|
"model", state.Model,
|
|
"context_window", estimate.ContextWindow,
|
|
"max_request_share", estimate.MaxRequestShare,
|
|
"hard_input_cap_tokens", estimate.HardInputCapTokens,
|
|
"compact_target_input_tokens", estimate.CompactTargetTokens,
|
|
"message_tokens", estimate.MessageTokens,
|
|
"tool_tokens", estimate.ToolTokens,
|
|
"input_tokens", estimate.InputTokens,
|
|
"output_reserve_tokens", estimate.OutputReserveTokens,
|
|
"action", action,
|
|
"reduction_step", step,
|
|
)
|
|
}
|