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"`