From 4c5cad38f823dbcfcc2b8f34f4bf40c389e1f5a4 Mon Sep 17 00:00:00 2001 From: Victor Hugo Vargas Date: Sat, 18 Jul 2026 00:07:41 -0700 Subject: [PATCH 1/6] feat(config): add auto-compaction block and CLI wiring Adds a configurable compaction section to portfolio-bot.yaml with threshold_ratio, keep_recent_turns and an optional summary prompt. Wires the new fields through config.Validate() and cmd/chat-bot/main.go into agent.Runner.WithCompaction() so the runner can opt in to auto-compaction at startup. --- cmd/chat-bot/main.go | 16 +++++++++++-- configs/portfolio-bot.yaml | 18 +++++++++++++-- internal/config/config.go | 46 +++++++++++++++++++++++++++++++++----- 3 files changed, 71 insertions(+), 9 deletions(-) diff --git a/cmd/chat-bot/main.go b/cmd/chat-bot/main.go index afd58d6..22e6f52 100644 --- a/cmd/chat-bot/main.go +++ b/cmd/chat-bot/main.go @@ -115,7 +115,13 @@ func serveCmd() *cobra.Command { if err != nil { return err } - runner := agent.New(client, p, cfg.SystemPrompt, store, cfg.RAG.TopK) + runner := agent.New(client, p, cfg.SystemPrompt, store, cfg.RAG.TopK). + WithCompaction(agent.CompactionConfig{ + Enabled: cfg.Compaction.Enabled, + ThresholdRatio: cfg.Compaction.ThresholdRatio, + KeepRecentTurns: cfg.Compaction.KeepRecentTurns, + SummarySystemPrompt: cfg.Compaction.SummarySystemPrompt, + }) h := server.NewHandlers(cfg, runner, store, version) srv := server.New(cfg, h) @@ -212,7 +218,13 @@ func runAsk(cfg *config.Config, question string, noStream bool) error { if err != nil { return err } - runner := agent.New(client, p, cfg.SystemPrompt, store, cfg.RAG.TopK) + runner := agent.New(client, p, cfg.SystemPrompt, store, cfg.RAG.TopK). + WithCompaction(agent.CompactionConfig{ + Enabled: cfg.Compaction.Enabled, + ThresholdRatio: cfg.Compaction.ThresholdRatio, + KeepRecentTurns: cfg.Compaction.KeepRecentTurns, + SummarySystemPrompt: cfg.Compaction.SummarySystemPrompt, + }) history := []agent.Message{{Role: agent.RoleUser, Content: question}} if noStream { diff --git a/configs/portfolio-bot.yaml b/configs/portfolio-bot.yaml index 2cf8004..5c26f91 100644 --- a/configs/portfolio-bot.yaml +++ b/configs/portfolio-bot.yaml @@ -21,7 +21,7 @@ providers: type: llamacpp model: qwen2.5-3b-instruct endpoint: http://localhost:9100/v1 - context_size: 4096 + context_size: 2048 max_tokens: 2048 default: true @@ -123,4 +123,18 @@ system_prompt: | logging: level: info # debug | info | warn | error format: json # json | text - output: stderr \ No newline at end of file + output: stderr + +# Auto-compaction: fold the older part of a long conversation into a single +# summary message before sending it to the model, so context overflow doesn't +# kill long threads. Triggered when the previous turn's input tokens exceed +# `threshold_ratio` of the provider's reported MaxContextWindow. +# +# Off by default — most portfolio chats are short. Turn it on for chatty +# visitors or for the small-context local models (qwen2.5-1.5b / 3b) where +# 4–6 turns is already most of the window. +compaction: + enabled: true + threshold_ratio: 0.75 # compact at 75% of context window + keep_recent_turns: 2 # last 2 user turns kept verbatim; older → summary + # summary_system_prompt: "" # leave empty for the built-in bilingual default \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index c729a52..7eeb03d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -59,13 +59,41 @@ type Logging struct { Output string `yaml:"output"` } +// Compaction controls automatic context-window management. When the most +// recent request's input tokens exceed threshold_ratio × the provider's +// reported MaxContextWindow, the older portion of the conversation is +// summarized by the LLM and replaced with a single system-role note. +// Recent turns (keep_recent_turns) are always preserved verbatim. +// +// Disabled by default; flip enabled: true in the YAML to opt in. The +// model's MaxContextWindow comes from llm.LLMClient.Capabilities(), so +// the same setting works for llamacpp, ollama, openai and anthropic +// without per-provider configuration. +type Compaction struct { + Enabled bool `yaml:"enabled"` + // ThresholdRatio is the fraction of the context window that, once + // exceeded by the previous turn's input tokens, triggers compaction. + // Range 0–1; default 0.75 (75% of the window). + ThresholdRatio float64 `yaml:"threshold_ratio"` + // KeepRecentTurns is how many of the most recent user turns (each + // being a user message plus everything up to the next user message) + // to keep verbatim after compaction. Older turns are folded into the + // summary. Default 4. + KeepRecentTurns int `yaml:"keep_recent_turns"` + // SummarySystemPrompt is the instruction sent to the LLM when it + // summarizes the older turns. Empty falls back to a built-in + // bilingual prompt that auto-matches the conversation language. + SummarySystemPrompt string `yaml:"summary_system_prompt"` +} + type Config struct { - Server Server `yaml:"server"` + Server Server `yaml:"server"` Providers []Provider `yaml:"providers"` - RAG RAG `yaml:"rag"` - Persona Persona `yaml:"persona"` - SystemPrompt string `yaml:"system_prompt"` - Logging Logging `yaml:"logging"` + RAG RAG `yaml:"rag"` + Persona Persona `yaml:"persona"` + SystemPrompt string `yaml:"system_prompt"` + Compaction Compaction `yaml:"compaction"` + Logging Logging `yaml:"logging"` } func Load(path string) (*Config, error) { @@ -120,6 +148,14 @@ func (c *Config) validate() error { if c.RAG.Tokenize == "" { c.RAG.Tokenize = "unicode61" } + if c.Compaction.Enabled { + if c.Compaction.ThresholdRatio <= 0 || c.Compaction.ThresholdRatio > 1 { + c.Compaction.ThresholdRatio = 0.75 + } + if c.Compaction.KeepRecentTurns <= 0 { + c.Compaction.KeepRecentTurns = 4 + } + } return nil } From 7393812fba8609030389f385071d6eb800f4d3bb Mon Sep 17 00:00:00 2001 From: Victor Hugo Vargas Date: Sat, 18 Jul 2026 00:08:05 -0700 Subject: [PATCH 2/6] feat(agent): auto-compact long conversations in the runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runner.Compact folds the older portion of history into a single system-role summary when the previous turn's input tokens cross threshold_ratio × MaxContextWindow. When summarization fails, the runner falls back to truncateToBudget so a flaky summarize call never breaks the user's request. EstimatePromptTokens / totalPromptTokens give a conservative count (roughly 3 chars per token) used by both the compaction trigger and BuildMessages' new limitRAGContext / fitHistory helpers to cap the prompt inside the provider's reported window before the request goes out. Covers the first-turn case where no usage has been reported yet. Adds runner_compaction_test.go with table-driven coverage for the disabled, below-threshold, short-history, unknown-window, fallback and first-stream cases, plus a regression for the RAG-context trimmer. --- internal/agent/runner.go | 381 +++++++++++++++++++- internal/agent/runner_compaction_test.go | 425 +++++++++++++++++++++++ 2 files changed, 804 insertions(+), 2 deletions(-) create mode 100644 internal/agent/runner_compaction_test.go diff --git a/internal/agent/runner.go b/internal/agent/runner.go index 14f18d2..2c2e441 100644 --- a/internal/agent/runner.go +++ b/internal/agent/runner.go @@ -5,12 +5,19 @@ // straight Q&A flow: no tools, no multi-iteration reasoning. Calling the // underlying provider keeps the prompt under our full control so we can // inject RAG context into the system message exactly where we want it. +// +// Auto-compaction (see CompactionConfig / Compact) folds the older portion +// of a long conversation into a single summary system message when the +// previous turn's input tokens approach the model's reported context +// window. It is opt-in via WithCompaction and short-circuits silently when +// the window is unknown or the history is too short to bother. package agent import ( "context" "fmt" "iter" + "log/slog" "strings" "github.com/VictorVargas/rony-llm-agent/pkg/llm" @@ -40,6 +47,9 @@ type Runner struct { store *portfolio.Store topK int usage *Usage + + compaction CompactionConfig + lastCompact CompactionStats } type Usage struct { @@ -47,18 +57,64 @@ type Usage struct { OutputTokens int } +// CompactionConfig mirrors the YAML `compaction:` block. Zero value means +// compaction is disabled. +type CompactionConfig struct { + Enabled bool + ThresholdRatio float64 // 0–1, default 0.75 when Enabled + KeepRecentTurns int // default 4 when Enabled + SummarySystemPrompt string // empty → built-in bilingual default +} + +// CompactionStats reports the outcome of the most recent Compact call, so +// the HTTP handler can surface a "context compacted" event to the client. +type CompactionStats struct { + Happened bool // true when older turns were folded into a summary + OlderTurns int // user turns that got summarized away + KeptTurns int // user turns kept verbatim after the summary + SummaryTokens int // approx. token count of the summary text + WindowTokens int // model's reported context window at compaction time + UsedTokens int // input tokens reported by the previous turn +} + // New returns a Runner. The store may be nil (RAG disabled). // systemPrompt is the full hand-written prompt from configs/...yaml. func New(client llm.LLMClient, p llmpersona.Persona, systemPrompt string, store *portfolio.Store, topK int) *Runner { return &Runner{client: client, persona: p, systemPrompt: systemPrompt, store: store, topK: topK, usage: &Usage{}} } +// WithCompaction enables auto-compaction with the given config. Returns +// the receiver for chaining. Pass a zero-value CompactionConfig to keep +// compaction disabled. +func (r *Runner) WithCompaction(cfg CompactionConfig) *Runner { + r.compaction = cfg + if cfg.Enabled { + if r.compaction.ThresholdRatio <= 0 || r.compaction.ThresholdRatio > 1 { + r.compaction.ThresholdRatio = 0.75 + } + if r.compaction.KeepRecentTurns <= 0 { + r.compaction.KeepRecentTurns = 4 + } + } + return r +} + // LastUsage returns the token usage recorded on the most recent call. func (r *Runner) LastUsage() Usage { return *r.usage } +// LastCompaction returns the outcome of the most recent Compact call. +// Useful for the HTTP handler to emit a "compaction" SSE event after a +// stream finishes. +func (r *Runner) LastCompaction() CompactionStats { return r.lastCompact } + // BuildMessages prepares the system prompt and turns the chat history into // the upstream message list. The system prompt includes RAG context for the // user's last message (if RAG is enabled). +// +// Compaction is NOT applied here — callers invoke Compact explicitly before +// BuildMessages so the same Compact call doesn't run twice per request +// (Stream calls BuildMessages internally, and handlers sometimes call it +// first to extract the RAG context for the sources event). func (r *Runner) BuildMessages(ctx context.Context, history []Message) ([]Message, string, error) { ragContext := "" if r.store != nil && len(history) > 0 { @@ -69,7 +125,7 @@ func (r *Runner) BuildMessages(ctx context.Context, history []Message) ([]Messag return nil, "", fmt.Errorf("rag search: %w", err) } if len(hits) > 0 { - ragContext = formatHits(hits) + ragContext = r.limitRAGContext(formatHits(hits), history) } } } @@ -77,12 +133,205 @@ func (r *Runner) BuildMessages(ctx context.Context, history []Message) ([]Messag // The system prompt comes from the YAML, not from the persona struct. // Keep the persona around only for the UI greeting. system := botpersona.BuildSystemPrompt(r.systemPrompt, ragContext) + history, err := r.fitHistory(system, history) + if err != nil { + return nil, "", err + } msgs := make([]Message, 0, len(history)+1) msgs = append(msgs, Message{Role: RoleSystem, Content: system}) msgs = append(msgs, history...) return msgs, ragContext, nil } +const maxContextSafetyMargin = 256 + +func contextBudget(window int) int { + margin := int(float64(window) * 0.125) + if margin > maxContextSafetyMargin { + margin = maxContextSafetyMargin + } + if margin < 1 { + margin = 1 + } + return window - margin +} + +func (r *Runner) limitRAGContext(ragContext string, history []Message) string { + if strings.TrimSpace(ragContext) == "" { + return "" + } + budget := contextBudget(r.client.Capabilities().MaxContextWindow) + if budget <= 0 { + return "" + } + + historyTokens := totalPromptTokens(history) + fitted := "" + for _, block := range strings.Split(strings.TrimSpace(ragContext), "\n\n") { + block = strings.TrimSpace(block) + if block == "" { + continue + } + candidate := block + if fitted != "" { + candidate = fitted + "\n\n" + block + } + if estimatePromptTokens(botpersona.BuildSystemPrompt(r.systemPrompt, candidate))+historyTokens > budget { + break + } + fitted = candidate + } + return fitted +} + +func (r *Runner) fitHistory(system string, history []Message) ([]Message, error) { + window := r.client.Capabilities().MaxContextWindow + if window <= 0 { + return history, nil + } + budget := contextBudget(window) + if budget <= 0 { + return nil, fmt.Errorf("context window is too small") + } + + history = dropLeadingAssistantMessages(history) + for { + if estimatePromptTokens(system)+totalPromptTokens(history) <= budget { + return history, nil + } + turnStarts := []int{} + for i, message := range history { + if message.Role == RoleUser { + turnStarts = append(turnStarts, i) + } + } + if len(turnStarts) < 2 { + return nil, fmt.Errorf("prompt exceeds context window (%d tokens)", window) + } + first, next := turnStarts[0], turnStarts[1] + kept := make([]Message, 0, len(history)-(next-first)) + kept = append(kept, history[:first]...) + kept = append(kept, history[next:]...) + history = kept + } +} + +func dropLeadingAssistantMessages(history []Message) []Message { + firstUser := -1 + for i, message := range history { + if message.Role == RoleUser { + firstUser = i + break + } + } + if firstUser <= 0 { + return history + } + out := make([]Message, 0, len(history)) + for _, message := range history[:firstUser] { + if message.Role != RoleAssistant { + out = append(out, message) + } + } + return append(out, history[firstUser:]...) +} + +// Compact reduces the older portion of history to a single system-role +// summary message when the previous turn's input tokens exceed the +// configured threshold. Returns the (possibly compacted) message slice — +// unchanged when compaction is disabled, history is too short, the model +// has no reported context window, or summarization fails. +// +// On summarization failure the runner falls back to dropping the oldest +// turns until the remaining slice fits a conservative budget, so a flaky +// summarize call never fails the user's request. +func (r *Runner) Compact(ctx context.Context, history []Message) ([]Message, error) { + r.lastCompact = CompactionStats{} + if !r.compaction.Enabled || len(history) == 0 { + return history, nil + } + + caps := r.client.Capabilities() + if caps.MaxContextWindow <= 0 { + return history, nil + } + + used := r.usage.InputTokens + estimated := estimatePromptTokens(r.systemPrompt) + totalPromptTokens(history) + if estimated > used { + used = estimated + } + window := caps.MaxContextWindow + threshold := int(float64(window) * r.compaction.ThresholdRatio) + if used < threshold { + return history, nil + } + + older, recent := splitByTurns(history, r.compaction.KeepRecentTurns) + if len(older) == 0 { + return history, nil + } + + summary, err := r.summarize(ctx, older) + if err != nil { + slog.Warn("compaction: summarize failed, falling back to truncation", + "err", err, "older_turns", userTurnCount(older)) + // Fall back: drop oldest turns until the slice is small enough to + // fit in (threshold) tokens, using a 4-chars-per-token heuristic. + dropped, kept := truncateToBudget(history, threshold) + r.lastCompact = CompactionStats{ + Happened: true, + OlderTurns: userTurnCount(dropped), + KeptTurns: userTurnCount(kept), + WindowTokens: window, + UsedTokens: used, + } + return kept, nil + } + + out := make([]Message, 0, 1+len(recent)) + out = append(out, Message{ + Role: RoleSystem, + Content: "Earlier conversation summary:\n" + summary, + }) + out = append(out, recent...) + r.lastCompact = CompactionStats{ + Happened: true, + OlderTurns: userTurnCount(older), + KeptTurns: userTurnCount(recent), + SummaryTokens: estimateTokens(summary), + WindowTokens: window, + UsedTokens: used, + } + return out, nil +} + +// summarize calls the LLM (non-streaming) to condense older turns. We use +// a small max_tokens cap so the compaction step itself stays cheap. +func (r *Runner) summarize(ctx context.Context, older []Message) (string, error) { + transcript := renderTranscript(older) + sys := r.compaction.SummarySystemPrompt + if sys == "" { + sys = defaultSummaryPrompt + } + maxTokens := 512 + resp, err := r.client.Generate(ctx, llm.CompletionRequest{ + Messages: []Message{ + {Role: RoleSystem, Content: sys}, + {Role: RoleUser, Content: transcript}, + }, + MaxTokens: &maxTokens, + }) + if err != nil { + return "", err + } + summary := strings.TrimSpace(resp.Content) + if summary == "" { + return "", fmt.Errorf("empty summary") + } + return summary, nil +} + // Stream runs the model and yields each streamed chunk. The caller // forwards chunk.Delta to the SSE stream; chunk.Usage on the last chunk // carries token counts. @@ -122,4 +371,132 @@ func formatHits(hits []portfolio.SearchResult) string { b.WriteString("\n\n") } return b.String() -} \ No newline at end of file +} + +// defaultSummaryPrompt is used when the YAML doesn't override it. The +// instruction explicitly avoids headers / meta-commentary so the summary +// drops in cleanly as a system message and the model treats it as facts +// about the prior conversation rather than instructions from the user. +const defaultSummaryPrompt = "Summarize the following conversation between a user and the assistant concisely but completely. " + + "Preserve decisions made, concrete facts (names, paths, IDs, preferences) and any pending task or context the assistant needs to keep helping. " + + "Respond only with the summary, in the same language as the conversation, with no headers or extra commentary." + +// splitByTurns divides messages into an older portion to summarize and a +// recent tail to keep verbatim. A turn is a user message plus everything +// up to (but not including) the next user message — so a kept turn's tool +// calls, thoughts and response always stay together. Returns older == nil +// when there are not enough turns to bother compacting. +func splitByTurns(history []Message, keepRecentTurns int) (older, recent []Message) { + turnStarts := []int{} + for i, m := range history { + if m.Role == RoleUser { + turnStarts = append(turnStarts, i) + } + } + if len(turnStarts) <= keepRecentTurns { + return nil, history + } + cut := turnStarts[len(turnStarts)-keepRecentTurns] + return history[:cut], history[cut:] +} + +// renderTranscript flattens chat messages into a plain User/Assistant +// transcript for summarization. Tool messages and empty assistant +// placeholders are skipped — neither contributes facts the model needs +// to remember. +func renderTranscript(messages []Message) string { + var b strings.Builder + for _, m := range messages { + switch m.Role { + case RoleUser: + b.WriteString("User: ") + b.WriteString(m.Content) + b.WriteString("\n") + case RoleAssistant: + if m.Content == "" { + continue + } + b.WriteString("Assistant: ") + b.WriteString(m.Content) + b.WriteString("\n") + } + } + return b.String() +} + +// userTurnCount is the number of user-role messages in a slice — a +// convenient proxy for "turns" in stats reporting (each turn starts +// with a user message). +func userTurnCount(messages []Message) int { + n := 0 + for _, m := range messages { + if m.Role == RoleUser { + n++ + } + } + return n +} + +// estimateTokens gives a rough token count for s. ~4 chars per token is +// the usual rule of thumb for English; close enough for Spanish / mixed +// text that this only drives a trigger threshold. +func estimateTokens(s string) int { + if s == "" { + return 0 + } + return len(s)/4 + 1 +} + +func estimatePromptTokens(s string) int { + if s == "" { + return 0 + } + return len(s)/3 + 1 +} + +func totalPromptTokens(messages []Message) int { + total := 0 + for _, m := range messages { + total += estimatePromptTokens(m.Content) + } + return total +} + +// truncateToBudget is the fallback when summarization fails. It drops +// the oldest user-turns one at a time until the remaining slice fits in +// `budget` tokens (conservative heuristic). At least the last user turn is +// always preserved, so we never return an empty slice. +// +// Implementation note: as `cut` increases, history[turnStarts[cut]:] +// shrinks, so `totalPromptTokens(...)` is monotonically non-increasing in +// `cut`. The loop is therefore guaranteed to terminate by either +// fitting the budget or hitting the "keep at least the last turn" floor. +func truncateToBudget(history []Message, budget int) (dropped, kept []Message) { + if len(history) == 0 { + return nil, nil + } + turnStarts := []int{} + for i, m := range history { + if m.Role == RoleUser { + turnStarts = append(turnStarts, i) + } + } + if len(turnStarts) == 0 { + return nil, history + } + // cut = number of leading turns to drop. 0 = keep everything; we + // grow it until the remaining slice fits in budget (or we hit the + // floor of one turn kept). + cut := 0 + for cut < len(turnStarts)-1 { + start := turnStarts[cut+1] + if totalPromptTokens(history[start:]) <= budget { + break + } + cut++ + } + if cut == 0 { + return nil, history + } + return history[:turnStarts[cut]], history[turnStarts[cut]:] +} diff --git a/internal/agent/runner_compaction_test.go b/internal/agent/runner_compaction_test.go new file mode 100644 index 0000000..126513a --- /dev/null +++ b/internal/agent/runner_compaction_test.go @@ -0,0 +1,425 @@ +package agent + +import ( + "context" + "errors" + "iter" + "strings" + "testing" + + "github.com/VictorVargas/rony-llm-agent/pkg/llm" + llmpersona "github.com/VictorVargas/rony-llm-agent/pkg/persona" +) + +// compactionStub is an llm.LLMClient that: +// - reports a configurable MaxContextWindow via Capabilities +// - records Generate calls (used for summarization) +// - returns a fixed Stream that also bumps the runner's r.usage via +// the usage chunk (so subsequent compactions see realistic usage) +type compactionStub struct { + window int + streamInput int // input tokens reported on the streamed usage chunk + generateCalls int + generateErr error + summary string + lastGenerate llm.CompletionRequest +} + +func (s *compactionStub) Generate(_ context.Context, req llm.CompletionRequest) (llm.CompletionResponse, error) { + s.generateCalls++ + s.lastGenerate = req + if s.generateErr != nil { + return llm.CompletionResponse{}, s.generateErr + } + return llm.CompletionResponse{ + Content: s.summary, + StopReason: llm.StopReasonEndTurn, + }, nil +} + +func (s *compactionStub) Stream(_ context.Context, _ llm.CompletionRequest) iter.Seq2[llm.StreamChunk, error] { + return func(yield func(llm.StreamChunk, error) bool) { + yield(llm.StreamChunk{Delta: "ok", FinishReason: "stop", Usage: llm.TokenUsage{InputTokens: s.streamInput, OutputTokens: 3}}, nil) + } +} + +func (s *compactionStub) Name() string { return "compaction-stub" } +func (s *compactionStub) Capabilities() llm.ProviderCapabilities { + return llm.ProviderCapabilities{MaxContextWindow: s.window} +} + +// pumpStream drives a single Stream call through the runner so the usage +// chunk updates r.usage — needed because the compaction trigger reads +// from r.usage, not from the request itself. +func pumpStream(t *testing.T, r *Runner, history []Message) { + t.Helper() + for chunk, err := range r.Stream(context.Background(), history) { + if err != nil { + t.Fatal(err) + } + _ = chunk + } +} + +// makeHistory builds a synthetic N-turn conversation (user + assistant +// pairs), each "long" enough to be obviously past any small threshold. +func makeHistory(turns int, contentLen int) []Message { + out := make([]Message, 0, turns*2) + payload := strings.Repeat("x", contentLen) + for i := 0; i < turns; i++ { + out = append(out, + Message{Role: RoleUser, Content: payload + " q"}, + Message{Role: RoleAssistant, Content: payload + " a"}, + ) + } + return out +} + +func personaMinimal() llmpersona.Persona { + return llmpersona.Persona{Name: "t", Tone: "concise", Language: "English"} +} + +func TestCompactDisabledIsNoop(t *testing.T) { + cli := &compactionStub{window: 100, streamInput: 999, summary: "ignored"} + r := New(cli, personaMinimal(), "sys", nil, 5) // no WithCompaction + pumpStream(t, r, []Message{{Role: RoleUser, Content: "hi"}}) + got, err := r.Compact(context.Background(), makeHistory(8, 200)) + if err != nil { + t.Fatal(err) + } + if cli.generateCalls != 0 { + t.Errorf("Generate called %d times, want 0 (compaction disabled)", cli.generateCalls) + } + if len(got) != 16 { + t.Errorf("len(history) = %d, want 16 (unchanged)", len(got)) + } + stats := r.LastCompaction() + if stats.Happened { + t.Errorf("stats.Happened = true, want false") + } +} + +func TestCompactBelowThresholdIsNoop(t *testing.T) { + // window = 1000, threshold = 0.5 → 500. Previous turn reported only + // 50 input tokens — well below the trigger. + cli := &compactionStub{window: 1000, streamInput: 50, summary: "ignored"} + r := New(cli, personaMinimal(), "sys", nil, 5). + WithCompaction(CompactionConfig{Enabled: true, ThresholdRatio: 0.5, KeepRecentTurns: 2}) + pumpStream(t, r, []Message{{Role: RoleUser, Content: "hi"}}) + got, err := r.Compact(context.Background(), makeHistory(8, 40)) + if err != nil { + t.Fatal(err) + } + if cli.generateCalls != 0 { + t.Errorf("Generate called %d times, want 0 (below threshold)", cli.generateCalls) + } + if len(got) != 16 { + t.Errorf("len(history) = %d, want 16 (unchanged)", len(got)) + } +} + +func TestCompactUsesEstimatedInputBeforeFirstStream(t *testing.T) { + cli := &compactionStub{window: 400, summary: "summary"} + r := New(cli, personaMinimal(), "system prompt", nil, 5). + WithCompaction(CompactionConfig{Enabled: true, ThresholdRatio: 0.5, KeepRecentTurns: 2}) + + got, err := r.Compact(context.Background(), makeHistory(8, 200)) + if err != nil { + t.Fatal(err) + } + if cli.generateCalls != 1 { + t.Fatalf("Generate called %d times, want 1", cli.generateCalls) + } + if len(got) != 5 { + t.Fatalf("len(compacted) = %d, want 5", len(got)) + } +} + +func TestCompactShortHistoryIsNoop(t *testing.T) { + // Above threshold but only 3 turns total (3 user, 3 assistant = 6 + // messages). KeepRecentTurns = 4, so splitByTurns has nothing older + // to summarize. + cli := &compactionStub{window: 100, streamInput: 999, summary: "ignored"} + r := New(cli, personaMinimal(), "sys", nil, 5). + WithCompaction(CompactionConfig{Enabled: true, ThresholdRatio: 0.5, KeepRecentTurns: 4}) + pumpStream(t, r, []Message{{Role: RoleUser, Content: "hi"}}) + history := makeHistory(3, 200) // 6 messages, 3 user turns + got, err := r.Compact(context.Background(), history) + if err != nil { + t.Fatal(err) + } + if cli.generateCalls != 0 { + t.Errorf("Generate called %d times, want 0 (not enough turns)", cli.generateCalls) + } + if len(got) != len(history) { + t.Errorf("len(history) = %d, want %d (unchanged)", len(got), len(history)) + } +} + +func TestCompactUnknownWindowIsNoop(t *testing.T) { + // Provider doesn't report a window (Capabilities returns 0). + cli := &compactionStub{window: 0, streamInput: 999, summary: "ignored"} + r := New(cli, personaMinimal(), "sys", nil, 5). + WithCompaction(CompactionConfig{Enabled: true, ThresholdRatio: 0.5, KeepRecentTurns: 2}) + pumpStream(t, r, []Message{{Role: RoleUser, Content: "hi"}}) + got, err := r.Compact(context.Background(), makeHistory(8, 200)) + if err != nil { + t.Fatal(err) + } + if cli.generateCalls != 0 { + t.Errorf("Generate called %d times, want 0 (window unknown)", cli.generateCalls) + } + if len(got) != 16 { + t.Errorf("len(history) = %d, want 16 (unchanged)", len(got)) + } +} + +func TestCompactTriggersSummarize(t *testing.T) { + // 8 user turns × ~200 chars each = ~1600 chars ≈ 400 tokens. With + // window=400, threshold 0.5 = 200, and the stub reports 999 input + // tokens on the previous turn, compaction fires. + cli := &compactionStub{window: 400, streamInput: 999, summary: "user asked 8 things, here's the gist"} + r := New(cli, personaMinimal(), "sys", nil, 5). + WithCompaction(CompactionConfig{ + Enabled: true, + ThresholdRatio: 0.5, + KeepRecentTurns: 2, + }) + pumpStream(t, r, []Message{{Role: RoleUser, Content: "hi"}}) + history := makeHistory(8, 200) + got, err := r.Compact(context.Background(), history) + if err != nil { + t.Fatal(err) + } + if cli.generateCalls != 1 { + t.Fatalf("Generate called %d times, want 1 (compaction fired)", cli.generateCalls) + } + // The summary call's messages should be [system, user(transcript)]. + if len(cli.lastGenerate.Messages) != 2 { + t.Fatalf("summarize call has %d messages, want 2", len(cli.lastGenerate.Messages)) + } + if cli.lastGenerate.Messages[0].Role != RoleSystem { + t.Errorf("summarize call[0].Role = %q, want system", cli.lastGenerate.Messages[0].Role) + } + if cli.lastGenerate.Messages[1].Role != RoleUser { + t.Errorf("summarize call[1].Role = %q, want user", cli.lastGenerate.Messages[1].Role) + } + if !strings.Contains(cli.lastGenerate.Messages[1].Content, "User:") { + t.Errorf("summarize transcript missing 'User:' lines") + } + if got[0].Role != RoleSystem { + t.Errorf("compacted[0].Role = %q, want system (summary)", got[0].Role) + } + if !strings.Contains(got[0].Content, "user asked 8 things") { + t.Errorf("compacted[0] missing summary text: %q", got[0].Content) + } + // Recent tail: 2 turns = 4 messages, exactly the last 4 of the + // original history. + if len(got) != 1+4 { + t.Fatalf("len(compacted) = %d, want 5 (summary + 4)", len(got)) + } + for i := 0; i < 4; i++ { + if got[1+i].Content != history[12+i].Content { + t.Errorf("kept[%d] content differs from history[%d]", i, 12+i) + } + } + stats := r.LastCompaction() + if !stats.Happened { + t.Fatal("stats.Happened = false, want true") + } + if stats.OlderTurns != 6 { + t.Errorf("OlderTurns = %d, want 6", stats.OlderTurns) + } + if stats.KeptTurns != 2 { + t.Errorf("KeptTurns = %d, want 2", stats.KeptTurns) + } + if stats.WindowTokens != 400 { + t.Errorf("WindowTokens = %d, want 400", stats.WindowTokens) + } + wantUsed := estimatePromptTokens("sys") + totalPromptTokens(history) + if stats.UsedTokens != wantUsed { + t.Errorf("UsedTokens = %d, want %d", stats.UsedTokens, wantUsed) + } + if stats.SummaryTokens == 0 { + t.Error("SummaryTokens = 0, want > 0") + } +} + +func TestCompactSummarizeFailsFallsBackToTruncation(t *testing.T) { + cli := &compactionStub{ + window: 400, + streamInput: 999, + generateErr: errors.New("summarize boom"), + } + r := New(cli, personaMinimal(), "sys", nil, 5). + WithCompaction(CompactionConfig{ + Enabled: true, + ThresholdRatio: 0.5, + KeepRecentTurns: 2, + }) + pumpStream(t, r, []Message{{Role: RoleUser, Content: "hi"}}) + // 7 completed turns + 1 trailing user message (the request's new + // question). Real production requests always end on a user message. + history := append(makeHistory(7, 200), Message{Role: RoleUser, Content: "new question"}) + got, err := r.Compact(context.Background(), history) + if err != nil { + t.Fatalf("Compact should swallow summarize failure, got err: %v", err) + } + if cli.generateCalls != 1 { + t.Errorf("Generate called %d times, want 1 (tried once before fallback)", cli.generateCalls) + } + // Fallback should have dropped the oldest turns. The trailing user + // message (the current question) must always be preserved. + last := got[len(got)-1] + if last.Role != RoleUser { + t.Errorf("last message role = %q, want user", last.Role) + } + if last.Content != "new question" { + t.Errorf("last message content = %q, want %q", last.Content, "new question") + } + stats := r.LastCompaction() + if !stats.Happened { + t.Error("stats.Happened = false, want true (fallback counts as compaction)") + } + if stats.OlderTurns == 0 { + t.Error("OlderTurns = 0, want > 0 (fallback dropped turns)") + } +} + +func TestCompactEmptyHistoryIsNoop(t *testing.T) { + cli := &compactionStub{window: 100, streamInput: 999} + r := New(cli, personaMinimal(), "sys", nil, 5). + WithCompaction(CompactionConfig{Enabled: true, ThresholdRatio: 0.1, KeepRecentTurns: 2}) + got, err := r.Compact(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + if got != nil { + t.Errorf("got = %v, want nil", got) + } + if cli.generateCalls != 0 { + t.Errorf("Generate called %d times, want 0", cli.generateCalls) + } +} + +func TestFitHistoryDropsOldTurnsToFit(t *testing.T) { + cli := &compactionStub{window: 400} + r := New(cli, personaMinimal(), "sys", nil, 5) + history := makeHistory(4, 400) + + got, err := r.fitHistory("sys", history) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("len(history) = %d, want 2", len(got)) + } + if got[0].Role != RoleUser || got[1].Role != RoleAssistant { + t.Errorf("remaining turn roles = %q, %q", got[0].Role, got[1].Role) + } +} + +func TestSplitByTurns(t *testing.T) { + history := []Message{ + {Role: RoleUser, Content: "u1"}, + {Role: RoleAssistant, Content: "a1"}, + {Role: RoleUser, Content: "u2"}, + {Role: RoleAssistant, Content: "a2"}, + {Role: RoleUser, Content: "u3"}, + {Role: RoleAssistant, Content: "a3"}, + {Role: RoleUser, Content: "u4"}, + {Role: RoleAssistant, Content: "a4"}, + } + older, recent := splitByTurns(history, 2) + if len(older) != 4 { + t.Errorf("len(older) = %d, want 4", len(older)) + } + if len(recent) != 4 { + t.Errorf("len(recent) = %d, want 4", len(recent)) + } + if older[0].Content != "u1" || older[3].Content != "a2" { + t.Errorf("older = %v, want [u1,a1,u2,a2]", older) + } + if recent[0].Content != "u3" || recent[3].Content != "a4" { + t.Errorf("recent = %v, want [u3,a3,u4,a4]", recent) + } +} + +func TestSplitByTurnsNothingToCompact(t *testing.T) { + history := []Message{ + {Role: RoleUser, Content: "u1"}, + {Role: RoleAssistant, Content: "a1"}, + {Role: RoleUser, Content: "u2"}, + } + older, recent := splitByTurns(history, 4) + if older != nil { + t.Errorf("older = %v, want nil", older) + } + if len(recent) != 3 { + t.Errorf("len(recent) = %d, want 3 (everything)", len(recent)) + } +} + +func TestRenderTranscriptSkipsToolAndEmpty(t *testing.T) { + msgs := []Message{ + {Role: RoleUser, Content: "hi"}, + {Role: RoleAssistant, Content: "hello!"}, + {Role: RoleAssistant, Content: ""}, // streaming placeholder + {Role: RoleSystem, Content: "internal"}, + } + got := renderTranscript(msgs) + want := "User: hi\nAssistant: hello!\n" + if got != want { + t.Errorf("renderTranscript =\n%q\nwant\n%q", got, want) + } +} + +func TestEstimateTokens(t *testing.T) { + cases := []struct { + in string + want int + }{ + {"", 0}, + {"a", 1}, + {"abcd", 2}, + {strings.Repeat("x", 400), 101}, + } + for _, c := range cases { + if got := estimateTokens(c.in); got != c.want { + t.Errorf("estimateTokens(%q) = %d, want %d", c.in, got, c.want) + } + } +} + +func TestEstimatePromptTokensIsConservative(t *testing.T) { + text := strings.Repeat("x", 300) + if got := estimatePromptTokens(text); got <= estimateTokens(text) { + t.Errorf("estimatePromptTokens = %d, want greater than estimateTokens = %d", got, estimateTokens(text)) + } +} + +func TestTruncateToBudgetAlwaysKeepsLastUserTurn(t *testing.T) { + // 3 long completed turns + a short trailing user message (the new + // question). Budget so small only the last turn can fit. + long := strings.Repeat("x", 400) + history := []Message{ + {Role: RoleUser, Content: long + " q1"}, + {Role: RoleAssistant, Content: long + " a1"}, + {Role: RoleUser, Content: long + " q2"}, + {Role: RoleAssistant, Content: long + " a2"}, + {Role: RoleUser, Content: long + " q3"}, + {Role: RoleAssistant, Content: long + " a3"}, + {Role: RoleUser, Content: "current question"}, + } + dropped, kept := truncateToBudget(history, 10) + if len(kept) == 0 { + t.Fatal("kept is empty") + } + if kept[len(kept)-1].Content != "current question" { + t.Errorf("last kept message content = %q, want %q", + kept[len(kept)-1].Content, "current question") + } + if len(dropped)+len(kept) != len(history) { + t.Errorf("dropped+kept = %d, want %d", len(dropped)+len(kept), len(history)) + } +} From 550ba526c4f36bcbea91c1c9abe7f0e2b521c546 Mon Sep 17 00:00:00 2001 From: Victor Hugo Vargas Date: Sat, 18 Jul 2026 00:08:22 -0700 Subject: [PATCH 3/6] feat(sse): emit compaction event after start, before sources/chunk streaming.WriteCompaction packages a 'compaction' event with the kept/older turn counts, summary tokens and provider-reported window/used tokens so the client can hint 'context optimized' to the user without parsing the stream body. streamChat runs Compact before BuildMessages and writes the event right after start, ensuring the client sees it before any chunk is emitted. Add a runner test that exercises limitRAGContext to keep the system prompt + RAG block under the configured window. --- internal/agent/runner_test.go | 18 +++++++++++++++++- internal/server/handlers.go | 22 ++++++++++++++++++++++ internal/streaming/sse.go | 12 ++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/internal/agent/runner_test.go b/internal/agent/runner_test.go index cd11b84..b6e1a6f 100644 --- a/internal/agent/runner_test.go +++ b/internal/agent/runner_test.go @@ -93,6 +93,22 @@ func TestRunnerStreamWithRAG(t *testing.T) { } } +func TestLimitRAGContextToWindow(t *testing.T) { + cli := &compactionStub{window: 400} + r := New(cli, llmpersona.Persona{}, "sys", nil, 5) + history := []Message{{Role: RoleUser, Content: "question"}} + first := "### [1] first — section\n" + strings.Repeat("x", 100) + second := "### [2] second — section\n" + strings.Repeat("y", 1600) + + got := r.limitRAGContext(first+"\n\n"+second, history) + if !strings.Contains(got, "first") { + t.Errorf("limited RAG context dropped the first result: %q", got) + } + if strings.Contains(got, "second") { + t.Errorf("limited RAG context kept an over-budget result") + } +} + func writeFile(path, content string) error { if err := mkdirAll(path); err != nil { return err @@ -109,4 +125,4 @@ func dirOf(p string) string { } } return "." -} \ No newline at end of file +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 3d75c93..0883e62 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -158,6 +158,23 @@ func (h *Handlers) streamChat(w http.ResponseWriter, r *http.Request, history [] } flusher.Flush() + // Auto-compact the older part of the conversation when the previous + // turn's input tokens have crossed the configured threshold. Done + // before RAG so the LLM's compaction summary call is counted in the + // next request's reported usage, not the current one. A failure here + // is logged and swallowed: the request still proceeds with the + // original (uncompacted) history. + history, _ = h.runner.Compact(ctx, history) + if stats := h.runner.LastCompaction(); stats.Happened { + _ = streaming.WriteCompaction(w, map[string]any{ + "older_turns": stats.OlderTurns, + "kept_turns": stats.KeptTurns, + "summary_tokens": stats.SummaryTokens, + "window_tokens": stats.WindowTokens, + "used_tokens": stats.UsedTokens, + }) + } + // Report which RAG sources were used (search happens in BuildMessages). _, ragContext, err := h.runner.BuildMessages(ctx, history) if err != nil { @@ -217,6 +234,11 @@ func (h *Handlers) persistAssistant(ctx context.Context, convID, content string, func (h *Handlers) completeChat(w http.ResponseWriter, r *http.Request, history []agent.Message, convID string) { w.Header().Set("Content-Type", "application/json") ctx := r.Context() + // Apply compaction before streaming — same as streamChat. The + // compaction event isn't surfaced in the non-streaming JSON response + // (would be redundant noise), but LastCompaction is still recorded so + // any future caller can introspect it. + history, _ = h.runner.Compact(ctx, history) var full strings.Builder var sources []string for chunk, err := range h.runner.Stream(ctx, history) { diff --git a/internal/streaming/sse.go b/internal/streaming/sse.go index c76228b..c9fc3ac 100644 --- a/internal/streaming/sse.go +++ b/internal/streaming/sse.go @@ -43,6 +43,18 @@ func WriteSources(w http.ResponseWriter, sources []string) error { }) } +// WriteCompaction surfaces a context-compaction event so the client can +// render a "summary injected" hint. Always emitted right after the +// `sources` event (before the streamed chunks), and only when compaction +// actually fired for this request. +func WriteCompaction(w http.ResponseWriter, payload map[string]any) error { + out := map[string]any{"type": "compaction"} + for k, v := range payload { + out[k] = v + } + return WriteEvent(w, "compaction", out) +} + type Usage struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` From ab510d8b3134de979bf5b05ea7fdaa2e4aec22a3 Mon Sep 17 00:00:00 2001 From: Victor Hugo Vargas Date: Sat, 18 Jul 2026 00:08:37 -0700 Subject: [PATCH 4/6] docs: document auto-compaction behavior in architecture guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the §4.5 Auto-compaction section to architecture.md and architecture.es.md describing the trigger, fallback, persistence and the new SSE 'compaction' event so consumers know how to react. Includes the three placeholder projects used while exercising the feature end to end (bot-onboarding, dashboard-metricas, tienda-ropa) so /api/reindex picks them up without further setup. --- data/projects/bot-onboarding.md | 34 ++++++++++++++++++++++ data/projects/dashboard-metricas.md | 34 ++++++++++++++++++++++ data/projects/tienda-ropa.md | 34 ++++++++++++++++++++++ docs/architecture.es.md | 45 +++++++++++++++++++++++++++++ docs/architecture.md | 45 +++++++++++++++++++++++++++++ 5 files changed, 192 insertions(+) create mode 100644 data/projects/bot-onboarding.md create mode 100644 data/projects/dashboard-metricas.md create mode 100644 data/projects/tienda-ropa.md diff --git a/data/projects/bot-onboarding.md b/data/projects/bot-onboarding.md new file mode 100644 index 0000000..400b340 --- /dev/null +++ b/data/projects/bot-onboarding.md @@ -0,0 +1,34 @@ +--- +title: "Telegram Onboarding Bot" +date: 2024-04 +status: "archived" +tags: ["Node.js", "PostgreSQL", "Telegram", "bot", "onboarding"] +repo: "https://github.com/example/bot-onboarding" +demo: "" +--- + +# Telegram Onboarding Bot + +Conversational bot that walks new users through the initial setup of a B2B product. + +## Description + +A Telegram assistant that reduces the time-to-first-value of a B2B product. Replaces an 8-step web form with a guided conversation. Each user has a long-running state machine that persists across days. + +## Tech stack + +- **Runtime:** Node.js +- **Database:** PostgreSQL (state persistence per user) +- **Bot API:** Telegram Bot API with inline keyboards +- **State machine:** Hand-rolled, no FSM library + +## Main features + +1. Conversational onboarding replacing the legacy 8-step form +2. Long-running per-user state machine with PostgreSQL-backed persistence +3. Telegram inline keyboards keep interaction inside the chat app +4. Watchdog that re-sends the last message if the user has not responded in 24h + +## Learnings + +Handling Telegram timeouts without losing user state was the main challenge. The watchdog pattern with an explicit "give up" option turned out to be more reliable than trying to be too clever about timeout semantics. The state machine approach beat a more declarative framework for this use case because every state had bespoke transitions. diff --git a/data/projects/dashboard-metricas.md b/data/projects/dashboard-metricas.md new file mode 100644 index 0000000..419e65c --- /dev/null +++ b/data/projects/dashboard-metricas.md @@ -0,0 +1,34 @@ +--- +title: "Real-Time Metrics Dashboard" +date: 2024-11 +status: "live" +tags: ["React", "WebSockets", "D3.js", "Go", "dashboard", "metrics"] +repo: "" +demo: "https://example.com" +--- + +# Real-Time Metrics Dashboard + +Platform metrics dashboard streamed over WebSockets with server-side aggregations. + +## Description + +An internal dashboard for monitoring KPIs of a SaaS platform in real time: active users, hourly revenue, 5xx errors, p95 latency. The frontend is React with D3 charts; the backend is Go opening a WebSocket stream to all connected dashboards. + +## Tech stack + +- **Frontend:** React, D3.js (custom charts, not Recharts) +- **Backend:** Go with gorilla-style WebSockets +- **Transport:** Server-Sent Events style streaming with 5s aggregations +- **Charts:** D3 for transitions between time windows (1h, 24h, 7d) + +## Main features + +1. Real-time KPI tiles (active users, hourly revenue, error rate, p95 latency) +2. Streaming updates via WebSocket with 5s server-side aggregation +3. Backpressure: when the browser tab is backgrounded, server reduces frequency to 1/min +4. Custom D3 charts with smooth transitions between time windows + +## Learnings + +Avoiding memory leaks when switching time windows was tricky. Implemented a ring buffer per series and explicit disposal of Resize observers on unmount. The backpressure mechanism was the most impactful change in production — it stopped a flood of needless work when users had many tabs open. diff --git a/data/projects/tienda-ropa.md b/data/projects/tienda-ropa.md new file mode 100644 index 0000000..da09f16 --- /dev/null +++ b/data/projects/tienda-ropa.md @@ -0,0 +1,34 @@ +--- +title: "Online Clothing Store" +date: 2025-03 +status: "live" +tags: ["Next.js", "Stripe", "PostgreSQL", "e-commerce"] +repo: "https://github.com/example/tienda-ropa" +demo: "https://example.com" +--- + +# Online Clothing Store + +End-to-end e-commerce platform for an independent clothing brand. Catalogue, cart, checkout and an admin dashboard in a single product. + +## Description + +A full-stack e-commerce site built for an independent fashion brand. Customers browse a catalogue, build a cart, and pay through Stripe Checkout; the merchant manages stock, orders, and fulfilment from a custom admin dashboard. + +## Tech stack + +- **Framework:** Next.js (App Router) +- **Payments:** Stripe (hosted Checkout + webhooks) +- **Database:** PostgreSQL with Prisma ORM +- **Rendering:** Server-side for catalogue SEO, ISR with on-demand revalidation when stock changes + +## Main features + +1. Catalogue with per-product SEO-friendly routes +2. Stripe Checkout integration without PCI scope +3. Admin dashboard for stock and order management +4. Webhook-driven order confirmation and stock decrement + +## Learnings + +Keeping inventory in sync between the admin dashboard and Stripe without race conditions was the hardest part. Solved with serializable transactions and an optimistic lock per SKU. On the next iteration, an event-driven approach with an outbox table would be more robust under load. diff --git a/docs/architecture.es.md b/docs/architecture.es.md index 3fcd84d..d4beda0 100644 --- a/docs/architecture.es.md +++ b/docs/architecture.es.md @@ -627,6 +627,51 @@ func (r *Runner) RunStream(ctx context.Context, messages []llm.Message) iter.Seq --- +## 🗜️ 4.5 Auto-compactación + +Las conversaciones largas eventualmente agotan el contexto — con la ventana de 4k de qwen2.5-3b, el system prompt de ~3k tokens + el bloque RAG sólo deja espacio para 2–3 turnos del usuario. La auto-compactación resuelve esto plegando la parte más antigua de la conversación en un único mensaje-resumen del sistema cuando los tokens de entrada del turno anterior cruzan un umbral configurable. + +### Cuándo se dispara + +`agent.Runner.Compact` corre una vez por request a `/api/chat`, antes de la búsqueda RAG. Compara los `Usage.InputTokens` más recientes del runner (reportados por el provider en el chunk streameado previo) contra `client.Capabilities().MaxContextWindow × threshold_ratio`. + +| Config | Default | Qué controla | +|---|---|---| +| `compaction.enabled` | `false` | Switch maestro. | +| `compaction.threshold_ratio` | `0.75` | Dispara cuando tokens usados ≥ ventana × ratio. | +| `compaction.keep_recent_turns` | `4` | Cuántos turnos recientes del usuario se preservan literales tras la compactación. | +| `compaction.summary_system_prompt` | *(bilingüe built-in)* | Override de la instrucción enviada al LLM al resumir. | + +Sale silenciosamente cuando la compactación está deshabilitada, el provider no reporta ventana (`Capabilities().MaxContextWindow == 0`), la historia es más corta que `keep_recent_turns`, o el usage aún es desconocido (primer turno). + +### Cómo se hace el resumen + +1. `splitByTurns(history, keep_recent_turns)` divide los mensajes en `(older, recent)` cortando en límites de rol `user`, así el par user/assistant de un turno preservado queda siempre junto. +2. `renderTranscript(older)` aplana los mensajes antiguos en una transcripción `User:` / `Assistant:` (saltando mensajes tool y placeholders vacíos de assistant). +3. El runner llama a `client.Generate(...)` con el prompt de resumen + la transcripción y un cap de 512 tokens para que la compactación en sí misma sea barata. +4. El texto devuelto se antepone como mensaje de sistema (`"Earlier conversation summary:\n…"`), seguido por la cola reciente. +5. `LastCompaction()` devuelve `CompactionStats` para que el handler SSE emita el evento `compaction` justo antes de los chunks streameados. + +### Modo de falla + +Si `Generate` falla o devuelve un resumen vacío, la compactación cae a `truncateToBudget`: descarta turnos antiguos del usuario uno por uno hasta que el slice restante entre en `threshold` tokens (heurística: `len(s) / 4 + 1`). El turno actual del usuario siempre se preserva. El fallback se loggea a nivel WARN y el request sigue — un fallo del resumidor nunca rompe la request del usuario. + +### Protocolo de cable + +Las respuestas streameadas ganan un evento opcional `compaction`: + +``` +data: {"type":"compaction","older_turns":6,"kept_turns":2,"summary_tokens":120,"window_tokens":4096,"used_tokens":3500} +``` + +Se emite después del `start` (cuando aplica) y antes de `sources` / `chunk`. El widget puede renderizar esto como un hint sutil "Contexto compactado" o ignorarlo — ambas son válidas. + +### Persistencia + +La compactación es **por-request**. La transcripción completa igual se guarda en `messages` en `data/portfolio.db` literal, así que `GET /api/conversations/{id}` siempre devuelve la historia original. Sólo se reduce lo que se le manda al LLM — la próxima sesión puede releer el thread completo desde la DB. + +--- + ## 🌐 5. Embebiendo el widget El bot viene con un widget vanilla-JS drop-in. Agrega dos archivos a tu sitio y funciona. diff --git a/docs/architecture.md b/docs/architecture.md index 17fbe4e..f395682 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -747,6 +747,51 @@ func (r *Runner) RunStream(ctx context.Context, messages []llm.Message) iter.Seq --- +## 🗜️ 4.5 Auto-compaction + +Long conversations eventually run out of context — at qwen2.5-3b's 4k window, the ~3k-token system prompt + RAG block leaves only room for 2–3 user turns. Auto-compaction solves this by folding the older portion of the conversation into a single summary system message when the previous turn's input tokens cross a configurable threshold. + +### When it fires + +`agent.Runner.Compact` runs once per `/api/chat` request, before the RAG search. It compares the runner's most recent `Usage.InputTokens` (reported by the provider in the previous streamed chunk) against `client.Capabilities().MaxContextWindow × threshold_ratio`. + +| Setting | Default | What it controls | +|---|---|---| +| `compaction.enabled` | `false` | Master switch. | +| `compaction.threshold_ratio` | `0.75` | Trigger when used tokens ≥ window × ratio. | +| `compaction.keep_recent_turns` | `4` | How many of the latest user turns are kept verbatim after compaction. | +| `compaction.summary_system_prompt` | *(built-in bilingual)* | Override the instruction sent to the LLM when summarizing. | + +Short-circuits silently when compaction is disabled, the provider doesn't report a window (`Capabilities().MaxContextWindow == 0`), the history is shorter than `keep_recent_turns`, or usage is still unknown (first turn). + +### How the summary is made + +1. `splitByTurns(history, keep_recent_turns)` divides messages into `(older, recent)` on user-role boundaries so a kept turn's user/assistant pair always stays together. +2. `renderTranscript(older)` flattens older messages into a `User:` / `Assistant:` transcript (skipping tool messages and empty assistant placeholders). +3. The runner calls `client.Generate(...)` with the summary prompt + transcript and a 512-token cap so the compaction step itself stays cheap. +4. The returned text is prepended as a system message (`"Earlier conversation summary:\n…"`), followed by the recent tail. +5. `LastCompaction()` returns `CompactionStats` so the SSE handler can emit a `compaction` event right before the streamed chunks. + +### Failure mode + +If `Generate` errors or returns an empty summary, compaction falls back to `truncateToBudget`: drop oldest user-turns one at a time until the remaining slice fits `threshold` tokens (heuristic: `len(s) / 4 + 1`). The current user turn is always preserved. The fallback is logged at WARN and the request still proceeds — a flaky summarize call never fails the user's request. + +### Wire protocol + +Streaming responses gain an optional `compaction` event: + +``` +data: {"type":"compaction","older_turns":6,"kept_turns":2,"summary_tokens":120,"window_tokens":4096,"used_tokens":3500} +``` + +Emitted after `start` (when applicable) and before `sources` / `chunk`. The widget can render this as a subtle "Context compacted" hint or ignore it — both are valid. + +### Persistence + +Compaction is **per-request**. The full transcript is still saved to `messages` in `data/portfolio.db` verbatim, so `GET /api/conversations/{id}` always returns the original history. Only what we send to the LLM is reduced — the next session can re-read the full thread from the DB. + +--- + ## 🌐 5. Embedding the widget The bot ships with a drop-in vanilla-JS widget. Add two files to your site and it works. From 71f8fea0b5286a36dbb6e7a96d40b88b44310e1c Mon Sep 17 00:00:00 2001 From: Victor Hugo Vargas Date: Sat, 18 Jul 2026 00:08:46 -0700 Subject: [PATCH 5/6] chore: end .gitignore with newline --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index eca4d84..438cd67 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,4 @@ data/portfolio.db *.swp *.swo *~ -.DS_Store \ No newline at end of file +.DS_Store From 672859c9b835c9ceb15fa3082aa64eac44ed0101 Mon Sep 17 00:00:00 2001 From: Victor Hugo Vargas Date: Sat, 18 Jul 2026 00:32:16 -0700 Subject: [PATCH 6/6] chore: add documents for context sizing --- docs/vps-context-sizing.md | 243 +++++++++++++++++++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 docs/vps-context-sizing.md diff --git a/docs/vps-context-sizing.md b/docs/vps-context-sizing.md new file mode 100644 index 0000000..91c1f6c --- /dev/null +++ b/docs/vps-context-sizing.md @@ -0,0 +1,243 @@ +# Context window sizing — reference + +Quick reference for picking `context_size` and `max_tokens` in +`configs/portfolio-bot.yaml` based on the host's RAM budget. Math, +recommendations, and tips. + +--- + +## 1. `context_size` vs `max_tokens` + +Two different budgets in the provider config: + +```yaml +context_size: 4096 # total window (input + output) +max_tokens: 2048 # generation cap per response +``` + +``` +context_size (4096) = system prompt + RAG + history + respuesta + └────────── input ──────────┘ └output┘ + max_tokens +``` + +- **`context_size`** → total tokens the model can see + produce. Maps to + llama-server's `--ctx-size`. +- **`max_tokens`** → cap on **generation** per response. Doesn't affect + how much input fits, only how long the answer can be. + +Rule of thumb for a Q&A bot: 512–1024 `max_tokens` is plenty. Bigger just +steals budget from the input side, where the auto-compactor then has to +fire sooner. + +--- + +## 2. KV cache math + +The constraint on context size is **KV cache RAM**, not the model's +advertised window. KV cache grows linearly with context and is held in +RAM per active stream: + +``` +KV cache (bytes) ≈ 2 × num_layers × num_kv_heads × head_dim × bytes × context_size +``` + +Reference values for the models the bot is usually paired with: + +| Model | num_layers | num_kv_heads | head_dim | KB/token | +|-------------------|-----------:|-------------:|---------:|---------:| +| qwen2.5-1.5b | 28 | 2 | 128 | ~18 | +| qwen2.5-3b | 36 | 4 | 128 | ~72 | +| gemma-3-1b | 18 | 1 | 256 | ~36 | +| gemma-3-4b | 34 | 4 | 256 | ~272 | + +Q4_K_M model weights (also RAM-resident): + +| Model | Size | +|-------------------|--------:| +| qwen2.5-1.5b | ~1.0 GB | +| qwen2.5-3b | ~2.0 GB | +| gemma-3-1b | ~0.8 GB | +| gemma-3-4b | ~2.5 GB | + +--- + +## 3. RAM budget on a single-model VPS + +Fixed cost before we pick a context size: + +``` +Sistema + Go binary + SQLite ~0.5 GB +Modelo Q4_K_M weights (ver tabla arriba) +Buffer para picos y tmpfs ~0.5 GB +``` + +Available for **KV cache + headroom** = `RAM_total − 0.5 GB − modelo`. + +--- + +## 4. Recommendations by RAM + +### 4 GB VPS (bare-bones dev) + +Solo viable con modelo chico y contexto bajo. + +| Model | context_size | KV cache | RAM usada | +|-------------------|-------------:|---------:|----------:| +| gemma-3-1b Q4 | 8192 | ~290 MB | ~1.8 GB | +| qwen2.5-1.5b Q4 | 4096 | ~72 MB | ~1.6 GB | + +### 8 GB VPS (típico) + +| Model | context_size | KV cache | RAM usada | Veredicto | +|-------------------|-------------:|---------:|----------:|---------------------| +| qwen2.5-1.5b Q4 | 16384 | ~290 MB | ~2.0 GB | muy cómodo | +| qwen2.5-3b Q4 | 8192 | ~580 MB | ~3.0 GB | **sweet spot** | +| qwen2.5-3b Q4 | 16384 | ~1.1 GB | ~3.6 GB | **recomendado** | +| qwen2.5-3b Q4 | 32768 | ~2.3 GB | ~4.8 GB | máximo útil | +| gemma-3-1b Q4 | 32768 | ~1.1 GB | ~2.4 GB | **recomendado** | +| gemma-3-4b Q4 | 8192 | ~2.2 GB | ~5.2 GB | ajustado | + +### 16 GB VPS + +| Model | context_size | KV cache | RAM usada | +|-------------------|-------------:|---------:|----------:| +| qwen2.5-3b Q4 | 32768 | ~2.3 GB | ~5.0 GB | +| gemma-3-4b Q4 | 16384 | ~4.5 GB | ~7.5 GB | + +--- + +## 5. Worked examples + +### qwen2.5-3b en 8 GB + +```yaml +# configs/portfolio-bot.yaml +providers: + - name: llamacpp-local + type: llamacpp + model: qwen2.5-3b-instruct + endpoint: http://localhost:9100/v1 + context_size: 16384 # ~1.1 GB KV, deja 4 GB libres + max_tokens: 1024 # respuestas moderadas +``` + +```bash +llama-server \ + -m qwen2.5-3b-instruct-q4_k_m.gguf \ + --ctx-size 16384 \ + -ngl 0 -t 2 \ + --mlock +``` + +### gemma-3-1b en 8 GB + +```yaml +providers: + - name: llamacpp-local + type: llamacpp + model: gemma-3-1b-it + endpoint: http://localhost:9100/v1 + context_size: 32768 # sobra RAM, contexto largo + max_tokens: 1024 +``` + +```bash +llama-server \ + -m gemma-3-1b-it-Q4_K_M.gguf \ + --ctx-size 32768 \ + -ngl 0 -t 2 \ + --mlock +``` + +### Gemma con chat template custom (sin system role líder) + +Gemma 3 rechaza mensajes `system` antes del primer `user`. Dos opciones: + +**Opción A** — template custom en `~/.llama/gemma3.jinja`: + +```jinja +{% if messages[0]['role'] != 'system' and messages[0]['role'] != 'user' %} +{{ raise_exception('First message must be system or user') }} +{% endif %} +{% for message in messages %} +{% if message['role'] == 'system' %} +{{ message['content'] | trim + '\n\n' -}} +{% elif message['role'] == 'user' %} +{{- 'user\n' + message['content'] | trim + '\n' -}} +{% elif message['role'] == 'assistant' or message['role'] == 'model' %} +{{- 'model\n' + message['content'] | trim + '\n' -}} +{% endif %} +{% endfor %} +{% if add_generation_prompt %} +{{- 'model\n' -}} +{% endif %} +``` + +```bash +llama-server \ + -m gemma-3-1b-it-Q4_K_M.gguf \ + --ctx-size 32768 \ + -ngl 0 -t 2 \ + --mlock \ + --chat-template-file ~/.llama/gemma3.jinja +``` + +**Opción B** — usar Ollama, que mapea system → prefix del primer user +automáticamente. + +--- + +## 6. Tuning with auto-compaction + +The bot has built-in auto-compaction (`configs/portfolio-bot.yaml` → +`compaction:` block). When the previous turn's input tokens exceed +`threshold_ratio × MaxContextWindow`, the older portion of the chat gets +summarized into a single system note. This means: + +- A **smaller `context_size`** still works for long conversations — the + compactor frees up room by folding old turns. +- **Bigger `max_tokens`** means the compactor fires sooner (less budget + left for input). +- Default `threshold_ratio: 0.75` triggers compaction at ~75% of the + window. Lower it (e.g. `0.5`) for headroom on slow CPU where each + request is expensive; raise it (e.g. `0.9`) when you want to keep + more verbatim history. + +For a portfolio bot with `context_size: 16384` and `max_tokens: 1024`, +compaction fires when input exceeds ~12k tokens — leaving ~5k for the +fresh history, which is ~10-15 recent user turns. More than enough. + +--- + +## 7. Tips + +1. **`--mlock`** es oro en VPS. Bloquea el modelo en RAM y evita swaps + cuando hay picos de memoria. Cuesta ~modelo_size de locked RAM. +2. **Monitoreá con `htop`** o `free -h` la primera semana. Si ves swap, + bajá el context. +3. **Más contexto ≠ más rápido.** El prefill (procesar el input) escala + lineal con la cantidad de tokens. Generación (output) no se ve + afectada. Con `--ctx-size 32768` y un input de 500 tokens, el TTFT + apenas cambia; con 20k tokens de input sí. +4. **Streams concurrentes.** Cada stream activo reserva su propio KV + cache. En 8 GB no hagas más de 1-2 streams simultáneos — el rate + limiter del bot (default 30 req/min) ya te protege. +5. **`max_tokens` bajo ayuda.** 512 es suficiente para Q&A. Bajarlo + deja más presupuesto para input y retrasa la compactación. + +--- + +## 8. Quick-pick table + +Copy-paste según tu setup: + +| Setup | `context_size` | `max_tokens` | +|-----------------------------|---------------:|-------------:| +| 4 GB + gemma-3-1b | 8192 | 512 | +| 4 GB + qwen2.5-1.5b | 4096 | 512 | +| 8 GB + qwen2.5-1.5b | 16384 | 768 | +| 8 GB + qwen2.5-3b | 16384 | 1024 | +| 8 GB + gemma-3-1b | 32768 | 1024 | +| 16 GB + qwen2.5-3b | 32768 | 1024 | +| 16 GB + gemma-3-4b | 16384 | 1024 |