Merge pull request #2 from VictorVargas/feat/auto-compaction

feat: auto-compact long conversations
This commit is contained in:
Victor Hugo Vargas Servin 2026-07-18 15:23:27 -07:00 committed by GitHub
commit dca916d395
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1362 additions and 13 deletions

View file

@ -115,7 +115,13 @@ func serveCmd() *cobra.Command {
if err != nil { if err != nil {
return err 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) h := server.NewHandlers(cfg, runner, store, version)
srv := server.New(cfg, h) srv := server.New(cfg, h)
@ -212,7 +218,13 @@ func runAsk(cfg *config.Config, question string, noStream bool) error {
if err != nil { if err != nil {
return err 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}} history := []agent.Message{{Role: agent.RoleUser, Content: question}}
if noStream { if noStream {

View file

@ -21,7 +21,7 @@ providers:
type: llamacpp type: llamacpp
model: qwen2.5-3b-instruct model: qwen2.5-3b-instruct
endpoint: http://localhost:9100/v1 endpoint: http://localhost:9100/v1
context_size: 4096 context_size: 2048
max_tokens: 2048 max_tokens: 2048
default: true default: true
@ -124,3 +124,17 @@ logging:
level: info # debug | info | warn | error level: info # debug | info | warn | error
format: json # json | text format: json # json | text
output: stderr 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
# 46 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

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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 23 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 ## 🌐 5. Embebiendo el widget
El bot viene con un widget vanilla-JS drop-in. Agrega dos archivos a tu sitio y funciona. El bot viene con un widget vanilla-JS drop-in. Agrega dos archivos a tu sitio y funciona.

View file

@ -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 23 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 ## 🌐 5. Embedding the widget
The bot ships with a drop-in vanilla-JS widget. Add two files to your site and it works. The bot ships with a drop-in vanilla-JS widget. Add two files to your site and it works.

243
docs/vps-context-sizing.md Normal file
View file

@ -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: 5121024 `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' %}
{{- '<start_of_turn>user\n' + message['content'] | trim + '<end_of_turn>\n' -}}
{% elif message['role'] == 'assistant' or message['role'] == 'model' %}
{{- '<start_of_turn>model\n' + message['content'] | trim + '<end_of_turn>\n' -}}
{% endif %}
{% endfor %}
{% if add_generation_prompt %}
{{- '<start_of_turn>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 |

View file

@ -5,12 +5,19 @@
// straight Q&A flow: no tools, no multi-iteration reasoning. Calling the // straight Q&A flow: no tools, no multi-iteration reasoning. Calling the
// underlying provider keeps the prompt under our full control so we can // underlying provider keeps the prompt under our full control so we can
// inject RAG context into the system message exactly where we want it. // 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 package agent
import ( import (
"context" "context"
"fmt" "fmt"
"iter" "iter"
"log/slog"
"strings" "strings"
"github.com/VictorVargas/rony-llm-agent/pkg/llm" "github.com/VictorVargas/rony-llm-agent/pkg/llm"
@ -40,6 +47,9 @@ type Runner struct {
store *portfolio.Store store *portfolio.Store
topK int topK int
usage *Usage usage *Usage
compaction CompactionConfig
lastCompact CompactionStats
} }
type Usage struct { type Usage struct {
@ -47,18 +57,64 @@ type Usage struct {
OutputTokens int OutputTokens int
} }
// CompactionConfig mirrors the YAML `compaction:` block. Zero value means
// compaction is disabled.
type CompactionConfig struct {
Enabled bool
ThresholdRatio float64 // 01, 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). // New returns a Runner. The store may be nil (RAG disabled).
// systemPrompt is the full hand-written prompt from configs/...yaml. // 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 { 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{}} 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. // LastUsage returns the token usage recorded on the most recent call.
func (r *Runner) LastUsage() Usage { return *r.usage } 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 // BuildMessages prepares the system prompt and turns the chat history into
// the upstream message list. The system prompt includes RAG context for the // the upstream message list. The system prompt includes RAG context for the
// user's last message (if RAG is enabled). // 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) { func (r *Runner) BuildMessages(ctx context.Context, history []Message) ([]Message, string, error) {
ragContext := "" ragContext := ""
if r.store != nil && len(history) > 0 { 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) return nil, "", fmt.Errorf("rag search: %w", err)
} }
if len(hits) > 0 { 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. // The system prompt comes from the YAML, not from the persona struct.
// Keep the persona around only for the UI greeting. // Keep the persona around only for the UI greeting.
system := botpersona.BuildSystemPrompt(r.systemPrompt, ragContext) 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 := make([]Message, 0, len(history)+1)
msgs = append(msgs, Message{Role: RoleSystem, Content: system}) msgs = append(msgs, Message{Role: RoleSystem, Content: system})
msgs = append(msgs, history...) msgs = append(msgs, history...)
return msgs, ragContext, nil 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 // Stream runs the model and yields each streamed chunk. The caller
// forwards chunk.Delta to the SSE stream; chunk.Usage on the last chunk // forwards chunk.Delta to the SSE stream; chunk.Usage on the last chunk
// carries token counts. // carries token counts.
@ -123,3 +372,131 @@ func formatHits(hits []portfolio.SearchResult) string {
} }
return b.String() return b.String()
} }
// 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]:]
}

View file

@ -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))
}
}

View file

@ -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 { func writeFile(path, content string) error {
if err := mkdirAll(path); err != nil { if err := mkdirAll(path); err != nil {
return err return err

View file

@ -59,12 +59,40 @@ type Logging struct {
Output string `yaml:"output"` 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 01; 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 { type Config struct {
Server Server `yaml:"server"` Server Server `yaml:"server"`
Providers []Provider `yaml:"providers"` Providers []Provider `yaml:"providers"`
RAG RAG `yaml:"rag"` RAG RAG `yaml:"rag"`
Persona Persona `yaml:"persona"` Persona Persona `yaml:"persona"`
SystemPrompt string `yaml:"system_prompt"` SystemPrompt string `yaml:"system_prompt"`
Compaction Compaction `yaml:"compaction"`
Logging Logging `yaml:"logging"` Logging Logging `yaml:"logging"`
} }
@ -120,6 +148,14 @@ func (c *Config) validate() error {
if c.RAG.Tokenize == "" { if c.RAG.Tokenize == "" {
c.RAG.Tokenize = "unicode61" 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 return nil
} }

View file

@ -158,6 +158,23 @@ func (h *Handlers) streamChat(w http.ResponseWriter, r *http.Request, history []
} }
flusher.Flush() 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). // Report which RAG sources were used (search happens in BuildMessages).
_, ragContext, err := h.runner.BuildMessages(ctx, history) _, ragContext, err := h.runner.BuildMessages(ctx, history)
if err != nil { 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) { func (h *Handlers) completeChat(w http.ResponseWriter, r *http.Request, history []agent.Message, convID string) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
ctx := r.Context() 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 full strings.Builder
var sources []string var sources []string
for chunk, err := range h.runner.Stream(ctx, history) { for chunk, err := range h.runner.Stream(ctx, history) {

View file

@ -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 { type Usage struct {
InputTokens int `json:"input_tokens"` InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"` OutputTokens int `json:"output_tokens"`