From 550ba526c4f36bcbea91c1c9abe7f0e2b521c546 Mon Sep 17 00:00:00 2001 From: Victor Hugo Vargas Date: Sat, 18 Jul 2026 00:08:22 -0700 Subject: [PATCH] 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"`