diff --git a/README.md b/README.md index 1ecaf3f0..a8c70bf5 100644 --- a/README.md +++ b/README.md @@ -203,8 +203,15 @@ export OPENAI_MODEL="model-name" * **True cl100k BPE Offline Tokenizer:** Embedded tokenizer calculates real BPE token counts offline with zero heuristic guesswork. * **Native Context-Aware Search:** High-performance codebase search with globster filtering that respects `.gitignore` and `.llmignore`. * **Agent Skills & MCP Support:** Natively consume external Model Context Protocol (MCP) servers and third-party Agent Skills with zero configuration overhead. +* **Context Compaction with an Expand Tool (staged: shadow → enabled):** Oversized tool outputs are segmented and scored; low-scoring segments are elided into `[[elided id=r:…]]` content-addressed pointers backed by a persistent record store (`~/.local/share/late/compaction-store.jsonl`), with the originals retrievable via the `expand` tool. Gate safety knobs (`compaction-max-elide-percent` tripwire, `compaction-protected-floor` for stacktraces/diffs) and a three-stage `-check-compaction` preflight ship with it. The elision score cutoff resolves as explicit `-compaction-threshold` flag > `compaction-threshold` in `config.json` > the 0.35 default (see the Quickstart). +* **413 / Context-Too-Large Recovery:** When a provider rejects the request body outright (`413 Request Entity Too Large`), the error carries actionable recovery guidance, and — with `compaction-mode: enabled` — the TUI automatically runs one recovery compaction pass (once per conversation, never a loop). The Quickstart ships a short playbook: replay the shadow log at candidate thresholds (`-replay-shadow=0.35,0.5,0.65,0.8`), set `compaction-threshold` accordingly, restart. +* **Full-History Compaction (`/jev-compact-context` + autocompact):** One command — or an automatic trigger at a configurable context-usage percent (`jev-autocompact`, `jev-autocompact-percent`) — scores the whole conversation after an append-only frozen prefix (persisted high-water mark; never re-scores what an earlier run froze) and elides low-scoring segments into pointers, originals retrievable via the `expand` tool (see the Quickstart). +* **Retrieved Context (`compaction-retrieval`, default off):** The read side of the compaction store — before each request its summaries are scored against the current task and the top matches are appended to the request's work area (never the frozen prefix, never persisted), with `kind=retrieve` decisions logged for replay (see the Quickstart). +* **Offline Demo & Shadow Replay Tooling:** `-replay-shadow=0.10,0.35` re-decides the shadow log at your own thresholds (kept/relocated/tokens-saved table + false-negative rate, no scorer round trips), and `compaction-backend: "offline"` runs the whole compaction flow — including `-check-compaction` — against a deterministic local scripted scorer with no API key and no network. Demo/tests only, never a production default (see the Quickstart). * **Git Worktree Support:** Run independent, parallel agent instances across multiple branches simultaneously with zero context bleeding. +The context-compaction feature set is a Go port of [jev-compaction](https://github.com/Waxmell114514/jev-compaction) (MIT); the port follows that repo's staged `shadow` → `enabled` rollout, so scoring starts out as pure observation and only ever elides once you explicitly opt into `enabled` mode. + --- ## License diff --git a/cmd/late/check_compaction_test.go b/cmd/late/check_compaction_test.go new file mode 100644 index 00000000..9809ebda --- /dev/null +++ b/cmd/late/check_compaction_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "bytes" + "flag" + "io" + "strings" + "testing" + + "late/internal/compaction" +) + +// TestCheckCompactionFlagParses pins the -check-compaction flag's CLI shape: +// it is a boolean (no value name in help), it defaults to false, it pairs +// with -compaction-mode, and it renders in the grouped help under Context +// compaction. +func TestCheckCompactionFlagParses(t *testing.T) { + newFlagSet := func() (*flag.FlagSet, *bool) { + fs := flag.NewFlagSet("check-test", flag.ContinueOnError) + fs.SetOutput(io.Discard) + check := fs.Bool("check-compaction", false, "usage of check-compaction") + fs.String("compaction-mode", "", "usage of compaction-mode") + return fs, check + } + + // Default: off. + fs, check := newFlagSet() + if err := fs.Parse(nil); err != nil { + t.Fatalf("Parse(nil): %v", err) + } + if *check { + t.Error("-check-compaction defaults to true, want false") + } + + // Set, alone. + fs, check = newFlagSet() + if err := fs.Parse([]string{"-check-compaction"}); err != nil { + t.Fatalf("Parse(-check-compaction): %v", err) + } + if !*check { + t.Error("-check-compaction did not parse as true") + } + + // Set, paired with -compaction-mode (the documented pairing: the check + // resolves the backend the same way a run with that mode would). + fs, check = newFlagSet() + if err := fs.Parse([]string{"-check-compaction", "-compaction-mode=enabled"}); err != nil { + t.Fatalf("Parse(-check-compaction -compaction-mode=enabled): %v", err) + } + if !*check { + t.Error("-check-compaction did not parse as true when paired with -compaction-mode") + } + if got := fs.Lookup("compaction-mode").Value.String(); got != "enabled" { + t.Errorf("-compaction-mode = %q, want enabled", got) + } + + // Help rendering: the flag shows up exactly once in the grouped output. + var buf bytes.Buffer + writeHelp(&buf, newHelpTestFlagSet(t)) + if n := countRenderedFlagLines(buf.String(), "check-compaction"); n != 1 { + t.Errorf("-check-compaction rendered %d times in help, want exactly 1:\n%s", n, buf.String()) + } +} + +// TestNoBackendCheckReport pins the stage-0 failure report: the guidance +// sentence, the FAIL verdict naming the backend stage, and no question/gate/ +// expand rows — the real stages cannot run without a backend. +func TestNoBackendCheckReport(t *testing.T) { + results := noBackendCheckResults(errNoBackendForTest()) + if len(results) != 1 { + t.Fatalf("got %d results, want exactly the stage-0 backend failure", len(results)) + } + if results[0].Stage != compaction.CheckStageBackend || results[0].OK { + t.Fatalf("results[0] = %+v, want a failing %q stage", results[0], compaction.CheckStageBackend) + } + + out := compaction.FormatCheckReport(results, false) + for _, want := range []string{ + "late compaction preflight", + "[FAIL] " + compaction.CheckStageBackend, + "no compaction backend configured (set the provider key or run with -compaction-mode pointing at a gateway)", + "no System One backend available", // the resolver's typed reason + `result: FAIL (stage "backend" failed)`, + "cost: n/a (the decisions client does not track token usage)", + } { + if !strings.Contains(out, want) { + t.Errorf("report missing %q:\n%s", want, out) + } + } + for _, banned := range []string{"[ok ] " + compaction.CheckStageQuestions, "[FAIL] " + compaction.CheckStageGate, "[FAIL] " + compaction.CheckStageExpand} { + if strings.Contains(out, banned) { + t.Errorf("report must not run the %q stage without a backend:\n%s", banned, out) + } + } +} + +// errNoBackendForTest builds the resolver's no-backend error without touching +// the process environment (a test machine may carry real provider keys, and +// runCompactionCheck must never be exercised against them). +func errNoBackendForTest() error { + return &compaction.NoBackendError{ + Detail: "typesafe: no API key for backend \"typesafe\": set TYPESAFE_API_KEY= in the environment, or write the key to /keys/compaction-typesafe.key", + } +} diff --git a/cmd/late/compaction_offline_test.go b/cmd/late/compaction_offline_test.go new file mode 100644 index 00000000..efb45a58 --- /dev/null +++ b/cmd/late/compaction_offline_test.go @@ -0,0 +1,149 @@ +package main + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" + + "late/internal/compaction" + appconfig "late/internal/config" + "late/internal/session" +) + +// The offline compaction tests never construct a server: compaction-backend +// "offline" is the no-key, no-network demo path, so every assertion below +// runs against the ScriptedScorer with nothing to connect to — an attempted +// request would fail and fail the test. + +// TestOfflineBackendNeedsNoEnvKeys is the wiring smoke test (Step 18): in an +// environment where NO System One backend resolves (empty env lookup, empty +// key dir), the config offline selection still builds a working pipeline and +// scores — no key, no env, no network. +func TestOfflineBackendNeedsNoEnvKeys(t *testing.T) { + // Negative control: the env-based resolution must fail in this machine + // state, so the offline path below is demonstrably not leaning on it. + if _, err := compaction.ResolveBackendIn(t.TempDir(), "", func(string) string { return "" }); err == nil { + t.Fatal("no backend should resolve with an empty environment and no key files") + } + + // The config selects offline; the resolver honors it with no warning. + backend, warning := appconfig.ResolveCompactionBackend(&appconfig.Config{CompactionBackend: appconfig.CompactionBackendOffline}) + if backend != appconfig.CompactionBackendOffline { + t.Fatalf("ResolveCompactionBackend() = %q (warning %q), want %q", backend, warning, appconfig.CompactionBackendOffline) + } + if warning != "" { + t.Fatalf("ResolveCompactionBackend() warning = %q, want none", warning) + } + + // The wiring that follows never resolves a backend: it builds the + // offline pipeline directly (mirroring main()'s offline branch) and + // scores through it. + pipeline := compaction.NewOfflinePipeline(compaction.PipelineOptions{}) + output := strings.Repeat("a", 200) + "\n\n" + strings.Repeat("b", 200) + "\n\n" + strings.Repeat("c", 200) + got, err := pipeline.ScoreToolOutput(context.Background(), "Bash", output) + if err != nil { + t.Fatalf("ScoreToolOutput() error = %v, want nil (offline scoring never touches the network)", err) + } + if len(got.Scores) != len(got.Segments) || len(got.Segments) != 3 { + t.Fatalf("got %d segments / %d scores, want 3/3", len(got.Segments), len(got.Scores)) + } + if len(got.Errors) != 0 { + t.Errorf("Errors = %v, want none", got.Errors) + } +} + +// TestCheckCompactionOfflinePassPath pins the -check-compaction offline +// branch at unit level: the three stages (questions, gate, expand) run +// against the offline scripted scorer and pass, and the flag's exit-code +// wrapper returns 0. +func TestCheckCompactionOfflinePassPath(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + results, ok := compaction.RunPreflightOffline(ctx) + if !ok { + t.Fatalf("offline preflight failed:\n%s", compaction.FormatCheckReport(results, ok)) + } + for _, r := range results { + if !r.OK { + t.Errorf("stage %q failed: %s", r.Stage, r.Detail) + } + } + stages := map[string]bool{} + for _, r := range results { + stages[r.Stage] = true + } + for _, want := range []string{compaction.CheckStageQuestions, compaction.CheckStageGate, compaction.CheckStageExpand} { + if !stages[want] { + t.Errorf("the three stages must all run offline; missing %q", want) + } + } + + // The flag wrapper: exit code 0 (it prints the report to stdout). + if code := runCompactionCheck(true); code != 0 { + t.Errorf("runCompactionCheck(offline) = %d, want 0", code) + } +} + +// TestOfflineSessionCompactContextEndToEnd is the history-side half of the +// offline end-to-end pin: the offline pipeline's scorer drives +// session.CompactContext (the /jev-compact-context flow) enabled-mode, the +// rewritten history carries [[elided id=r:…]] content-id pointers, and +// Reconstruct over the store restores the original message byte for byte. +// No network anywhere. +func TestOfflineSessionCompactContextEndToEnd(t *testing.T) { + path := filepath.Join(t.TempDir(), "history.json") + sess := compactionTestSession(t, path) + original := sess.History[1].Content.Text + + pipeline := compaction.NewOfflinePipeline(compaction.PipelineOptions{}) + store := compaction.NewStore() + + report, err := sess.CompactContext(context.Background(), pipeline.HistoryScorer(), store, session.CompactionOptions{ + Threshold: 1.0, // below 1.0 always elides: scripted scores are < 1 + }) + if err != nil { + t.Fatalf("CompactContext() error = %v, want a clean offline walk", err) + } + if report.ShadowOnly { + t.Error("a mutating offline run must not report ShadowOnly") + } + if report.MessagesCompacted != 1 || report.SegmentsElided < 1 { + t.Fatalf("report = %+v, want the assistant message compacted with ≥1 segment elided", report) + } + + // The user message is never compacted; the assistant message carries a + // content-id pointer. + if got := sess.History[0].Content.Text; got != "Please analyze this build log." { + t.Errorf("user message mutated:\n%s", got) + } + compacted := sess.History[1].Content.Text + if !strings.Contains(compacted, "[[elided id=r:") { + t.Fatalf("compacted history carries no pointer:\n%s", compacted) + } + + // Byte-for-byte inverse through the same store. + if expanded := compaction.Reconstruct(compacted, store); expanded != original { + t.Errorf("Reconstruct did not restore the original byte for byte (%d vs %d bytes)", len(expanded), len(original)) + } + + // The expand tool's read side (what a real enabled-mode session wires) + // resolves the pointer from the same store. + if text, ok := store.Get(pointerID(t, compacted)); !ok || text != original { + t.Errorf("store.Get(pointer) = (%q, %v), want the original message", text, ok) + } +} + +// pointerID extracts the first [[elided id=…]] pointer id from text via the +// compaction package's own parser. +func pointerID(t *testing.T, text string) string { + t.Helper() + for _, line := range strings.Split(text, "\n") { + if p, ok := compaction.ParsePointer(line); ok { + return p.ID + } + } + t.Fatalf("no pointer in:\n%s", text) + return "" +} diff --git a/cmd/late/compaction_runner_test.go b/cmd/late/compaction_runner_test.go new file mode 100644 index 00000000..b05134d5 --- /dev/null +++ b/cmd/late/compaction_runner_test.go @@ -0,0 +1,293 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "late/internal/client" + "late/internal/compaction" + "late/internal/session" +) + +// fakeHistoryScorer scores every segment with one fixed score, standing in +// for the compaction pipeline's decision client. +type fakeHistoryScorer struct { + score float64 +} + +func (f fakeHistoryScorer) ScoreBatch(_ context.Context, _ string, items map[string]compaction.Item) (map[string]float64, error) { + out := make(map[string]float64, len(items)) + for id := range items { + out[id] = f.score + } + return out, nil +} + +func compactionTestSession(t *testing.T, path string) *session.Session { + t.Helper() + // A completing mutating run persists the compaction high-water mark + // through the session meta sidecar — sandbox SessionDir so these tests + // never write into the real user sessions directory (t.TempDir paths + // keep everything inside the test sandbox). + sessionDir := t.TempDir() + originalSessionDir := session.SessionDir + session.SessionDir = func() (string, error) { return sessionDir, nil } + t.Cleanup(func() { session.SessionDir = originalSessionDir }) + return session.New(nil, path, []client.ChatMessage{ + {Role: "user", Content: client.TextContent("Please analyze this build log.")}, + // The compaction candidate: assistant content annotating a tool + // call. (A pure-prose assistant message — no tool calls — is never + // compacted; the walk preserves it byte-identically.) + { + Role: "assistant", + Content: client.TextContent(strings.Repeat("verbose analysis ", 200)), + ToolCalls: []client.ToolCall{{Index: 0, ID: "call_1", Type: "function", Function: client.FunctionCall{Name: "Bash", Arguments: `{"cmd":"make build"}`}}}, + }, + }, "system prompt", false) +} + +// TestHistoryCompactionRunnerPersistsMutatingRun: an enabled-mode run +// compacts the session history in place and persists it to the session's +// history path, mirroring the orchestrator's own SaveHistory call sites. +func TestHistoryCompactionRunnerPersistsMutatingRun(t *testing.T) { + path := filepath.Join(t.TempDir(), "history.json") + sess := compactionTestSession(t, path) + store := compaction.NewStore() + + runner := historyCompactionRunner(sess, fakeHistoryScorer{score: 0}, store, false, compaction.DefaultRelocationThreshold, nil, nil) + report, err := runner(context.Background()) + if err != nil { + t.Fatalf("runner() error = %v", err) + } + if report.ShadowOnly { + t.Fatal("mutating run must not report ShadowOnly") + } + if report.SegmentsElided == 0 || report.TokensSaved <= 0 { + t.Fatalf("expected a real elision, report = %+v", report) + } + if !strings.Contains(sess.History[1].Content.Text, "[[elided") { + t.Fatal("compaction must rewrite the assistant message in place") + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("SaveHistory did not write %s: %v", path, err) + } + var saved []client.ChatMessage + if err := json.Unmarshal(data, &saved); err != nil { + t.Fatalf("saved history is not valid JSON: %v", err) + } + if len(saved) != 2 { + t.Fatalf("saved history has %d messages, want 2", len(saved)) + } + if !strings.Contains(saved[1].Content.String(), "[[elided") { + t.Fatal("the persisted history must contain the compacted message") + } +} + +// TestHistoryCompactionRunnerShadowRunSkipsPersistence: a shadow run computes +// the honest would-save report without mutating history or writing the +// session file. +func TestHistoryCompactionRunnerShadowRunSkipsPersistence(t *testing.T) { + path := filepath.Join(t.TempDir(), "history.json") + sess := compactionTestSession(t, path) + store := compaction.NewStore() + original := sess.History[1].Content.Text + + runner := historyCompactionRunner(sess, fakeHistoryScorer{score: 0}, store, true, compaction.DefaultRelocationThreshold, nil, nil) + report, err := runner(context.Background()) + if err != nil { + t.Fatalf("runner() error = %v", err) + } + if !report.ShadowOnly { + t.Fatal("shadow run must report ShadowOnly") + } + if report.SegmentsElided == 0 || report.TokensSaved <= 0 { + t.Fatalf("shadow report must quantify the would-save, report = %+v", report) + } + if sess.History[1].Content.Text != original { + t.Fatal("shadow run must not mutate history") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("shadow run must not write the history file, stat error = %v", err) + } + if _, ok := store.Get("elide-1"); ok { + t.Fatal("shadow run must not store originals") + } +} + +// failingHistoryScorer returns no usable scores at all, standing in for a +// wholesale scorer failure (the mid-walk abort path). +type failingHistoryScorer struct{} + +func (failingHistoryScorer) ScoreBatch(_ context.Context, _ string, _ map[string]compaction.Item) (map[string]float64, error) { + return nil, errors.New("scorer down") +} + +// readShadowLines reads the JSONL shadow log and decodes each line. +func readShadowLines(t *testing.T, path string) []compaction.ShadowEntry { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read shadow log %s: %v", path, err) + } + var entries []compaction.ShadowEntry + for i, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + var e compaction.ShadowEntry + if err := json.Unmarshal([]byte(line), &e); err != nil { + t.Fatalf("shadow log line %d is not valid JSON: %v (%q)", i, err, line) + } + entries = append(entries, e) + } + return entries +} + +// TestHistoryCompactionRunnerAppendsRunSummary: every mutating run appends +// exactly one "history-run" summary line carrying the report's totals, keyed +// by the same task hash the per-segment decisions use. +func TestHistoryCompactionRunnerAppendsRunSummary(t *testing.T) { + path := filepath.Join(t.TempDir(), "history.json") + sess := compactionTestSession(t, path) + store := compaction.NewStore() + shadowLog, err := compaction.NewShadowLogAt(filepath.Join(t.TempDir(), "shadow.jsonl")) + if err != nil { + t.Fatal(err) + } + + runner := historyCompactionRunner(sess, fakeHistoryScorer{score: 0}, store, false, compaction.DefaultRelocationThreshold, shadowLog, nil) + report, err := runner(context.Background()) + if err != nil { + t.Fatalf("runner() error = %v", err) + } + + entries := readShadowLines(t, shadowLog.Path()) + if len(entries) != 1 { + t.Fatalf("got %d shadow log lines, want 1 run summary", len(entries)) + } + e := entries[0] + if e.Type != compaction.EntryTypeHistoryRun { + t.Errorf("Type = %q, want %q", e.Type, compaction.EntryTypeHistoryRun) + } + if e.SegmentID != "" || e.Decision != "" { + t.Errorf("run summary must carry no decision fields, got segment_id=%q decision=%q", e.SegmentID, e.Decision) + } + if e.TS.IsZero() { + t.Error("run summary TS was not defaulted to now") + } + if want := compaction.HashTask("Please analyze this build log."); e.TaskHash != want { + t.Errorf("TaskHash = %q, want the session task's digest %q", e.TaskHash, want) + } + if e.TaskHash != report.TaskHash { + t.Errorf("TaskHash = %q, want the report's %q", e.TaskHash, report.TaskHash) + } + if e.Run == nil { + t.Fatal("run summary Run is nil") + } + want := compaction.RunSummary{ + Scanned: report.MessagesScanned, + Scored: report.MessagesScored, + Elided: report.SegmentsElided, + TokensBefore: report.TokensBefore, + TokensAfter: report.TokensAfter, + TokensSaved: report.TokensSaved, + } + if *e.Run != want { + t.Errorf("Run = %+v, want %+v", *e.Run, want) + } + if e.Run.Shadow { + t.Error("a mutating run must not report Shadow in its summary") + } + if e.Run.Err != "" { + t.Errorf("a clean run must log no error, got %q", e.Run.Err) + } +} + +// TestHistoryCompactionRunnerLogsFailedRunSummary: a run that fails still +// appends its summary (with the error string), and a shadow run's summary +// says so — the failure must not cost the audit trail. +func TestHistoryCompactionRunnerLogsFailedRunSummary(t *testing.T) { + path := filepath.Join(t.TempDir(), "history.json") + sess := compactionTestSession(t, path) + store := compaction.NewStore() + shadowLog, err := compaction.NewShadowLogAt(filepath.Join(t.TempDir(), "shadow.jsonl")) + if err != nil { + t.Fatal(err) + } + + runner := historyCompactionRunner(sess, failingHistoryScorer{}, store, true, compaction.DefaultRelocationThreshold, shadowLog, nil) + report, err := runner(context.Background()) + if err == nil { + t.Fatal("runner() error = nil, want the scorer failure") + } + + entries := readShadowLines(t, shadowLog.Path()) + if len(entries) != 1 { + t.Fatalf("got %d shadow log lines, want 1 run summary even for the failed run", len(entries)) + } + e := entries[0] + if e.Type != compaction.EntryTypeHistoryRun || e.Run == nil { + t.Fatalf("entry = %+v, want a history-run summary", e) + } + if !e.Run.Shadow { + t.Error("a shadow run must report Shadow in its summary") + } + if e.Run.Err != err.Error() { + t.Errorf("Run.Err = %q, want the runner error %q", e.Run.Err, err.Error()) + } + if e.Run.Scored != report.MessagesScored { + t.Errorf("Run.Scored = %d, want the report's %d", e.Run.Scored, report.MessagesScored) + } +} + +// TestHistoryCompactionRunnerThreadsGate: the runner hands the pipeline's +// GateConfig down to the walk, so the gate's max-elide-fraction tripwire +// fires per message (nothing elided, Tripwires counted, one tripwire shadow +// entry next to the run summary) where a nil-gate runner would elide +// everything. +func TestHistoryCompactionRunnerThreadsGate(t *testing.T) { + path := filepath.Join(t.TempDir(), "history.json") + sess := compactionTestSession(t, path) + store := compaction.NewStore() + shadowLog, err := compaction.NewShadowLogAt(filepath.Join(t.TempDir(), "shadow.jsonl")) + if err != nil { + t.Fatal(err) + } + gate := compaction.DefaultGateConfig() + gate.KeepThreshold = compaction.DefaultRelocationThreshold + gate.MaxElideFraction = 0.5 + + runner := historyCompactionRunner(sess, fakeHistoryScorer{score: 0}, store, false, compaction.DefaultRelocationThreshold, shadowLog, &gate) + report, err := runner(context.Background()) + if err != nil { + t.Fatalf("runner() error = %v", err) + } + if report.Tripwires != 1 { + t.Fatalf("Tripwires = %d, want 1 (every segment scores 0 — the whole message trips the fraction)", report.Tripwires) + } + if report.SegmentsElided != 0 || report.TokensSaved != 0 { + t.Errorf("the tripwire must elide nothing, report = %+v", report) + } + if strings.Contains(sess.History[1].Content.Text, "[[elided") { + t.Fatal("the tripped message must stay byte-identical (no pointers)") + } + + // The shadow log holds the walk's tripwire entry plus the run summary. + entries := readShadowLines(t, shadowLog.Path()) + if len(entries) != 2 { + t.Fatalf("got %d shadow log lines, want 1 tripwire + 1 run summary", len(entries)) + } + if entries[0].Type != compaction.EntryTypeTripwire || entries[0].Action != compaction.TripwireAction { + t.Errorf("entry[0] Type/Action = %q/%q, want the walk's tripwire", entries[0].Type, entries[0].Action) + } + if entries[1].Type != compaction.EntryTypeHistoryRun { + t.Errorf("entry[1] Type = %q, want the run summary", entries[1].Type) + } +} diff --git a/cmd/late/compaction_store_test.go b/cmd/late/compaction_store_test.go new file mode 100644 index 00000000..10a501cd --- /dev/null +++ b/cmd/late/compaction_store_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// TestOpenCompactionStoreAtPersistsAndReloads: the main() wiring helper +// returns a working file-backed store — a record stored before a reopen is +// still readable after it (the restart/resume contract of Step 12). +func TestOpenCompactionStoreAtPersistsAndReloads(t *testing.T) { + path := filepath.Join(t.TempDir(), "compaction-store.jsonl") + store := openCompactionStoreAt(path) + if store == nil { + t.Fatal("openCompactionStoreAt must never return nil") + } + if store.Path() != path { + t.Fatalf("Path() = %q, want %q", store.Path(), path) + } + store.Put("r:deadbeef", "original text") + + reopened := openCompactionStoreAt(path) + if text, ok := reopened.Get("r:deadbeef"); !ok || text != "original text" { + t.Errorf("reopened Get = (%q, %v), want the stored original", text, ok) + } +} + +// TestOpenCompactionStoreAtDegradesToInMemory: an unopenable path (its +// parent is a regular file) must still return a working in-memory store — +// compaction degrades but keeps working, the shadow-log warning pattern — +// and reports the degrade via its empty Path. +func TestOpenCompactionStoreAtDegradesToInMemory(t *testing.T) { + blocker := filepath.Join(t.TempDir(), "blocker") + if err := os.WriteFile(blocker, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + + store := openCompactionStoreAt(filepath.Join(blocker, "store.jsonl")) + if store == nil { + t.Fatal("the degraded fallback must return a usable store, not nil") + } + if store.Path() != "" { + t.Errorf("degraded store Path() = %q, want empty (in-memory)", store.Path()) + } + store.Put("r:deadbeef", "kept in memory") + if text, ok := store.Get("r:deadbeef"); !ok || text != "kept in memory" { + t.Errorf("degraded store Get = (%q, %v), want the in-memory text", text, ok) + } + if store.Len() != 1 { + t.Errorf("degraded store Len() = %d, want 1", store.Len()) + } +} diff --git a/cmd/late/help.go b/cmd/late/help.go index 7b6a2580..a96e89d8 100644 --- a/cmd/late/help.go +++ b/cmd/late/help.go @@ -23,6 +23,8 @@ var flagGroups = []struct { {"Model & streaming", []string{"logit-bias", "suppress-thinking-words", "max-stream-retries"}, ""}, {"Subagents", []string{"enable-subagents", "subagent-max-turns", "subagent-logit-bias", "save-subagent-histories"}, ""}, {"Tools", []string{"use-tools", "enable-bash", "enable-images", "enable-sqz"}, ""}, + {"Context compaction", []string{"compaction-mode", "compaction-threshold", "check-compaction", "replay-shadow"}, + "Full-history compaction: /jev-compact-context in the TUI (requires\n compaction-mode other than off; shadow mode is report-only). config.json\n jev-autocompact + jev-autocompact-percent (default 99) run the same\n compaction automatically when context usage crosses the percent. The\n check-compaction flag runs a three-stage preflight (questions, gate,\n expand) against the resolved backend and exits; replay-shadow prints the\n shadow log's replay table and exits. config.json compaction-backend:\n \"offline\" swaps the scoring backend for a deterministic local scripted\n scorer — the whole flow, and check-compaction, run with no API key and\n no network (for demos and tests; never a production default)."}, {"Supervision & safety", []string{"ask-for-user-approval", "i-promise-i-have-backups-and-will-not-file-issues"}, "These two flags are mutually exclusive: pass at most one. The default\n (ask-for-user-approval) can be changed by adding a \"permission-mode\"\n entry to late's config.json with one of the values above."}, } diff --git a/cmd/late/help_test.go b/cmd/late/help_test.go index ada41ebd..68bfe18c 100644 --- a/cmd/late/help_test.go +++ b/cmd/late/help_test.go @@ -18,18 +18,19 @@ func newHelpTestFlagSet(t *testing.T) *flag.FlagSet { "suppress-thinking-words", "save-subagent-histories", "enable-sqz", "ask-for-user-approval", "i-promise-i-have-backups-and-will-not-file-issues", "enable-images", - "use-tools", "enable-bash", "enable-subagents", + "use-tools", "enable-bash", "enable-subagents", "check-compaction", } for _, name := range bools { def := name == "use-tools" || name == "enable-bash" || name == "enable-subagents" fs.Bool(name, def, "usage of "+name) } - strs := []string{"system-prompt", "system-prompt-file", "append-system-prompt", "theme", "prompt", "logit-bias", "subagent-logit-bias"} + strs := []string{"system-prompt", "system-prompt-file", "append-system-prompt", "theme", "prompt", "logit-bias", "subagent-logit-bias", "compaction-mode", "replay-shadow"} for _, name := range strs { fs.String(name, "", "usage of "+name) } fs.Int("subagent-max-turns", 500, "usage of subagent-max-turns") fs.Int("max-stream-retries", 100, "usage of max-stream-retries") + fs.Float64("compaction-threshold", 0.35, "usage of compaction-threshold") return fs } diff --git a/cmd/late/main.go b/cmd/late/main.go index bb16d27e..909ce25d 100644 --- a/cmd/late/main.go +++ b/cmd/late/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "flag" "fmt" "late/internal/agent" @@ -20,6 +21,7 @@ import ( "late/internal/assets" "late/internal/client" + "late/internal/compaction" appconfig "late/internal/config" "late/internal/mcp" "late/internal/pathutil" @@ -29,6 +31,7 @@ import ( "late/internal/tui" "encoding/json" + "text/tabwriter" tea "charm.land/bubbletea/v2" "charm.land/glamour/v2" @@ -119,6 +122,15 @@ func main() { logitBiasReq := flag.String("logit-bias", "", "Main-agent token bias: JSON object or comma-separated TOKEN_ID:BIAS pairs.") suppressThinkingWordsReq := flag.Bool("suppress-thinking-words", false, "Bias anti-overthinking tokens (requires the same model for main agent and subagents).") subagentLogitBiasReq := flag.String("subagent-logit-bias", "", "Subagent token bias: JSON object or comma-separated TOKEN_ID:BIAS pairs.") + // Compaction (staged rollout of the jev-compaction port): off = no + // scoring at all; shadow = score tool outputs + shadow log only + // (default, no behavior change); enabled = additionally relocate + // low-scoring segments out of oversized tool results (registers the + // expand tool so originals stay retrievable). + compactionModeReq := flag.String("compaction-mode", "", "Tool-output compaction stage: off, shadow (score + shadow log only), or enabled (also relocate low-scoring segments; adds the expand tool). Overrides config.json compaction-mode. Default: shadow.") + compactionThresholdReq := flag.Float64("compaction-threshold", compaction.DefaultRelocationThreshold, "Score (0-1] below which tool-output segments are elided when -compaction-mode=enabled. Overrides config.json compaction-threshold; default 0.35.") + replayShadowReq := flag.String("replay-shadow", "", "Replay the default shadow log at the given comma-separated thresholds (e.g. 0.10,0.35,0.50): print the kept/relocated/tokens-saved/still-missed table plus the false-negative rate, then exit. Read-only; the TUI does not start.") + checkCompactionReq := flag.Bool("check-compaction", false, "Run the compaction preflight against the resolved System One backend — real requests checking (1) decisions answers and parse, (2) the gate relocates something from a real tool output, (3) a pointer expands back byte for byte — print the per-stage report and exit (0 pass, 1 fail; the TUI does not start). Pairs with -compaction-mode. With config.json compaction-backend \"offline\" the same three stages run against the deterministic local scripted scorer: no key, no network.") flag.Usage = func() { writeHelp(os.Stderr, flag.CommandLine) @@ -137,6 +149,34 @@ func main() { return } + // -replay-shadow: read-only offline replay of the default shadow log — + // one kept/relocated/tokens-saved/still-missed row per given threshold + // (re-decided from the recorded scores, no scorer round trip) plus the + // false-negative rate — then exit without starting the TUI. + // + // This branch deliberately runs BEFORE appconfig.LoadConfig: the replay + // consumes only the shadow log, never config.json, and LoadConfig has + // side effects a read-only diagnostic must not take — it CREATES a + // default config.json when the file is missing and tightens the config + // dir/file permissions. The price is that the startup config warnings + // (invalid compaction-mode, compaction-threshold-percent, …) are not + // printed on this path; they surface on any normal run or + // -check-compaction (which resolves the config below). If a replay ever + // needs to honor a config setting, move this branch below the + // LoadConfig block and accept the side effects. + if *replayShadowReq != "" { + thresholds, err := parseReplayThresholds(*replayShadowReq) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + if err := runReplayShadow(thresholds); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + return + } + // --continue and --continue-project are mutually exclusive: both select // the session to resume, so asking for two is ambiguous (same rule and // messaging style as the permission flags). @@ -367,6 +407,22 @@ func main() { if err != nil { fmt.Fprintf(os.Stderr, "Warning: Failed to load app config: %v\n", err) } + // Surface an invalid compaction-threshold-percent the same way the + // invalid permission-mode is reported: warn once and use the default. + if _, compactionWarning := appconfig.ResolveCompactionThreshold(appConfig); compactionWarning != "" { + fmt.Fprintf(os.Stderr, "Warning: %s\n", compactionWarning) + } + // Same warn-and-fall-back pattern for the auto-compaction threshold. + if _, _, autocompactWarning := appconfig.ResolveAutocompact(appConfig); autocompactWarning != "" { + fmt.Fprintf(os.Stderr, "Warning: %s\n", autocompactWarning) + } + // Per-model jev-autocompact-percent overrides use the same key inside + // each models[] entry; an out-of-range per-model value warns and falls + // back to the global threshold (it cannot fail the models[] key walk — + // that covers key names, not value ranges). + for _, modelWarning := range appConfig.AutocompactWarnings() { + fmt.Fprintf(os.Stderr, "Warning: %s\n", modelWarning) + } enabledTools := make(map[string]bool) if appConfig != nil { for toolName, enabled := range appConfig.EnabledTools { @@ -486,6 +542,11 @@ func main() { if loadedSessionMeta.WorkingDir != "" { sess.SetWorkingDir(loadedSessionMeta.WorkingDir) } + // Restore the compaction high-water mark so the frozen prefix stays + // append-only across restarts: resumed sessions never re-score or + // rewrite messages a previous run already froze. Legacy sidecars + // without the field carry zero — the count-based prefix then applies. + sess.SetCompactionHighWater(loadedSessionMeta.CompactionHighWater) } else { sess.SetSubagentMetadata(0, &saveSubagentHistories) } @@ -535,6 +596,225 @@ func main() { } } + // Compaction (staged rollout stage 2 of the jev-compaction port). + // Mode resolution: the -compaction-mode flag beats config.json + // compaction-mode; both are validated against the same three values + // (invalid → warn + shadow, the safe default). + compactionMode, compactionModeWarning := appconfig.ResolveCompactionMode(appConfig) + if *compactionModeReq != "" { + if appconfig.IsValidCompactionMode(*compactionModeReq) { + compactionMode = *compactionModeReq + compactionModeWarning = "" + } else { + compactionMode = appconfig.DefaultCompactionMode + compactionModeWarning = fmt.Sprintf("ignoring invalid -compaction-mode %q; using %q", + *compactionModeReq, appconfig.DefaultCompactionMode) + } + } + if compactionModeWarning != "" { + fmt.Fprintf(os.Stderr, "Warning: %s\n", compactionModeWarning) + } + + // Retrieval read side (Step 17): compaction-retrieval scores the record + // store's digest against the current task before every stream request + // and stages the top-k relevant records into the outgoing request's work + // area (ephemeral — never the frozen prefix, never persisted). Resolved + // with the same warn-on-invalid pattern as the other compaction knobs; + // the warning fires for the inert combinations (mode not "enabled", + // where the store never fills). + compactionRetrieval, compactionRetrievalWarning := appconfig.ResolveCompactionRetrieval(appConfig) + if compactionRetrievalWarning != "" { + fmt.Fprintf(os.Stderr, "Warning: %s\n", compactionRetrievalWarning) + } + + // Backend selection (Step 18): config.json compaction-backend points + // scoring at a specific scorer. The only value today is "offline" — the + // deterministic scripted scorer (no API key, no network; demos and tests + // only, its scores are content hashes). A set value WINS over the + // environment: JEV_API and auto-detection are consulted only when the + // entry is absent, because the config entry is the explicit statement + // about where scoring happens. Invalid values warn and fall back to the + // env-based path. + compactionBackendName, compactionBackendWarning := appconfig.ResolveCompactionBackend(appConfig) + if compactionBackendWarning != "" { + fmt.Fprintf(os.Stderr, "Warning: %s\n", compactionBackendWarning) + } + + // -check-compaction: run the compaction preflight (Step 16) against the + // backend THIS run would resolve and exit — the TUI never starts. The + // mode resolution above is deliberately shared with the normal startup + // path (the check must vet exactly the backend the session would use), + // and -compaction-mode pairs with the flag, so an invalid mode warns + // here the same way it would in a real run. The check itself exercises + // scoring, the gate, and expansion — a superset of what shadow mode does + // — so it applies in every mode: it is the "would have caught the + // too-small local backend before integration" tool. With the offline + // backend selected in config.json the same stages run against the + // scripted scorer — no key, no network, and they pass by construction. + if *checkCompactionReq { + os.Exit(runCompactionCheck(compactionBackendName == appconfig.CompactionBackendOffline)) + } + + // Elision threshold: segments scoring strictly below it are relocated + // out of oversized tool results when compaction-mode is enabled + // (default per the upstream repo's own shadow-log replay data). + // Precedence: an explicitly passed -compaction-threshold flag > + // config.json compaction-threshold > the 0.35 default. The resolver + // receives the flag value only when it was explicitly passed + // (flag.Visit — config loads after flag.Parse, so this is the only + // reliable explicit-flag signal); 0 otherwise, so the config entry can + // win over the flag's built-in default. + compactionThresholdFlagValue := 0.0 + flag.Visit(func(f *flag.Flag) { + if f.Name == "compaction-threshold" { + compactionThresholdFlagValue = *compactionThresholdReq + } + }) + compactionThreshold, compactionThresholdWarning := appconfig.ResolveCompactionScoreThreshold(appConfig, compactionThresholdFlagValue) + if compactionThresholdWarning != "" { + fmt.Fprintf(os.Stderr, "Warning: %s\n", compactionThresholdWarning) + } + + // Gate safety knobs (reference-parity semantics for the elide decision). + // Max elide fraction: a scorer that wants to drop more than this share + // of an output's tokens is distrusted and nothing is elided. Protected + // floor: stacktrace and diff segments are only elided below this score. + compactionMaxElidePercent, maxElideWarning := appconfig.ResolveCompactionMaxElidePercent(appConfig) + if maxElideWarning != "" { + fmt.Fprintf(os.Stderr, "Warning: %s\n", maxElideWarning) + } + compactionProtectedFloorPercent, protectedFloorWarning := appconfig.ResolveCompactionProtectedFloor(appConfig) + if protectedFloorWarning != "" { + fmt.Fprintf(os.Stderr, "Warning: %s\n", protectedFloorWarning) + } + + // The TUI's /jev-compact-context command and auto-trigger reuse this + // pipeline (its scoring client) and elide store; both stay nil when + // compaction is off or no backend resolved, which disables them. + var ( + compactionPipeline *compaction.Pipeline + compactionStore *compaction.Store + compactionShadowLog *compaction.ShadowLog + // compactionBackend is the resolved backend behind the pipeline, + // captured for the Step 16 startup probe; nil when compaction is + // off, no backend resolved, or the offline scripted scorer is + // selected (nothing to probe — there is no network to reach). + compactionBackend *compaction.ResolvedBackend + // historyGate is the gate config the pipeline was applied (the same + // reference-parity safety semantics — keep threshold, elide-fraction + // tripwire, protected-kind floors) handed to the history compaction + // runner so the walk's keep/elide calls mirror the tool-output + // path's; nil when compaction is off or no pipeline was built. + historyGate *compaction.GateConfig + ) + if compactionMode != appconfig.CompactionModeOff { + // Scoring source: the offline scripted scorer (compaction-backend + // "offline") or, when the config entry is absent/invalid, the + // env-resolved System One backend as before. The config entry wins: + // an "offline" session never resolves a backend, never needs a key, + // and never sends a request. + compactionOffline := compactionBackendName == appconfig.CompactionBackendOffline + var ( + backend compaction.ResolvedBackend + scoringOK bool + ) + if compactionOffline { + scoringOK = true + } else { + resolved, backendErr := compaction.ResolveBackendEnv("") + if backendErr != nil { + if compactionMode == appconfig.CompactionModeEnabled { + // Relocation without a backend would fail-open every + // oversized result (nothing ever elided): warn and drop to + // the shadow stage per the staged rollout. + fmt.Fprintf(os.Stderr, "Warning: compaction-mode %q needs a System One backend (%v); falling back to %q\n", + appconfig.CompactionModeEnabled, backendErr, appconfig.CompactionModeShadow) + compactionMode = appconfig.CompactionModeShadow + } else { + fmt.Fprintf(os.Stderr, "Warning: compaction-mode %q has no System One backend (%v)\n", + appconfig.CompactionModeShadow, backendErr) + } + } else { + backend = resolved + scoringOK = true + } + } + // The pipeline only exists behind usable scoring: without it every + // decisions call would burn retries and fail-open, so this run + // proceeds with compaction off instead (the warning above explains). + // Offline scoring is always usable — that is the point of the demo + // path. + if scoringOK { + shadowLog, shadowErr := compaction.NewShadowLog() + if shadowErr != nil { + // Logging is best-effort: scoring (and relocation) still + // run without it. + fmt.Fprintf(os.Stderr, "Warning: compaction shadow log unavailable (%v); continuing without it\n", shadowErr) + shadowLog = nil + } + compactionShadowLog = shadowLog + var pipeline *compaction.Pipeline + if compactionOffline { + // Step 18 demo path: the same segmentation, gate, pointers, + // and store over the deterministic scripted scorer. Demos + // and tests only — the scripted scores are content hashes, + // not essentialness judgments, and must never become a + // production default. + pipeline = compaction.NewOfflinePipeline(compaction.PipelineOptions{Shadow: shadowLog}) + } else { + compactionBackend = &backend + pipeline = compaction.NewPipeline(backend, "", shadowLog, compaction.PipelineOptions{}) + } + // GateConfig: the reference-parity elision safety semantics — + // keep threshold, elide-fraction tripwire, and protected-kind + // floors — threaded from config.json (defaults mirror the + // reference pipeline.py). KeepThreshold uses the SAME resolved + // compactionThreshold as EnableRelocation below — one source of + // truth for the elision cutoff (flag > config > default). + gate := compaction.DefaultGateConfig() + gate.KeepThreshold = compactionThreshold + gate.MaxElideFraction = float64(compactionMaxElidePercent) / 100 + protectedFloor := float64(compactionProtectedFloorPercent) / 100 + gate.ProtectedKinds = map[compaction.SegmentKind]float64{ + compaction.KindStacktrace: protectedFloor, + compaction.KindDiff: protectedFloor, + } + pipeline.ApplyGateConfig(gate) + // The history walk (/jev-compact-context + the auto-trigger) + // runs the same gate: identical keep/elide calls on both + // compaction paths, same source of truth for every knob. + historyGate = &gate + if compactionMode == appconfig.CompactionModeEnabled { + // The record store persists elided originals across + // restarts: [[elided …]] pointers saved into a session + // history must still resolve after `late` exits, so + // relocation is backed by the append-only JSONL store at + // compaction.DefaultStorePath instead of a throwaway + // in-memory map. Open failure degrades to the in-memory + // store — compaction keeps working, pointers merely stop + // surviving restarts (the shadow-log warning pattern). + store := openCompactionStore() + // Outcomes: the expand tool attributes every expand back to + // the record and its contributing segment ids through the + // shadow log attached here (Step 13's false-negative + // ledger). Nil-safe — a missing shadow log simply disables + // outcome logging. + store = store.WithShadowLog(compactionShadowLog) + pipeline.EnableRelocation(store, compactionThreshold) + // The expand tool returns relocated originals. Registered on + // the main registry before any spawn: subagents inherit it + // (and the same store) from the parent registry. + sess.Registry.Register(tool.ExpandTool{Store: store}) + compactionStore = store + } + // Shared by the root agent and every subagent: ExecuteToolCalls + // consults it for both (shadow mode scores and logs without + // changing results). + executor.SetToolResultCompactor(pipeline) + compactionPipeline = pipeline + } + } + // Resolve theme: --theme flag > $LATE_THEME > config.json > bundled base. themeID := *themeReq if themeID == "" { @@ -625,6 +905,69 @@ func main() { model.CommandHandler = pluginManager.HandleCommand } + // History compaction for /jev-compact-context + the auto-trigger: the + // session's CompactContext shares the pipeline's scoring client and its + // elide-id space (the same store the expand tool reads). Shadow mode + // reports without mutating; enabled mode persists the compacted history. + if compactionPipeline != nil { + if compactionStore == nil { + // Shadow mode: the history walk still mints pointer ids for its + // honest report, so it needs a store even though nothing is + // applied; a fresh one keeps those ids out of the (absent) + // expand tool's id space. + compactionStore = compaction.NewStore() + } + model.Compactor = historyCompactionRunner(sess, compactionPipeline.HistoryScorer(), compactionStore, + compactionMode != appconfig.CompactionModeEnabled, compactionThreshold, compactionShadowLog, historyGate) + // The TUI's one-shot 413 payload-recovery compaction only fires when + // compaction can actually shrink history: mode "enabled" (after the + // shadow fallback above, which downgrades to shadow when the + // backend is unavailable — compactionMode is re-read here, so the + // fallback is honored), not shadow report-only runs. + // + // Ordering invariant: this assignment runs before tea.NewProgram + // below, and the TUI can only observe a 413 after a run starts — + // which requires a submitted message through the live program. So + // no event can trigger the recovery before the flag is set: the + // startup race is closed by construction, not by synchronization. + model.CompactionApplies = compactionMode == appconfig.CompactionModeEnabled + } + + // Retrieval hooks (Step 17): BaseOrchestrator runs the hook at every + // turn start — right before that turn's stream request — so each agent + // (root and every subagent, which each own a session) stages retrieved + // context into its own request's work area. The hook owns its errors: + // a failed retrieval warns once and the turn proceeds without it; the + // next turns retry, so one flaky scoring round never disables the + // feature for the session. + var retrievalWarnOnce sync.Once + var retrievalHookFor func(s *session.Session) func(context.Context) + // diagSink reads the mid-session diagnostics sink at hook-run time: diag + // (below, after the TUI program exists) assigns it, so closures created + // here — before the program starts — route their warnings through the + // live TUI instead of raw stderr. The write happens before p.Run() and + // before any agent run can start (runs begin only when the TUI submits + // a message), so the assignment happens-before every read. nil (CLI + // flows, bootstrap) keeps the os.Stderr fallback. + var diagSink func(msg string) + if compactionRetrieval && compactionPipeline != nil { + retrievalHookFor = func(s *session.Session) func(context.Context) { + return func(ctx context.Context) { + if _, err := s.InjectRetrieved(ctx, compactionPipeline, compactionStore, + compaction.DefaultRetrieveK, compaction.DefaultRetrieveBudgetTokens, compaction.DefaultRetrieveThreshold); err != nil { + retrievalWarnOnce.Do(func() { + if diagSink != nil { + diagSink(fmt.Sprintf("Warning: compaction retrieval skipped (%v); later turns retry\n", err)) + return + } + fmt.Fprintf(os.Stderr, "Warning: compaction retrieval skipped (%v); later turns retry\n", err) + }) + } + } + } + rootAgent.SetRetrievalHook(retrievalHookFor(sess)) + } + // Register plugin slash commands + theme catalog so plugin commands fire // when the user presses Enter. if pluginManager != nil && pluginManager.Count() > 0 { @@ -679,6 +1022,31 @@ func main() { model.BootstrapStatus = "Starting..." p := tea.NewProgram(model, pOpts...) + // diag is the mid-session diagnostics sink: compaction's mid-session + // warnings — the pipeline's one-time auth-poison note and the retrieval- + // skip notice — are delivered to the live TUI as DiagnosticMsg warning + // toasts instead of raw fmt.Fprintf(os.Stderr, ...) writes, which paint + // text over the alt-screen (duplicated footer rows, displaced agent-name + // line). The trailing newline the stderr formatting carries is trimmed + // here so the toast text is clean. Sources without a sink installed + // (CLI flows, pre-TUI bootstrap) still fall back to os.Stderr. Every + // diagnostic is ALSO appended to the durable critical-error log + // (~/.local/share/late/late-errors.log): a toast disappears with the + // terminal, the file does not — best-effort, never fails the caller. + diag := func(msg string) { + common.LogError("diagnostic", strings.TrimRight(msg, "\n")) + p.Send(tui.DiagnosticMsg{Text: strings.TrimRight(msg, "\n")}) + } + // Publish the sink to closures created before the program existed (see + // diagSink above), and give the compaction pipeline's one-time + // auth-poison warning the same route: it can fire mid-session (first + // scoring call after a key is revoked) and must not paint raw stderr + // over the alt-screen either. + diagSink = diag + if compactionPipeline != nil { + compactionPipeline.SetWarningSink(diag) + } + // toolSync serializes plugin/MCP tool-registry refreshes triggered by // MCP servers' own tools/list_changed notifications (wired via // mcpClient.OnToolsChanged below). It recomputes the full current tool/ @@ -723,6 +1091,40 @@ func main() { } }() + // Startup compaction probe (Step 16): one cheap ScoreBatch with a single + // small item against the resolved backend, in its own goroutine so the + // first paint never waits for the backend. A failure never tears the + // pipeline down — scoring is fail-open by contract and shadow mode is + // harmless — it warns once on stderr, surfaces the reason in the status + // bar, and, ONLY for a typed auth rejection, disables the session's + // scoring through the same path a live 401 takes (the probe's client is + // a throwaway, so without this the live pipeline would learn on its + // first real scoring call against a backend that can only say 401). The + // probe is deliberately NOT logged as a shadow decision: it is not a + // scoring decision, and one probe line per launch would pollute the + // replay ledger. + if compactionPipeline != nil && compactionBackend != nil { + probeBackend := *compactionBackend + go func() { + ctx, cancel := context.WithTimeout(context.Background(), compactionProbeTimeout) + defer cancel() + if err := compaction.ProbeBackend(ctx, probeBackend, ""); err != nil { + fmt.Fprintf(os.Stderr, "Warning: compaction backend probe failed (%v); scoring fails open this session\n", err) + common.LogErrorf("compaction", "backend probe failed: %v", err) + p.Send(tui.BootstrapStatusMsg{ + Text: "compaction: backend probe failed — scoring fails open", + Warning: true, + }) + var ce *compaction.Error + if errors.As(err, &ce) && ce.Kind == compaction.KindAuth { + compactionPipeline.DisableAuth(ce.Error()) + } + return + } + p.Send(tui.BootstrapStatusMsg{Text: "compaction: backend probe OK", Active: false}) + }() + } + if *enableSubagentsReq { runner := func(ctx context.Context, goal string, ctxFiles []string, agentType string) (string, error) { var currentSubagentClient *client.Client @@ -753,6 +1155,15 @@ func main() { } child.SetMiddlewares(buildMiddlewares(pluginManager, p, child.Registry())) + // Retrieval read side (Step 17): children get the same per-turn + // hook as the root agent — the work-area injection is per-agent + // session, while the record store and pipeline are shared. + if retrievalHookFor != nil { + if bo, ok := child.(*orchestrator.BaseOrchestrator); ok { + bo.SetRetrievalHook(retrievalHookFor(bo.Session())) + } + } + res, err := child.Execute("") if err != nil { return "", err @@ -776,6 +1187,231 @@ func main() { } } +// compactionCheckTimeout bounds the whole -check-compaction preflight (three +// stages of real requests against the backend, one attempt each; the offline +// backend's stages are local and finish in microseconds) and +// compactionProbeTimeout bounds the light startup probe. Generous enough for +// a slow local gateway, short enough that a dead endpoint cannot hang the +// flag or the startup path. +const ( + compactionCheckTimeout = 90 * time.Second + compactionProbeTimeout = 30 * time.Second +) + +// runCompactionCheck runs the compaction preflight against the backend the +// normal startup path resolves — the same compaction.ResolveBackendEnv("") +// call the pipeline wiring makes — and returns the process exit code: 0 when +// every stage passes, 1 otherwise. A missing backend or key is stage 0's +// failure: the report then says what to configure instead of starting a run +// that cannot score anything. With offline (config.json compaction-backend +// "offline") the same three stages run against the deterministic scripted +// scorer instead: no backend is resolved, no key is consulted, no request is +// sent, and the stages pass by construction — the demo path's self-test. +func runCompactionCheck(offline bool) int { + if offline { + ctx, cancel := context.WithTimeout(context.Background(), compactionCheckTimeout) + defer cancel() + results, ok := compaction.RunPreflightOffline(ctx) + fmt.Print(compaction.FormatCheckReport(results, ok)) + if !ok { + return 1 + } + return 0 + } + backend, backendErr := compaction.ResolveBackendEnv("") + if backendErr != nil { + fmt.Print(compaction.FormatCheckReport(noBackendCheckResults(backendErr), false)) + return 1 + } + ctx, cancel := context.WithTimeout(context.Background(), compactionCheckTimeout) + defer cancel() + results, ok := compaction.RunPreflight(ctx, backend, "", nil) + fmt.Print(compaction.FormatCheckReport(results, ok)) + if !ok { + return 1 + } + return 0 +} + +// noBackendCheckResults builds the stage-0 failure report for a run with no +// resolved compaction backend: the three real stages cannot run without one, +// and the detail carries the guidance plus the resolver's typed reason (which +// backend was tried and what each was missing). +func noBackendCheckResults(backendErr error) []compaction.CheckResult { + return []compaction.CheckResult{{ + Stage: compaction.CheckStageBackend, + OK: false, + Detail: fmt.Sprintf("no compaction backend configured (set the provider key or run with -compaction-mode pointing at a gateway): %v", backendErr), + }} +} + +// openCompactionStore opens the persistent elided-record store at the +// default path (compaction.DefaultStorePath), degrading to the in-memory +// store — with a stderr warning — when the path cannot be resolved or the +// file cannot be opened. Compaction must keep working even when its +// persistence layer fails, exactly like the shadow log: the session loses +// only cross-restart pointer resolution, nothing else. +func openCompactionStore() *compaction.Store { + path, err := compaction.DefaultStorePath() + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: compaction record store path unavailable (%v); continuing in-memory — elided originals will not survive restarts\n", err) + common.LogErrorf("compaction-store", "record store path unavailable: %v", err) + return compaction.NewStore() + } + return openCompactionStoreAt(path) +} + +// openCompactionStoreAt is openCompactionStore for an explicit path; split +// out so tests can exercise the degrade-to-in-memory fallback without +// touching the real user store. +func openCompactionStoreAt(path string) *compaction.Store { + store, err := compaction.OpenStore(path) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: compaction record store unavailable (%v); continuing in-memory — elided originals will not survive restarts\n", err) + common.LogErrorf("compaction-store", "record store unavailable at %s: %v", path, err) + return compaction.NewStore() + } + return store +} + +// parseReplayThresholds parses a -replay-shadow value: a comma-separated +// list of keep thresholds in (0, 1], e.g. "0.10,0.35,0.50". Surrounding +// whitespace is tolerated. Empty entries, non-numeric values, and +// out-of-range thresholds are errors — the caller asked for an explicit +// replay, so silently clamping would misrepresent it. +func parseReplayThresholds(s string) ([]float64, error) { + var out []float64 + for _, part := range strings.Split(s, ",") { + part = strings.TrimSpace(part) + if part == "" { + return nil, fmt.Errorf("invalid -replay-shadow value %q: empty threshold", s) + } + th, err := strconv.ParseFloat(part, 64) + if err != nil { + return nil, fmt.Errorf("invalid -replay-shadow threshold %q: %v", part, err) + } + if th <= 0 || th > 1 { + return nil, fmt.Errorf("invalid -replay-shadow threshold %v: must be in (0, 1]", th) + } + out = append(out, th) + } + return out, nil +} + +// runReplayShadow prints the replay table (one row per threshold, re-decided +// from the log's recorded scores without any scorer round trip) plus the +// false-negative rate line for the default shadow log, then returns; the +// caller exits. Strictly read-only: a missing log is reported as "nothing +// scored yet" and nothing is created — the only constructor reached, +// NewShadowLogAt, runs after a stat confirmed the file exists (its parent +// mkdir is then a no-op) and nothing appends to it. +func runReplayShadow(thresholds []float64) error { + path, err := compaction.DefaultShadowPath() + if err != nil { + return err + } + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + fmt.Printf("No shadow log at %s — nothing scored yet (compaction modes shadow and enabled write it).\n", path) + return nil + } + return err + } + shadowLog, err := compaction.NewShadowLogAt(path) + if err != nil { + return err + } + rows, err := shadowLog.ReplayTable(thresholds) + if err != nil { + return err + } + ffr, err := shadowLog.FalseNegativeRate() + if err != nil { + return err + } + fmt.Printf("Shadow log: %s\n\n", path) + fmt.Print(formatReplayTable(rows, ffr)) + return nil +} + +// formatReplayTable renders the replay rows as an aligned table — threshold, +// kept, relocated, tokens saved, still missed — followed by the +// false-negative rate line. Split from runReplayShadow so tests can pin the +// exact rendering. +func formatReplayTable(rows []compaction.ReplayRow, ffr float64) string { + var b strings.Builder + tw := tabwriter.NewWriter(&b, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, "threshold\tkept\trelocated\ttokens saved\tstill missed") + for _, r := range rows { + fmt.Fprintf(tw, "%.2f\t%d\t%d\t%d\t%d\n", r.Threshold, r.Kept, r.Relocated, r.TokensSaved, r.StillMissed) + } + tw.Flush() + fmt.Fprintf(&b, "\nfalse-negative rate: %.1f%%\n", ffr*100) + return b.String() +} + +// historyCompactionRunner adapts the live session for the TUI's +// /jev-compact-context command and auto-trigger: each call runs one +// session.CompactContext pass — scoring history segments against the ongoing +// task with the pipeline's decision client and relocating low scorers into +// the shared elide store — and persists the mutated history the same way the +// orchestrator's own SaveHistory call sites do. Shadow runs (compaction-mode +// "shadow") compute the honest would-save report without touching history, +// so they skip persistence. threshold mirrors the pipeline's elision +// threshold so both compaction paths make the same keep/elide calls. +// shadowLog receives one "history-run" summary line per run — shadow or +// mutating, failed or clean — so runs are auditable next to the per-segment +// decisions they produced; it may be nil (logging is unavailable), and an +// append failure is a stderr warning, never a run failure. gate is the +// pipeline's GateConfig: with it the walk makes the same per-kind-floor and +// max-elide-fraction-tripwire calls the tool-output path makes (and threads +// the shadow log down for the walk's own tripwire entries); nil keeps the +// legacy flat-threshold walk (tests that pin that behavior pass nil). +func historyCompactionRunner(sess *session.Session, scorer session.HistoryScorer, store session.ElideStore, shadow bool, threshold float64, shadowLog *compaction.ShadowLog, gate *compaction.GateConfig) func(context.Context) (session.CompactionReport, error) { + return func(ctx context.Context) (session.CompactionReport, error) { + report, err := sess.CompactContext(ctx, scorer, store, session.CompactionOptions{ + Threshold: threshold, + ShadowOnly: shadow, + Gate: gate, + ShadowLog: shadowLog, + }) + if !shadow { + // The walk mutated (or partially mutated — a mid-walk scorer + // failure leaves consistent pointers and stored originals) + // history: persist it even when err != nil. + if saveErr := session.SaveHistory(sess.HistoryPath, sess.History); saveErr != nil { + err = errors.Join(err, fmt.Errorf("saving compacted history: %w", saveErr)) + } + } + if err != nil { + // Durable record of the failure (walk aborts, save failures): + // the toast/TUI notice is ephemeral, the error log is not. + // Best-effort — never fails the run. + common.LogErrorf("compaction", "history compaction run failed (shadow=%v): %v", shadow, err) + } + if shadowLog != nil { + run := compaction.RunSummary{ + Shadow: shadow, + Scanned: report.MessagesScanned, + Scored: report.MessagesScored, + Elided: report.SegmentsElided, + TokensBefore: report.TokensBefore, + TokensAfter: report.TokensAfter, + TokensSaved: report.TokensSaved, + } + if err != nil { + run.Err = err.Error() + } + // Best-effort: a logging failure must never fail the compaction + // itself, so it only surfaces as a warning. + if appendErr := shadowLog.AppendRun(report.TaskHash, run); appendErr != nil { + fmt.Fprintf(os.Stderr, "Warning: compaction run summary not logged (%v)\n", appendErr) + } + } + return report, err + } +} + // deriveEffectiveSessionID derives this run's session ID from the FINAL // history path so resumed sessions keep their original ID. It returns "" // for empty or unsafe results (a crafted meta file could claim an ID like diff --git a/cmd/late/replay_shadow_test.go b/cmd/late/replay_shadow_test.go new file mode 100644 index 00000000..70378c5f --- /dev/null +++ b/cmd/late/replay_shadow_test.go @@ -0,0 +1,114 @@ +package main + +import ( + "flag" + "strings" + "testing" + + "late/internal/compaction" +) + +// pad mirrors tabwriter's column padding (max cell width + 2 spaces) so the +// expected table lines below stay readable instead of hand-counted. +func pad(s string, w int) string { + return s + strings.Repeat(" ", w-len(s)) +} + +// TestFormatReplayTable pins the exact rendering of the -replay-shadow +// output: five aligned columns plus the false-negative rate line. +func TestFormatReplayTable(t *testing.T) { + rows := []compaction.ReplayRow{ + {Threshold: 0.35, Kept: 2, Relocated: 1, TokensSaved: 120, StillMissed: 1}, + {Threshold: 0.10, Kept: 4, Relocated: 0, TokensSaved: 0, StillMissed: 0}, + } + got := formatReplayTable(rows, 0.5) + wantLines := []string{ + // Column widths: max cell width per column + 2 padding; the last + // column is never padded. + pad("threshold", 11) + pad("kept", 6) + pad("relocated", 11) + pad("tokens saved", 14) + "still missed", + pad("0.35", 11) + pad("2", 6) + pad("1", 11) + pad("120", 14) + "1", + pad("0.10", 11) + pad("4", 6) + pad("0", 11) + pad("0", 14) + "0", + "", + "false-negative rate: 50.0%", + } + want := strings.Join(wantLines, "\n") + "\n" + if got != want { + t.Errorf("formatReplayTable() =\n%q\nwant\n%q", got, want) + } +} + +// TestFormatReplayTableEmpty: no rows still renders the header and the rate +// line — an empty log must not print a bare number with no context. +func TestFormatReplayTableEmpty(t *testing.T) { + got := formatReplayTable(nil, 0) + wantLines := []string{ + pad("threshold", 11) + pad("kept", 6) + pad("relocated", 11) + pad("tokens saved", 14) + "still missed", + "", + "false-negative rate: 0.0%", + } + want := strings.Join(wantLines, "\n") + "\n" + if got != want { + t.Errorf("formatReplayTable(nil) =\n%q\nwant\n%q", got, want) + } +} + +// TestParseReplayThresholds: comma-separated thresholds in (0, 1], tolerant +// of surrounding whitespace, strict about everything else. +func TestParseReplayThresholds(t *testing.T) { + cases := []struct { + name string + in string + want []float64 + wantErr bool + }{ + {name: "reference example", in: "0.10,0.35,0.50", want: []float64{0.1, 0.35, 0.5}}, + {name: "single", in: "0.5", want: []float64{0.5}}, + {name: "whitespace tolerated", in: " 0.1 , 0.5 ", want: []float64{0.1, 0.5}}, + {name: "upper boundary", in: "1", want: []float64{1}}, + {name: "empty string", in: "", wantErr: true}, + {name: "empty entry", in: "0.1,,0.2", wantErr: true}, + {name: "non-numeric", in: "0.1,two", wantErr: true}, + {name: "zero", in: "0", wantErr: true}, + {name: "negative", in: "-0.2", wantErr: true}, + {name: "above one", in: "1.5", wantErr: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := parseReplayThresholds(tc.in) + if tc.wantErr { + if err == nil { + t.Fatalf("parseReplayThresholds(%q) error = nil, want an error", tc.in) + } + return + } + if err != nil { + t.Fatalf("parseReplayThresholds(%q) error = %v", tc.in, err) + } + if len(got) != len(tc.want) { + t.Fatalf("parseReplayThresholds(%q) = %v, want %v", tc.in, got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("parseReplayThresholds(%q)[%d] = %v, want %v", tc.in, i, got[i], tc.want[i]) + } + } + }) + } +} + +// TestReplayShadowFlagParses: cheap smoke test that the flag accepts the +// documented =value form and its value flows into the threshold parser. +func TestReplayShadowFlagParses(t *testing.T) { + fs := flag.NewFlagSet("smoke", flag.ContinueOnError) + replay := fs.String("replay-shadow", "", "smoke") + if err := fs.Parse([]string{"-replay-shadow=0.10,0.35"}); err != nil { + t.Fatalf("Parse() error = %v", err) + } + thresholds, err := parseReplayThresholds(*replay) + if err != nil { + t.Fatalf("parseReplayThresholds() error = %v", err) + } + if len(thresholds) != 2 || thresholds[0] != 0.1 || thresholds[1] != 0.35 { + t.Errorf("thresholds = %v, want [0.1 0.35]", thresholds) + } +} diff --git a/docs/config-reference.md b/docs/config-reference.md new file mode 100644 index 00000000..9a57df79 --- /dev/null +++ b/docs/config-reference.md @@ -0,0 +1,224 @@ +# config.json Reference + +The complete reference for Late's `config.json`: every accepted key, its type, +its default, the CLI flag it mirrors where one exists, and what it does. The +tables are kept in lockstep with the `Config` struct in +`internal/config/config.go` by a reflection test +(`internal/config/docs_test.go`). + +For a guided setup read the [Quickstart](quickstart.md) first; this page is the +exhaustive reference. + +## File location + +| Platform | Path | +| --- | --- | +| macOS | `~/Library/Application Support/late/config.json` | +| Linux | `~/.config/late/config.json` (honors `XDG_CONFIG_HOME`) | +| Windows | `%APPDATA%\late\config.json` | + +A missing file is not an error: on first run Late writes a default config that +enables every built-in tool. The file and its directory are permission-hardened +to `0600` / `0700` on every load. Two naming conventions coexist: the compaction +entries use kebab-case, while the older provider/tool entries use snake_case — +both spellings are exactly as listed below. + +## How values are parsed and validated + +Late decodes `config.json` with `encoding/json`. What that means in practice: + +* **Wrong-typed values are fatal** — a string where a number is required aborts + startup with the decoder's error, and no TUI is launched. +* **Unknown top-level keys are currently ignored** — a typo at the top level + silently does nothing. The one exception is the next rule, which covers the + section where hand-edited typos hurt most. +* **Unknown keys inside `models[]` entries are fatal and located** — every + entry's keys are validated against the known set (`id`, `url`, `key`, + `model`, `jev-autocompact-percent`), reporting an unknown key with its + 1-based line and column, the entry's name (its `id`, else its `model`), and + a did-you-mean suggestion when a known key is within edit distance 3: + + ``` + error in /Users/u/Library/Application Support/late/config.json at line 6, column 7: models[local] entry "urll" is not a valid entry key. Did you mean "url"? + ``` + + ``` + error in /Users/u/Library/Application Support/late/config.json at line 12, column 7: models[frontier] entry "jev-autocompact_percent" is not a valid entry key. Did you mean "jev-autocompact-percent"? + ``` + +* **Value-range problems only warn** — a value that parses but is out of range + (e.g. `compaction-threshold-percent: 400`, a `jev-autocompact-percent` of + `0.5`, an invalid `compaction-mode`) prints one `Warning:` line at startup + and falls back to that setting's default; it never aborts startup. + +Boolean entries are plain JSON `true`/`false` — no other spellings are +accepted. + +## Examples + +### Minimal starter + +```json +{ + "models": [ + { + "id": "local", + "url": "http://localhost:8080", + "key": "", + "model": "qwen3.6-35b-a3b" + } + ] +} +``` + +### Fuller example + +The compaction block (score cutoff, context percentages, gate knobs, offline +backend, auto-compaction, retrieval) plus the provider block. Note that +`"compaction-backend": "offline"` selects the deterministic scripted scorer for +demos and tests — no API key, no network — and must never become a production +default. The `frontier` model entry also carries a per-model +`jev-autocompact-percent` override: the agents routed to it auto-compact at +70% of their window while every other agent uses the global 99%. + +```json +{ + "enabled_tools": { + "read_file": true, + "write_file": true, + "target_edit": true, + "bash": true + }, + "permission-mode": "ask-for-user-approval", + "models": [ + { + "id": "frontier", + "url": "https://api.deepseek.com", + "key": "sk-your-key", + "model": "deepseek-flash", + "jev-autocompact-percent": 70 + }, + { + "id": "local", + "url": "http://localhost:8080", + "key": "", + "model": "qwen3.6-35b-a3b" + } + ], + "agent_models": { + "orchestrator": "frontier", + "coder": "local" + }, + + "compaction-mode": "enabled", + "compaction-threshold": 0.65, + "compaction-threshold-percent": 80, + "compaction-max-elide-percent": 70, + "compaction-protected-floor": 5, + "compaction-backend": "offline", + "jev-autocompact": true, + "jev-autocompact-percent": 99, + "compaction-retrieval": true +} +``` + +## Key reference + +Types: `string`, `number`, `boolean`, `array`, `object`. "CLI flag" is the +equivalent flag where one exists; Go's flag package accepts one or two dashes +(`-flag` / `--flag`). + +### Models and providers + +| Key | Type | Default | CLI flag | Description | +| --- | --- | --- | --- | --- | +| `models` | array | `[]` | — | Model registry for `/model` and `agent_models`; each entry is `{id, url, key, model, jev-autocompact-percent}` (see below). | +| `agent_models` | object | `{}` | — | Maps agent roles (`orchestrator`, `researcher`, `coder`, …) to a `models` entry `id` (or, legacy, its model name); persisted by `/model`. | +| `openai_base_url` | string | `http://localhost:8080` | — | Base URL of the main OpenAI-compatible API; `OPENAI_BASE_URL` env overrides when set. | +| `openai_api_key` | string | `""` | — | API key for the main provider; `OPENAI_API_KEY` env overrides when set. | +| `openai_model` | string | `""` | — | Main model id used when no `models`/`agent_models` routing applies; `OPENAI_MODEL` env overrides when set. | +| `late_subagent_base_url` | string | `""` (inherits main) | — | Dedicated subagent base URL; wins over the legacy `subagent_base_url`; `LATE_SUBAGENT_BASE_URL` env overrides when set. | +| `late_subagent_api_key` | string | `""` (inherits main) | — | Dedicated subagent API key; wins over the legacy `subagent_api_key`; `LATE_SUBAGENT_API_KEY` env overrides when set. | +| `late_subagent_model` | string | `""` (inherits main) | — | Dedicated subagent model; wins over the legacy `subagent_model`; `LATE_SUBAGENT_MODEL` env overrides when set. | + +### Supervision and preferences + +| Key | Type | Default | CLI flag | Description | +| --- | --- | --- | --- | --- | +| `permission-mode` | string | `"ask-for-user-approval"` | `--ask-for-user-approval` / `--unsupervised` | How dangerous commands are supervised: `"ask-for-user-approval"` or `"unsupervised"`; a passed flag overrides config and the flags are mutually exclusive; an invalid value warns and falls back to the safe default. | +| `save_subagent_histories` | boolean | `false` | `--save-subagent-histories` | Persist subagent conversation histories under `//subagents/` (precedence: explicitly passed flag > per-session saved preference > this entry). | +| `theme` | string | `""` | `--theme` | Plugin theme id (`plugin:name` or bare name); precedence `--theme` > `LATE_THEME` env > this entry > bundled base; persisted by `/themes`. | +| `skills_dir` | string | `""` | — | Legacy entry, currently unused: skill discovery reads the platform skills directory (`~/.config/late/skills/` etc.) and project-local `.late/skills/`, not this value. | + +### Tools and output + +| Key | Type | Default | CLI flag | Description | +| --- | --- | --- | --- | --- | +| `enabled_tools` | object | all built-in tools `true` | — | Per-tool switches (see below); missing entries are filled from the defaults. | + +### Context compaction + +`compaction-threshold`, `compaction-threshold-percent`, and +`jev-autocompact-percent` are three different knobs: a per-segment SCORE +cutoff, the context-usage level the info bar reports headroom for, and the +context-usage level that fires the auto-trigger. + +| Key | Type | Default | CLI flag | Description | +| --- | --- | --- | --- | --- | +| `compaction-mode` | string | `"shadow"` | `--compaction-mode` | Staged rollout stage: `off` (no scoring), `shadow` (score + shadow log only, no behavior change), or `enabled` (also relocate low-scoring segments and register the `expand` tool); invalid values warn and fall back to `shadow`. | +| `compaction-threshold` | number | `0.35` | `--compaction-threshold` | Elision score cutoff: segments scoring strictly below it are elided when `compaction-mode` is `enabled`; valid range (0,1]; out-of-range values warn and fall back. | +| `compaction-threshold-percent` | number | `80` | — | Context-usage percentage the TUI info bar reports remaining headroom against; 1-100 valid, `0` = default, anything else warns and falls back. | +| `compaction-max-elide-percent` | number | `70` | — | Elide-fraction tripwire: when the scorer wants to elide more than this share of an output's tokens it is distrusted and NOTHING is elided; 1-100 valid; cannot be fully disabled. | +| `compaction-protected-floor` | number | `5` | — | Score floor (as a percentage) under which protected segment kinds (stacktrace, diff) may be elided; at any higher score they are kept; 1-100 valid. | +| `compaction-backend` | string | `""` | — | Where scores come from; only `"offline"` today (deterministic scripted scorer, demos/tests only); a set value wins over `JEV_API`/auto-detection; an invalid value warns and falls back to env resolution. | +| `compaction-retrieval` | boolean | `false` | — | Read side of the record store: before every request the top-k relevant digest summaries are appended to the request's work area; inert (warns) unless `compaction-mode` is `enabled`. | +| `jev-autocompact` | boolean | `false` | — | Run the full-history compaction (`/jev-compact-context` flow) automatically when context usage crosses `jev-autocompact-percent`. | +| `jev-autocompact-percent` | number | `99` | — | Context-usage percentage that fires the auto-trigger; 1-100 valid, `0` = default, anything else warns and falls back. Each `models[]` entry can override it for the agents routed to that model (see the `models` entries schema below). | + +### Legacy entries + +| Key | Type | Default | CLI flag | Description | +| --- | --- | --- | --- | --- | +| `subagent_base_url` | string | `""` | — | Legacy subagent base URL; superseded by `late_subagent_base_url`, which wins when both are set. | +| `subagent_api_key` | string | `""` | — | Legacy subagent API key; superseded by `late_subagent_api_key`. | +| `subagent_model` | string | `""` | — | Legacy subagent model; superseded by `late_subagent_model`. | + +## Nested schemas + +### `models` entries + +Each element of the `models` array is an object: + +* `id` (string, optional) — stable identifier referenced by `agent_models` and + the `/model` picker; omit it to fall back to the model name. +* `url` (string, required) — OpenAI-compatible base URL. +* `key` (string, required, may be `""`) — API key; local servers need none. +* `model` (string, required) — the model name the provider serves. +* `jev-autocompact-percent` (number 1-100, optional, default = the global + `jev-autocompact-percent`) — per-model override of the auto-compaction + trigger: different models have different context sizes, so the percentage + at which compaction should fire is a property of the model, not just of the + installation. Resolution for any agent: its `agent_models`-routed model + entry's value (when valid) > the global `jev-autocompact-percent` > `99`. + Out-of-range values warn at startup and fall back to the global (the + models[] key walk covers key names, not value ranges). + +Unknown keys inside an entry are a fatal located error (see +[How values are parsed and validated](#how-values-are-parsed-and-validated)) — +the same rule the decoder cannot apply at the top level, enforced where the +typed decode would silently drop a hand-edited typo. + +### `agent_models` values + +Keys are agent roles (`orchestrator`, `researcher`, `coder`); values reference +a `models` entry by its `id` (preferred — providers exposing the same model +name stay distinguishable) or, for configs created before ids existed, by the +model name. + +### `enabled_tools` entries + +Keys are tool names, values booleans. Defaults (all `true`): `read_file`, +`write_file`, `target_edit`, `spawn_subagent`, `bash`, `search_content`, +`find_files`, `create_todos`, `list_todos`, `finish_todo`. Missing entries are +merged from the defaults on load. When `compaction-mode` is `enabled`, the +`expand` tool is additionally registered so elided originals stay retrievable. diff --git a/docs/quickstart.md b/docs/quickstart.md index 9493131a..f138577d 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -161,6 +161,101 @@ When Late creates subagents, each appears in its own tab while it works and disa --- +## Context Compaction + +Late can keep oversized tool outputs out of the orchestrator's context. The port follows [jev-compaction](https://github.com/Waxmell114514/jev-compaction) (MIT) and its staged shadow → enabled rollout. Pick a stage with `--compaction-mode` (or `compaction-mode` in `config.json`): + +* `off` — no scoring, no logging. +* `shadow` (default) — tool outputs are segmented and Jev-scored, and what *would* be elided is recorded to the shadow log (`~/.local/share/late/compaction-shadow.jsonl`). No behavior change. +* `enabled` — tool outputs over 4000 characters are segmented and Jev-scored; low-scoring segments are elided into `[[elided …]]` pointers. The `expand` tool retrieves the original text on demand. + +Scoring is fail-open: any backend error keeps the original tool output. Segments scoring strictly below the elision threshold are elided. That threshold resolves as: explicit `-compaction-threshold` flag > `compaction-threshold` in `config.json` (valid range (0,1]) > the default `0.35`. Don't confuse the three similarly named knobs: `compaction-threshold` is the per-segment SCORE cutoff, `compaction-threshold-percent` sets the context level the info bar reports headroom for, and `jev-autocompact-percent` sets the context level that fires the auto-trigger. + +The scorer is keep-biased by design, so real scores on dense, code-heavy sessions cluster well above the default — 0.4–0.9 is normal for good content. If a compaction run "saved only a handful of tokens", that is usually the threshold, not a broken scorer: see the 413 playbook below for how to pick a better one from your own shadow log. + +### If you hit 413 / context too large + +A provider that caps the request body (many proxies reject bodies over ~2 MB) answers every turn with `API error (413)` — Late renders it as *"request body exceeds this provider's limit (413): compact the context with /jev-compact-context (consider raising compaction-threshold in config.json) or start a new session with /new"*. With `compaction-mode: enabled`, the TUI also automatically runs ONE recovery compaction pass (never a loop — at most once per conversation, until `/new`). Work through this playbook: + +1. **Measure first**: `late -replay-shadow=0.35,0.5,0.65,0.8` re-decides your existing shadow log at each threshold (read-only, no scorer round trips) and prints the kept/relocated/tokens-saved/still-missed table per threshold, so you can see which cutoff would actually free the space you need. +2. **Set the threshold**: put the winning value in `config.json` as `"compaction-threshold": 0.65` (or pass `-compaction-threshold=0.65` for a single run) and restart `late`. +3. **If nothing relocates even at a high threshold**, the session is genuinely dense: start a new session with `/new` and re-seed only the context that matters. + +### Pointers and the record store + +An elided run of segments is replaced by one pointer line standing exactly where the run stood: + +``` +[[elided id=r:1a2b3c4d lines=12-18 tokens=214 "first line of the elided run, cut at 120 chars…"]] +``` + +* `id` is content-addressed: the first 8 hex chars of a SHA-256 of the run text, so the same run always maps to the same id and the same record (re-compacting identical text never duplicates records). +* `lines` is the run's 1-based line range in the original output, `tokens` its token count, and the quoted summary is a 120-char preview so the agent can decide whether the `expand` tool is worth calling. + +Under `compaction-mode: enabled` the originals are persisted to an append-only JSONL store at `~/.local/share/late/compaction-store.jsonl`, so pointers saved into a session history still resolve — and `expand` still works — after `late` exits. If the store cannot be opened, compaction degrades to in-memory (with a warning): the session keeps working, only cross-restart pointer resolution is lost. + +### Gate safety knobs + +The elide decision runs through the reference pipeline's gate (defaults mirror `pipeline.py`): + +* `compaction-max-elide-percent` (default `70`, range 1–100) — the tripwire: when the scorer wants to elide more than this share of a tool output's tokens, it is distrusted and NOTHING is elided for that output (recorded in the result and the shadow log). +* `compaction-protected-floor` (default `5`, range 1–100) — stacktrace and diff segments are only elided below this score, whatever the normal threshold: a dropped hunk or trace silently corrupts everything built on top of it. +* The token min-gate (fixed 400 tokens) skips scoring entirely for outputs below the floor — the round trip costs more than any possible elision saves. + +### Preflight and replay (`-check-compaction`, `-replay-shadow`) + +```bash +late -check-compaction # three-stage preflight against the resolved backend +late -replay-shadow=0.10,0.35,0.50 # replay the shadow log at other thresholds +``` + +* `-check-compaction` runs the three stages the reference `check.py` runs — (1) decisions answers parse, (2) the gate actually relocates something from a real ~2KB tool output, (3) a pointer expands back byte for byte — prints a per-stage report with latencies, and exits 0/1 without starting the TUI. Run it after configuring a backend and before trusting the feature; the first failing stage names what broke (bad key, malformed request, unreachable server). +* `-replay-shadow=` is read-only offline replay: it re-decides every logged score at each comma-separated threshold (no scorer round trips) and prints the kept/relocated/tokens-saved/still-missed table plus the false-negative rate, so you can pick the elision threshold from your own traffic instead of the default (see the 413 playbook above). + +### Offline demo (`compaction-backend: "offline"`) + +```json +{ + "compaction-mode": "enabled", + "compaction-backend": "offline" +} +``` + +`compaction-backend: "offline"` swaps the System One scoring backend for a deterministic local scripted scorer: scores are a SHA-256 of the task and segment text mapped into [0,1), so the whole flow — shadow scoring, the gate, pointers, `expand`, `/jev-compact-context`, `-check-compaction` — runs with NO API key, NO network, and identical results every run. It exists for demos and tests only: the scores measure nothing about essentialness, so never ship it as a default (the value wins over `JEV_API`/auto-detection when set, and `-check-compaction` passes by construction on this backend). + +### Full-History Compaction (`/jev-compact-context`) + +`/jev-compact-context` in the TUI compacts the whole conversation, not just tool outputs: every message after a frozen prefix (the first quarter of the history, so the system prompt and earliest exchanges stay byte-identical for prompt caching) is segmented and Jev-scored against the ongoing task. Low-scoring segments are replaced in place with `[[elided …]]` pointer lines and their originals move to the store, where the `expand` tool retrieves them on demand; user messages are never compacted. The command requires `compaction-mode` ≠ `off`; under `shadow` it runs report-only, showing what a real run would save without touching history. + +The frozen prefix is append-only across runs AND restarts: every completed run advances a persisted high-water mark (session meta `CompactionHighWater`), and messages below the mark are never re-scored or rewritten — resumed sessions never spend tokens re-compacting what an earlier run already froze. A run that would mutate a message below the mark fails loudly instead of corrupting the cache anchor. Pointer-bearing messages are final (never re-scored, never nested). + +### Auto-Compaction (`jev-autocompact`) + +```json +{ + "jev-autocompact": true, + "jev-autocompact-percent": 99 +} +``` + +* `jev-autocompact` (bool, default `false`) — when enabled and the context usage crosses the percent, the same compaction runs automatically. +* `jev-autocompact-percent` (default `99`, valid range 1–100) — the context-usage percentage that fires it. The trigger runs once per crossing and re-arms after compaction shrinks usage back down (or when `/new` starts a fresh conversation). + +### Retrieved Context (`compaction-retrieval`) + +```json +{ + "compaction-mode": "enabled", + "compaction-retrieval": true +} +``` + +`compaction-retrieval` (bool, default `false`) turns on the read side of the compaction store: before every stream request, the store's per-record summaries are scored against the current task and the top matches (up to 5 records scoring ≥ 0.5, capped at a 24k-token digest budget) are appended to the END of the outgoing request as a "Retrieved context" block. That is the work area by construction — it never touches the frozen prefix, never lands in history, and never shows in the transcript, so it costs tokens only for the request that carries it and is re-scored fresh every turn. Each scored record is logged to the shadow log as a `kind=retrieve` decision (`injected`/`skipped`), so `-replay-shadow` tooling can audit what retrieval would have injected at other thresholds. The switch only does something under `compaction-mode: enabled` — that is the only mode whose record store ever fills (the resolver warns about the inert combinations) — and a scoring failure stages nothing for that turn rather than injecting unranked records. + +> Credits: the scoring protocol, the gate, the pointer format, the shadow log, and the staged shadow → enabled rollout are a Go port of [jev-compaction](https://github.com/Waxmell114514/jev-compaction) (MIT). The offline scripted scorer mirrors that repo's testing.py demo, which drives the same flow against a scripted stand-in scorer with no backend. + +--- + ## Tool Approval Potentially destructive commands and file changes require approval unless you have already granted permission for that scope. diff --git a/internal/client/client.go b/internal/client/client.go index ce6c2f3f..463fa1e1 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -651,6 +652,44 @@ func (e *StatusError) Error() string { return fmt.Sprintf("status: %d", e.StatusCode) } +// ErrPayloadTooLarge is the stable sentinel carried by every HTTP 413 +// (Request Entity Too Large) API error: the provider rejected the request +// BODY outright, so resending the identical body can never succeed. Callers +// classify with errors.Is and must never retry — the executor's stream +// retry tiers all treat it as fail-fast — and the TUI surfaces the recovery +// guidance carried in the error text (PayloadTooLargeGuidance). +var ErrPayloadTooLarge = errors.New("payload too large") + +// PayloadTooLargeGuidance is the actionable message rendered for 413 +// failures. It travels inside the error text itself, so every surface that +// prints the error (TUI error box, logs) carries the recovery steps without +// any special-casing on the display side. +const PayloadTooLargeGuidance = "request body exceeds this provider's limit (413): compact the context with /jev-compact-context (consider raising compaction-threshold in config.json) or start a new session with /new" + +// PayloadTooLargeError marks an HTTP 413 response. It wraps the underlying +// StatusError so errors.As still recovers the status/body/code details, and +// it carries ErrPayloadTooLarge so errors.Is classifies it anywhere in the +// (executor's "stream error: %w") wrap chain. Its Error text is the +// actionable guidance, not the legacy "API error (413): ..." line: a 413 is +// always operator-actionable, and the provider's raw body (which only +// repeats the verdict or names the byte cap) is appended for diagnostics. +type PayloadTooLargeError struct { + Status *StatusError +} + +func (e *PayloadTooLargeError) Error() string { + if e.Status != nil && e.Status.Body != "" { + return PayloadTooLargeGuidance + " (provider: " + e.Status.Body + ")" + } + return PayloadTooLargeGuidance +} + +// Unwrap exposes both the sentinel (for errors.Is classification) and the +// underlying StatusError (for errors.As recovery of StatusCode/Body/Type). +func (e *PayloadTooLargeError) Unwrap() []error { + return []error{ErrPayloadTooLarge, e.Status} +} + // StreamInterruptedError reports a transport failure while reading a // 200-OK response body mid-stream: connection reset, HTTP/2 RST_STREAM // or GOAWAY, truncated body. The server already accepted the request, @@ -715,6 +754,13 @@ func (c *Client) formatError(resp *http.Response) error { if ra := parseRetryAfter(resp.Header.Get("Retry-After")); ra > 0 { se.RetryAfter = ra } + // 413 is classified, not just reported: the request body itself exceeded + // the provider's limit, so the error carries the ErrPayloadTooLarge + // sentinel and the actionable guidance instead of the generic status + // line. RetryAfter stays parsed (harmless: no retry tier consumes a 413). + if resp.StatusCode == http.StatusRequestEntityTooLarge { + return &PayloadTooLargeError{Status: se} + } return se } diff --git a/internal/client/payload_too_large_test.go b/internal/client/payload_too_large_test.go new file mode 100644 index 00000000..fff7b27f --- /dev/null +++ b/internal/client/payload_too_large_test.go @@ -0,0 +1,90 @@ +package client + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestFormatError_413IsClassifiedPayloadTooLarge pins the 413 contract: the +// error carries the ErrPayloadTooLarge sentinel (errors.Is, through any wrap +// chain), still recovers the *StatusError details (errors.As), and renders +// the actionable PayloadTooLargeGuidance text instead of the generic +// "API error (413): ..." line. +func TestFormatError_413IsClassifiedPayloadTooLarge(t *testing.T) { + t.Run("json body keeps sentinel, status details, and guidance", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusRequestEntityTooLarge) + fmt.Fprint(w, `{"error":{"message":"Request body too large","type":"invalid_request_error"}}`) + })) + defer server.Close() + + c := NewClient(Config{BaseURL: server.URL}) + _, err := c.ChatCompletion(context.Background(), ChatCompletionRequest{ + Model: "test-model", + Messages: []ChatMessage{{Role: "user", Content: TextContent("hi")}}, + }) + if err == nil { + t.Fatal("expected error for 413 response, got nil") + } + if !errors.Is(err, ErrPayloadTooLarge) { + t.Fatalf("error %v (%T) does not carry the ErrPayloadTooLarge sentinel", err, err) + } + var se *StatusError + if !errors.As(err, &se) { + t.Fatalf("error %T is not (nor wraps) a *StatusError", err) + } + if se.StatusCode != http.StatusRequestEntityTooLarge { + t.Errorf("StatusCode = %d, want 413", se.StatusCode) + } + if want := "Request body too large"; se.Body != want { + t.Errorf("Body = %q, want %q", se.Body, want) + } + if se.Type != "invalid_request_error" { + t.Errorf("Type = %q, want invalid_request_error", se.Type) + } + // The rendered text is the actionable guidance, and the provider's + // body survives as a parenthetical diagnostic. + if !strings.Contains(err.Error(), PayloadTooLargeGuidance) { + t.Errorf("Error() = %q, want it to contain the guidance", err.Error()) + } + if !strings.Contains(err.Error(), "(provider: Request body too large)") { + t.Errorf("Error() = %q, want it to keep the provider body for diagnostics", err.Error()) + } + if strings.Contains(err.Error(), "API error (413)") { + t.Errorf("Error() = %q, want the actionable guidance instead of the legacy status line", err.Error()) + } + }) + + t.Run("empty body renders exactly the guidance", func(t *testing.T) { + se := formatErrorStatusError(t, errorResp(http.StatusRequestEntityTooLarge, nil, "")) + err := error(&PayloadTooLargeError{Status: se}) + if got := err.Error(); got != PayloadTooLargeGuidance { + t.Errorf("Error() = %q, want exactly the guidance %q", got, PayloadTooLargeGuidance) + } + }) + + t.Run("sentinel and guidance survive the executor wrap chain", func(t *testing.T) { + // Mirrors executor.go's "stream error: %w" wrapping: both the + // errors.Is classification and the guidance text must survive. + wrapped := fmt.Errorf("stream error: %w", &PayloadTooLargeError{Status: &StatusError{ + StatusCode: http.StatusRequestEntityTooLarge, + Status: "413 Payload Too Large", + Body: "Request body too large", + }}) + if !errors.Is(wrapped, ErrPayloadTooLarge) { + t.Fatal("errors.Is(wrapped, ErrPayloadTooLarge) failed through the wrap chain") + } + if !strings.Contains(wrapped.Error(), PayloadTooLargeGuidance) { + t.Errorf("wrapped.Error() = %q, want it to contain the guidance", wrapped.Error()) + } + var se *StatusError + if !errors.As(wrapped, &se) || se.StatusCode != http.StatusRequestEntityTooLarge { + t.Fatalf("errors.As through the wrap chain recovered %v, want the 413 *StatusError", wrapped) + } + }) +} diff --git a/internal/common/errorlog.go b/internal/common/errorlog.go new file mode 100644 index 00000000..687cfa40 --- /dev/null +++ b/internal/common/errorlog.go @@ -0,0 +1,232 @@ +package common + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" + "unicode/utf8" + + "late/internal/pathutil" +) + +// The durable critical-error log: an append-only JSONL record of the +// operational failures a late session should not lose — compaction walk +// aborts and save failures, store/shadow open failures, auth poisoning, and +// the mid-session diagnostics the TUI surfaces as toasts. A toast disappears +// with the terminal; this file does not, so post-mortems can answer "what +// broke" without reproducing the session. +// +// The mechanics mirror compaction's ShadowLog exactly: goroutine-safe +// (mutex), crash-atomic per line (ONE Write call of the whole line on an +// O_APPEND descriptor, so concurrent late processes interleave whole lines), +// the file is created 0600 and its parent directories 0700, and the path +// follows the same platform pattern as every other late data file +// (~/.local/share/late/late-errors.log; Windows keeps it under the config +// dir). +// +// Logging is best-effort BY CONTRACT: Log never returns an error and never +// fails the operation it was called from — a broken log must not be able to +// break compaction, persistence, or the diagnostic path. Callers go through +// the process-wide LogError/LogErrorf helpers (lazily opened default log, +// installable with SetErrorLog so tests can point it at a temp path); a nil +// or unopenable log makes them silent no-ops. + +// DefaultErrorLogPath returns the critical-error log location: +// ~/.local/share/late/late-errors.log, resolved through pathutil.LateDataDir +// — the same platform handling as the session dir, the shadow log, and the +// record store (Windows keeps everything under the config dir). +func DefaultErrorLogPath() (string, error) { + dir, err := pathutil.LateDataDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "late-errors.log"), nil +} + +// errorLogLine is the shape of one log line: +// {"ts":"RFC3339","component":"...","message":"..."}. +type errorLogLine struct { + TS string `json:"ts"` + Component string `json:"component"` + Message string `json:"message"` +} + +// ErrorLog is the append-only critical-error log. Safe for concurrent use. +// +// The log is deliberately append-only and NEVER rotated: it is a +// post-mortem record ("what broke in that session"), not a telemetry +// stream, and compaction/store failures are rare by design. Growth is +// bounded on the write side instead — one message is capped at +// maxErrorLogMessage — and the file stays small in practice; if it ever +// outgrows its usefulness, delete it: late recreates it on the next append. +type ErrorLog struct { + path string + mu sync.Mutex +} + +// OpenErrorLog opens (creating parent directories 0700) the default +// critical-error log at DefaultErrorLogPath. The file itself is created 0600 +// on first append. +func OpenErrorLog() (*ErrorLog, error) { + p, err := DefaultErrorLogPath() + if err != nil { + return nil, err + } + return OpenErrorLogAt(p) +} + +// OpenErrorLogAt opens the critical-error log at path, creating parent +// directories with 0700 (the log file itself is created 0600 on first +// append). An empty path is an error — a silent no-op log must be a decision +// (SetErrorLog(nil)), not a forgotten argument. +func OpenErrorLogAt(path string) (*ErrorLog, error) { + if path == "" { + return nil, fmt.Errorf("error log path is empty") + } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("create error log dir %s: %w", dir, err) + } + return &ErrorLog{path: path}, nil +} + +// Path returns the log file path. +func (l *ErrorLog) Path() string { + if l == nil { + return "" + } + return l.path +} + +// Log appends one line naming the component ("compaction", "store", +// "diagnostic", ...) and the message. The line is serialized first so a +// marshal failure cannot leave a torn line behind; every failure — marshal, +// open, write — is swallowed: logging is best-effort and must never fail the +// operation it was called from. A nil log is a no-op. Messages longer than +// maxErrorLogMessage are truncated (truncateErrorMessage) — the log is +// never rotated, so one huge error must not dictate the file's growth. +func (l *ErrorLog) Log(component, message string) { + if l == nil { + return + } + line, err := json.Marshal(errorLogLine{ + TS: time.Now().UTC().Format(time.RFC3339), + Component: component, + Message: truncateErrorMessage(message), + }) + if err != nil { + return // plain-string struct; defensive only + } + line = append(line, '\n') + + l.mu.Lock() + defer l.mu.Unlock() + f, err := os.OpenFile(l.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return + } + defer f.Close() + _, _ = f.Write(line) +} + +// Logf is Log with formatting. +func (l *ErrorLog) Logf(component, format string, args ...any) { + if l == nil { + return + } + l.Log(component, fmt.Sprintf(format, args...)) +} + +// maxErrorLogMessage caps one entry's message. The log is append-only and +// never rotated (see ErrorLog), so one enormous error — a provider failure +// embedding a full response body, say — must not dictate the file's +// growth. Longer messages are cut at the cap with an explicit truncation +// marker; the line stays valid single-line JSON. +const maxErrorLogMessage = 16 << 10 // 16 KiB + +// truncateErrorMessage caps message at maxErrorLogMessage bytes, backing off +// to a clean rune boundary so the capped text stays valid UTF-8, and appends +// a marker naming how many bytes were dropped. +func truncateErrorMessage(message string) string { + if len(message) <= maxErrorLogMessage { + return message + } + cut := message[:maxErrorLogMessage] + for len(cut) > 0 { + if r, size := utf8.DecodeLastRuneInString(cut); r != utf8.RuneError || size > 1 { + break + } + cut = cut[:len(cut)-1] + } + return cut + fmt.Sprintf(" …[+%d bytes truncated]", len(message)-len(cut)) +} + +// The process-wide critical-error log. Tests install a temp-path log with +// SetErrorLog; production wiring opens the default once at startup. +// errorLogInstalled records that SetErrorLog ran, so the lazily opened +// default can never overwrite an explicit install (see publishProcessLog). +var ( + errorLogOnce sync.Once + errorLogMu sync.Mutex + processLog *ErrorLog + errorLogInstalled bool +) + +// SetErrorLog installs l as the process-wide critical-error log; nil +// disables it (every LogError call becomes a no-op). +func SetErrorLog(l *ErrorLog) { + errorLogMu.Lock() + defer errorLogMu.Unlock() + processLog = l + errorLogInstalled = true + // A later lazy open must not clobber an explicit install. + errorLogOnce.Do(func() {}) +} + +// LogError appends one line to the process-wide critical-error log, opening +// it lazily at the default path on first use (an unopenable log stays nil: +// logging is best-effort). Never fails the caller. +func LogError(component, message string) { + ensureProcessLog() + errorLogMu.Lock() + l := processLog + errorLogMu.Unlock() + l.Log(component, message) +} + +// LogErrorf is LogError with formatting. +func LogErrorf(component, format string, args ...any) { + ensureProcessLog() + errorLogMu.Lock() + l := processLog + errorLogMu.Unlock() + l.Logf(component, format, args...) +} + +// ensureProcessLog lazily opens the default log exactly once. A failure +// leaves processLog nil and is swallowed — best-effort by contract. +func ensureProcessLog() { + errorLogOnce.Do(func() { + l, err := OpenErrorLog() + if err != nil { + return // no log this process; logging must not break anything + } + publishProcessLog(l) + }) +} + +// publishProcessLog installs the lazily opened default log unless an +// explicit SetErrorLog landed while the open was in flight — the install +// wins, whatever the interleaving. Both writers hold errorLogMu, and the +// sync.Once guarantees the lazy open runs at most once, so this closes the +// one clobber window the lazy open used to have. +func publishProcessLog(l *ErrorLog) { + errorLogMu.Lock() + defer errorLogMu.Unlock() + if !errorLogInstalled { + processLog = l + } +} diff --git a/internal/common/errorlog_test.go b/internal/common/errorlog_test.go new file mode 100644 index 00000000..954da397 --- /dev/null +++ b/internal/common/errorlog_test.go @@ -0,0 +1,344 @@ +package common + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + "unicode/utf8" +) + +// TestErrorLog_AppendsJSONLines pins the durable critical-error log: 0600 +// file, 0700 parent dirs, one JSON line per entry shaped +// {"ts":RFC3339,"component":...,"message":...}, appends across calls. +func TestErrorLog_AppendsJSONLines(t *testing.T) { + path := filepath.Join(t.TempDir(), "sub", "late-errors.log") + l, err := OpenErrorLogAt(path) + if err != nil { + t.Fatalf("OpenErrorLogAt() error = %v", err) + } + + l.Log("compaction", "walk aborted after 2 messages") + l.Logf("diagnostic", "hook %s failed: %d", "pre-tool", 3) + + // File and directory permissions. + st, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat(%s) error = %v", path, err) + } + if perm := st.Mode().Perm(); perm != 0o600 { + t.Errorf("log file mode = %o, want 600", perm) + } + if dirSt, err := os.Stat(filepath.Dir(path)); err != nil { + t.Fatalf("Stat(dir) error = %v", err) + } else if perm := dirSt.Mode().Perm(); perm != 0o700 { + t.Errorf("log dir mode = %o, want 700", perm) + } + + // Line shape and count. + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + var lines []errorLogLine + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + var e errorLogLine + if err := json.Unmarshal([]byte(line), &e); err != nil { + t.Fatalf("log line %q is not valid JSON: %v", line, err) + } + if _, err := time.Parse(time.RFC3339, e.TS); err != nil { + t.Errorf("log line ts %q is not RFC3339: %v", e.TS, err) + } + lines = append(lines, e) + } + if err := sc.Err(); err != nil { + t.Fatal(err) + } + if len(lines) != 2 { + t.Fatalf("got %d log lines, want 2", len(lines)) + } + if lines[0].Component != "compaction" || lines[0].Message != "walk aborted after 2 messages" { + t.Errorf("first line = %+v, want the compaction entry", lines[0]) + } + if lines[1].Component != "diagnostic" || lines[1].Message != "hook pre-tool failed: 3" { + t.Errorf("second line = %+v, want the formatted diagnostic entry", lines[1]) + } +} + +// TestErrorLog_BestEffort pins the best-effort contract: logging never +// panics and never fails the caller — a nil log, an empty path, and an +// unwritable location are all silent no-ops. +func TestErrorLog_BestEffort(t *testing.T) { + var nilLog *ErrorLog + nilLog.Log("compaction", "must not panic") // nil receiver + + if _, err := OpenErrorLogAt(""); err == nil { + t.Error("OpenErrorLogAt(\"\") = nil error, want an error") + } + + // A path whose parent is a FILE cannot be created. + file := filepath.Join(t.TempDir(), "blocker") + if err := os.WriteFile(file, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := OpenErrorLogAt(filepath.Join(file, "late-errors.log")); err == nil { + t.Error("OpenErrorLogAt under a file-parent = nil error, want an error") + } +} + +// TestProcessWideErrorLog pins the global helpers: LogError goes through the +// lazily opened process log; SetErrorLog re-points it (and nil disables). +func TestProcessWideErrorLog(t *testing.T) { + path := filepath.Join(t.TempDir(), "late-errors.log") + l, err := OpenErrorLogAt(path) + if err != nil { + t.Fatal(err) + } + + old := processLog + t.Cleanup(func() { SetErrorLog(old) }) + + SetErrorLog(l) + LogError("test", "via the process log") + LogErrorf("test", "formatted %d", 42) + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if n := strings.Count(string(data), "\n"); n != 2 { + t.Fatalf("process log holds %d lines, want 2:\n%s", n, data) + } + if !strings.Contains(string(data), `"component":"test"`) { + t.Errorf("log missing the component field:\n%s", data) + } + + // A nil install disables logging; a later LogError must not panic or + // resurrect the previous log. + SetErrorLog(nil) + LogError("test", "silently dropped") + data, err = os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "silently dropped") { + t.Error("a nil process log must drop entries, not append them") + } +} + +// TestErrorLog_ConcurrentAppends: appends from many goroutines interleave as +// whole lines — the file always parses line-per-line. +func TestErrorLog_ConcurrentAppends(t *testing.T) { + path := filepath.Join(t.TempDir(), "late-errors.log") + l, err := OpenErrorLogAt(path) + if err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + for i := 0; i < 32; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + l.Logf("concurrent", "writer %d says hello hello hello", n) + }(i) + } + wg.Wait() + + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + count := 0 + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + var e errorLogLine + if err := json.Unmarshal([]byte(line), &e); err != nil { + t.Fatalf("torn or invalid line %q: %v", line, err) + } + count++ + } + if count != 32 { + t.Errorf("parsed %d lines, want 32", count) + } +} + +// TestTruncateErrorMessage pins the write-side growth bound: messages at or +// under the cap pass through untouched; longer ones are cut to the cap, +// stay valid UTF-8 (never torn mid-rune, whatever the boundary lands on), +// keep their prefix, and name how many bytes were dropped. +func TestTruncateErrorMessage(t *testing.T) { + if got := truncateErrorMessage("short"); got != "short" { + t.Errorf("truncateErrorMessage(short) = %q, want it unchanged", got) + } + + big := strings.Repeat("x", maxErrorLogMessage+100) + got := truncateErrorMessage(big) + if !utf8.ValidString(got) { + t.Error("ASCII truncation produced invalid UTF-8") + } + if !strings.HasPrefix(got, strings.Repeat("x", 64)) { + t.Error("truncation dropped content before the cap") + } + if !strings.HasSuffix(got, " bytes truncated]") { + t.Errorf("truncated message %q… lacks the truncation marker", got[:32]) + } + if want := maxErrorLogMessage + len(" …[+100 bytes truncated]"); len(got) != want { + t.Errorf("truncated message is %d bytes, want %d", len(got), want) + } + + // A cap that lands mid-rune (3-byte runes: 16384 % 3 != 0) backs off to + // a clean boundary instead of emitting a torn sequence. + torn := strings.Repeat("日", maxErrorLogMessage/3+10) + got = truncateErrorMessage(torn) + if !utf8.ValidString(got) { + t.Error("rune-boundary truncation produced invalid UTF-8") + } + if !strings.HasPrefix(got, strings.Repeat("日", 64)) { + t.Error("rune-boundary truncation dropped content before the cap") + } + if !strings.HasSuffix(got, " bytes truncated]") { + t.Errorf("rune-boundary truncated message %q… lacks the truncation marker", got[:32]) + } +} + +// TestErrorLog_TruncatesHugeMessages pins the bound end to end: a message +// far past the cap lands as ONE valid JSON line carrying the truncation +// marker, and a normal message passes through untouched. +func TestErrorLog_TruncatesHugeMessages(t *testing.T) { + path := filepath.Join(t.TempDir(), "late-errors.log") + l, err := OpenErrorLogAt(path) + if err != nil { + t.Fatal(err) + } + + l.Log("compaction", strings.Repeat("x", 4*maxErrorLogMessage)+" ends here") + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var e errorLogLine + if err := json.Unmarshal(data, &e); err != nil { + t.Fatalf("truncated line is not valid JSON: %v", err) + } + if e.Component != "compaction" { + t.Errorf("component = %q, want compaction", e.Component) + } + if !strings.HasSuffix(e.Message, " bytes truncated]") { + t.Errorf("truncated message lacks the truncation marker: %q…", e.Message[:min(64, len(e.Message))]) + } + + l.Log("compaction", "small and fine") + data, err = os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), `"message":"small and fine"`) { + t.Errorf("small message was altered:\n%s", data) + } +} + +// TestPublishProcessLog_InstallWinsOverLazyOpen pins the lazy-open race +// fix: a default-log open finishing AFTER an explicit SetErrorLog must not +// clobber the install. Before the fix, ensureProcessLog published the +// default log unconditionally once its sync.Once body ran, so an install +// landing inside the open window was silently reverted. +func TestPublishProcessLog_InstallWinsOverLazyOpen(t *testing.T) { + oldLog, oldInstalled := processLog, errorLogInstalled + t.Cleanup(func() { + errorLogMu.Lock() + defer errorLogMu.Unlock() + processLog, errorLogInstalled = oldLog, oldInstalled + }) + + lazy, err := OpenErrorLogAt(filepath.Join(t.TempDir(), "lazy.log")) + if err != nil { + t.Fatal(err) + } + installed, err := OpenErrorLogAt(filepath.Join(t.TempDir(), "installed.log")) + if err != nil { + t.Fatal(err) + } + + // SetErrorLog runs first (as it does in every test and in any wiring + // that installs a sink), then the lazy open's Once body finishes and + // publishes the default. + SetErrorLog(installed) + publishProcessLog(lazy) + + errorLogMu.Lock() + got := processLog + errorLogMu.Unlock() + if got != installed { + t.Error("the lazy open clobbered an explicit SetErrorLog install") + } +} + +// TestProcessWideErrorLog_ConcurrentInstallAndLog smoke-tests the global +// helpers under concurrent use: SetErrorLog racing LogError must neither +// panic nor tear lines (go test -race covers the memory model). Every +// append lands in one of the two installed logs, and every line in either +// file must parse as whole JSON. +func TestProcessWideErrorLog_ConcurrentInstallAndLog(t *testing.T) { + dir := t.TempDir() + pathA := filepath.Join(dir, "a.log") + pathB := filepath.Join(dir, "b.log") + a, err := OpenErrorLogAt(pathA) + if err != nil { + t.Fatal(err) + } + b, err := OpenErrorLogAt(pathB) + if err != nil { + t.Fatal(err) + } + old := processLog + t.Cleanup(func() { SetErrorLog(old) }) + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + target := a + if n%2 == 1 { + target = b + } + for j := 0; j < 50; j++ { + SetErrorLog(target) + LogError("race", "concurrent install and append") + } + }(i) + } + wg.Wait() + + for _, p := range []string{pathA, pathB} { + data, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + if line == "" { + continue + } + var e errorLogLine + if err := json.Unmarshal([]byte(line), &e); err != nil { + t.Errorf("torn or invalid line in %s: %q: %v", p, line, err) + } + } + } +} diff --git a/internal/common/utils.go b/internal/common/utils.go index 36c522d0..ccae541f 100644 --- a/internal/common/utils.go +++ b/internal/common/utils.go @@ -49,44 +49,79 @@ func EstimateTokenCountFast(text string) int { // CalculateHistoryTokensFast calculates token count quickly without blocking on BPE load, // ensuring the initial TUI frame renders immediately with a populated token bar. +// +// It walks exactly the same fields as CalculateHistoryTokens — message +// Content + ReasoningContent + every tool call's name and arguments, plus +// the system prompt, tool definitions, and the per-message/per-block +// overhead constants — through the shared calculateHistoryTokens walk; the +// ONLY divergence from the slow path is per-token precision: +// EstimateTokenCountFast falls back to the ~3.5 chars/token heuristic while +// the BPE vocabulary is still loading in the background. Acceptable for the +// pre-discovery frame the async token-count traffic replaces (update.go +// recomputes with CalculateHistoryTokens); do not use this for any +// accounting that persists or gates. func CalculateHistoryTokensFast(history []client.ChatMessage, systemPrompt string, tools []client.ToolDefinition) int { - total := EstimateTokenCountFast(systemPrompt) + 10 // System prompt + overhead - for _, t := range tools { - total += EstimateTokenCountFast(t.Function.Name) + EstimateTokenCountFast(t.Function.Description) - total += len(t.Function.Parameters) / 4 - } - if len(tools) > 0 { - total += 10 - } - for _, msg := range history { - total += EstimateTokenCountFast(msg.Content.String()) + EstimateTokenCountFast(msg.ReasoningContent) + 4 - } - return total + return calculateHistoryTokens(history, systemPrompt, tools, EstimateTokenCountFast) } // EstimateToolDefinitionTokens estimates tokens used by tool definitions. func EstimateToolDefinitionTokens(tools []client.ToolDefinition) int { + return estimateToolDefinitionTokensWith(tools, EstimateTokenCount) +} + +// tokenEstimator is the per-string estimator the shared token walks are +// parameterized by: the exact cl100k_base BPE count (the slow path) or the +// fast heuristic used while the BPE vocabulary is still loading. +type tokenEstimator func(string) int + +// estimateMessageTokensWith walks one message's token-bearing fields — +// Content, ReasoningContent, and every tool call's name and arguments — +// counting each string with est and adding the per-message overhead for +// roles and delimiters (approx 4 tokens). It is the single structural +// definition the slow (EstimateMessageTokens) and fast first-paint walks +// share, so the two can only ever disagree on per-token precision, never on +// which fields count. +func estimateMessageTokensWith(msg client.ChatMessage, est tokenEstimator) int { + tokens := est(msg.Content.String()) + est(msg.ReasoningContent) + for _, tc := range msg.ToolCalls { + tokens += est(tc.Function.Name) + est(tc.Function.Arguments) + } + return tokens + 4 +} + +// estimateToolDefinitionTokensWith estimates tokens used by tool +// definitions, counting name and description with est and the JSON +// parameters as raw bytes/4, plus the base overhead for the tools block +// (none when there are no tools). +func estimateToolDefinitionTokensWith(tools []client.ToolDefinition, est tokenEstimator) int { if len(tools) == 0 { return 0 } - // Simplified: estimate based on JSON representation overhead total := 0 for _, t := range tools { - total += EstimateTokenCount(t.Function.Name) + EstimateTokenCount(t.Function.Description) - // Parameters are more complex, but we can estimate them too + total += est(t.Function.Name) + est(t.Function.Description) total += len(t.Function.Parameters) / 4 } return total + 10 // Base overhead for tools block } +// calculateHistoryTokens is the shared history walk both public counters are +// one-line parameterizations of: system prompt + overhead, the tool +// definitions, and every message walked by estimateMessageTokensWith — the +// same fields for every estimator, so CalculateHistoryTokens and +// CalculateHistoryTokensFast agree structurally by construction. +func calculateHistoryTokens(history []client.ChatMessage, systemPrompt string, tools []client.ToolDefinition, est tokenEstimator) int { + total := est(systemPrompt) + 10 // System prompt + overhead + total += estimateToolDefinitionTokensWith(tools, est) + for _, msg := range history { + total += estimateMessageTokensWith(msg, est) + } + return total +} + // EstimateMessageTokens estimates tokens for a full chat message including tool calls and role overhead. func EstimateMessageTokens(msg client.ChatMessage) int { - tokens := EstimateTokenCount(msg.Content.String()) + EstimateTokenCount(msg.ReasoningContent) - for _, tc := range msg.ToolCalls { - tokens += EstimateTokenCount(tc.Function.Name) + EstimateTokenCount(tc.Function.Arguments) - } - // Per-message overhead for roles and delimiters (approx 4 tokens) - return tokens + 4 + return estimateMessageTokensWith(msg, EstimateTokenCount) } // EstimateEventTokens estimates tokens for a content event. @@ -96,11 +131,5 @@ func EstimateEventTokens(event ContentEvent) int { // CalculateHistoryTokens calculates the total token count from history, system prompt, and tools. func CalculateHistoryTokens(history []client.ChatMessage, systemPrompt string, tools []client.ToolDefinition) int { - total := EstimateTokenCount(systemPrompt) + 10 // System prompt + overhead - total += EstimateToolDefinitionTokens(tools) - - for _, msg := range history { - total += EstimateMessageTokens(msg) - } - return total + return calculateHistoryTokens(history, systemPrompt, tools, EstimateTokenCount) } diff --git a/internal/common/utils_test.go b/internal/common/utils_test.go index e2513bf7..564fed57 100644 --- a/internal/common/utils_test.go +++ b/internal/common/utils_test.go @@ -149,3 +149,74 @@ func TestCalculateHistoryTokens(t *testing.T) { }) } } + +// fixtureHistory builds a history dense in tool calls — the shape the old +// CalculateHistoryTokensFast undercounted, since it skipped ToolCall tokens — +// plus a multimodal-free tool result and a reasoning message. +func fixtureHistory() []client.ChatMessage { + return []client.ChatMessage{ + {Role: "user", Content: client.TextContent("Please analyze this build log.")}, + { + Role: "assistant", + Content: client.TextContent("Running the build."), + ReasoningContent: "The build failed before; check the logs.", + ToolCalls: []client.ToolCall{ + {Index: 0, ID: "call_1", Type: "function", Function: client.FunctionCall{Name: "Bash", Arguments: `{"cmd":"make build","target":"all"}`}}, + {Index: 1, ID: "call_2", Type: "function", Function: client.FunctionCall{Name: "Read", Arguments: `{"path":"internal/session/compact.go"}`}}, + }, + }, + {Role: "tool", ToolCallID: "call_1", Content: client.TextContent("make: *** [build] Error 1\nverbose failure output line 2")}, + {Role: "assistant", Content: client.TextContent("The build failed because of a missing dependency in the module graph.")}, + } +} + +// TestCalculateHistoryTokensFastAgreesStructurally pins the structural +// reconciliation: once the BPE vocabulary is loaded (the warm-up call blocks +// on it), EstimateTokenCountFast and EstimateTokenCount return identical +// counts for every string, so the fast and slow history walks — which share +// one calculateHistoryTokens structure — must return the SAME total for a +// fixture dense in tool calls. (Before the reconciliation the fast walk +// skipped ToolCall tokens and disagreed by construction.) +func TestCalculateHistoryTokensFastAgreesStructurally(t *testing.T) { + // Warm the BPE: EstimateTokenCount blocks until the embedded vocab is + // loaded, after which bpeIfReady() is non-nil and the fast estimator IS + // the exact one. + EstimateTokenCount("warm up the vocabulary") + if bpeIfReady() == nil { + t.Fatal("BPE did not load; the structural comparison below would be meaningless") + } + + history := fixtureHistory() + systemPrompt := "You are Late, a coding agent." + tools := []client.ToolDefinition{ + {Function: client.FunctionDefinition{Name: "Bash", Description: "Run a shell command", Parameters: []byte(`{"type":"object","properties":{"cmd":{"type":"string"}}}`)}}, + {Function: client.FunctionDefinition{Name: "Read", Description: "Read a file", Parameters: []byte(`{"type":"object","properties":{"path":{"type":"string"}}}`)}}, + } + + fast := CalculateHistoryTokensFast(history, systemPrompt, tools) + slow := CalculateHistoryTokens(history, systemPrompt, tools) + if fast != slow { + t.Errorf("CalculateHistoryTokensFast() = %d; CalculateHistoryTokens() = %d — the walks must agree structurally (only per-token precision may differ)", fast, slow) + } + + // The tool calls actually carry weight in the fixture: the shared + // per-message walk must count them (name + arguments), so a message with + // tool calls costs more than the same message without them. + withCalls := estimateMessageTokensWith(fixtureHistory()[1], EstimateTokenCount) + stripped := fixtureHistory()[1] + stripped.ToolCalls = nil + withoutCalls := estimateMessageTokensWith(stripped, EstimateTokenCount) + if withCalls <= withoutCalls { + t.Errorf("tool calls must contribute tokens: with = %d, without = %d", withCalls, withoutCalls) + } + + // The slow public API is a one-line parameterization of the same walk: + // EstimateMessageTokens and EstimateToolDefinitionTokens must match their + // shared-walk forms exactly. + if got, want := EstimateMessageTokens(history[1]), estimateMessageTokensWith(history[1], EstimateTokenCount); got != want { + t.Errorf("EstimateMessageTokens() = %d; shared walk = %d", got, want) + } + if got, want := EstimateToolDefinitionTokens(tools), estimateToolDefinitionTokensWith(tools, EstimateTokenCount); got != want { + t.Errorf("EstimateToolDefinitionTokens() = %d; shared walk = %d", got, want) + } +} diff --git a/internal/compaction/atomicity_test.go b/internal/compaction/atomicity_test.go new file mode 100644 index 00000000..178513c1 --- /dev/null +++ b/internal/compaction/atomicity_test.go @@ -0,0 +1,338 @@ +package compaction + +import ( + "context" + "fmt" + "strings" + "testing" +) + +// oversizedJSONParagraph builds one >2x-DefaultMaxSegChars JSON paragraph: a +// single pretty-printed object (no blank lines, so it is ONE paragraph) whose +// newline-pretty lines force splitOversized to cut it mid-structure — the +// exact shape the atomicity rule exists for. +func oversizedJSONParagraph() string { + var b strings.Builder + b.WriteString("{\n") + for i := 0; i < 60; i++ { + fmt.Fprintf(&b, " \"key_%03d\": \"value %03d with some padding text to bulk the line past trivial lengths\",\n", i, i) + } + b.WriteString(" \"final\": true\n}") + if len(b.String()) <= 2*DefaultMaxSegChars { + panic("fixture too small; the test needs a >2x maxSegChars paragraph") + } + return b.String() +} + +// TestSegmentSegments_CutPiecesShareGroup pins the grouping invariant the +// atomicity decision rides on: pieces splitOversized cut from one oversized +// paragraph share a Group, distinct paragraphs get distinct groups, and the +// pieces tile the paragraph's bytes. +func TestSegmentSegments_CutPiecesShareGroup(t *testing.T) { + blob := oversizedJSONParagraph() + segs := SegmentSegments(blob, 0) + if len(segs) < 3 { + t.Fatalf("SegmentSegments() = %d segments, want ≥3 pieces of the oversized paragraph", len(segs)) + } + for i, seg := range segs { + if seg.Group != segs[0].Group { + t.Errorf("piece %d Group = %d, want %d (all pieces of one paragraph share the group)", i, seg.Group, segs[0].Group) + } + if seg.Group == 0 { + t.Errorf("piece %d Group = 0, want a nonzero group id from SegmentSegments", i) + } + } + // The pieces tile the paragraph byte for byte. + var rebuilt strings.Builder + for _, seg := range segs { + rebuilt.WriteString(seg.Text) + } + if rebuilt.String() != blob { + t.Error("the pieces do not reconstruct the original paragraph") + } + + // Distinct paragraphs get distinct groups. + multi := strings.Repeat("alpha paragraph\n\n", 2) + blob + multiSegs := SegmentSegments(multi, 0) + if multiSegs[0].Group == multiSegs[len(multiSegs)-1].Group { + t.Error("separate paragraphs must not share a group") + } +} + +// TestAtomicElideDecisions pins the shared decision math: a group elides as +// a unit on its MINIMUM sibling score against the MINIMUM sibling floor, and +// ungrouped segments decide on their own score and floor. +func TestAtomicElideDecisions(t *testing.T) { + segs := []Segment{ + {ID: "seg-1", Group: 1}, + {ID: "seg-2", Group: 1}, + {ID: "seg-3", Group: 1}, + {ID: "seg-4"}, // ungrouped: decides alone + } + // One low sibling (seg-2 at 0.1) below the flat floor 0.35 → the whole + // paragraph elides, including its 0.9 siblings; the ungrouped 0.9 stays. + elide := AtomicElideDecisions(segs, + []float64{0.9, 0.1, 0.9, 0.9}, + []float64{0.35, 0.35, 0.35, 0.35}) + want := []bool{true, true, true, false} + for i := range want { + if elide[i] != want[i] { + t.Errorf("elide[%d] = %v, want %v", i, elide[i], want[i]) + } + } + + // All siblings above the floor → the whole paragraph is kept. + elide = AtomicElideDecisions(segs, + []float64{0.9, 0.5, 0.9, 0.9}, + []float64{0.35, 0.35, 0.35, 0.35}) + for i := range elide { + if elide[i] { + t.Errorf("elide[%d] = true, want the whole paragraph kept", i) + } + } + + // Mixed floors: the group decides on the MINIMUM sibling floor, so a + // protected sibling (stacktrace floor 0.05) keeps the paragraph unless + // the minimum sibling score falls below even that floor. + protected := []Segment{{ID: "seg-1", Group: 1}, {ID: "seg-2", Group: 1}} + elide = AtomicElideDecisions(protected, + []float64{0.2, 0.2}, // below the text floor 0.35, above 0.05 + []float64{0.35, 0.05}) + if elide[0] || elide[1] { + t.Error("a protected sibling's floor must keep the paragraph above the protected floor") + } + elide = AtomicElideDecisions(protected, + []float64{0.01, 0.9}, + []float64{0.35, 0.05}) + if !elide[0] || !elide[1] { + t.Error("a sibling below even the protected floor must elide the whole paragraph") + } + + // Mismatched slices: a caller bug — no decisions, not a panic. + if got := AtomicElideDecisions(segs, []float64{0.5}, []float64{0.35}); got != nil { + t.Errorf("AtomicElideDecisions with mismatched slices = %v, want nil", got) + } +} + +// TestPipeline_CompactToolOutput_ParagraphAtomicity is the end-to-end pin: +// a >2x maxSegChars JSON paragraph cut into pieces elides as ONE unit when a +// single piece scores below the floor — the pointer references the full +// original and Reconstruct restores it byte for byte — and stays byte-exact +// when every piece is above the floor. +func TestPipeline_CompactToolOutput_ParagraphAtomicity(t *testing.T) { + const threshold = 0.35 + blob := oversizedJSONParagraph() + segs := SegmentSegments(blob, 0) + if len(segs) < 3 { + t.Fatalf("fixture sanity: %d segments, want ≥3", len(segs)) + } + + t.Run("one low piece elides the whole paragraph", func(t *testing.T) { + // Only the middle piece scores below the floor; without atomicity + // the elided middle would leave an unparseable JSON remnant behind. + scores := map[string]float64{"seg-1": 0.9, "seg-2": 0.1, "seg-3": 0.9} + for i := 4; i <= len(segs); i++ { + scores[fmt.Sprintf("seg-%d", i)] = 0.9 + } + d := newDecisionsServer(t, fixedScoresHandler(scores)) + store := NewStore() + p := NewPipeline(ResolvedBackend{Backend: Backend{Name: "test", URL: d.srv.URL, Model: "jev-latest"}, APIKey: "k"}, "k", nil, PipelineOptions{}) + p.EnableRelocation(store, threshold) + applyTestGate(p, threshold, 1) // tripwire off: the whole paragraph elides on purpose + + got, err := p.CompactToolOutput(context.Background(), "Bash", blob) + if err != nil { + t.Fatalf("CompactToolOutput() error = %v", err) + } + if len(got.Elided) != 1 { + t.Fatalf("Elided = %d runs, want 1 (the whole paragraph)", len(got.Elided)) + } + if got.Elided[0].Segments != len(segs) { + t.Errorf("elided run covers %d segments, want all %d pieces", got.Elided[0].Segments, len(segs)) + } + if got.Elided[0].Text != blob { + t.Error("the elided run's text is not the FULL original paragraph") + } + if !strings.HasPrefix(got.CompactText, "[[elided id=r:") { + t.Errorf("compacted text should be one pointer line, got:\n%s", truncateForTest(got.CompactText)) + } + // The pointer references the full original through the store, and + // Reconstruct is the byte-for-byte inverse. + if stored, ok := store.Get(got.Elided[0].ID); !ok || stored != blob { + t.Error("the store does not hold the full original under the pointer's id") + } + if restored := Reconstruct(got.CompactText, store); restored != blob { + t.Error("Reconstruct(compacted) is not byte-for-byte") + } + }) + + t.Run("all pieces above the floor keep the paragraph", func(t *testing.T) { + scores := map[string]float64{} + for i := 1; i <= len(segs); i++ { + scores[fmt.Sprintf("seg-%d", i)] = 0.9 + } + d := newDecisionsServer(t, fixedScoresHandler(scores)) + store := NewStore() + p := NewPipeline(ResolvedBackend{Backend: Backend{Name: "test", URL: d.srv.URL, Model: "jev-latest"}, APIKey: "k"}, "k", nil, PipelineOptions{}) + p.EnableRelocation(store, threshold) + applyTestGate(p, threshold, 1) + + got, err := p.CompactToolOutput(context.Background(), "Bash", blob) + if err != nil { + t.Fatalf("CompactToolOutput() error = %v", err) + } + if len(got.Elided) != 0 || got.CompactText != blob { + t.Error("a paragraph whose pieces all stay above the floor must be kept byte-for-byte") + } + if store.Len() != 0 { + t.Error("nothing should have been stored") + } + }) + + t.Run("shadow decisions match the atomic elide", func(t *testing.T) { + scores := map[string]float64{"seg-1": 0.9, "seg-2": 0.1, "seg-3": 0.9} + for i := 4; i <= len(segs); i++ { + scores[fmt.Sprintf("seg-%d", i)] = 0.9 + } + d := newDecisionsServer(t, fixedScoresHandler(scores)) + shadowPath := t.TempDir() + "/shadow.jsonl" + shadow, err := NewShadowLogAt(shadowPath) + if err != nil { + t.Fatal(err) + } + p := NewPipeline(ResolvedBackend{Backend: Backend{Name: "test", URL: d.srv.URL, Model: "jev-latest"}, APIKey: "k"}, "k", shadow, PipelineOptions{}) + p.EnableRelocation(NewStore(), threshold) + applyTestGate(p, threshold, 1) + + if _, err := p.CompactToolOutput(context.Background(), "Bash", blob); err != nil { + t.Fatalf("CompactToolOutput() error = %v", err) + } + entries, malformed, err := shadow.readEntries() + if err != nil { + t.Fatal(err) + } + if malformed != 0 || len(entries) != len(segs) { + t.Fatalf("shadow log = %d entries (%d malformed), want %d clean decisions", len(entries), malformed, len(segs)) + } + for _, e := range entries { + // Every piece's recorded decision is the paragraph's shared + // one, made on the minimum sibling score — so score vs + // threshold replays it exactly. + if e.Decision != DecisionElide { + t.Errorf("decision for %s = %q, want %q (the paragraph elided as a unit)", e.SegmentID, e.Decision, DecisionElide) + } + if e.Score != 0.1 || e.Threshold != threshold { + t.Errorf("entry %s records score %v vs threshold %v, want the group-min 0.1 vs %v", e.SegmentID, e.Score, e.Threshold, threshold) + } + } + }) +} + +// truncateForTest caps a string for failure messages. +func truncateForTest(s string) string { + if len(s) <= 200 { + return s + } + return s[:200] + "…" +} + +// TestAtomicDecisionScores_ProtectedSiblingPinsGroupKeep pins the +// protection-override rule: a sibling at the unelidable score ceiling (the +// 1.0 score protectedScore clamps activate_skill results to, or the +// fail-open keep score) pins its whole cut paragraph to keep — even when a +// normal sibling scores far below its own floor. The naive min-floor reduce +// would INVERT protection here: min(1.0-floor sibling, 0.35-floor sibling) +// = 0.35, and the protected piece would elide along with its low-scoring +// sibling. It also pins the recorded decision inputs, so the shadow +// log's score-vs-floor replay reproduces the keep a mutating run makes. +func TestAtomicDecisionScores_ProtectedSiblingPinsGroupKeep(t *testing.T) { + segs := []Segment{ + {ID: "seg-1", Group: 1}, // protected sibling: score clamped to the ceiling + {ID: "seg-2", Group: 1}, // normal sibling scoring below its floor + {ID: "seg-3"}, // ungrouped control: decides alone + } + decide, decFloors := AtomicDecisionScores(segs, + []float64{unelidableScore, 0.1, 0.1}, + []float64{0.35, 0.35, 0.35}) + if decide == nil { + t.Fatal("AtomicDecisionScores() = nil, want decisions") + } + for i := 0; i < 2; i++ { + if decide[i] != unelidableScore { + t.Errorf("decide[%d] = %v, want the unelidable ceiling %v (the group is pinned to keep)", i, decide[i], unelidableScore) + } + if decFloors[i] != 0.35 { + t.Errorf("decFloors[%d] = %v, want the group-minimum floor 0.35", i, decFloors[i]) + } + } + // The ungrouped control keeps its own inputs — and still elides. + if decide[2] != 0.1 || decFloors[2] != 0.35 { + t.Errorf("ungrouped control = (%v, %v), want (0.1, 0.35)", decide[2], decFloors[2]) + } + + elide := AtomicElideDecisions(segs, + []float64{unelidableScore, 0.1, 0.1}, + []float64{0.35, 0.35, 0.35}) + if elide[0] || elide[1] { + t.Error("a group holding an unelidable sibling must never elide") + } + if !elide[2] { + t.Error("the ungrouped low scorer must still elide") + } + + // Replay consistency: the pinned decision inputs re-decide as keep. + if decide[0] < decFloors[0] { + t.Error("the recorded decision inputs must replay as keep (score >= floor)") + } + + // The fail-open keep score (keepScore — also the ceiling) pins the + // group the same way: a piece the scorer could not answer keeps its + // whole paragraph instead of eliding with a low sibling. + elide = AtomicElideDecisions(segs, + []float64{keepScore, 0.1, 0.9}, + []float64{0.35, 0.35, 0.35}) + if elide[0] || elide[1] { + t.Error("a fail-open keep score must pin its group to keep") + } + if elide[2] { + t.Error("the ungrouped 0.9 control must stay kept") + } +} + +// TestPipeline_CompactToolOutput_UnscoreablePieceKeepsParagraph is the +// end-to-end shape of the unelidable pin: one piece of a cut oversized +// paragraph comes back at the fail-open ceiling (the scorer refused it — +// here, an explicit 1.0, the value ScoreBatch reports for unscoreable +// items) while its siblings score low. The paragraph is ATOMIC and the +// ceiling sibling is unelidable, so nothing is elided — the pre-pin +// min-score reduce would have elided the whole paragraph, ceiling sibling +// included. +func TestPipeline_CompactToolOutput_UnscoreablePieceKeepsParagraph(t *testing.T) { + const threshold = 0.35 + blob := oversizedJSONParagraph() + segs := SegmentSegments(blob, 0) + if len(segs) < 3 { + t.Fatalf("fixture sanity: %d segments, want ≥3", len(segs)) + } + + scores := map[string]float64{"seg-1": keepScore} // the ceiling sibling + for i := 2; i <= len(segs); i++ { + scores[fmt.Sprintf("seg-%d", i)] = 0.1 // low siblings + } + d := newDecisionsServer(t, fixedScoresHandler(scores)) + store := NewStore() + p := NewPipeline(ResolvedBackend{Backend: Backend{Name: "test", URL: d.srv.URL, Model: "jev-latest"}, APIKey: "k"}, "k", nil, PipelineOptions{}) + p.EnableRelocation(store, threshold) + applyTestGate(p, threshold, 1) + + got, err := p.CompactToolOutput(context.Background(), "Bash", blob) + if err != nil { + t.Fatalf("CompactToolOutput() error = %v", err) + } + if len(got.Elided) != 0 || got.CompactText != blob { + t.Error("a paragraph holding a ceiling-score sibling must be kept byte-for-byte") + } + if store.Len() != 0 { + t.Error("nothing should have been stored") + } +} diff --git a/internal/compaction/check.go b/internal/compaction/check.go new file mode 100644 index 00000000..bed3331e --- /dev/null +++ b/internal/compaction/check.go @@ -0,0 +1,363 @@ +package compaction + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// This file is Step 16 of the implementation plan: the preflight check (a +// port of the reference check.py) plus the light startup probe. Both make +// REAL requests against the resolved backend — never a stub — and answer the +// three questions that would have caught the too-small local backend before +// it was integrated into a session: +// +// 1. questions — a minimal ScoreBatch parses and every id comes back with a +// numeric score (the port's decision protocol is ONE "noul" question per +// item, so "all question types" here means every item of the batch), +// 2. gate — a Pipeline over a synthetic ~2KB tool output actually relocates +// something: with the keep threshold pinned at 1.0 every score below 1.0 +// elides, whatever the real scorer answers, proving the gate produces +// [[elided id=r:…]] pointers and store records from real output, +// 3. expand — Reconstruct expands the pointer back byte for byte. +// +// The first failing stage stops the run (the later stages depend on the +// earlier ones), and its Detail names the failure class (the Step 15 typed +// errors) so the operator learns WHICH thing broke — bad key, malformed +// request, unreachable server — not just that something did. The offline +// variant (RunPreflightOffline, in offline.go) runs the same stages over the +// scripted scorer for the compaction-backend "offline" demo path: its stages +// are local by design, not stubbed backends. + +// Stage names in a preflight report, in run order. "backend" is stage 0: the +// report's opening line (and, when the CLI cannot resolve a backend at all, +// the only entry — the three real stages cannot run without one). +const ( + CheckStageBackend = "backend" + CheckStageQuestions = "questions" + CheckStageGate = "gate" + CheckStageExpand = "expand" +) + +// checkToolName is the tool name the gate stage compacts for. It salts the +// content id of the relocated run, exactly as a real tool name would. +const checkToolName = "preflight" + +// probeTask is the task the probe batches score against; probeTexts are the +// item texts. Small on purpose: the probe (and the questions stage) measures +// "can this backend speak the decisions protocol", not "how fast is it on a +// real workload". +const probeTask = "Preflight probe: score how essential each segment is for the ongoing task." + +// probeTexts returns count item texts for the probe batch (capped at the +// three the questions stage uses). +func probeTexts(count int) map[string]Item { + texts := []string{ + "Preflight probe segment 1: the build log shows three failing tests in package compaction, all timing out on the same fixture.", + "Preflight probe segment 2: a benchmark table timing the scoring endpoint across batch sizes of 1, 8, and 32 questions.", + "Preflight probe segment 3: a stack trace from an unrelated crash that was already fixed last week.", + } + if count > len(texts) { + count = len(texts) + } + items := make(map[string]Item, count) + for i := 0; i < count; i++ { + items[fmt.Sprintf("probe-%d", i+1)] = Item{Text: texts[i]} + } + return items +} + +// CheckResult is one preflight stage's outcome: its Stage name, whether it +// passed, how long the real work took, and a Detail that on failure names the +// failure class (bad or missing API key, malformed request, unreachable +// server, oversized output) plus the underlying error. +type CheckResult struct { + Stage string + OK bool + Latency time.Duration + Detail string +} + +// RunPreflight runs the three-stage compaction preflight against the REAL +// resolved backend and reports one CheckResult per stage, stage 0 ("backend") +// first. apiKey overrides backend.APIKey when non-empty (the same rule as +// NewDecisionClient); hc, when non-nil, replaces the check clients' transport +// (tests inject fast/recorded ones). The first failing stage stops the run — +// the gate stage needs a scoring backend and the expand stage needs the gate +// stage's pointers — so a failed report is shorter than a passed one. The +// boolean is the overall verdict: every stage OK. +// +// The stages run over ONE throwaway client (never a production client: the +// check must not share rate-limit state or an auth poison flag with a live +// pipeline) that sends ONE attempt per request: a preflight names what broke, +// it does not ride out an outage with retries. The offline preflight +// (RunPreflightOffline) runs the same stages over the scripted scorer. +func RunPreflight(ctx context.Context, backend ResolvedBackend, apiKey string, hc *http.Client) ([]CheckResult, bool) { + return runPreflightStages(ctx, newCheckClient(backend, apiKey, hc), backendDetail(backend, apiKey)) +} + +// runPreflightStages is the three-stage body shared by the backend preflight +// (RunPreflight) and the offline one (RunPreflightOffline): scorer scores +// every stage, backendLine is stage 0's opening line. Stage order and the +// stop-at-first-failure contract are documented on RunPreflight. +func runPreflightStages(ctx context.Context, scorer Scorer, backendLine string) ([]CheckResult, bool) { + results := make([]CheckResult, 0, 4) + + // Stage 0 — backend: the report's opening line (name, endpoint, model, + // key provenance). Reaching here at all means resolution succeeded; the + // CLI turns a resolution failure into this same stage with OK=false. + results = append(results, CheckResult{ + Stage: CheckStageBackend, + OK: true, + Detail: backendLine, + }) + + // Stage 1 — questions: a minimal batch must parse and every id must + // come back with a numeric score and no failure. + start := time.Now() + qErr := probeScoreBatch(ctx, scorer, len(probeTexts(3))) + qr := CheckResult{Stage: CheckStageQuestions, Latency: time.Since(start)} + if qErr != nil { + qr.Detail = failureDetail(qErr) + return append(results, qr), false + } + qr.OK = true + qr.Detail = "3/3 questions answered with numeric scores (noul protocol)" + results = append(results, qr) + + // Stage 2 — gate: a throwaway pipeline (in-memory store, no shadow log) + // compacts a synthetic ~2KB tool output with the keep threshold pinned + // at 1.0, so every score below 1.0 elides whatever the scorer answers. + original := checkToolOutput() + store := NewStore() + start = time.Now() + compacted, gErr := checkGate(ctx, scorer, store, original) + gr := CheckResult{Stage: CheckStageGate, Latency: time.Since(start)} + if gErr != nil { + gr.Detail = failureDetail(gErr) + return append(results, gr), false + } + if !strings.Contains(compacted.CompactText, "[[elided id=r:") { + gr.Detail = "gate relocated nothing: no [[elided id=r: pointer in the compacted output (no segment scored below the keep threshold 1.0)" + return append(results, gr), false + } + if store.Len() < 1 { + gr.Detail = "gate produced pointers but the store gained no record: expanding them would fail" + return append(results, gr), false + } + gr.OK = true + gr.Detail = fmt.Sprintf("gate relocated %d run(s) — %d of %d segments, %d tokens — into %d store record(s)", + len(compacted.Elided), countElidedSegments(compacted.Elided), len(SegmentSegments(original, 0)), + countElidedTokens(compacted.Elided), store.Len()) + results = append(results, gr) + + // Stage 3 — expand: the pointer(s) must reconstruct the original output + // byte for byte. + start = time.Now() + expanded := Reconstruct(compacted.CompactText, store) + er := CheckResult{Stage: CheckStageExpand, Latency: time.Since(start)} + if expanded != original { + er.Detail = fmt.Sprintf("reconstructed text differs from the original (%d vs %d bytes)", len(expanded), len(original)) + return append(results, er), false + } + er.OK = true + er.Detail = fmt.Sprintf("%d pointer(s) expanded back byte for byte (%d bytes)", len(compacted.Elided), len(original)) + return append(results, er), true +} + +// ProbeBackend is the light startup probe (Step 16): one ScoreBatch with a +// single small item against a throwaway client. Startup calls it in a +// goroutine AFTER the TUI is up, so it never blocks the first paint; on a +// typed auth rejection the caller disables the session's scoring the same way +// a live 401 would (the probe's client is throwaway, so the live pipeline +// would otherwise only learn on its first real call), and any other failure +// just warns — scoring is fail-open by contract. The returned error is the +// client's classified failure (a *Error for protocol failures). +func ProbeBackend(ctx context.Context, backend ResolvedBackend, apiKey string) error { + return probeScoreBatch(ctx, newCheckClient(backend, apiKey, nil), 1) +} + +// probeScoreBatch is the questions-stage logic shared by RunPreflight (three +// items) and ProbeBackend (one): one ScoreBatch whose every id must come back +// with a score and without a failure. ScoreBatch fail-opens rather than +// dropping ids, so a missing score is defensive; the real verdict is the +// joined-error return, non-nil exactly when at least one item failed. The +// scorer is the Scorer interface: the offline preflight passes its scripted +// scorer, which by construction always answers every id. +func probeScoreBatch(ctx context.Context, c Scorer, count int) error { + items := probeTexts(count) + scores, err := c.ScoreBatch(ctx, probeTask, items) + if err != nil { + return err + } + for id := range items { + if _, ok := scores[id]; !ok { + return fmt.Errorf("decision response missing a score for %q", id) + } + } + return nil +} + +// newCheckClient builds a throwaway decisions client for the preflight and +// the startup probe: a fresh client (never a production one), hc swapped in +// when non-nil, and ONE attempt per request — a preflight names what broke, +// it does not ride out an outage with retries (retrying would only stretch +// the report or the startup path past the caller's timeout). +func newCheckClient(backend ResolvedBackend, apiKey string, hc *http.Client) *DecisionClient { + c := NewDecisionClient(backend, apiKey) + if hc != nil { + c.http = hc + } + c.maxAttempts = 1 + c.baseBackoff, c.maxBackoff = time.Millisecond, time.Millisecond + return c +} + +// checkGate runs the gate stage: a throwaway pipeline (in-memory store, no +// shadow log — the check writes nothing to disk) compacts the synthetic tool +// output with the keep threshold pinned at 1.0 and every gate guard neutral +// (no protected kinds, no tripwire — elided tokens can never exceed 100% of +// the total — and the token min-gate off). Whatever the scorer answers, any +// score below 1.0 elides: the stage proves the gate relocates real output +// into the store and leaves pointers behind. The backend preflight passes a +// check client shrunk to one attempt (newCheckClient); the offline preflight +// passes its scripted scorer. +func checkGate(ctx context.Context, scorer Scorer, store *Store, output string) (CompactResult, error) { + p := newPipelineWithScorer(scorer, nil, 0) + p.warnTo = io.Discard // the check's own report is the warning surface + p.EnableRelocation(store, 1.0) + p.ApplyGateConfig(GateConfig{ + KeepThreshold: 1.0, + MinGateTokens: 0, + MaxElideFraction: 1.0, + ProtectedKinds: map[SegmentKind]float64{}, + }) + return p.CompactToolOutput(ctx, checkToolName, output) +} + +// checkToolOutput builds the synthetic ~2KB tool output the gate stage +// compacts: four ~500-byte paragraphs (above the 80-byte tiny-paragraph +// floor, under the 1200-byte segment cap) separated by blank lines, so the +// output segments into four pieces and the gate has several independent +// elision decisions to make. +func checkToolOutput() string { + var b strings.Builder + for i := 1; i <= 4; i++ { + fmt.Fprintf(&b, "preflight segment %d: %s\n\n", i, strings.Repeat(fmt.Sprintf("word%d ", i), 84)) + } + return strings.TrimSuffix(b.String(), "\n\n") +} + +// countElidedSegments sums the Elided runs' segment counts. +func countElidedSegments(runs []ElidedSegment) int { + n := 0 + for _, r := range runs { + n += r.Segments + } + return n +} + +// countElidedTokens sums the Elided runs' token counts. +func countElidedTokens(runs []ElidedSegment) int { + n := 0 + for _, r := range runs { + n += r.Tokens + } + return n +} + +// backendDetail renders the report's opening line: which backend, which +// endpoint, which model, and where the API key came from. +func backendDetail(backend ResolvedBackend, apiKey string) string { + key := "none" + if backend.APIKey != "" || apiKey != "" { + key = string(backend.KeySource) + if key == "" { + key = "provided" + } + } + return fmt.Sprintf("backend %q url %s model %q (api key: %s)", + backend.Backend.Name, backend.Backend.URL, backend.Backend.Model, key) +} + +// failureDetail renders a stage failure: the taxonomy class first — the +// report must name WHICH thing broke — then the underlying error with its +// newlines flattened so the report stays line-oriented. The class wordings +// are the check's own operator-facing phrasing; the Step 15 typed errors +// carry their policy-oriented detail() strings underneath. +func failureDetail(err error) string { + class := "" + var ce *Error + if errors.As(err, &ce) { + switch ce.Kind { + case KindAuth: + class = "bad or missing API key" + case KindValidation: + class = "malformed request (backend rejected it)" + case KindBudget: + class = "output too large for the backend budget" + case KindUnavailable: + class = "unreachable or erroring backend" + } + } + msg := strings.ReplaceAll(err.Error(), "\n", "; ") + if class == "" { + return msg + } + return class + ": " + msg +} + +// FormatCheckReport renders the human-readable preflight report: one status +// line per stage (status, latency, detail), a verdict naming the first +// failing stage on failure, and the cost line. The decisions client does not +// track token usage (the wire usage object is decoded and discarded), so the +// report says so instead of inventing a cost. +func FormatCheckReport(results []CheckResult, ok bool) string { + var b strings.Builder + b.WriteString("late compaction preflight\n") + for _, r := range results { + status := "ok " + if !r.OK { + status = "FAIL" + } + detail := strings.ReplaceAll(r.Detail, "\n", "; ") + if detail == "" { + detail = "-" + } + fmt.Fprintf(&b, " [%s] %-9s %8s %s\n", status, r.Stage, formatLatency(r.Latency), detail) + } + if ok { + fmt.Fprintf(&b, "result: PASS (%d/%d stages ok)\n", len(results), len(results)) + } else { + stage := "" + for _, r := range results { + if !r.OK { + stage = r.Stage + break + } + } + if stage == "" { + b.WriteString("result: FAIL\n") + } else { + fmt.Fprintf(&b, "result: FAIL (stage %q failed)\n", stage) + } + } + b.WriteString("cost: n/a (the decisions client does not track token usage)\n") + return b.String() +} + +// formatLatency renders one stage's latency for the report column. +func formatLatency(d time.Duration) string { + switch { + case d >= time.Second: + return fmt.Sprintf("%.2fs", d.Seconds()) + case d >= time.Millisecond: + return fmt.Sprintf("%dms", d.Milliseconds()) + default: + return fmt.Sprintf("%.2fms", float64(d)/float64(time.Millisecond)) + } +} diff --git a/internal/compaction/check_test.go b/internal/compaction/check_test.go new file mode 100644 index 00000000..c2748064 --- /dev/null +++ b/internal/compaction/check_test.go @@ -0,0 +1,293 @@ +package compaction + +import ( + "context" + "errors" + "net" + "net/http" + "strings" + "testing" + "time" +) + +// allScoresHandler answers every request with the given score for every +// asked ref — the uniform-scorer stand-in for gate-stage edge cases. +func allScoresHandler(score float64) func(int, capturedRequest) (int, string) { + return func(_ int, req capturedRequest) (int, string) { + scores := make(map[string]float64, len(req.Req.Questions)) + for ref := range req.Req.Questions { + scores[ref] = score + } + return http.StatusOK, answersBody(scores) + } +} + +// statusHandler answers every request with the given status and body: the +// typed-failure stand-in (401 auth, 422 validation). +func statusHandler(status int, body string) func(int, capturedRequest) (int, string) { + return func(_ int, _ capturedRequest) (int, string) { + return status, body + } +} + +// checkBackend wraps a decisions-server URL in a ResolvedBackend shaped like +// the providers layer's output. +func checkBackend(url string) ResolvedBackend { + return ResolvedBackend{ + Backend: Backend{Name: "test", URL: url, Model: "jev-latest"}, + APIKey: "test-key", + KeySource: KeySourceEnv, + } +} + +// TestRunPreflightAllPass: a working backend passes all three real stages — +// questions parse, the gate relocates something from real output into the +// store, and the pointer expands back byte for byte — and the report carries +// the backend stage 0 first. +func TestRunPreflightAllPass(t *testing.T) { + d := newDecisionsServer(t, echoHandler) // probe ids score 0.42, seg ids 0.01*N + results, ok := RunPreflight(context.Background(), checkBackend(d.srv.URL), "", nil) + if !ok { + t.Fatalf("RunPreflight() ok = false, want all stages to pass:\n%s", FormatCheckReport(results, ok)) + } + if len(results) != 4 { + t.Fatalf("got %d results, want 4 (backend, questions, gate, expand)", len(results)) + } + for i, want := range []string{CheckStageBackend, CheckStageQuestions, CheckStageGate, CheckStageExpand} { + if results[i].Stage != want { + t.Errorf("results[%d].Stage = %q, want %q", i, results[i].Stage, want) + } + if !results[i].OK { + t.Errorf("results[%d] (%s) = FAIL, want ok: %s", i, results[i].Stage, results[i].Detail) + } + if results[i].Latency < 0 { + t.Errorf("results[%d].Latency = %v, want non-negative", i, results[i].Latency) + } + } + // The gate stage really relocated: its detail says so, and the expand + // stage names the byte-for-byte round trip. + if !strings.Contains(results[2].Detail, "relocated") || !strings.Contains(results[2].Detail, "1 store record") { + t.Errorf("gate detail = %q, want the relocation summary", results[2].Detail) + } + if !strings.Contains(results[3].Detail, "byte for byte") { + t.Errorf("expand detail = %q, want the round-trip summary", results[3].Detail) + } + // Stage 0 names the backend, endpoint, model, and key source. + for _, want := range []string{`"test"`, d.srv.URL, `"jev-latest"`, "env"} { + if !strings.Contains(results[0].Detail, want) { + t.Errorf("backend detail = %q, want it to contain %q", results[0].Detail, want) + } + } +} + +// TestRunPreflightAuthFailure: a 401 backend fails stage 1 with the auth +// class named, and the later stages never run. +func TestRunPreflightAuthFailure(t *testing.T) { + d := newDecisionsServer(t, statusHandler(http.StatusUnauthorized, `{"error": {"message": "bad key"}}`)) + results, ok := RunPreflight(context.Background(), checkBackend(d.srv.URL), "", nil) + if ok { + t.Fatal("RunPreflight() ok = true, want the auth failure to fail the run") + } + if len(results) != 2 { + t.Fatalf("got %d results, want 2 (backend + questions; later stages must not run)", len(results)) + } + failed := results[1] + if failed.Stage != CheckStageQuestions { + t.Fatalf("failing stage = %q, want %q", failed.Stage, CheckStageQuestions) + } + if failed.OK { + t.Error("questions stage reported OK on a 401 backend") + } + if !strings.Contains(failed.Detail, "bad or missing API key") { + t.Errorf("detail = %q, want the auth class named", failed.Detail) + } +} + +// TestRunPreflightValidationFailure: a 422 backend (the too-small local +// model's signature failure) is named as a malformed request, not an outage. +func TestRunPreflightValidationFailure(t *testing.T) { + d := newDecisionsServer(t, statusHandler(http.StatusUnprocessableEntity, `{"error": {"message": "model too small"}}`)) + results, ok := RunPreflight(context.Background(), checkBackend(d.srv.URL), "", nil) + if ok { + t.Fatal("RunPreflight() ok = true, want the validation failure to fail the run") + } + if len(results) != 2 || results[1].Stage != CheckStageQuestions { + t.Fatalf("failing stage = %+v, want questions", results) + } + if got := results[1].Detail; !strings.Contains(got, "malformed request (backend rejected it)") { + t.Errorf("detail = %q, want the validation class named", got) + } +} + +// TestRunPreflightUnreachable: a closed port fails stage 1 with the +// unavailable class named (transport failure, not a malformed request). +func TestRunPreflightUnreachable(t *testing.T) { + // Grab a port and close it: nothing is listening there anymore. + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + addr := l.Addr().String() + if err := l.Close(); err != nil { + t.Fatal(err) + } + + results, ok := RunPreflight(context.Background(), checkBackend("http://"+addr), "", nil) + if ok { + t.Fatal("RunPreflight() ok = true, want the unreachable backend to fail the run") + } + if len(results) != 2 || results[1].Stage != CheckStageQuestions { + t.Fatalf("failing stage = %+v, want questions", results) + } + if got := results[1].Detail; !strings.Contains(got, "unreachable or erroring backend") { + t.Errorf("detail = %q, want the unavailable class named", got) + } +} + +// TestRunPreflightUniformKeepScoresFailGate: a backend that answers 1.0 for +// everything parses fine (stage 1 passes) but relocates nothing at the 1.0 +// keep threshold — the gate stage must say so instead of passing vacuously. +func TestRunPreflightUniformKeepScoresFailGate(t *testing.T) { + d := newDecisionsServer(t, allScoresHandler(1.0)) + results, ok := RunPreflight(context.Background(), checkBackend(d.srv.URL), "", nil) + if ok { + t.Fatal("RunPreflight() ok = true, want the vacuous gate to fail the run") + } + if len(results) != 3 { + t.Fatalf("got %d results, want 3 (backend + questions + gate; expand must not run)", len(results)) + } + failed := results[2] + if failed.Stage != CheckStageGate { + t.Fatalf("failing stage = %q, want %q", failed.Stage, CheckStageGate) + } + if !strings.Contains(failed.Detail, "relocated nothing") { + t.Errorf("detail = %q, want the no-elision explanation", failed.Detail) + } +} + +// TestProbeBackend: the light startup probe passes against a working backend +// and returns a typed auth error against a 401 one (the caller's cue to +// disable the session's scoring). +func TestProbeBackend(t *testing.T) { + d := newDecisionsServer(t, echoHandler) + if err := ProbeBackend(context.Background(), checkBackend(d.srv.URL), ""); err != nil { + t.Fatalf("ProbeBackend() error = %v, want nil", err) + } + + bad := newDecisionsServer(t, statusHandler(http.StatusForbidden, `{"error": {"message": "forbidden"}}`)) + err := ProbeBackend(context.Background(), checkBackend(bad.srv.URL), "") + if err == nil { + t.Fatal("ProbeBackend() error = nil, want the 403 classified") + } + var ce *Error + if !errors.As(err, &ce) || ce.Kind != KindAuth { + t.Fatalf("error = %v, want a typed auth error", err) + } +} + +// TestFailureDetailClasses pins the report's class wordings — the operator +// learns WHICH class broke from the detail's prefix. +func TestFailureDetailClasses(t *testing.T) { + for _, tc := range []struct { + kind ErrorKind + want string + cause string + }{ + {KindAuth, "bad or missing API key", "compaction: auth (401) during score-batch"}, + {KindValidation, "malformed request (backend rejected it)", "compaction: validation (422) during score-batch"}, + {KindBudget, "output too large for the backend budget", "compaction: budget during score-batch"}, + {KindUnavailable, "unreachable or erroring backend", "compaction: unavailable during score-batch"}, + } { + err := &Error{Kind: tc.kind, Op: opScoreBatch, Err: errors.New(tc.cause)} + got := failureDetail(err) + if !strings.HasPrefix(got, tc.want+": ") { + t.Errorf("failureDetail(%v) = %q, want the %q prefix", tc.kind, got, tc.want) + } + } + // An unclassified error still renders (cause only). + if got := failureDetail(errors.New("mystery")); got != "mystery" { + t.Errorf("failureDetail(unclassified) = %q, want the bare cause", got) + } + // Joined item errors (ScoreBatch's failure shape) resolve to their class. + joined := errors.Join( + &ItemScoreError{ItemID: "probe-1", Err: &Error{Kind: KindAuth, Op: opScoreBatch}}, + &ItemScoreError{ItemID: "probe-2", Err: &Error{Kind: KindAuth, Op: opScoreBatch}}, + ) + if got := failureDetail(joined); !strings.HasPrefix(got, "bad or missing API key: ") { + t.Errorf("failureDetail(joined) = %q, want the auth class prefix", got) + } +} + +// TestFormatCheckReportPass: the report names every stage, the verdict, and +// the honest cost line. +func TestFormatCheckReportPass(t *testing.T) { + results := []CheckResult{ + {Stage: CheckStageBackend, OK: true, Detail: `backend "gateway" url https://gw/decisions model "jev-latest" (api key: env)`}, + {Stage: CheckStageQuestions, OK: true, Latency: 142 * time.Millisecond, Detail: "3/3 questions answered with numeric scores (noul protocol)"}, + {Stage: CheckStageGate, OK: true, Latency: 231 * time.Millisecond, Detail: "gate relocated 1 run(s) — 4 of 4 segments, 512 tokens — into 1 store record(s)"}, + {Stage: CheckStageExpand, OK: true, Latency: 900 * time.Microsecond, Detail: "1 pointer(s) expanded back byte for byte (2016 bytes)"}, + } + out := FormatCheckReport(results, true) + for _, want := range []string{ + "late compaction preflight", + "[ok ] " + CheckStageBackend, + "[ok ] " + CheckStageQuestions, + "[ok ] " + CheckStageGate, + "[ok ] " + CheckStageExpand, + "142ms", + "231ms", + "0.90ms", + "result: PASS (4/4 stages ok)", + "cost: n/a (the decisions client does not track token usage)", + } { + if !strings.Contains(out, want) { + t.Errorf("report missing %q:\n%s", want, out) + } + } +} + +// TestFormatCheckReportFailure: the failing stage's row and the verdict carry +// the class-naming detail, and the report stays line-oriented even when the +// underlying error spans lines (ScoreBatch joins per-item errors with \n). +func TestFormatCheckReportFailure(t *testing.T) { + results := []CheckResult{ + {Stage: CheckStageBackend, OK: true, Detail: `backend "test" url http://x model "m" (api key: env)`}, + {Stage: CheckStageQuestions, OK: false, Latency: 96 * time.Millisecond, + Detail: "bad or missing API key: score item \"probe-1\": compaction: auth (401) during score-batch: denied\nscore item \"probe-2\": compaction: auth (401) during score-batch: denied"}, + } + out := FormatCheckReport(results, false) + for _, want := range []string{ + "[ok ] " + CheckStageBackend, + "[FAIL] " + CheckStageQuestions, + "bad or missing API key", + `result: FAIL (stage "questions" failed)`, + "cost: n/a", + } { + if !strings.Contains(out, want) { + t.Errorf("report missing %q:\n%s", want, out) + } + } + // The multi-line detail was flattened: the report has one row per stage + // plus header, verdict, and cost lines. + if lines := strings.Count(out, "\n"); lines != 5 { + t.Errorf("report has %d lines, want 5 (header + 2 stages + verdict + cost):\n%s", lines, out) + } + // The latency column formats seconds too. + if got := formatLatency(1500 * time.Millisecond); got != "1.50s" { + t.Errorf("formatLatency(1.5s) = %q, want 1.50s", got) + } +} + +// TestCheckToolOutputShape pins the synthetic gate-stage fixture: ~2KB, four +// segments (the fixture must give the gate several decisions to make). +func TestCheckToolOutputShape(t *testing.T) { + out := checkToolOutput() + if len(out) < 1800 || len(out) > 2400 { + t.Errorf("checkToolOutput() = %d bytes, want ~2KB", len(out)) + } + segs := SegmentSegments(out, 0) + if len(segs) != 4 { + t.Fatalf("got %d segments, want 4", len(segs)) + } + assertSegmentsInvariant(t, out, segs) +} diff --git a/internal/compaction/client.go b/internal/compaction/client.go new file mode 100644 index 00000000..7591a749 --- /dev/null +++ b/internal/compaction/client.go @@ -0,0 +1,808 @@ +package compaction + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "math/rand/v2" + "net" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "late/internal/common" +) + +// Item is one unit of tool output to score. +type Item struct { + // Text is the segment text sent to the decision model. + Text string + // Tokens is the precomputed token estimate; 0 means estimate from Text. + // Callers that already counted (the pipeline passes each Segment's + // Tokens) avoid re-running the BPE. + Tokens int +} + +// keepScore is the fail-open score: when an item cannot be scored it is +// reported as fully essential so the downstream decision keeps it. +const keepScore = 1.0 + +// defaultTask is used when a caller hands ScoreBatch an empty task. +const defaultTask = "Continue the user's ongoing task." + +// Production knobs for the decisions protocol. +const ( + // MaxItemsPerRequest is the protocol ceiling of 32 questions per request. + MaxItemsPerRequest = 32 + // MaxStateTokens is the protocol ceiling of 64k tokens for + // state + questions in one request; batches are packed under it and + // single items exceeding it alone are skipped with an error (fail-open). + MaxStateTokens = 64_000 + // decisionRatePerSec/Burst pace the process-local request rate. The + // endpoint tolerates ~1200 RPM fleet-wide; 16 req/s with burst 16 lets a + // 33-item output fire its two batches back-to-back without stampeding. + decisionRatePerSec = 16 + decisionRateBurst = 16 + // perAttemptTimeout bounds one HTTP attempt (request + response body). + perAttemptTimeout = 30 * time.Second + // Retry policy: full-jitter backoff, base 500ms capped at 30s, 4 + // attempts (initial + 3 retries) — the same curve as the agent's + // infrastructure retry tier in internal/executor. + defaultMaxAttempts = 4 + defaultBaseBackoff = 500 * time.Millisecond + defaultMaxBackoff = 30 * time.Second + // retryAfterCeiling caps a server-requested Retry-After wait (mirrors + // internal/executor): a hostile or buggy Retry-After must not hang the + // agent for hours. + retryAfterCeiling = 5 * time.Minute + // requestOverheadTokens is a conservative estimate of the fixed JSON + // skeleton around the state and questions (model, task, the questions + // object, braces and field names). + requestOverheadTokens = 48 + // perItemWireTokens is a conservative flat allowance for the JSON keys + // and braces around one state item and its question ("ref"/"text", + // "type"/"instructions"/"criteria") that the text and ref estimates do + // not count. + perItemWireTokens = 16 + // maxDecisionStatusBodyBytes bounds how much of an error response body + // is read before parsing. + maxDecisionStatusBodyBytes = 8192 + // maxDecisionResponseBytes bounds a 200 response body: answers echo only + // refs and small noul objects, so anything near this bound is hostile. + maxDecisionResponseBytes = 8 << 20 +) + +// noulQuestionFor builds the one question the client asks per item, named +// for that item: the reference's ADMIT_QUESTION (pipeline.py) structure — +// instructions plus true/false criteria — carried by the AdmitQuestion* +// constants in retrieve.go (the reference's conservative shape with wording +// sharpened against real-run replay; see the constants), with scorer.py's +// _ref_question "Considering item only:" prefix — the batch shares one +// state, so the question text is the only thing that tells the model which +// of the batch's items a given answer is about. +func noulQuestionFor(ref string) decisionQuestion { + return decisionQuestion{ + Type: "noul", + Instructions: "Considering item " + ref + " only: " + AdmitQuestionInstructions, + Criteria: decisionCriteria{ + True: AdmitQuestionTrue, + False: AdmitQuestionFalse, + }, + } +} + +// budgetQuestionTokens estimates one per-ref question payload's token cost +// from a representative short ref — the same "i0" stand-in the reference's +// planner uses (scorer.py: estimate_tokens( +// _ref_question(question, "i0").to_payload())). ScoreBatch adds each item's +// own ref on top, so a long id still counts against the budget. +func budgetQuestionTokens() int { + b, err := json.Marshal(noulQuestionFor("i0")) + if err != nil { + // json.Marshal cannot fail on this plain-string struct; the fallback + // merely keeps the budget estimate conservative. + return 128 + } + return common.EstimateTokenCount(string(b)) +} + +// Wire types for the System One decisions protocol — the reference's +// openrouter.py ask() body, scorer.py build_state, and types.py +// Noul.to_payload: +// +// POST {"model": …, "state": {"task": …, "items": [{"ref": …, "text": …}]}, +// "questions": {ref: {"type": "noul", "instructions": …, +// "criteria": {"true": …, "false": …}}}} +// → {"answers": {ref: {"type": "noul", "noul": }}, "usage": {…}} +// +// state.items is an ARRAY of {ref, text} pairs (the reference builds it +// positionally), not a map keyed by ref, and the task travels only in +// state.task — the question instructions never embed it. +type decisionRequest struct { + Model string `json:"model"` + State decisionState `json:"state"` + Questions map[string]decisionQuestion `json:"questions"` +} + +// decisionState is one batch's shared state: the task digest plus the +// batch's items, in request order. +type decisionState struct { + Task string `json:"task"` + Items []stateItem `json:"items"` +} + +// stateItem is one entry of the state's items array: the item's ref (the +// question/answer key) and the text being scored. +type stateItem struct { + Ref string `json:"ref"` + Text string `json:"text"` +} + +// decisionQuestion is the wire form of a Noul question: instructions plus +// the true/false criteria the answer's probability weighs. +type decisionQuestion struct { + Type string `json:"type"` + Instructions string `json:"instructions"` + Criteria decisionCriteria `json:"criteria"` +} + +// decisionCriteria carries the two criterion descriptions of a Noul question. +type decisionCriteria struct { + True string `json:"true"` + False string `json:"false"` +} + +type decisionResponse struct { + Answers map[string]json.RawMessage `json:"answers"` + Usage decisionUsage `json:"usage"` +} + +type decisionUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalTokens int `json:"total_tokens"` +} + +// DecisionStatusError is a non-200 response from the decisions endpoint. +// Like internal/client.StatusError it carries a bounded diagnostic body and +// the parsed Retry-After so the retry loop can honor server pacing. +type DecisionStatusError struct { + StatusCode int + Status string + Body string + RetryAfter time.Duration +} + +func (e *DecisionStatusError) Error() string { + if e.Body != "" { + return fmt.Sprintf("decisions API error (%d): %s", e.StatusCode, e.Body) + } + return fmt.Sprintf("decisions API status: %d", e.StatusCode) +} + +// ItemScoreError records why one item fell back to the keep score. ItemID is +// the caller's key into the returned score map. +type ItemScoreError struct { + ItemID string + Err error +} + +func (e *ItemScoreError) Error() string { + return fmt.Sprintf("score item %q: %v", e.ItemID, e.Err) +} + +func (e *ItemScoreError) Unwrap() error { return e.Err } + +// DecisionClient scores items against one System One decisions backend. +// +// It is safe for concurrent use: the configuration is fixed at construction +// and the only mutable state is the rate limiter's, which is mutex-guarded. +type DecisionClient struct { + backend ResolvedBackend + apiKey string + limiter *tokenBucket + http *http.Client + + // unavailable is the poison flag: set the moment the backend rejects + // auth (401/403), which no retry or re-sent key can fix. Once set, + // ScoreBatch fails open without touching the network and Unavailable() + // lets callers stop asking for the rest of the session (no per-message + // request storms against a backend that can only say 401 again). + unavailable atomic.Bool + + // Retry knobs. Unexported fields rather than constants solely so + // same-package tests can shrink the curve; production code must never + // reassign them (mirrors the executor's throttle-knob convention). + maxAttempts int + baseBackoff time.Duration + maxBackoff time.Duration +} + +// Unavailable reports whether the client has been poisoned by an auth +// rejection (401/403): the backend refused the credentials, so scoring is +// disabled for the rest of the session. Callers check this to stop asking +// for scores; ScoreBatch itself keeps failing open (keep scores, typed +// KindAuth errors) without touching the network. +func (c *DecisionClient) Unavailable() bool { + return c.unavailable.Load() +} + +// poison marks the client unavailable after an auth rejection. Idempotent. +func (c *DecisionClient) poison() { + c.unavailable.Store(true) +} + +// NewDecisionClient builds a client for a resolved backend. apiKey overrides +// backend.APIKey when non-empty (the pipeline passes its own key through). +// A nil-or-empty model or URL fails lazily at call time as item errors — +// ResolveBackend produces a ready backend in normal use. +func NewDecisionClient(backend ResolvedBackend, apiKey string) *DecisionClient { + if apiKey == "" { + apiKey = backend.APIKey + } + return &DecisionClient{ + backend: backend, + apiKey: apiKey, + limiter: newTokenBucket(decisionRatePerSec, decisionRateBurst), + http: &http.Client{Timeout: perAttemptTimeout}, + maxAttempts: defaultMaxAttempts, + baseBackoff: defaultBaseBackoff, + maxBackoff: defaultMaxBackoff, + } +} + +// packed is one item staged into a request batch with its token estimate. +type packed struct { + id string + it Item + tok int +} + +// ScoreBatch scores every item for the given task with one "noul" question +// per item ("Considering item only: " — the +// reference's ADMIT_QUESTION structure via the AdmitQuestion* constants), +// with +// the task traveling only in state.task, batched under the protocol ceilings: +// at most +// MaxItemsPerRequest items and MaxStateTokens estimated tokens per request +// (batches are formed in lexicographic item-ID order, so the split is +// deterministic). +// +// It never fails hard: any item that cannot be scored comes back as keepScore +// (1.0) plus a recorded error. The returned error is non-nil exactly when at +// least one item failed, as an errors.Join of *ItemScoreError values whose +// Err carries the classified *Error where one exists: budget violations +// (KindBudget, raised before send), and the request-level class (auth, +// validation, unavailable) when the whole batch failed. An auth rejection +// (401/403) additionally poisons the client: Unavailable() turns true and +// every later ScoreBatch fails open with typed auth errors and no requests. +func (c *DecisionClient) ScoreBatch(ctx context.Context, task string, items map[string]Item) (map[string]float64, error) { + scores := make(map[string]float64, len(items)) + if len(items) == 0 { + return scores, nil + } + if strings.TrimSpace(task) == "" { + task = defaultTask + } + + ids := make([]string, 0, len(items)) + for id := range items { + ids = append(ids, id) + } + sort.Strings(ids) + + if c.Unavailable() { + // Poisoned by an earlier auth rejection: scoring is off for the + // session. Fail open without touching the network — every id gets + // the keep score and one typed auth error. + errs := make([]error, 0, len(ids)) + for _, id := range ids { + scores[id] = keepScore + errs = append(errs, &ItemScoreError{ + ItemID: id, + Err: authError(0, opScoreBatch, errScoringDisabled), + }) + } + return scores, errors.Join(errs...) + } + + // The per-question token cost is estimated once from a representative + // payload (the reference's planner stands in the ref "i0"); each item + // then adds its own ref and a flat wire allowance, so long ids and the + // per-item JSON keys still count against the budget. + questionTokens := budgetQuestionTokens() + itemCost := func(itemTokens int, ref string) int { + return itemTokens + questionTokens + common.EstimateTokenCount(ref) + perItemWireTokens + } + overhead := requestOverheadTokens + common.EstimateTokenCount(task) + questionTokens + + var ( + errs []error + batch []packed + batchTokens = overhead + ) + flush := func() { + if len(batch) == 0 { + return + } + answered, answerErrs, reqErr := c.scoreBatchRequest(ctx, task, batch) + for _, p := range batch { + switch { + case reqErr != nil: + // The whole request failed: every item in it fail-opens. + scores[p.id] = keepScore + errs = append(errs, &ItemScoreError{ItemID: p.id, Err: reqErr}) + case answerErrs[p.id] != nil: + scores[p.id] = keepScore + errs = append(errs, &ItemScoreError{ItemID: p.id, Err: answerErrs[p.id]}) + default: + scores[p.id] = answered[p.id] + } + } + batch = nil + batchTokens = overhead + } + + for _, id := range ids { + it := items[id] + tok := it.Tokens + if tok <= 0 { + tok = common.EstimateTokenCount(it.Text) + } + if cost := overhead + itemCost(tok, id); cost > MaxStateTokens { + // A single item that cannot fit even alone in a request is a + // budget violation (the reference's JevBudgetError: raised + // before sending). It is skipped per item — the fail-open keep + // score stands — while the rest of the batch still scores. + scores[id] = keepScore + errs = append(errs, &ItemScoreError{ + ItemID: id, + Err: budgetError(opScoreBatch, fmt.Errorf("item too large to score: %d estimated tokens exceeds the %d-token state+questions budget", + tok, MaxStateTokens)), + }) + continue + } + if len(batch) >= MaxItemsPerRequest || batchTokens+itemCost(tok, id) > MaxStateTokens { + flush() + } + batch = append(batch, packed{id: id, it: it, tok: tok}) + batchTokens += itemCost(tok, id) + } + flush() + + return scores, errors.Join(errs...) +} + +// scoreBatchRequest sends one batch as a single decisions request and returns +// the parsed scores plus any per-item answer errors. A non-nil request error +// means the whole batch failed (after retries — except auth and validation, +// which come back on the first attempt, never retried); per-item errors mark +// individual unusable answers. The batch's shared state carries the task plus +// the items as a {ref, text} array; each ref gets its own question naming it +// (noulQuestionFor, the reference's _ref_question). +func (c *DecisionClient) scoreBatchRequest(ctx context.Context, task string, batch []packed) (map[string]float64, map[string]error, error) { + if c.Unavailable() { + // Poisoned mid-call (an earlier batch in this same ScoreBatch hit + // an auth rejection): fail the remaining batches without touching + // the network — the caller fail-opens each item. + return nil, nil, authError(0, opScoreBatch, errScoringDisabled) + } + items := make([]stateItem, len(batch)) + questions := make(map[string]decisionQuestion, len(batch)) + for i, p := range batch { + items[i] = stateItem{Ref: p.id, Text: p.it.Text} + questions[p.id] = noulQuestionFor(p.id) + } + body, err := json.Marshal(decisionRequest{ + Model: c.backend.Backend.Model, + State: decisionState{Task: task, Items: items}, + Questions: questions, + }) + if err != nil { + return nil, nil, fmt.Errorf("encode decisions request: %w", err) + } + + var lastErr error + for attempt := 1; attempt <= c.maxAttempts; attempt++ { + if err := ctx.Err(); err != nil { + return nil, nil, fmt.Errorf("decisions request canceled: %w", err) + } + answered, answerErrs, err := c.attempt(ctx, body) + if err == nil { + for _, p := range batch { + if _, ok := answered[p.id]; !ok && answerErrs[p.id] == nil { + answerErrs[p.id] = fmt.Errorf("decision response missing answer") + } + } + return answered, answerErrs, nil + } + lastErr = err + if !isRetryableDecisionError(err) { + break + } + if attempt == c.maxAttempts { + break + } + if err := sleepCtx(ctx, c.retryDelay(attempt, retryAfterFrom(err))); err != nil { + return nil, nil, fmt.Errorf("decisions request canceled during backoff: %w", err) + } + } + return nil, nil, lastErr +} + +// attempt performs one HTTP attempt: pacing (process-local token bucket, +// then the fleet-wide LLM slot), the request with Bearer auth and the +// X-Title marker, and the response decode. +func (c *DecisionClient) attempt(ctx context.Context, body []byte) (map[string]float64, map[string]error, error) { + if err := c.limiter.wait(ctx); err != nil { + return nil, nil, err + } + // Fleet-wide pacing: compaction/scoring calls share the scoring + // concurrency slot (see acquireScoringSlot in llm_slot.go). No early + // return sits between the + // acquire and Do, so the deferred release covers every exit below. + release := acquireScoringSlot(ctx) + if release == nil && ctx.Err() != nil { + return nil, nil, fmt.Errorf("waiting for an LLM concurrency slot: %w", ctx.Err()) + } + if release != nil { + defer release() + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.backend.Backend.URL, bytes.NewReader(body)) + if err != nil { + return nil, nil, err + } + req.Header.Set("Content-Type", "application/json") + if c.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+c.apiKey) + } + // jev-compaction marks its decisions traffic with this title. + req.Header.Set("X-Title", "jev-compaction") + + resp, err := c.http.Do(req) + if err != nil { + // Transport failure — classified as Unavailable (the reference's + // JevUnavailableError: retried, then fail-open). A permanent + // TLS/scheme mismatch wraps the same class, but the retry decision + // still refuses it via the transport checks. + return nil, nil, classifyStatus(opScoreBatch, 0, "", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + statusErr := newDecisionStatusError(resp) + classified := classifyStatus(opScoreBatch, statusErr.StatusCode, statusErr.Body, statusErr) + if classified.Kind == KindAuth { + // The backend refused the credentials: nothing sent after this + // can succeed. Poison the client so the rest of the session + // stops calling (fail-open, no request storms). + c.poison() + } + return nil, nil, classified + } + + var decoded decisionResponse + if err := json.NewDecoder(io.LimitReader(resp.Body, maxDecisionResponseBytes)).Decode(&decoded); err != nil { + // A 200 that does not speak the protocol is the backend being + // broken, not the request being wrong: Unavailable, retried like + // any other transient failure. + return nil, nil, unavailableError(0, opScoreBatch, fmt.Errorf("decode decisions response: %w", err)) + } + answered, answerErrs := parseAnswers(decoded.Answers) + return answered, answerErrs, nil +} + +// newDecisionStatusError converts a non-200 response into a +// *DecisionStatusError, reading the error body once and bounded. +func newDecisionStatusError(resp *http.Response) *DecisionStatusError { + e := &DecisionStatusError{ + StatusCode: resp.StatusCode, + Status: resp.Status, + } + body, _ := io.ReadAll(io.LimitReader(resp.Body, maxDecisionStatusBodyBytes)) + e.Body = strings.TrimSpace(string(body)) + if ra := parseRetryAfter(resp.Header.Get("Retry-After")); ra > 0 { + e.RetryAfter = ra + } + return e +} + +// retryableStatuses are the protocol's transient failures. +var retryableStatuses = map[int]bool{ + http.StatusRequestTimeout: true, // 408 + http.StatusTooManyRequests: true, // 429 + http.StatusInternalServerError: true, // 500 + http.StatusBadGateway: true, // 502 + http.StatusServiceUnavailable: true, // 503 + http.StatusGatewayTimeout: true, // 504 + 529: true, // origin-is-overloaded (Cloudflare-style) +} + +// nonRetryableStatuses are deterministic client/protocol rejections: burning +// retries cannot change the answer, so they fail fast into the fail-open +// keep score. +var nonRetryableStatuses = map[int]bool{ + http.StatusBadRequest: true, // 400 + http.StatusUnauthorized: true, // 401 + http.StatusPaymentRequired: true, // 402 + http.StatusForbidden: true, // 403 + http.StatusNotFound: true, // 404 + http.StatusMethodNotAllowed: true, // 405 + http.StatusUnprocessableEntity: true, // 422 +} + +// isRetryableDecisionError classifies a failed attempt: protocol-transient +// statuses, any other 5xx (server-side trouble), and ordinary transport +// failures are retryable; deterministic client errors, TLS/certificate +// failures, and cancellation are not. Typed taxonomy errors decide by class: +// only KindUnavailable can ever be retried — auth (401/403) and validation +// (400/404/422) come back to the caller on the first attempt (the reference +// never retries JevValidationError) — and even an Unavailable classification +// still falls through to the transport checks, so a permanent TLS/scheme +// failure wrapped as Unavailable is not retried either. +func isRetryableDecisionError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return false + } + var ce *Error + if errors.As(err, &ce) { + switch ce.Kind { + case KindAuth, KindValidation, KindBudget: + // Deterministic: retrying cannot change the answer. Budget is + // raised before send and resolved by splitting, not retrying. + return false + } + // KindUnavailable falls through to the transport checks below. + } + var se *DecisionStatusError + if errors.As(err, &se) { + if nonRetryableStatuses[se.StatusCode] { + return false + } + if retryableStatuses[se.StatusCode] { + return true + } + // Unlisted statuses: any other server-side trouble is treated as + // transient; anything else is not worth a retry. + return se.StatusCode >= 500 && se.StatusCode <= 599 + } + var ue *url.Error + if errors.As(err, &ue) { + return !isPermanentTransportError(ue) + } + var ne net.Error + if errors.As(err, &ne) { + return ne.Timeout() + } + // Unknown failures (e.g. a malformed 200 body): the fail-open bias + // prefers one more attempt over giving up early. + return true +} + +// isPermanentTransportError reports whether a url.Error wraps a cause that +// retrying cannot fix: TLS certificate/trust failures, non-TLS bytes on a +// TLS connection, or an unsupported URL scheme. +func isPermanentTransportError(ue *url.Error) bool { + var authErr x509.UnknownAuthorityError + if errors.As(ue.Err, &authErr) { + return true + } + var hostErr x509.HostnameError + if errors.As(ue.Err, &hostErr) { + return true + } + var certErr x509.CertificateInvalidError + if errors.As(ue.Err, &certErr) { + return true + } + var recordErr tls.RecordHeaderError + if errors.As(ue.Err, &recordErr) { + return true + } + msg := ue.Err.Error() + return strings.HasPrefix(msg, "tls:") || + strings.HasPrefix(msg, "unsupported protocol scheme") || + strings.HasPrefix(msg, "http: server gave HTTP response to HTTPS client") +} + +// retryAfterFrom extracts the server-requested Retry-After from a +// *DecisionStatusError anywhere in the error chain; 0 when absent. +func retryAfterFrom(err error) time.Duration { + var se *DecisionStatusError + if errors.As(err, &se) { + return se.RetryAfter + } + return 0 +} + +// maxRetryAttempt clamps the attempt exponent so the 1<<(attempt-1) shift +// can never overflow before the cap is applied. +const maxRetryAttempt = 40 + +// retryDelay returns the full-jitter wait before retry attempt (1-based): +// uniform over [0, min(maxBackoff, baseBackoff*2^(attempt-1))], never shorter +// than the server-requested Retry-After (capped at retryAfterCeiling) — +// the same shape as internal/executor's effectiveRetryDelay. +func (c *DecisionClient) retryDelay(attempt int, retryAfter time.Duration) time.Duration { + if attempt < 1 { + attempt = 1 + } + if attempt > maxRetryAttempt { + attempt = maxRetryAttempt + } + backoff := c.baseBackoff * (1 << (attempt - 1)) + if backoff <= 0 || backoff > c.maxBackoff { // <=0 guards shift overflow + backoff = c.maxBackoff + } + delay := rand.N(backoff) + if retryAfter > retryAfterCeiling { + retryAfter = retryAfterCeiling + } + if retryAfter > delay { + return retryAfter + } + return delay +} + +// parseAnswers converts the raw answer map into scores, tolerating the +// protocol's noul answer objects and, for local gateways, bare numeric and +// string-number payloads, clamped into [0,1]. Unusable answers land in the +// returned per-item error map instead of failing the batch. +func parseAnswers(raw map[string]json.RawMessage) (map[string]float64, map[string]error) { + answered := make(map[string]float64, len(raw)) + errs := make(map[string]error) + for id, v := range raw { + s, err := parseScore(v) + if err != nil { + errs[id] = err + continue + } + answered[id] = s + } + return answered, errs +} + +// parseScore parses one answer payload: the protocol's noul answer object +// ({"type":"noul","noul":} — types.py parse_answer), a bare JSON +// number, or a string holding one (the bare forms stay tolerated for local +// gateways and test stubs), clamped into [0,1]. A noul-typed object without +// a usable "noul" number falls through and is rejected — it must not decode +// as a silent 0 (an elision). +func parseScore(v json.RawMessage) (float64, error) { + trimmed := bytes.TrimSpace(v) + if len(trimmed) == 0 { + return 0, fmt.Errorf("empty answer") + } + var noul struct { + Type string `json:"type"` + Noul *float64 `json:"noul"` + } + if err := json.Unmarshal(trimmed, &noul); err == nil && noul.Type == "noul" && noul.Noul != nil { + return clampScore(*noul.Noul), nil + } + var f float64 + if err := json.Unmarshal(trimmed, &f); err == nil { + return clampScore(f), nil + } + var s string + if err := json.Unmarshal(trimmed, &s); err == nil { + f, err := strconv.ParseFloat(strings.TrimSpace(s), 64) + if err != nil { + return 0, fmt.Errorf("non-numeric answer %q", s) + } + return clampScore(f), nil + } + payload := string(trimmed) + if len(payload) > 64 { + payload = payload[:64] + "…" + } + return 0, fmt.Errorf("unexpected answer payload %s", payload) +} + +// clampScore pins a score into [0,1]; out-of-range model output is clamped +// rather than rejected so one wild answer does not fail an item. +func clampScore(f float64) float64 { + return math.Max(0, math.Min(1, f)) +} + +// sleepCtx waits d, returning early with the context's error if ctx ends. +func sleepCtx(ctx context.Context, d time.Duration) error { + if d <= 0 { + return nil + } + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + return nil + } +} + +// parseRetryAfter parses a Retry-After header value in either delta-seconds +// ("2") or HTTP-date form. Empty, invalid, and non-positive values yield 0. +// (Port-local copy of internal/client's helper, which is unexported and +// deliberately not part of the provider layer compaction reuses.) +func parseRetryAfter(v string) time.Duration { + v = strings.TrimSpace(v) + if v == "" { + return 0 + } + if secs, err := strconv.Atoi(v); err == nil { + if secs <= 0 { + return 0 + } + return time.Duration(secs) * time.Second + } + if date, err := http.ParseTime(v); err == nil { + if d := time.Until(date); d > 0 { + return d + } + } + return 0 +} + +// tokenBucket is a minimal process-local rate limiter: the decisions endpoint +// is metered per request, and a 33-item tool output would otherwise fire its +// batches back-to-back. It is mutex-guarded and safe for concurrent use. +type tokenBucket struct { + mu sync.Mutex + tokens float64 + burst float64 + rate float64 // tokens per second + last time.Time +} + +func newTokenBucket(rate, burst float64) *tokenBucket { + return &tokenBucket{tokens: burst, burst: burst, rate: rate, last: time.Now()} +} + +// wait blocks until one token is available or ctx ends. A nil bucket is +// unlimited (used by tests that bypass construction defaults). +func (b *tokenBucket) wait(ctx context.Context) error { + if b == nil { + return nil + } + for { + b.mu.Lock() + now := time.Now() + b.tokens += now.Sub(b.last).Seconds() * b.rate + b.last = now + if b.tokens > b.burst { + b.tokens = b.burst + } + if b.tokens >= 1 { + b.tokens-- + b.mu.Unlock() + return nil + } + deficit := time.Duration((1 - b.tokens) / b.rate * float64(time.Second)) + b.mu.Unlock() + if deficit < time.Millisecond { + deficit = time.Millisecond + } + t := time.NewTimer(deficit) + select { + case <-ctx.Done(): + t.Stop() + return ctx.Err() + case <-t.C: + } + } +} diff --git a/internal/compaction/client_test.go b/internal/compaction/client_test.go new file mode 100644 index 00000000..15b00f28 --- /dev/null +++ b/internal/compaction/client_test.go @@ -0,0 +1,747 @@ +package compaction + +import ( + "context" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" +) + +// capturedDecisionRequest mirrors the wire request so tests can assert the +// reference protocol shape (model, state with a {ref,text} items array, +// per-item noul questions with instructions and criteria). +type capturedDecisionRequest struct { + Model string `json:"model"` + State struct { + Task string `json:"task"` + Items []stateItem `json:"items"` + } `json:"state"` + Questions map[string]decisionQuestion `json:"questions"` +} + +// capturedRequest is one decoded request plus its auth headers. +type capturedRequest struct { + Req capturedDecisionRequest + Auth string + Title string + ContentType string + Path string +} + +// decisionsServer is a scripted decisions endpoint. Each attempt calls +// handler with the 1-based attempt number and the decoded request and serves +// the returned status and body. retryAfter, when non-empty, is set on every +// non-200 response. +type decisionsServer struct { + srv *httptest.Server + retryAfter string + mu sync.Mutex + got []capturedRequest + + handler func(attempt int, req capturedRequest) (int, string) +} + +func newDecisionsServer(t *testing.T, handler func(attempt int, req capturedRequest) (int, string)) *decisionsServer { + t.Helper() + d := &decisionsServer{handler: handler} + d.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, maxDecisionResponseBytes)) + if err != nil { + t.Errorf("read request body: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + var cr capturedRequest + if err := json.Unmarshal(body, &cr.Req); err != nil { + t.Errorf("decode request body: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + cr.Auth = r.Header.Get("Authorization") + cr.Title = r.Header.Get("X-Title") + cr.ContentType = r.Header.Get("Content-Type") + cr.Path = r.URL.Path + + d.mu.Lock() + d.got = append(d.got, cr) + attempt := len(d.got) + d.mu.Unlock() + + status, respBody := handler(attempt, cr) + if status != http.StatusOK && d.retryAfter != "" { + w.Header().Set("Retry-After", d.retryAfter) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = io.WriteString(w, respBody) + })) + t.Cleanup(d.srv.Close) + return d +} + +func (d *decisionsServer) requests() []capturedRequest { + d.mu.Lock() + defer d.mu.Unlock() + return append([]capturedRequest(nil), d.got...) +} + +// answersBody builds a decisions response serving each ref its score as the +// protocol's noul answer object ({"type":"noul","noul":}). +func answersBody(scores map[string]float64) string { + answers := make(map[string]any, len(scores)) + for ref, s := range scores { + answers[ref] = map[string]any{"type": "noul", "noul": s} + } + b, err := json.Marshal(map[string]any{"answers": answers, "usage": map[string]any{"input_tokens": 10, "output_tokens": 0}}) + if err != nil { + panic(err) + } + return string(b) +} + +// echoHandler answers every request with a score derived from the item id +// (ids "seg-N" score 0.01*N, kept inside [0,1] so clamping never kicks in), +// so tests can verify round-tripping. +func echoHandler(_ int, req capturedRequest) (int, string) { + scores := make(map[string]float64, len(req.Req.Questions)) + for ref := range req.Req.Questions { + var n int + if _, err := fmt.Sscanf(ref, "seg-%d", &n); err == nil { + scores[ref] = float64(n) * 0.01 + } else { + scores[ref] = 0.42 + } + } + return http.StatusOK, answersBody(scores) +} + +// fastClient builds a DecisionClient against the given URL with the retry +// curve shrunk for tests, keeping the production attempt count unless +// overridden (attempts <= 0 keeps the default of 4). +func fastClient(url string, attempts int) *DecisionClient { + c := NewDecisionClient(ResolvedBackend{Backend: Backend{Name: "test", URL: url, Model: "jev-latest"}}, "test-key") + c.baseBackoff = time.Millisecond + c.maxBackoff = 2 * time.Millisecond + if attempts > 0 { + c.maxAttempts = attempts + } + return c +} + +// TestScoreBatch_BatchesAboveProtocolCeiling: 33 items must split into two +// requests of ≤32 questions each, with every item scored exactly once. +func TestScoreBatch_BatchesAboveProtocolCeiling(t *testing.T) { + d := newDecisionsServer(t, echoHandler) + c := fastClient(d.srv.URL+"/v1/systemone", 0) + + items := make(map[string]Item, 33) + for i := 1; i <= 33; i++ { + items[fmt.Sprintf("seg-%d", i)] = Item{Text: fmt.Sprintf("paragraph %d", i), Tokens: 10} + } + scores, err := c.ScoreBatch(context.Background(), "Ship the release", items) + if err != nil { + t.Fatalf("ScoreBatch() error = %v", err) + } + + reqs := d.requests() + if len(reqs) != 2 { + t.Fatalf("got %d requests, want 2 (32-item ceiling)", len(reqs)) + } + seen := map[string]bool{} + for i, r := range reqs { + if n := len(r.Req.Questions); n > MaxItemsPerRequest { + t.Errorf("request %d carried %d questions, want ≤%d", i+1, n, MaxItemsPerRequest) + } + if r.Req.Model != "jev-latest" { + t.Errorf("request %d model = %q, want jev-latest", i+1, r.Req.Model) + } + if r.Req.State.Task != "Ship the release" { + t.Errorf("request %d state.task = %q, want Ship the release", i+1, r.Req.State.Task) + } + for ref := range r.Req.Questions { + if seen[ref] { + t.Errorf("item %s sent in more than one request", ref) + } + seen[ref] = true + q := r.Req.Questions[ref] + if q.Type != "noul" { + t.Errorf("item %s question type = %q, want noul", ref, q.Type) + } + wantInstructions := "Considering item " + ref + " only: " + AdmitQuestionInstructions + if q.Instructions != wantInstructions { + t.Errorf("item %s question instructions = %q, want %q", ref, q.Instructions, wantInstructions) + } + if q.Criteria.True != AdmitQuestionTrue || q.Criteria.False != AdmitQuestionFalse { + t.Errorf("item %s question criteria = %+v, want the admit true/false texts", ref, q.Criteria) + } + // state.items is an array of {ref,text}: the asked ref must + // appear exactly once, carrying its own text. + var item *stateItem + for j := range r.Req.State.Items { + if r.Req.State.Items[j].Ref == ref { + if item != nil { + t.Errorf("item %s appears more than once in state.items", ref) + } + item = &r.Req.State.Items[j] + } + } + if item == nil { + t.Errorf("item %s has a question but no state.items entry", ref) + continue + } + var n int + if _, err := fmt.Sscanf(ref, "seg-%d", &n); err == nil { + if want := fmt.Sprintf("paragraph %d", n); item.Text != want { + t.Errorf("item %s state text = %q, want %q", ref, item.Text, want) + } + } + } + } + if len(seen) != 33 { + t.Errorf("total distinct questions = %d, want 33", len(seen)) + } + if len(scores) != 33 { + t.Fatalf("got %d scores, want 33", len(scores)) + } + for i := 1; i <= 33; i++ { + ref := fmt.Sprintf("seg-%d", i) + if want := float64(i) * 0.01; scores[ref] != want { + t.Errorf("scores[%s] = %v, want %v", ref, scores[ref], want) + } + } +} + +// TestScoreBatch_ReferenceWireShape is the golden test: the exact bytes of a +// two-item decisions request must match the reference protocol (scorer.py +// build_state + _ref_question, types.py Noul.to_payload, openrouter.py ask) +// — state.items is an ARRAY of {ref,text} pairs, each question carries +// instructions ("Considering item only: …") plus true/false criteria, +// the task travels only in state.task, and the answer comes back as a noul +// object. The question texts are interpolated from the AdmitQuestion* +// constants, so their wording is pinned verbatim against pipeline.py's +// ADMIT_QUESTION below. +func TestScoreBatch_ReferenceWireShape(t *testing.T) { + rawCh := make(chan []byte, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read request body: %v", err) + } + rawCh <- body + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"answers": {"seg-1": {"type": "noul", "noul": 0.75}, "seg-2": {"type": "noul", "noul": 0.25}}}`) + })) + t.Cleanup(srv.Close) + + c := fastClient(srv.URL, 1) + if _, err := c.ScoreBatch(context.Background(), "Ship the release", map[string]Item{ + "seg-1": {Text: "alpha text", Tokens: 5}, + "seg-2": {Text: "beta text", Tokens: 5}, + }); err != nil { + t.Fatalf("ScoreBatch() error = %v", err) + } + raw := <-rawCh + + want := `{"model":"jev-latest","state":{"task":"Ship the release","items":[` + + `{"ref":"seg-1","text":"alpha text"},{"ref":"seg-2","text":"beta text"}]},` + + `"questions":{` + + `"seg-1":{"type":"noul","instructions":"Considering item seg-1 only: ` + AdmitQuestionInstructions + `","criteria":{"true":"` + AdmitQuestionTrue + `","false":"` + AdmitQuestionFalse + `"}},` + + `"seg-2":{"type":"noul","instructions":"Considering item seg-2 only: ` + AdmitQuestionInstructions + `","criteria":{"true":"` + AdmitQuestionTrue + `","false":"` + AdmitQuestionFalse + `"}}}}` + if string(raw) != want { + t.Errorf("request body does not match the reference wire shape:\n got: %s\nwant: %s", raw, want) + } + + // The admit wording must stay pinned — the golden request above + // interpolates the constants, so the text itself is pinned here: the + // sharpened boundary (noise = progress output/confirmations/boilerplate/ + // re-derivable dumps; essential = concrete, non-re-derivable facts; + // verbose intermediate logs are noise, their final results essential). + if AdmitQuestionInstructions != "Will this item still be needed later in the task described in `task`? "+ + "Answer false if it is NOISE: progress output, success or progress confirmations, repeated "+ + "boilerplate, or a large repetitive dump (verbose intermediate logs, build or test output, "+ + "file listings) whose key facts — file paths, commands, error messages, final results — are "+ + "retained in the surrounding kept content or can be re-derived by rerunning the step. "+ + "Verbose intermediate logs are noise even when they mention relevant words; the final result "+ + "or summary of such a log is essential. "+ + "Answer true only if it is ESSENTIAL: it contains concrete facts a later step may have to "+ + "refer back to — file paths, commands and their outcomes, error messages, decisions, user "+ + "preferences, todo state, numbers or results, or the key fields of an API response — that "+ + "are not retained elsewhere and cannot be re-derived." { + t.Errorf("AdmitQuestionInstructions drifted from the pinned admit wording: %q", AdmitQuestionInstructions) + } + if AdmitQuestionTrue != "The item carries concrete facts a later step may need — paths, commands, "+ + "errors, decisions, results, or key response fields — that are not retained elsewhere and "+ + "cannot be re-derived." { + t.Errorf("AdmitQuestionTrue drifted from the pinned wording: %q", AdmitQuestionTrue) + } + if AdmitQuestionFalse != "The item is progress noise, a confirmation, repeated boilerplate, or a "+ + "verbose dump whose useful facts are retained nearby or re-derivable — eliding it loses "+ + "nothing a later step cannot recover." { + t.Errorf("AdmitQuestionFalse drifted from the pinned wording: %q", AdmitQuestionFalse) + } +} + +// TestScoreBatch_PacksByTokenBudget: items whose combined tokens exceed the +// 64k state+questions ceiling split across requests even under 32 items. +func TestScoreBatch_PacksByTokenBudget(t *testing.T) { + d := newDecisionsServer(t, echoHandler) + c := fastClient(d.srv.URL, 0) + + // Three 30k-token items: two fit together under 64k, the third must + // start a new request. + items := map[string]Item{ + "seg-1": {Text: "one", Tokens: 30_000}, + "seg-2": {Text: "two", Tokens: 30_000}, + "seg-3": {Text: "three", Tokens: 30_000}, + } + scores, err := c.ScoreBatch(context.Background(), "task", items) + if err != nil { + t.Fatalf("ScoreBatch() error = %v", err) + } + if got := len(d.requests()); got != 2 { + t.Fatalf("got %d requests, want 2 (token-budget packing)", got) + } + if len(scores) != 3 { + t.Errorf("got %d scores, want 3", len(scores)) + } +} + +// TestScoreBatch_OversizedItemSkipped: an item that cannot fit in any +// request is skipped with an error and fail-opens to 1.0; the rest still +// score and the oversized text never reaches the wire. +func TestScoreBatch_OversizedItemSkipped(t *testing.T) { + d := newDecisionsServer(t, echoHandler) + c := fastClient(d.srv.URL, 0) + + items := map[string]Item{ + "seg-1": {Text: "small", Tokens: 100}, + "seg-huge": {Text: "huge", Tokens: MaxStateTokens}, // alone busts the budget once overhead is added + "seg-2": {Text: "small too", Tokens: 100}, + } + scores, err := c.ScoreBatch(context.Background(), "task", items) + if err == nil { + t.Fatal("ScoreBatch() error = nil, want an oversized-item error") + } + var ise *ItemScoreError + if !errors.As(err, &ise) || ise.ItemID != "seg-huge" { + t.Fatalf("error = %v, want *ItemScoreError for seg-huge", err) + } + if !strings.Contains(err.Error(), "too large") { + t.Errorf("error %q should say the item is too large", err.Error()) + } + if scores["seg-huge"] != keepScore { + t.Errorf("oversized item score = %v, want %v (fail-open)", scores["seg-huge"], keepScore) + } + if scores["seg-1"] != 0.01 || scores["seg-2"] != 0.02 { + t.Errorf("small items = %v/%v, want the echoHandler scores 0.01/0.02, not fail-open %v", scores["seg-1"], scores["seg-2"], keepScore) + } + for i, r := range d.requests() { + found := false + for _, it := range r.Req.State.Items { + if it.Ref == "seg-huge" { + found = true + } + } + if found { + t.Errorf("request %d carried the oversized item", i+1) + } + } +} + +// TestScoreBatch_RetryOn429HonorsRetryAfter: a 429 with Retry-After delays +// the retry by at least the requested amount, and the retry succeeds. +func TestScoreBatch_RetryOn429HonorsRetryAfter(t *testing.T) { + d := newDecisionsServer(t, func(attempt int, req capturedRequest) (int, string) { + if attempt == 1 { + return http.StatusTooManyRequests, `{"error": {"message": "slow down"}}` + } + return echoHandler(attempt, req) + }) + // Retry-After is honored as a floor; the local backoff is shrunk to ~0, + // so the observed elapsed time proves the header was honored. + c := fastClient(d.srv.URL, 0) + c.baseBackoff = time.Millisecond + c.maxBackoff = time.Millisecond + d.retryAfter = "1" + + start := time.Now() + scores, err := c.ScoreBatch(context.Background(), "task", map[string]Item{"seg-1": {Text: "x", Tokens: 5}}) + elapsed := time.Since(start) + if err != nil { + t.Fatalf("ScoreBatch() error = %v", err) + } + if scores["seg-1"] != 0.01 { + t.Errorf("scores[seg-1] = %v, want the echoHandler score 0.01 after the successful retry", scores["seg-1"]) + } + if elapsed < 900*time.Millisecond { + t.Errorf("retry happened after %v, want ≥ ~1s (Retry-After floor)", elapsed) + } + if got := len(d.requests()); got != 2 { + t.Errorf("got %d requests, want 2 (429 then success)", got) + } +} + +// TestScoreBatch_NonRetryableStatusFailsFast: 401 must not be retried; all +// items fail-open to 1.0 with one recorded error each. +func TestScoreBatch_NonRetryableStatusFailsFast(t *testing.T) { + d := newDecisionsServer(t, func(_ int, _ capturedRequest) (int, string) { + return http.StatusUnauthorized, `{"error": {"message": "bad key"}}` + }) + c := fastClient(d.srv.URL, 4) + + items := map[string]Item{ + "seg-1": {Text: "a", Tokens: 5}, + "seg-2": {Text: "b", Tokens: 5}, + } + scores, err := c.ScoreBatch(context.Background(), "task", items) + if err == nil { + t.Fatal("ScoreBatch() error = nil, want a recorded failure") + } + if got := len(d.requests()); got != 1 { + t.Errorf("got %d requests, want 1 (401 is non-retryable)", got) + } + for ref := range items { + if scores[ref] != keepScore { + t.Errorf("scores[%s] = %v, want %v (fail-open)", ref, scores[ref], keepScore) + } + } + var itemErrs int + for _, e := range strings.Split(err.Error(), "\n") { + if strings.Contains(e, "score item \"seg-") { + itemErrs++ + } + } + if itemErrs != 2 { + t.Errorf("error reports %d item failures, want 2 (one per item): %v", itemErrs, err) + } +} + +// TestScoreBatch_ProviderOutageFailsOpen: a hard outage retries the full +// budget and then keeps everything, with one recorded error per item. +func TestScoreBatch_ProviderOutageFailsOpen(t *testing.T) { + d := newDecisionsServer(t, func(_ int, _ capturedRequest) (int, string) { + return http.StatusServiceUnavailable, `{"error": {"message": "overloaded"}}` + }) + c := fastClient(d.srv.URL, 4) + + items := map[string]Item{ + "seg-1": {Text: "a", Tokens: 5}, + "seg-2": {Text: "b", Tokens: 5}, + } + scores, err := c.ScoreBatch(context.Background(), "task", items) + if err == nil { + t.Fatal("ScoreBatch() error = nil, want a recorded failure") + } + if got := len(d.requests()); got != 4 { + t.Errorf("got %d requests, want 4 (full attempt budget)", got) + } + for ref := range items { + if scores[ref] != keepScore { + t.Errorf("scores[%s] = %v, want %v (fail-open keeps everything on outage)", ref, scores[ref], keepScore) + } + } + for _, ref := range []string{"seg-1", "seg-2"} { + if !strings.Contains(err.Error(), fmt.Sprintf("score item %q", ref)) { + t.Errorf("error %v missing the failure for %q", err, ref) + } + } +} + +// TestScoreBatch_AuthAndTitleHeaders asserts Bearer auth and the +// jev-compaction X-Title marker on every request. +func TestScoreBatch_AuthAndTitleHeaders(t *testing.T) { + d := newDecisionsServer(t, echoHandler) + c := fastClient(d.srv.URL, 0) + + if _, err := c.ScoreBatch(context.Background(), "task", map[string]Item{"seg-1": {Text: "x", Tokens: 5}}); err != nil { + t.Fatalf("ScoreBatch() error = %v", err) + } + for i, r := range d.requests() { + if r.Auth != "Bearer test-key" { + t.Errorf("request %d Authorization = %q, want %q", i+1, r.Auth, "Bearer test-key") + } + if r.Title != "jev-compaction" { + t.Errorf("request %d X-Title = %q, want %q", i+1, r.Title, "jev-compaction") + } + if r.ContentType != "application/json" { + t.Errorf("request %d Content-Type = %q, want application/json", i+1, r.ContentType) + } + } +} + +// TestScoreBatch_NoKeySkipsAuthHeader: an empty key must not produce a bare +// "Bearer " header. +func TestScoreBatch_NoKeySkipsAuthHeader(t *testing.T) { + d := newDecisionsServer(t, echoHandler) + c := NewDecisionClient(ResolvedBackend{Backend: Backend{URL: d.srv.URL, Model: "jev-latest"}}, "") + c.baseBackoff, c.maxBackoff = time.Millisecond, time.Millisecond + + if _, err := c.ScoreBatch(context.Background(), "task", map[string]Item{"seg-1": {Text: "x", Tokens: 5}}); err != nil { + t.Fatalf("ScoreBatch() error = %v", err) + } + if r := d.requests()[0]; r.Auth != "" { + t.Errorf("Authorization = %q, want no auth header without a key", r.Auth) + } +} + +// TestScoreBatch_PerBackendURL asserts each provider's endpoint is hit — +// typesafe, openrouter, and gateway URL shapes over one test server. +func TestScoreBatch_PerBackendURL(t *testing.T) { + d := newDecisionsServer(t, echoHandler) + paths := []string{ + "/v1/systemone", // typesafe-shaped + "/api/alpha/decisions", // openrouter-shaped + "/gateway/decisions", // gateway-shaped + } + for _, p := range paths { + c := fastClient(d.srv.URL+p, 0) + if _, err := c.ScoreBatch(context.Background(), "task", map[string]Item{"seg-1": {Text: "x", Tokens: 5}}); err != nil { + t.Fatalf("ScoreBatch(%s) error = %v", p, err) + } + } + got := d.requests() + if len(got) != len(paths) { + t.Fatalf("got %d requests, want %d", len(got), len(paths)) + } + for i, p := range paths { + if got[i].Path != p { + t.Errorf("request %d hit %q, want %q", i+1, got[i].Path, p) + } + } +} + +// TestScoreBatch_AnswerParsingLenient: the protocol's noul answer object is +// the primary answer form; bare numbers and string numbers (local gateways, +// test stubs) stay tolerated; everything parses clamped into [0,1]; junk +// answers (non-numeric strings, wrong-type objects, noul objects without a +// number) fail that item only. +func TestScoreBatch_AnswerParsingLenient(t *testing.T) { + d := newDecisionsServer(t, func(_ int, _ capturedRequest) (int, string) { + return http.StatusOK, `{"answers": { + "seg-num": 0.25, + "seg-str": "0.5", + "seg-high": 1.7, + "seg-low": -0.2, + "seg-obj": {"type": "noul", "noul": 0.75}, + "seg-obj-high": {"type": "noul", "noul": 2.0}, + "seg-junk": "banana", + "seg-wrongtype": {"type": "choice", "choice": "a"}, + "seg-nonoul": {"type": "noul"} + }}` + }) + c := fastClient(d.srv.URL, 0) + + items := map[string]Item{ + "seg-num": {Text: "a", Tokens: 1}, + "seg-str": {Text: "b", Tokens: 1}, + "seg-high": {Text: "c", Tokens: 1}, + "seg-low": {Text: "d", Tokens: 1}, + "seg-obj": {Text: "e", Tokens: 1}, + "seg-obj-high": {Text: "f", Tokens: 1}, + "seg-junk": {Text: "g", Tokens: 1}, + "seg-wrongtype": {Text: "h", Tokens: 1}, + "seg-nonoul": {Text: "i", Tokens: 1}, + } + scores, err := c.ScoreBatch(context.Background(), "task", items) + if err == nil { + t.Fatal("ScoreBatch() error = nil, want the unusable answers recorded as errors") + } + want := map[string]float64{ + "seg-num": 0.25, + "seg-str": 0.5, + "seg-high": 1, // clamped + "seg-low": 0, // clamped + "seg-obj": 0.75, + "seg-obj-high": 1, // clamped + "seg-junk": 1, // fail-open + "seg-wrongtype": 1, // fail-open + "seg-nonoul": 1, // fail-open (noul object without a number must not decode as a silent 0) + } + for ref, w := range want { + if scores[ref] != w { + t.Errorf("scores[%s] = %v, want %v", ref, scores[ref], w) + } + } + for _, id := range []string{"seg-junk", "seg-wrongtype", "seg-nonoul"} { + found := false + for _, e := range strings.Split(err.Error(), "\n") { + if strings.Contains(e, fmt.Sprintf("score item %q", id)) { + found = true + } + } + if !found { + t.Errorf("error %v missing the failure for %q", err, id) + } + } +} + +// TestScoreBatch_MissingAnswerFailsOpenItemOnly: a response that omits one +// ref keeps that ref at 1.0 without touching the others. +func TestScoreBatch_MissingAnswerFailsOpenItemOnly(t *testing.T) { + d := newDecisionsServer(t, func(_ int, _ capturedRequest) (int, string) { + return http.StatusOK, `{"answers": {"seg-1": {"type": "noul", "noul": 0.3}}}` + }) + c := fastClient(d.srv.URL, 0) + + scores, err := c.ScoreBatch(context.Background(), "task", map[string]Item{ + "seg-1": {Text: "a", Tokens: 1}, + "seg-2": {Text: "b", Tokens: 1}, + }) + if err == nil { + t.Fatal("ScoreBatch() error = nil, want the missing answer recorded") + } + if scores["seg-1"] != 0.3 { + t.Errorf("scores[seg-1] = %v, want 0.3", scores["seg-1"]) + } + if scores["seg-2"] != keepScore { + t.Errorf("scores[seg-2] = %v, want %v (fail-open)", scores["seg-2"], keepScore) + } +} + +// TestScoreBatch_EmptyItems is a no-op. +func TestScoreBatch_EmptyItems(t *testing.T) { + c := fastClient("http://127.0.0.1:1/x", 0) + scores, err := c.ScoreBatch(context.Background(), "task", nil) + if err != nil { + t.Fatalf("ScoreBatch() error = %v", err) + } + if len(scores) != 0 { + t.Errorf("scores = %v, want empty", scores) + } +} + +// TestScoreBatch_EmptyTaskGetsDefault: an empty task still produces a valid +// question. +func TestScoreBatch_EmptyTaskGetsDefault(t *testing.T) { + d := newDecisionsServer(t, echoHandler) + c := fastClient(d.srv.URL, 0) + if _, err := c.ScoreBatch(context.Background(), " ", map[string]Item{"seg-1": {Text: "x", Tokens: 1}}); err != nil { + t.Fatalf("ScoreBatch() error = %v", err) + } + if got := d.requests()[0].Req.State.Task; got != defaultTask { + t.Errorf("state.task = %q, want %q", got, defaultTask) + } +} + +// TestIsRetryableDecisionError covers the status/transport matrix: the +// protocol's retryable set {408,429,500,502,503,504,529} plus transport +// failures, versus the non-retryable set {400,401,402,403,404,405,422} plus +// TLS errors and cancellation. +func TestIsRetryableDecisionError(t *testing.T) { + statusErr := func(code int) *DecisionStatusError { + return &DecisionStatusError{StatusCode: code, Status: fmt.Sprintf("%d x", code)} + } + retryable := []error{ + statusErr(408), statusErr(429), statusErr(500), statusErr(502), + statusErr(503), statusErr(504), statusErr(529), statusErr(501), + &url.Error{Op: "Post", Err: errors.New("connection refused")}, + errors.New("decode decisions response: unexpected EOF"), + } + nonRetryable := []error{ + statusErr(400), statusErr(401), statusErr(402), statusErr(403), + statusErr(404), statusErr(405), statusErr(422), statusErr(409), + statusErr(301), + &url.Error{Op: "Post", Err: x509.UnknownAuthorityError{}}, + &url.Error{Op: "Post", Err: fmt.Errorf("tls: handshake failure")}, + &url.Error{Op: "Post", Err: errors.New(`unsupported protocol scheme "ftp"`)}, + context.Canceled, + fmt.Errorf("wrapped: %w", context.DeadlineExceeded), + } + for _, err := range retryable { + if !isRetryableDecisionError(err) { + t.Errorf("isRetryableDecisionError(%v) = false, want true", err) + } + } + for _, err := range nonRetryable { + if isRetryableDecisionError(err) { + t.Errorf("isRetryableDecisionError(%v) = true, want false", err) + } + } + if isRetryableDecisionError(nil) { + t.Error("isRetryableDecisionError(nil) = true, want false") + } +} + +// TestDecisionClient_ScoringLimiterShared proves the compaction client goes +// through the scoring-slot wrapper: under a cap of 1, two concurrent +// ScoreBatch calls must serialize inside the server. +func TestDecisionClient_ScoringLimiterShared(t *testing.T) { + // First, the wrapper itself: unlimited → nil, capped → non-nil. + setScoringConcurrency(0) + if rel := acquireScoringSlot(context.Background()); rel != nil { + t.Error("acquireScoringSlot with unlimited limiter returned non-nil release") + } + setScoringConcurrency(1) + defer setScoringConcurrency(0) + rel := acquireScoringSlot(context.Background()) + if rel == nil { + t.Fatal("acquireScoringSlot with cap 1 returned nil release") + } + rel() + + var ( + mu sync.Mutex + inFlight int + peak int + ) + d := newDecisionsServer(t, func(_ int, req capturedRequest) (int, string) { + mu.Lock() + inFlight++ + if inFlight > peak { + peak = inFlight + } + mu.Unlock() + defer func() { + mu.Lock() + inFlight-- + mu.Unlock() + }() + time.Sleep(50 * time.Millisecond) + return echoHandler(1, req) + }) + c := fastClient(d.srv.URL, 0) + c.limiter = nil // isolate the scoring slot's effect from the token bucket + + var wg sync.WaitGroup + for i := 0; i < 2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := c.ScoreBatch(context.Background(), "task", map[string]Item{"seg-1": {Text: "x", Tokens: 1}}); err != nil { + t.Errorf("ScoreBatch() error = %v", err) + } + }() + } + wg.Wait() + if peak != 1 { + t.Errorf("peak concurrent handler runs = %d, want 1 — scoring calls must share the scoring bound", peak) + } +} + +// TestParseRetryAfter covers both header forms and the invalid ones. +func TestParseRetryAfter(t *testing.T) { + cases := []struct { + in string + want time.Duration + }{ + {"", 0}, + {"0", 0}, + {"-3", 0}, + {"junk", 0}, + {"2", 2 * time.Second}, + } + for _, tc := range cases { + if got := parseRetryAfter(tc.in); got != tc.want { + t.Errorf("parseRetryAfter(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} diff --git a/internal/compaction/errors.go b/internal/compaction/errors.go new file mode 100644 index 00000000..63a93e91 --- /dev/null +++ b/internal/compaction/errors.go @@ -0,0 +1,186 @@ +package compaction + +import ( + "errors" + "fmt" + "net/http" + "strings" +) + +// ErrorKind classifies a compaction scoring failure into the reference's +// error taxonomy (jev-compaction types.py): JevAuthError, +// JevValidationError, JevBudgetError, JevUnavailableError. The class decides +// what the retry loop — and every caller — does with the failure: +// +// - KindAuth: the backend rejected the credentials (401/403). Never +// retried; the DecisionClient poisons itself (Unavailable) so callers +// can stop asking for the rest of the session. +// - KindValidation: the request was malformed or the backend cannot serve +// it at all (400/404/422 — e.g. a too-small local model). NEVER retried: +// it is a bug or a permanent mismatch, not a transient failure (the +// reference never retries JevValidationError). +// - KindBudget: the request would violate a hard limit (the 64k +// state+questions token budget, the 32-item ceiling). Raised BEFORE +// sending; the caller splits and retries — ScoreBatch's packing does +// exactly that, and the single item that cannot fit even alone is +// skipped per item. +// - KindUnavailable: the backend is transiently broken (429/5xx/timeout/ +// connection failure). Retried with backoff; when the attempt budget is +// exhausted callers fail open (keep everything). +type ErrorKind int + +const ( + KindAuth ErrorKind = iota + KindValidation + KindBudget + KindUnavailable +) + +// String returns the taxonomy's lowercase class name. +func (k ErrorKind) String() string { + switch k { + case KindAuth: + return "auth" + case KindValidation: + return "validation" + case KindBudget: + return "budget" + case KindUnavailable: + return "unavailable" + default: + return fmt.Sprintf("errorkind(%d)", int(k)) + } +} + +// detail is the fixed per-class explanation in Error's message. +func (k ErrorKind) detail() string { + switch k { + case KindAuth: + return "bad or missing API key" + case KindValidation: + return "malformed request (never retried — this is a bug, not a transient failure)" + case KindBudget: + return "request would violate a hard limit (split and retry)" + case KindUnavailable: + return "backend unavailable" + default: + return "unclassified failure" + } +} + +// Error is one classified compaction failure. Kind drives the retry loop and +// every caller's policy (fail open vs. give up for the session); Err is the +// underlying cause — often a *DecisionStatusError carrying the bounded +// response body and the server's Retry-After. +type Error struct { + // Kind is the taxonomy class of the failure. + Kind ErrorKind + // Status is the HTTP status that produced the failure, 0 when the + // failure was not status-shaped (transport failure, or a budget check + // raised before send). + Status int + // Op names the failing operation ("score-batch"), for diagnostics. + Op string + // Err is the underlying cause; nil when the class alone says it all. + Err error +} + +func (e *Error) Error() string { + var b strings.Builder + b.WriteString("compaction: ") + b.WriteString(e.Kind.String()) + if e.Status > 0 { + fmt.Fprintf(&b, " (%d)", e.Status) + } + if e.Op != "" { + b.WriteString(" during ") + b.WriteString(e.Op) + } + b.WriteString(": ") + b.WriteString(e.Kind.detail()) + if e.Err != nil { + b.WriteString(": ") + b.WriteString(e.Err.Error()) + } + return b.String() +} + +// Unwrap exposes the underlying cause so errors.Is/errors.As can walk the +// chain — retryAfterFrom pulling the server's Retry-After out of the wrapped +// *DecisionStatusError, callers matching cancellation, tests matching the +// class through an errors.Join of *ItemScoreError values. +func (e *Error) Unwrap() error { return e.Err } + +// opScoreBatch names the decisions-protocol operation in typed errors. +const opScoreBatch = "score-batch" + +// errScoringDisabled is the cause carried by the typed auth errors a +// poisoned client synthesizes without touching the network. +var errScoringDisabled = errors.New("scoring disabled for the session: the backend rejected the API key") + +// The per-class constructors so failure sites read as the class they raise. + +func authError(status int, op string, err error) *Error { + return &Error{Kind: KindAuth, Status: status, Op: op, Err: err} +} + +func validationError(status int, op string, err error) *Error { + return &Error{Kind: KindValidation, Status: status, Op: op, Err: err} +} + +// budgetError takes no status: budget violations are raised before sending, +// so no HTTP response exists to classify. +func budgetError(op string, err error) *Error { + return &Error{Kind: KindBudget, Op: op, Err: err} +} + +func unavailableError(status int, op string, err error) *Error { + return &Error{Kind: KindUnavailable, Status: status, Op: op, Err: err} +} + +// classifyStatus maps one failed request onto the taxonomy: +// +// - 401/403 → KindAuth (bad or missing key — never retried, client poisons) +// - 400/404/422 (and any other unlisted 4xx) → KindValidation (never +// retried) +// - 429/408/5xx → KindUnavailable (retried, then fail-open) +// - status 0 → KindUnavailable: the failure was transport-shaped +// (timeout, connection refused, TLS), not an HTTP response at all +// +// body carries the (bounded) response text for diagnostics when cause is +// nil; a non-nil cause wins. The result is nil only when there is nothing +// to describe (no status, no body, no cause). +func classifyStatus(op string, status int, body string, cause error) *Error { + if status == 0 && cause == nil && body == "" { + return nil + } + err := cause + if err == nil && body != "" { + err = errors.New(body) + } + return &Error{Kind: kindForStatus(status), Status: status, Op: op, Err: err} +} + +// kindForStatus is the status → class mapping behind classifyStatus. +func kindForStatus(status int) ErrorKind { + switch status { + case http.StatusUnauthorized, http.StatusForbidden: + return KindAuth + case http.StatusBadRequest, http.StatusNotFound, http.StatusUnprocessableEntity: + return KindValidation + case 0, http.StatusRequestTimeout, http.StatusTooManyRequests: + return KindUnavailable + } + if status >= 500 && status <= 599 { + return KindUnavailable + } + if status >= 400 && status < 500 { + // Unlisted 4xx (402, 405, 409, …): deterministic rejections — + // retrying cannot change the answer. Validation is the closest + // class: surface it, never retry it. + return KindValidation + } + // 3xx and anything exotic: the backend is not answering the protocol + // correctly; treat it as availability trouble. + return KindUnavailable +} diff --git a/internal/compaction/errors_test.go b/internal/compaction/errors_test.go new file mode 100644 index 00000000..862fcccc --- /dev/null +++ b/internal/compaction/errors_test.go @@ -0,0 +1,506 @@ +package compaction + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "testing" + "time" +) + +// --- taxonomy unit tests ------------------------------------------------------ + +// TestErrorKindString pins the lowercase class names used in messages. +func TestErrorKindString(t *testing.T) { + cases := map[ErrorKind]string{ + KindAuth: "auth", + KindValidation: "validation", + KindBudget: "budget", + KindUnavailable: "unavailable", + ErrorKind(99): "errorkind(99)", + } + for k, want := range cases { + if got := k.String(); got != want { + t.Errorf("ErrorKind(%d).String() = %q, want %q", int(k), got, want) + } + } +} + +// TestClassifyStatus covers the status → class mapping: 401/403 auth; +// 400/404/422 (and unlisted 4xx) validation, never retried; 429/408/5xx and +// status 0 (transport) unavailable. +func TestClassifyStatus(t *testing.T) { + cases := []struct { + status int + want ErrorKind + }{ + {401, KindAuth}, + {403, KindAuth}, + {400, KindValidation}, + {404, KindValidation}, + {422, KindValidation}, + {402, KindValidation}, // unlisted 4xx: deterministic, never retried + {405, KindValidation}, + {409, KindValidation}, + {429, KindUnavailable}, + {408, KindUnavailable}, + {500, KindUnavailable}, + {501, KindUnavailable}, + {502, KindUnavailable}, + {503, KindUnavailable}, + {504, KindUnavailable}, + {529, KindUnavailable}, + {302, KindUnavailable}, + // (status 0 with no body and no cause classifies to nil — there is + // nothing to describe; with a transport cause it is Unavailable, + // covered in TestClassifyStatusCauseAndBody.) + } + for _, tc := range cases { + e := classifyStatus(opScoreBatch, tc.status, "", nil) + if e == nil { + t.Fatalf("classifyStatus(%d) = nil, want an error", tc.status) + } + if e.Kind != tc.want { + t.Errorf("classifyStatus(%d).Kind = %v, want %v", tc.status, e.Kind, tc.want) + } + if e.Status != tc.status { + t.Errorf("classifyStatus(%d).Status = %d, want %d", tc.status, e.Status, tc.status) + } + } +} + +// TestClassifyStatusCauseAndBody: a non-nil cause is preserved through +// Unwrap (the *DecisionStatusError's Retry-After must survive), and the body +// becomes the cause when no cause is given. +func TestClassifyStatusCauseAndBody(t *testing.T) { + cause := &DecisionStatusError{StatusCode: 429, RetryAfter: 3 * time.Second} + e := classifyStatus(opScoreBatch, 429, `{"error":{"message":"slow down"}}`, cause) + if e.Err != cause { + t.Errorf("classifyStatus().Err = %v, want the provided cause", e.Err) + } + if got := errors.Unwrap(e); got != cause { + t.Errorf("Unwrap() = %v, want the cause", got) + } + var se *DecisionStatusError + if !errors.As(e, &se) || se.RetryAfter != 3*time.Second { + t.Errorf("errors.As through the typed error lost the *DecisionStatusError: %v", e) + } + + bodyOnly := classifyStatus(opScoreBatch, 401, `{"error":{"message":"bad key"}}`, nil) + if bodyOnly == nil || bodyOnly.Err == nil || !strings.Contains(bodyOnly.Err.Error(), "bad key") { + t.Errorf("classifyStatus with only a body must wrap the body as the cause, got %+v", bodyOnly) + } + + plain := classifyStatus(opScoreBatch, 0, "", errors.New("connection refused")) + if plain == nil || plain.Kind != KindUnavailable || plain.Err == nil { + t.Errorf("classifyStatus(0, cause) = %+v, want Unavailable carrying the cause", plain) + } + + if e := classifyStatus(opScoreBatch, 0, "", nil); e != nil { + t.Errorf("classifyStatus with nothing to describe = %v, want nil", e) + } +} + +// TestErrorMessageFormat pins the message shape: "compaction: auth (401) +// during score-batch: bad or missing API key: ". +func TestErrorMessageFormat(t *testing.T) { + cases := []struct { + name string + err *Error + want string + }{ + { + name: "auth with status and op", + err: authError(401, opScoreBatch, errors.New("bad key")), + want: "compaction: auth (401) during score-batch: bad or missing API key: bad key", + }, + { + name: "validation never retried", + err: validationError(422, opScoreBatch, nil), + want: "compaction: validation (422) during score-batch: malformed request (never retried — this is a bug, not a transient failure)", + }, + { + name: "budget raised before send", + err: budgetError(opScoreBatch, errors.New("item too large to score")), + want: "compaction: budget during score-batch: request would violate a hard limit (split and retry): item too large to score", + }, + { + name: "unavailable with status", + err: unavailableError(503, opScoreBatch, nil), + want: "compaction: unavailable (503) during score-batch: backend unavailable", + }, + { + name: "no op", + err: authError(403, "", nil), + want: "compaction: auth (403): bad or missing API key", + }, + } + for _, tc := range cases { + if got := tc.err.Error(); got != tc.want { + t.Errorf("%s:\n got %q\nwant %q", tc.name, got, tc.want) + } + } +} + +// TestConstructorsSetFields: each class constructor sets Kind plus the +// fields it is given. +func TestConstructorsSetFields(t *testing.T) { + cause := errors.New("boom") + cases := []struct { + got *Error + want Error + }{ + {authError(401, "op", cause), Error{Kind: KindAuth, Status: 401, Op: "op", Err: cause}}, + {validationError(422, "op", cause), Error{Kind: KindValidation, Status: 422, Op: "op", Err: cause}}, + {budgetError("op", cause), Error{Kind: KindBudget, Status: 0, Op: "op", Err: cause}}, + {unavailableError(429, "op", cause), Error{Kind: KindUnavailable, Status: 429, Op: "op", Err: cause}}, + } + for i, tc := range cases { + if *tc.got != tc.want { + t.Errorf("constructor %d = %+v, want %+v", i, *tc.got, tc.want) + } + } +} + +// TestIsRetryableDecisionError_Typed extends the status/transport matrix +// with the typed taxonomy: auth, validation, and budget are never retried; +// unavailable is (unless a permanent transport failure hides underneath). +func TestIsRetryableDecisionError_Typed(t *testing.T) { + never := []error{ + authError(401, opScoreBatch, errors.New("bad key")), + authError(403, opScoreBatch, nil), + validationError(400, opScoreBatch, nil), + validationError(422, opScoreBatch, nil), + budgetError(opScoreBatch, errors.New("too large")), + // A wrapped cause must not smuggle retryability past the class. + fmt.Errorf("score item %q: %w", "seg-1", authError(401, opScoreBatch, nil)), + } + for _, err := range never { + if isRetryableDecisionError(err) { + t.Errorf("isRetryableDecisionError(%v) = true, want false (never retried)", err) + } + } + + retryable := []error{ + unavailableError(429, opScoreBatch, nil), + unavailableError(503, opScoreBatch, &DecisionStatusError{StatusCode: 503}), + unavailableError(0, opScoreBatch, errors.New("connection refused")), + } + for _, err := range retryable { + if !isRetryableDecisionError(err) { + t.Errorf("isRetryableDecisionError(%v) = false, want true", err) + } + } + + // A permanent TLS failure classified Unavailable still must not be + // retried: the transport checks see through the typed wrapper (the + // real shape — http.Client wraps transport failures in a *url.Error). + permanent := unavailableError(0, opScoreBatch, + &url.Error{Op: "Post", URL: "https://x", Err: errors.New("tls: handshake failure")}) + if isRetryableDecisionError(permanent) { + t.Errorf("isRetryableDecisionError(%v) = true, want false (permanent TLS)", permanent) + } +} + +// --- wire behavior (httptest) -------------------------------------------------- + +// TestScoreBatch_AuthErrorNoRetryPoisonsClient: a 401 is answered on the +// first attempt — no retries — every item fail-opens with a typed auth +// error, and the client is poisoned: Unavailable() flips and a second +// ScoreBatch fails open with zero further requests. +func TestScoreBatch_AuthErrorNoRetryPoisonsClient(t *testing.T) { + d := newDecisionsServer(t, func(_ int, _ capturedRequest) (int, string) { + return http.StatusUnauthorized, `{"error": {"message": "bad key"}}` + }) + c := fastClient(d.srv.URL, 4) + + items := map[string]Item{ + "seg-1": {Text: "a", Tokens: 5}, + "seg-2": {Text: "b", Tokens: 5}, + } + scores, err := c.ScoreBatch(context.Background(), "task", items) + if err == nil { + t.Fatal("ScoreBatch() error = nil, want a recorded auth failure") + } + if got := len(d.requests()); got != 1 { + t.Errorf("got %d requests, want 1 (401 is never retried)", got) + } + for ref := range items { + if scores[ref] != keepScore { + t.Errorf("scores[%s] = %v, want %v (fail-open)", ref, scores[ref], keepScore) + } + } + var ae *Error + if !errors.As(err, &ae) || ae.Kind != KindAuth || ae.Status != 401 { + t.Fatalf("error = %v, want a *Error KindAuth (401) through the item join", err) + } + var ise *ItemScoreError + if !errors.As(err, &ise) || ise.ItemID != "seg-1" { + t.Errorf("error = %v, want *ItemScoreError entries for the failed items", err) + } + if !c.Unavailable() { + t.Error("an auth rejection must poison the client (Unavailable() = true)") + } + + // The poisoned client fail-opens without touching the network again. + scores2, err2 := c.ScoreBatch(context.Background(), "task", items) + if err2 == nil { + t.Fatal("second ScoreBatch() error = nil, want the disabled-scoring auth error") + } + if got := len(d.requests()); got != 1 { + t.Errorf("poisoned client made %d requests total, want 1 (no network)", got) + } + for ref := range items { + if scores2[ref] != keepScore { + t.Errorf("second scores[%s] = %v, want %v (fail-open)", ref, scores2[ref], keepScore) + } + } + if !errors.Is(err2, errScoringDisabled) { + t.Errorf("second error = %v, want it rooted in errScoringDisabled", err2) + } + var ae2 *Error + if !errors.As(err2, &ae2) || ae2.Kind != KindAuth { + t.Errorf("second error = %v, want a typed auth error", err2) + } +} + +// TestScoreBatch_ValidationErrorNoRetry: 422 — the reference's +// JevValidationError — comes back on the first attempt, never retried, and +// does NOT poison the client (the key is fine; the request or the backend is +// not). +func TestScoreBatch_ValidationErrorNoRetry(t *testing.T) { + d := newDecisionsServer(t, func(_ int, _ capturedRequest) (int, string) { + return http.StatusUnprocessableEntity, `{"error": {"message": "malformed"}}` + }) + c := fastClient(d.srv.URL, 4) + + _, err := c.ScoreBatch(context.Background(), "task", map[string]Item{"seg-1": {Text: "x", Tokens: 5}}) + if err == nil { + t.Fatal("ScoreBatch() error = nil, want the typed validation error") + } + if got := len(d.requests()); got != 1 { + t.Errorf("got %d requests, want 1 (422 is never retried)", got) + } + var ae *Error + if !errors.As(err, &ae) || ae.Kind != KindValidation || ae.Status != 422 { + t.Fatalf("error = %v, want a *Error KindValidation (422)", err) + } + if c.Unavailable() { + t.Error("a validation failure must not poison the client") + } +} + +// TestScoreBatch_BadRequestNoRetry: the 400 a too-small backend throws at +// every message must not burn retries — one request, typed validation error, +// client still usable (unpoisoned). +func TestScoreBatch_BadRequestNoRetry(t *testing.T) { + d := newDecisionsServer(t, func(_ int, _ capturedRequest) (int, string) { + return http.StatusBadRequest, `{"error": {"message": "model too small"}}` + }) + c := fastClient(d.srv.URL, 4) + + _, err := c.ScoreBatch(context.Background(), "task", map[string]Item{"seg-1": {Text: "x", Tokens: 5}}) + if err == nil { + t.Fatal("ScoreBatch() error = nil, want the typed validation error") + } + if got := len(d.requests()); got != 1 { + t.Errorf("got %d requests, want 1 (400 must not burn retries)", got) + } + var ae *Error + if !errors.As(err, &ae) || ae.Kind != KindValidation || ae.Status != 400 { + t.Fatalf("error = %v, want a *Error KindValidation (400)", err) + } + if c.Unavailable() { + t.Error("a 400 must not poison the client") + } +} + +// TestScoreBatch_AuthStopsRemainingBatches: a 33-item output splits into two +// requests; the auth rejection on the first must stop the second (poisoned +// mid-call) — exactly one request ever reaches the wire. +func TestScoreBatch_AuthStopsRemainingBatches(t *testing.T) { + d := newDecisionsServer(t, func(_ int, _ capturedRequest) (int, string) { + return http.StatusUnauthorized, `{"error": {"message": "bad key"}}` + }) + c := fastClient(d.srv.URL, 4) + + items := make(map[string]Item, 33) + for i := 1; i <= 33; i++ { + items[fmt.Sprintf("seg-%d", i)] = Item{Text: fmt.Sprintf("paragraph %d", i), Tokens: 10} + } + scores, err := c.ScoreBatch(context.Background(), "task", items) + if err == nil { + t.Fatal("ScoreBatch() error = nil, want the typed auth error") + } + if got := len(d.requests()); got != 1 { + t.Errorf("got %d requests, want 1 (the second batch must be poisoned off the wire)", got) + } + for i := 1; i <= 33; i++ { + ref := fmt.Sprintf("seg-%d", i) + if scores[ref] != keepScore { + t.Errorf("scores[%s] = %v, want %v (fail-open)", ref, scores[ref], keepScore) + } + } + var ae *Error + if !errors.As(err, &ae) || ae.Kind != KindAuth { + t.Errorf("error = %v, want a typed auth error", err) + } +} + +// TestScoreBatch_UnavailableRetriedThenSuccess: a 500 is transient — the +// retry succeeds, the real scores flow, nothing is poisoned. +func TestScoreBatch_UnavailableRetriedThenSuccess(t *testing.T) { + d := newDecisionsServer(t, func(attempt int, req capturedRequest) (int, string) { + if attempt == 1 { + return http.StatusInternalServerError, `{"error": {"message": "boom"}}` + } + return echoHandler(attempt, req) + }) + c := fastClient(d.srv.URL, 0) + + scores, err := c.ScoreBatch(context.Background(), "task", map[string]Item{"seg-1": {Text: "x", Tokens: 5}}) + if err != nil { + t.Fatalf("ScoreBatch() error = %v, want success after the retry", err) + } + if got := len(d.requests()); got != 2 { + t.Errorf("got %d requests, want 2 (500 then success)", got) + } + if scores["seg-1"] != 0.01 { + t.Errorf("scores[seg-1] = %v, want the echoHandler score 0.01", scores["seg-1"]) + } + if c.Unavailable() { + t.Error("a recovered outage must not poison the client") + } +} + +// TestScoreBatch_UnavailableAlwaysFailsOpen: a hard outage burns the full +// attempt budget, then keeps everything with a typed unavailable error — and +// leaves the client usable (an outage is not an auth failure). +func TestScoreBatch_UnavailableAlwaysFailsOpen(t *testing.T) { + d := newDecisionsServer(t, func(_ int, _ capturedRequest) (int, string) { + return http.StatusServiceUnavailable, `{"error": {"message": "overloaded"}}` + }) + c := fastClient(d.srv.URL, 4) + + scores, err := c.ScoreBatch(context.Background(), "task", map[string]Item{ + "seg-1": {Text: "a", Tokens: 5}, + "seg-2": {Text: "b", Tokens: 5}, + }) + if err == nil { + t.Fatal("ScoreBatch() error = nil, want a recorded failure") + } + if got := len(d.requests()); got != 4 { + t.Errorf("got %d requests, want 4 (full attempt budget)", got) + } + for ref := range map[string]bool{"seg-1": true, "seg-2": true} { + if scores[ref] != keepScore { + t.Errorf("scores[%s] = %v, want %v (fail-open)", ref, scores[ref], keepScore) + } + } + var ae *Error + if !errors.As(err, &ae) || ae.Kind != KindUnavailable || ae.Status != 503 { + t.Errorf("error = %v, want a *Error KindUnavailable (503)", err) + } + if c.Unavailable() { + t.Error("an outage must not poison the client (only auth does)") + } +} + +// TestScoreBatch_BudgetErrorIsTyped: the oversized-item skip keeps its +// per-item fail-open contract, and its cause is now a typed budget error +// (raised before send). +func TestScoreBatch_BudgetErrorIsTyped(t *testing.T) { + d := newDecisionsServer(t, echoHandler) + c := fastClient(d.srv.URL, 0) + + items := map[string]Item{ + "seg-1": {Text: "small", Tokens: 100}, + "seg-huge": {Text: "huge", Tokens: MaxStateTokens}, + } + scores, err := c.ScoreBatch(context.Background(), "task", items) + if err == nil { + t.Fatal("ScoreBatch() error = nil, want the oversized-item error") + } + var ise *ItemScoreError + if !errors.As(err, &ise) || ise.ItemID != "seg-huge" { + t.Fatalf("error = %v, want *ItemScoreError for seg-huge", err) + } + var be *Error + if !errors.As(ise.Err, &be) || be.Kind != KindBudget { + t.Errorf("item error = %v, want a typed budget cause", ise.Err) + } + if scores["seg-huge"] != keepScore { + t.Errorf("oversized item score = %v, want %v (per-item fail-open)", scores["seg-huge"], keepScore) + } + if scores["seg-1"] != 0.01 { + t.Errorf("scores[seg-1] = %v, want 0.01 (the rest of the batch still scores)", scores["seg-1"]) + } +} + +// --- pipeline auth-poison behavior --------------------------------------------- + +// TestPipeline_AuthErrorDisablesScoringForSession: the first auth rejection +// fail-opens with the typed error and logs exactly one warning; every later +// CompactToolOutput returns the output untouched with Disabled set and makes +// no further backend requests. +func TestPipeline_AuthErrorDisablesScoringForSession(t *testing.T) { + output, _, _, _ := relocationOutput() + d := newDecisionsServer(t, func(_ int, _ capturedRequest) (int, string) { + return http.StatusUnauthorized, `{"error": {"message": "bad key"}}` + }) + store := NewStore() + p := NewPipeline(ResolvedBackend{Backend: Backend{Name: "test", URL: d.srv.URL, Model: "jev-latest"}, APIKey: "k"}, "k", nil, PipelineOptions{}) + p.EnableRelocation(store, 0.35) + // The relocation fixtures are a few dozen tokens — far below the + // reference 400-token min-gate — so disable the gate or the scoring + // round trip (and with it the auth failure) never happens. + applyTestGate(p, 0.35, 0.7) + var warnings strings.Builder + p.warnTo = &warnings + + first, err := p.CompactToolOutput(context.Background(), "Bash", output) + if err == nil { + t.Fatal("first CompactToolOutput() error = nil, want the typed auth error") + } + var ae *Error + if !errors.As(err, &ae) || ae.Kind != KindAuth { + t.Fatalf("error = %v, want a typed auth error", err) + } + if first.CompactText != output { + t.Errorf("fail-open must return the original text unchanged, got:\n%s", first.CompactText) + } + if first.Disabled == "" { + t.Error("first result must report Disabled with the reason") + } + if got := warnings.String(); !strings.Contains(got, "compaction scoring disabled") { + t.Errorf("warnings = %q, want exactly one clear disable note", got) + } + if got := len(d.requests()); got != 1 { + t.Errorf("got %d requests, want 1", got) + } + if store.Len() != 0 { + t.Error("store must stay empty when scoring failed") + } + + // Every later call short-circuits: no request, original text, Disabled + // still reported, and NO additional warning (the sync.Once-style guard). + second, err := p.CompactToolOutput(context.Background(), "Bash", output) + if err != nil { + t.Fatalf("second CompactToolOutput() error = %v, want nil (disabled, not failed)", err) + } + if second.CompactText != output || second.Disabled == "" { + t.Errorf("second result = %+v, want the original text with Disabled set", second) + } + if got := len(d.requests()); got != 1 { + t.Errorf("poisoned pipeline made %d requests total, want 1 (no request storms)", got) + } + if got := warnings.String(); strings.Count(got, "\n") != 1 { + t.Errorf("warnings = %q, want exactly one line no matter how many calls fail", got) + } + if store.Len() != 0 { + t.Error("a disabled pipeline must not store anything") + } +} diff --git a/internal/compaction/gate.go b/internal/compaction/gate.go new file mode 100644 index 00000000..d34d2a12 --- /dev/null +++ b/internal/compaction/gate.go @@ -0,0 +1,272 @@ +package compaction + +// GateConfig carries the elide-decision safety semantics of the reference +// pipeline (jev-compaction pipeline.py GateConfig): the score under which a +// segment may be elided, the token floor under which gating is skipped +// entirely, the elide-fraction tripwire, and per-kind score floors. +// +// Apply it to a pipeline with ApplyGateConfig; a pipeline that never had a +// gate config applied runs on DefaultGateConfig. +type GateConfig struct { + // KeepThreshold is the score strictly below which a segment is elided. + // Applying a config with a value in (0, 1] makes it the relocation + // threshold exactly as EnableRelocation would (whichever was set last + // stays in force); 0 or an out-of-range value leaves the current + // threshold untouched. Reference: keep_threshold=0.35. + KeepThreshold float64 + // MinGateTokens is the estimated-token floor under which + // CompactToolOutput skips scoring entirely and returns the input + // unchanged: below it the scoring round trip costs more than any + // possible elision saves. 0 disables the token gate. Reference: + // min_gate_tokens=400. + MinGateTokens int + // MaxElideFraction is the tripwire: when the scorer wants to elide more + // than this share of the output's tokens, the scorer is distrusted and + // NOTHING is elided (the tripwire is recorded in the result and the + // shadow log). Values outside (0, 1] fall back to the default; 1 + // effectively disables the tripwire. Reference: max_elide_fraction=0.7. + MaxElideFraction float64 + // ProtectedKinds maps segment kinds to their own elision floor: a + // segment of that kind is only elided below the floor, no matter its + // score relative to KeepThreshold. nil means the reference defaults + // (stacktrace and diff at 0.05); pass an empty non-nil map for no + // protected kinds. Reference: protected_kinds={stacktrace,diff} at + // protected_floor=0.05. + ProtectedKinds map[SegmentKind]float64 +} + +// Reference-parity gate defaults (jev-compaction pipeline.py). +const ( + // DefaultMinGateTokens is the token floor under which gating is skipped: + // the round trip costs more than elision could save. + DefaultMinGateTokens = 400 + // DefaultMaxElideFraction is the tripwire fraction: past it the scorer + // is distrusted and nothing is elided. + DefaultMaxElideFraction = 0.7 + // DefaultProtectedFloor is the score floor for protected kinds: + // stacktrace and diff segments are elided only below it. + DefaultProtectedFloor = 0.05 +) + +// TripwireMaxElideFraction is the CompactResult.Tripwire value recorded when +// the max-elide-fraction tripwire fired (everything kept). +const TripwireMaxElideFraction = "max_elide_fraction" + +// TripwireAction is the shadow-log action recorded on a tripwire entry. +const TripwireAction = "tripwire" + +// unelidableScore is the score ceiling that marks an item unelidable: the +// protected origins' minimum effective score (protectedScore clamps their +// items' scores up to it) and the scorer's fail-open keep score are both +// exactly it. Scores live in [0, 1], so an item at the ceiling can never sit +// strictly below an elide floor in [0, 1] — no gate setting can elide it. +// AtomicDecisionScores extends that guarantee to cut paragraphs: a group +// with one sibling at the ceiling is pinned to keep. +const unelidableScore = 1.0 + +// OriginSourceSkillTool is the Origin.Source of records relocated from +// activate_skill tool results ("tool:activate_skill") — and the origin whose +// tool results are protected from elision altogether: activate_skill's +// output IS the skill's instructions, the guidance the agent was told to +// follow. Eliding it would silently strip what the agent believes it must +// do, with nothing left in context to say so. See protectedScore. +const OriginSourceSkillTool = OriginSourceToolPrefix + "activate_skill" + +// SkillToolName is the registry name of the skill-activation tool whose +// results are protected from elision. +const SkillToolName = "activate_skill" + +// protectedOrigins maps the Origin.Source values whose content must never be +// elided to the minimum effective score their items may carry. The floor is +// unelidableScore (1.0): a score clamped up to it can never sit strictly +// below any keep threshold in (0, 1], so the item is unelidable at any gate +// setting — and AtomicDecisionScores pins a cut paragraph containing such a +// sibling to keep as well. +var protectedOrigins = map[string]float64{ + OriginSourceSkillTool: unelidableScore, +} + +// originScoreFloor returns the minimum effective score for items originating +// from source, and whether such a floor applies at all. Only protected +// origins carry one. +func originScoreFloor(source string) (float64, bool) { + f, ok := protectedOrigins[source] + return f, ok +} + +// protectedScore raises score to the origin's score floor when one applies: +// a tool result produced by an activate_skill call is reported as fully +// essential no matter what the scorer answered, so no gate setting can elide +// it. Sources without a floor pass the score through unchanged. +func protectedScore(source string, score float64) float64 { + if f, ok := originScoreFloor(source); ok && score < f { + return f + } + return score +} + +// ProtectedTool reports whether toolName's results must never be elided: +// the gate clamps their scores up to the origin's floor (1.0 for +// activate_skill), making them unelidable at any threshold. The history walk +// uses it to skip a tool message whose originating call was activate_skill +// before it is ever segmented or scored. +func ProtectedTool(toolName string) bool { + _, ok := originScoreFloor(OriginSourceToolPrefix + toolName) + return ok +} + +// GateFloorFor returns the score under which a segment of kind may be +// elided given a GateConfig: the protected floor when the kind has one, +// else the config's KeepThreshold. It is the floor resolution the history +// walk (session.CompactContext with a non-nil gate) applies per segment, +// the package-level form of resolvedGate.floor for callers that hold the +// config directly. A pipeline's effective keep threshold can differ from +// the applied config's when EnableRelocation ran last, so pass a config +// whose KeepThreshold is already the resolved threshold — main.go builds +// the walk gate with exactly the resolved compaction threshold. +func GateFloorFor(gate GateConfig, kind SegmentKind) float64 { + if f, ok := gate.ProtectedKinds[kind]; ok { + return f + } + return gate.KeepThreshold +} + +// DefaultGateConfig returns the reference-parity gate configuration. +func DefaultGateConfig() GateConfig { + return GateConfig{ + KeepThreshold: DefaultRelocationThreshold, + MinGateTokens: DefaultMinGateTokens, + MaxElideFraction: DefaultMaxElideFraction, + ProtectedKinds: map[SegmentKind]float64{ + KindStacktrace: DefaultProtectedFloor, + KindDiff: DefaultProtectedFloor, + }, + } +} + +// clamped normalizes cfg the way the config resolvers do: out-of-range +// values fall back to safe behavior instead of eliding everything or +// nothing by accident. KeepThreshold outside (0, 1] becomes 0 — "unset", the +// armed relocation threshold stays in force. MaxElideFraction outside (0, 1] +// falls back to DefaultMaxElideFraction. A negative MinGateTokens becomes 0 +// (the token gate is a floor, never a mandate). A nil ProtectedKinds map +// means the reference defaults; per-kind floors are clamped into [0, 1]. +func (g GateConfig) clamped() GateConfig { + out := g + if out.KeepThreshold <= 0 || out.KeepThreshold > 1 { + out.KeepThreshold = 0 + } + if out.MaxElideFraction <= 0 || out.MaxElideFraction > 1 { + out.MaxElideFraction = DefaultMaxElideFraction + } + if out.MinGateTokens < 0 { + out.MinGateTokens = 0 + } + if out.ProtectedKinds == nil { + out.ProtectedKinds = DefaultGateConfig().ProtectedKinds + } else { + floors := make(map[SegmentKind]float64, len(out.ProtectedKinds)) + for kind, floor := range out.ProtectedKinds { + if floor < 0 { + floor = 0 + } + if floor > 1 { + floor = 1 + } + floors[kind] = floor + } + out.ProtectedKinds = floors + } + return out +} + +// resolvedGate is the gate as it applies at decision time: the stored (or +// default) config plus the keep threshold actually in force — the relocation +// threshold set by ApplyGateConfig or EnableRelocation, whichever last. +type resolvedGate struct { + cfg GateConfig + keep float64 +} + +// floor returns the score under which a segment of kind may be elided: the +// protected floor when the kind has one, else the keep threshold. +func (g resolvedGate) floor(kind SegmentKind) float64 { + if f, ok := g.cfg.ProtectedKinds[kind]; ok { + return f + } + return g.keep +} + +// resolveGate snapshots the effective gate configuration. Safe for +// concurrent use (one pipeline is shared by every agent's tool calls). +func (p *Pipeline) resolveGate() resolvedGate { + if p == nil { + return resolvedGate{cfg: DefaultGateConfig(), keep: DefaultRelocationThreshold} + } + p.relocMu.Lock() + defer p.relocMu.Unlock() + return p.resolveGateLocked() +} + +// resolveGateLocked is resolveGate without the lock; callers hold relocMu. +func (p *Pipeline) resolveGateLocked() resolvedGate { + cfg := DefaultGateConfig() + if p.gateSet { + cfg = p.gate + } + return resolvedGate{cfg: cfg, keep: p.threshold} +} + +// minGateTokens returns the token floor under which CompactToolOutput skips +// scoring (0 disables the gate). A pipeline without an applied gate config +// runs on the reference default. +func (p *Pipeline) minGateTokens() int { + if p == nil { + return 0 + } + p.relocMu.Lock() + defer p.relocMu.Unlock() + if p.gateSet { + return p.gate.MinGateTokens + } + return DefaultGateConfig().MinGateTokens +} + +// ApplyGateConfig stores cfg as the pipeline's gate configuration, replacing +// any previous one (a partial struct replaces the whole gate; base partial +// overrides on DefaultGateConfig). A positive KeepThreshold in (0, 1] also +// becomes the relocation threshold, exactly as EnableRelocation would set +// it — whichever of the two was applied last stays in force, so the +// -compaction-threshold flag keeps precedence by being applied after the +// gate. Out-of-range values are clamped (see GateConfig.clamped). Safe for +// concurrent use; a nil pipeline is a no-op. +func (p *Pipeline) ApplyGateConfig(cfg GateConfig) { + if p == nil { + return + } + g := cfg.clamped() + p.relocMu.Lock() + defer p.relocMu.Unlock() + p.gate = g + p.gateSet = true + if g.KeepThreshold > 0 { + p.threshold = g.KeepThreshold + } +} + +// logTripwire records one tripwire entry in the shadow log: the scorer +// wanted to elide past MaxElideFraction, so nothing was elided. Append +// failures are ignored — logging must not be able to break compaction, and +// the tripwire has already done its job by the time this runs. +func (p *Pipeline) logTripwire(taskHash string, totalTokens int) { + if p == nil || p.shadow == nil { + return + } + _ = p.shadow.Append(ShadowEntry{ + TS: p.now(), + TaskHash: taskHash, + Tokens: totalTokens, + Type: EntryTypeTripwire, + Action: TripwireAction, + }) +} diff --git a/internal/compaction/gate_test.go b/internal/compaction/gate_test.go new file mode 100644 index 00000000..22197443 --- /dev/null +++ b/internal/compaction/gate_test.go @@ -0,0 +1,349 @@ +package compaction + +import ( + "context" + "encoding/json" + "path/filepath" + "strings" + "testing" +) + +// TestDefaultGateConfig_ReferenceParity pins the reference-parity defaults +// (jev-compaction pipeline.py: keep_threshold=0.35, min_gate_tokens=400, +// max_elide_fraction=0.7, protected_kinds={stacktrace,diff} at 0.05). +func TestDefaultGateConfig_ReferenceParity(t *testing.T) { + g := DefaultGateConfig() + if g.KeepThreshold != 0.35 { + t.Errorf("KeepThreshold = %v, want 0.35", g.KeepThreshold) + } + if g.MinGateTokens != 400 { + t.Errorf("MinGateTokens = %d, want 400", g.MinGateTokens) + } + if g.MaxElideFraction != 0.7 { + t.Errorf("MaxElideFraction = %v, want 0.7", g.MaxElideFraction) + } + if len(g.ProtectedKinds) != 2 { + t.Fatalf("ProtectedKinds = %v, want exactly stacktrace and diff", g.ProtectedKinds) + } + for _, kind := range []SegmentKind{KindStacktrace, KindDiff} { + if g.ProtectedKinds[kind] != 0.05 { + t.Errorf("ProtectedKinds[%q] = %v, want 0.05", kind, g.ProtectedKinds[kind]) + } + } +} + +// TestPipeline_MinGateTokensUnsetUsesDefault: a pipeline without an applied +// gate config runs on the reference default token floor. +func TestPipeline_MinGateTokensUnsetUsesDefault(t *testing.T) { + p := NewPipeline(ResolvedBackend{}, "", nil, PipelineOptions{}) + if got := p.minGateTokens(); got != DefaultMinGateTokens { + t.Errorf("minGateTokens() = %d, want the default %d", got, DefaultMinGateTokens) + } +} + +// TestApplyGateConfig_ClampsInvalidValues: out-of-range gate values fall +// back to safe behavior the way the config resolvers do — an invalid keep +// threshold leaves the armed relocation threshold in force, an invalid +// tripwire fraction falls back to the reference default, and per-kind +// floors clamp into [0, 1]. +func TestApplyGateConfig_ClampsInvalidValues(t *testing.T) { + var nilPipeline *Pipeline + nilPipeline.ApplyGateConfig(DefaultGateConfig()) // must not panic + + p := NewPipeline(ResolvedBackend{}, "", nil, PipelineOptions{}) + p.EnableRelocation(NewStore(), 0.5) + p.ApplyGateConfig(GateConfig{ + KeepThreshold: 1.5, // invalid → unset + MinGateTokens: -10, // invalid → 0 (gate disabled) + MaxElideFraction: 42, // invalid → reference default + }) + got := p.resolveGate() + if got.keep != 0.5 { + t.Errorf("keep threshold = %v, want the armed 0.5 to stay in force", got.keep) + } + if got.cfg.MaxElideFraction != DefaultMaxElideFraction { + t.Errorf("MaxElideFraction = %v, want the default %v", got.cfg.MaxElideFraction, DefaultMaxElideFraction) + } + if got.cfg.MinGateTokens != 0 { + t.Errorf("MinGateTokens = %d, want 0", got.cfg.MinGateTokens) + } + if got.cfg.ProtectedKinds[KindStacktrace] != DefaultProtectedFloor { + t.Errorf("nil ProtectedKinds must resolve to the reference defaults, got %v", got.cfg.ProtectedKinds) + } + + // A valid keep threshold takes over the relocation threshold, exactly + // as EnableRelocation would. + p.ApplyGateConfig(GateConfig{KeepThreshold: 0.2}) + if _, threshold := p.relocationArmed(); threshold != 0.2 { + t.Errorf("relocation threshold = %v, want the applied keep threshold 0.2", threshold) + } + + // Per-kind floors clamp into [0, 1]. + p.ApplyGateConfig(GateConfig{ProtectedKinds: map[SegmentKind]float64{KindDiff: 7, KindStacktrace: -1}}) + got = p.resolveGate() + if got.cfg.ProtectedKinds[KindDiff] != 1 || got.cfg.ProtectedKinds[KindStacktrace] != 0 { + t.Errorf("ProtectedKinds = %v, want floors clamped into [0,1]", got.cfg.ProtectedKinds) + } +} + +// protectedKindOutput builds three separate paragraphs (each over the +// 80-char tiny-paragraph floor): prose, a Go panic trace (kind stacktrace), +// prose again. It fails the test if the middle segment is not classified as +// a stacktrace — the fixture the protected-floor behavior hangs on. +func protectedKindOutput(t *testing.T) (output, traceSegment string) { + t.Helper() + prose := strings.Repeat("ordinary prose paragraph. ", 5) + trace := "goroutine 1 [running]:\nmain.main()\n\t/home/dev/app/main.go:42 +0x1a4\nexit status 2" + output = prose + "\n\n" + trace + "\n\n" + prose + segs := SegmentSegments(output, 0) + if len(segs) != 3 { + t.Fatalf("SegmentSegments() = %d segments, want 3", len(segs)) + } + if segs[1].Kind != KindStacktrace { + t.Fatalf("middle segment Kind = %q, want %q", segs[1].Kind, KindStacktrace) + } + return output, segs[1].Text +} + +// TestPipeline_ProtectedKindFloor: a stacktrace segment scoring below the +// keep threshold is still kept unless it scores below its own, much lower, +// protected floor (0.05) — the reference's protected_kinds semantics. +func TestPipeline_ProtectedKindFloor(t *testing.T) { + output, traceSegment := protectedKindOutput(t) + + newArmed := func(stackScore float64) *Pipeline { + d := newDecisionsServer(t, fixedScoresHandler(map[string]float64{ + "seg-1": 0.9, + "seg-2": stackScore, + "seg-3": 0.9, + })) + store := NewStore() + p := NewPipeline(ResolvedBackend{Backend: Backend{Name: "test", URL: d.srv.URL, Model: "jev-latest"}, APIKey: "k"}, "k", nil, PipelineOptions{}) + p.EnableRelocation(store, 0.35) + p.ApplyGateConfig(GateConfig{KeepThreshold: 0.35, MinGateTokens: 0, MaxElideFraction: 0.7}) + return p + } + + t.Run("between keep threshold and protected floor is kept", func(t *testing.T) { + // 0.2 is below the 0.35 keep threshold — a plain segment would be + // elided — but far above the 0.05 stacktrace floor. + got, err := newArmed(0.2).CompactToolOutput(context.Background(), "Bash", output) + if err != nil { + t.Fatalf("CompactToolOutput() error = %v", err) + } + if len(got.Elided) != 0 { + t.Errorf("Elided = %v, want nothing elided (stacktrace protected at 0.2)", got.Elided) + } + if got.CompactText != output { + t.Error("CompactText must be the original byte-for-byte when nothing is elided") + } + if got.Tripwire != "" { + t.Errorf("Tripwire = %q, want empty", got.Tripwire) + } + }) + + t.Run("below the protected floor is elided", func(t *testing.T) { + got, err := newArmed(0.04).CompactToolOutput(context.Background(), "Bash", output) + if err != nil { + t.Fatalf("CompactToolOutput() error = %v", err) + } + if len(got.Elided) != 1 { + t.Fatalf("Elided = %d segments, want exactly the stacktrace one", len(got.Elided)) + } + if got.Elided[0].Text != traceSegment { + t.Errorf("elided text = %q, want the trace segment", got.Elided[0].Text) + } + // The trace's tail is past the 60-char pointer preview, so it must + // not appear in the compact text at all (the preview's first line + // legitimately does). + if strings.Contains(got.CompactText, "exit status 2") { + t.Error("CompactText kept the below-floor stacktrace segment") + } + }) +} + +// TestPipeline_TripwireMaxElideFraction: when the scorer wants to elide more +// than MaxElideFraction of the output's tokens, it is distrusted — nothing +// is elided, the result reports the tripwire, and the shadow log records the +// override alongside (not instead of) the scorer's own decisions. +func TestPipeline_TripwireMaxElideFraction(t *testing.T) { + newPipeline := func(shadow *ShadowLog, store *Store, maxElide float64) *Pipeline { + d := newDecisionsServer(t, fixedScoresHandler(map[string]float64{ + "seg-1": 0.01, "seg-2": 0.02, "seg-3": 0.03, // everything below 0.35 + })) + p := NewPipeline(ResolvedBackend{Backend: Backend{Name: "test", URL: d.srv.URL, Model: "jev-latest"}, APIKey: "k"}, "k", shadow, PipelineOptions{}) + p.EnableRelocation(store, 0.35) + p.ApplyGateConfig(GateConfig{KeepThreshold: 0.35, MinGateTokens: 0, MaxElideFraction: maxElide}) + return p + } + + output, _, _, _ := relocationOutput() + + t.Run("all elided trips and keeps everything", func(t *testing.T) { + shadow, err := NewShadowLogAt(filepath.Join(t.TempDir(), "shadow.jsonl")) + if err != nil { + t.Fatal(err) + } + store := NewStore() + p := newPipeline(shadow, store, 0.7) + + got, err := p.CompactToolOutput(context.Background(), "Bash", output) + if err != nil { + t.Fatalf("CompactToolOutput() error = %v", err) + } + if got.Tripwire != TripwireMaxElideFraction { + t.Errorf("Tripwire = %q, want %q", got.Tripwire, TripwireMaxElideFraction) + } + if got.CompactText != output { + t.Error("the tripwire must keep the original output byte-for-byte") + } + if len(got.Elided) != 0 { + t.Errorf("Elided = %v, want empty after the tripwire", got.Elided) + } + if store.Len() != 0 { + t.Error("store must stay empty after the tripwire") + } + + // The shadow log holds what the scorer wanted (three elide + // decisions) plus one tripwire entry Replay skips. + lines := readLines(t, shadow.Path()) + if len(lines) != 4 { + t.Fatalf("got %d shadow lines, want 3 decisions + 1 tripwire", len(lines)) + } + tripwires := 0 + for _, line := range lines { + var e ShadowEntry + if err := json.Unmarshal([]byte(line), &e); err != nil { + t.Fatalf("shadow line invalid: %v", err) + } + if e.Type != EntryTypeTripwire { + continue + } + tripwires++ + if e.Action != TripwireAction { + t.Errorf("tripwire Action = %q, want %q", e.Action, TripwireAction) + } + if e.SegmentID != "" { + t.Errorf("tripwire entry must carry no segment id, got %q", e.SegmentID) + } + } + if tripwires != 1 { + t.Errorf("got %d tripwire entries, want 1", tripwires) + } + + report, err := shadow.Replay(0.35) + if err != nil { + t.Fatalf("Replay() error = %v", err) + } + if report.Entries != 3 || report.ElidedEntries != 3 { + t.Errorf("Replay() = %+v, want the scorer's 3 elide decisions with the tripwire entry skipped", report) + } + }) + + t.Run("fraction at the limit does not trip", func(t *testing.T) { + // MaxElideFraction 1 means only an over-100% claim trips (impossible), + // so the all-elide batch goes through untouched. + p := newPipeline(nil, NewStore(), 1) + got, err := p.CompactToolOutput(context.Background(), "Bash", output) + if err != nil { + t.Fatalf("CompactToolOutput() error = %v", err) + } + if got.Tripwire != "" { + t.Errorf("Tripwire = %q, want empty", got.Tripwire) + } + // All three segments are consecutive: one run, one pointer, three + // grouped segments. + if len(got.Elided) != 1 || got.Elided[0].Segments != 3 { + t.Errorf("Elided = %+v, want one run grouping all 3 segments", got.Elided) + } + }) +} + +// TestPipeline_PartialElisionDoesNotTrip: one of three segments elided is +// normal operation — no tripwire, normal pointer output. +func TestPipeline_PartialElisionDoesNotTrip(t *testing.T) { + d := newDecisionsServer(t, fixedScoresHandler(map[string]float64{ + "seg-1": 0.9, "seg-2": 0.1, "seg-3": 0.9, + })) + store := NewStore() + p := NewPipeline(ResolvedBackend{Backend: Backend{Name: "test", URL: d.srv.URL, Model: "jev-latest"}, APIKey: "k"}, "k", nil, PipelineOptions{}) + p.EnableRelocation(store, 0.35) + p.ApplyGateConfig(GateConfig{KeepThreshold: 0.35, MinGateTokens: 0, MaxElideFraction: 0.7}) + + output, _, _, _ := relocationOutput() + got, err := p.CompactToolOutput(context.Background(), "Bash", output) + if err != nil { + t.Fatalf("CompactToolOutput() error = %v", err) + } + if got.Tripwire != "" { + t.Errorf("Tripwire = %q, want empty", got.Tripwire) + } + if len(got.Elided) != 1 { + t.Errorf("Elided = %d segments, want exactly the low scorer", len(got.Elided)) + } +} + +// TestPipeline_MinGateTokensSkipsScoring: an output estimated below +// MinGateTokens is returned unchanged without any backend call and without +// shadow entries — the round trip would cost more than elision could save. +// Disabling the token gate lets the same output through. +func TestPipeline_MinGateTokensSkipsScoring(t *testing.T) { + var requests int + d := newDecisionsServer(t, func(_ int, req capturedRequest) (int, string) { + requests++ + return echoHandler(1, req) + }) + shadow, err := NewShadowLogAt(filepath.Join(t.TempDir(), "shadow.jsonl")) + if err != nil { + t.Fatal(err) + } + store := NewStore() + p := NewPipeline(ResolvedBackend{Backend: Backend{Name: "test", URL: d.srv.URL, Model: "jev-latest"}, APIKey: "k"}, "k", shadow, PipelineOptions{}) + p.EnableRelocation(store, 0.35) + + // ~150 chars: at most 150 tokens under any tokenizer, far below the + // 400-token reference default that applies while no gate config was set. + output := strings.Repeat("tiny output ", 12) + + got, err := p.CompactToolOutput(context.Background(), "Bash", output) + if err != nil { + t.Fatalf("CompactToolOutput() error = %v", err) + } + if got.CompactText != output || len(got.Elided) != 0 || got.Tripwire != "" { + t.Errorf("below MinGateTokens the output must pass through unchanged, got %+v", got) + } + if requests != 0 { + t.Errorf("got %d scoring requests, want 0 below MinGateTokens", requests) + } + if store.Len() != 0 { + t.Error("store must stay empty below MinGateTokens") + } + report, err := shadow.Replay(0.35) + if err != nil { + t.Fatalf("Replay() error = %v", err) + } + if report.Entries != 0 { + t.Errorf("shadow entries = %d, want 0 (scoring skipped entirely)", report.Entries) + } + + // Disabling the token gate lets the very same output through: scored, + // elided (echoHandler scores seg-1 at 0.01), stored. + p.ApplyGateConfig(GateConfig{KeepThreshold: 0.35, MinGateTokens: 0, MaxElideFraction: 1}) + got, err = p.CompactToolOutput(context.Background(), "Bash", output) + if err != nil { + t.Fatalf("CompactToolOutput() error = %v", err) + } + if requests == 0 { + t.Error("output must be scored once the token gate is disabled") + } + if len(got.Elided) != 1 { + t.Errorf("Elided = %d segments, want the single segment elided", len(got.Elided)) + } + if got.Tripwire != "" { + t.Errorf("Tripwire = %q, want empty", got.Tripwire) + } + // The single-segment output is one run stored under its content id. + if _, ok := store.Get(ContentID(output, "Bash", "r")); !ok { + t.Error("store must hold the elided original once the token gate is disabled") + } +} diff --git a/internal/compaction/llm_slot.go b/internal/compaction/llm_slot.go new file mode 100644 index 00000000..5e32773b --- /dev/null +++ b/internal/compaction/llm_slot.go @@ -0,0 +1,64 @@ +package compaction + +// Private scoring-side LLM concurrency slot, mirroring the process-wide +// client limiter's contract so compaction/scoring calls stay paced instead +// of stampeding the account-level provider limit in parallel with the +// agent's own traffic. It is intentionally package-local: the shared +// process-wide limiter lives with the LLM client feature; when it lands, +// this slot can delegate to it. +// +// A slot covers the provider-visible request lifetime, not just the +// handshake: for a scoring POST that means from just before the HTTP +// request until the response body is fully consumed. The limiter is active +// from package init with the same default capacity the fleet limiter uses; +// tests adjust it via setScoringConcurrency. + +import ( + "context" + "sync" +) + +var ( + // scoringMu guards scoringSlots/scoringCapacity. + scoringMu sync.Mutex + // scoringSlots is the counting channel; nil means unlimited. + scoringSlots chan struct{} + // scoringCapacity mirrors the channel bound; 0 or negative means + // unlimited. Default matches the fleet limiter's default cap. + scoringCapacity = 6 +) + +// setScoringConcurrency sets the cap on concurrent in-flight scoring +// requests. 0 or negative = unlimited. Tests only. +func setScoringConcurrency(n int) { + scoringMu.Lock() + defer scoringMu.Unlock() + scoringCapacity = n + if n <= 0 { + scoringSlots = nil + return + } + scoringSlots = make(chan struct{}, n) +} + +// acquireScoringSlot blocks until a slot is free or ctx is done. It returns +// a release func that must be called exactly once when the request finishes; +// the returned func is nil when the limiter is unlimited or when ctx ended +// before a slot could be acquired (callers distinguish the two via +// ctx.Err()). Release never blocks. +func acquireScoringSlot(ctx context.Context) func() { + scoringMu.Lock() + slots, capacity := scoringSlots, scoringCapacity + scoringMu.Unlock() + if capacity <= 0 || slots == nil { + return nil + } + select { + case slots <- struct{}{}: + return func() { <-slots } + case <-ctx.Done(): + // All slots busy and the caller gave up while queued: abandon the + // wait with no release. + return nil + } +} diff --git a/internal/compaction/offline.go b/internal/compaction/offline.go new file mode 100644 index 00000000..3ac17060 --- /dev/null +++ b/internal/compaction/offline.go @@ -0,0 +1,101 @@ +package compaction + +import ( + "context" + "crypto/sha256" + "fmt" +) + +// The offline scripted scorer (implementation_plan.md Step 18): a +// deterministic, fully local stand-in for the System One decision backend so +// the whole compaction flow — shadow scoring, the gate, pointers, the expand +// round trip, history compaction, the preflight — can be tried WITHOUT an +// API key or network access, exactly like the reference repo's testing.py, +// which drives the same flow against a scripted echo scorer. +// +// FOR DEMOS AND TESTS ONLY. The scripted scores measure nothing about +// essentiality — they are a hash of the input — so anything elided by them is +// elided by hash luck, not by judgment. Never wire it as a production +// default: production compaction always goes through ResolveBackendEnv and +// the DecisionClient; "offline" is an explicit config.json choice +// (compaction-backend: "offline") that exists to make the flow demonstrable. + +// Scorer is the batch-scoring interface the Pipeline consumes; see +// pipeline.go. ScriptedScorer implements it, as does *DecisionClient (the +// compile-time assertion lives next to the interface). + +// OfflineBackendName is the compaction-backend value that selects the +// scripted scorer. It lives here so the resolver, main, and docs spell the +// one string the same way. (internal/config re-exports it as +// CompactionBackendOffline for its own API surface.) +const OfflineBackendName = "offline" + +// ScriptedScorer scores every item deterministically from its content: the +// first byte of sha256(task + "\x00" + text) mapped into [0, 1) (byte/256 — +// 1.0 is unreachable, so the keep score never collides with a scripted +// score). Same task and text → same score, always, in every process and on +// every platform; no randomness, no clock, no I/O. It never fails and never +// touches the network, so the pipeline's fail-open path stays cold offline. +// +// The zero value is ready to use. It is safe for concurrent use (no state). +// +// Demos and tests only — see the package-level note above. +type ScriptedScorer struct{} + +// ScoreBatch implements Scorer with the deterministic content hash described +// on the type. An empty task falls back to the client's defaultTask so the +// score depends only on content even when the caller has no task text (the +// same normalization DecisionClient applies). +func (ScriptedScorer) ScoreBatch(_ context.Context, task string, items map[string]Item) (map[string]float64, error) { + if task == "" { + task = defaultTask + } + scores := make(map[string]float64, len(items)) + for id, it := range items { + scores[id] = scriptedScore(task, it.Text) + } + return scores, nil +} + +// scriptedScore maps sha256(task + "\x00" + text)'s first byte into [0, 1). +// Stable across processes by construction: sha256 is unkeyed and the string +// encoding is Go-independent UTF-8. +func scriptedScore(task, text string) float64 { + sum := sha256.Sum256([]byte(task + "\x00" + text)) + return float64(sum[0]) / 256.0 +} + +// NewOfflinePipeline builds a Pipeline over the ScriptedScorer: the same +// segmentation, gate, shadow log, pointer, and expand machinery as +// NewPipeline, with scoring done locally and deterministically instead of +// through the decisions protocol. No backend is resolved, no API key is +// consulted, and no request is ever sent — the pipeline is safe to drive in +// an airgapped environment. opts.Shadow behaves exactly as NewPipeline's +// shadow argument (a nil shadow disables logging); opts.MaxSegChars falls +// back to DefaultMaxSegChars. +// +// Demos and tests only: the scripted scorer's scores do not measure +// essentiality (see the package-level note). +func NewOfflinePipeline(opts PipelineOptions) *Pipeline { + return newPipelineWithScorer(ScriptedScorer{}, opts.Shadow, opts.MaxSegChars) +} + +// offlineBackendDetail is stage 0's opening line in the offline preflight +// report: there is no endpoint, no model, and no key — the scorer is the +// compiled-in scripted one. +func offlineBackendDetail() string { + return fmt.Sprintf("backend %q url none model %q (api key: none — deterministic offline scorer; no network)", + OfflineBackendName, "scripted-scorer") +} + +// RunPreflightOffline runs the same three preflight stages as RunPreflight +// (questions, gate, expand) against the ScriptedScorer: everything runs +// locally, no backend is resolved, and no request is sent, so the check +// passes without an API key or network. It backs `-check-compaction` when +// config.json selects compaction-backend "offline" — the demo path's +// self-test. The boolean is the overall verdict; the stage contract +// (stop at the first failure, stage 0 first) is RunPreflight's. ctx only +// bounds the local work (cancellation), never network I/O — there is none. +func RunPreflightOffline(ctx context.Context) ([]CheckResult, bool) { + return runPreflightStages(ctx, ScriptedScorer{}, offlineBackendDetail()) +} diff --git a/internal/compaction/offline_test.go b/internal/compaction/offline_test.go new file mode 100644 index 00000000..c0508065 --- /dev/null +++ b/internal/compaction/offline_test.go @@ -0,0 +1,263 @@ +package compaction + +import ( + "context" + "strings" + "testing" +) + +// The offline scripted scorer's tests construct NO server of any kind: the +// whole point of the offline path is that scoring never touches the network. +// Any HTTP attempt would have nothing to connect to and would surface as a +// scoring error, so the nil-error assertions below double as the no-network +// proof. + +// TestScorerInterfaceSatisfied pins the interface contract the pipeline +// consumes: the production decision client and the offline scripted scorer +// are both Scorer, so either can sit behind the same Pipeline. +func TestScorerInterfaceSatisfied(t *testing.T) { + var _ Scorer = (*DecisionClient)(nil) + var _ Scorer = ScriptedScorer{} +} + +// TestScriptedScorerDeterministicAndBounded: the same task and text always +// produce the same score (the property that makes offline demos reproducible +// and shadow logs replayable), every score lands in [0, 1) (1.0 — the +// fail-open keep score — is unreachable, so a scripted score can never be +// mistaken for one), and differing content or task changes the score. +func TestScriptedScorerDeterministicAndBounded(t *testing.T) { + scorer := ScriptedScorer{} + items := map[string]Item{ + "seg-1": {Text: "the build log shows three failing tests"}, + "seg-2": {Text: "a benchmark table timing the scoring endpoint"}, + "seg-3": {Text: "unrelated stack trace, already fixed"}, + } + task := "Preserve what the Bash tool output contributed toward the ongoing task." + + first, err := scorer.ScoreBatch(context.Background(), task, items) + if err != nil { + t.Fatalf("ScoreBatch() error = %v, want nil (the scripted scorer never fails)", err) + } + if len(first) != len(items) { + t.Fatalf("got %d scores, want one per item (%d)", len(first), len(items)) + } + for _, text := range []string{ + "the build log shows three failing tests", + "a benchmark table timing the scoring endpoint", + "unrelated stack trace, already fixed", + "", + strings.Repeat("determinism probe ", 500), + } { + one, err := scorer.ScoreBatch(context.Background(), task, map[string]Item{"x": {Text: text}}) + if err != nil { + t.Fatalf("ScoreBatch(%q) error = %v", text, err) + } + again, err := scorer.ScoreBatch(context.Background(), task, map[string]Item{"x": {Text: text}}) + if err != nil { + t.Fatalf("ScoreBatch(%q) second call error = %v", text, err) + } + if one["x"] != again["x"] { + t.Errorf("score for %q changed across identical calls: %v vs %v", text, one["x"], again["x"]) + } + if one["x"] < 0 || one["x"] >= 1 { + t.Errorf("score for %q = %v, want [0,1)", text, one["x"]) + } + } + + // Different content scores differently (the content hash must actually + // depend on the content). + other, err := scorer.ScoreBatch(context.Background(), task, map[string]Item{"seg-1": {Text: "something entirely different"}}) + if err != nil { + t.Fatal(err) + } + if other["seg-1"] == first["seg-1"] { + t.Errorf("different texts collided on score %v", first["seg-1"]) + } + // ...and the same text under a different task too. + otherTask, err := scorer.ScoreBatch(context.Background(), "another task", map[string]Item{"seg-1": items["seg-1"]}) + if err != nil { + t.Fatal(err) + } + if otherTask["seg-1"] == first["seg-1"] { + t.Errorf("the task did not affect the score (both %v)", first["seg-1"]) + } +} + +// TestScriptedScorerEmptyTaskNormalization: an empty task scores like the +// client's defaultTask normalization — the score depends on content even +// when the caller has no task text, matching DecisionClient's behavior. +func TestScriptedScorerEmptyTaskNormalization(t *testing.T) { + scorer := ScriptedScorer{} + item := map[string]Item{"x": {Text: "stable text"}} + empty, err := scorer.ScoreBatch(context.Background(), "", item) + if err != nil { + t.Fatal(err) + } + viaDefault, err := scorer.ScoreBatch(context.Background(), defaultTask, item) + if err != nil { + t.Fatal(err) + } + if empty["x"] != viaDefault["x"] { + t.Errorf("empty task scored %v, want the defaultTask score %v", empty["x"], viaDefault["x"]) + } + // An empty-item batch is not an error, like the protocol client. + if got, err := scorer.ScoreBatch(context.Background(), "t", nil); err != nil || len(got) != 0 { + t.Errorf("ScoreBatch(nil) = (%v, %v), want (empty, nil)", got, err) + } +} + +// TestOfflinePipelineEndToEndByteForByte is the Step 18 core pin, offline +// (no server exists in this test): NewOfflinePipeline over the scripted +// scorer, relocation armed (enabled mode), compacts a tool output into +// [[elided id=r:…]] pointers backed by store records, and Reconstruct +// expands them back byte for byte. A second, identical compaction produces +// the identical compacted text and pointer ids — the determinism the demo +// path advertises. +func TestOfflinePipelineEndToEndByteForByte(t *testing.T) { + original := strings.Join([]string{ + "essential first paragraph: the migration changes the row format and the reader must know that.", + strings.Repeat("filler number one ", 60), + strings.Repeat("filler number two ", 60), + "essential last paragraph: the benchmark table below is the only evidence of the regression.", + }, "\n\n") + + run := func() (CompactResult, *Store) { + p := NewOfflinePipeline(PipelineOptions{}) + store := NewStore() + // Neutral gate: no min-gate floor (the fixture is small) and a + // disabled tripwire, so the scripted decisions stand. + p.ApplyGateConfig(GateConfig{ + MinGateTokens: 0, + MaxElideFraction: 1.0, + ProtectedKinds: map[SegmentKind]float64{}, + }) + p.EnableRelocation(store, 1.0) // below 1.0 always elides: scripted scores are < 1 + res, err := p.CompactToolOutput(context.Background(), "Bash", original) + if err != nil { + t.Fatalf("CompactToolOutput() error = %v, want nil (offline scoring never fails)", err) + } + return res, store + } + + res, store := run() + if len(res.Elided) == 0 { + t.Fatal("nothing was elided: the offline pipeline relocated nothing") + } + if !strings.Contains(res.CompactText, "[[elided id=r:") { + t.Fatalf("compacted text carries no content-id pointer:\n%s", res.CompactText) + } + if res.Tripwire != "" || res.Disabled != "" { + t.Errorf("tripwire = %q, disabled = %q, want both empty", res.Tripwire, res.Disabled) + } + for _, e := range res.Elided { + rec, ok := store.GetRecord(e.ID) + if !ok { + t.Fatalf("store is missing record %q for the elided run", e.ID) + } + if rec.Text != e.Text { + t.Errorf("record %q text differs from the elided run text", e.ID) + } + } + if expanded := Reconstruct(res.CompactText, store); expanded != original { + t.Errorf("Reconstruct did not restore the original byte for byte (%d vs %d bytes)", len(expanded), len(original)) + } + + // Determinism: an identical second run (fresh store) yields the identical + // compacted text — same scores, same decisions, same content ids. + res2, _ := run() + if res2.CompactText != res.CompactText { + t.Errorf("second offline compaction differs:\n--- first ---\n%s\n--- second ---\n%s", res.CompactText, res2.CompactText) + } + for i := range res.Elided { + if res2.Elided[i].ID != res.Elided[i].ID { + t.Errorf("pointer id %d differs across identical runs: %q vs %q", i, res.Elided[i].ID, res2.Elided[i].ID) + } + } +} + +// TestOfflinePipelineShadowLog: the offline pipeline logs to the shadow log +// exactly like the online one (opts.Shadow) — one keep decision per segment +// in shadow mode — so replay tooling works on offline demo runs too. +func TestOfflinePipelineShadowLog(t *testing.T) { + shadow, err := NewShadowLogAt(t.TempDir() + "/shadow.jsonl") + if err != nil { + t.Fatal(err) + } + p := NewOfflinePipeline(PipelineOptions{Shadow: shadow}) + output := strings.Repeat("a", 200) + "\n\n" + strings.Repeat("b", 200) + "\n\n" + strings.Repeat("c", 200) + got, err := p.ScoreToolOutput(context.Background(), "Bash", output) + if err != nil { + t.Fatalf("ScoreToolOutput() error = %v", err) + } + if len(got.Segments) != 3 || len(got.Scores) != 3 { + t.Fatalf("got %d segments / %d scores, want 3/3", len(got.Segments), len(got.Scores)) + } + report, err := shadow.Replay(0.5) + if err != nil { + t.Fatalf("Replay() error = %v", err) + } + if report.Entries != 3 { + t.Errorf("shadow log recorded %d entries, want 3", report.Entries) + } +} + +// TestOfflinePipelineHistoryScorer: the offline pipeline exposes its +// ScriptedScorer through HistoryScorer, so session.CompactContext runs +// offline through the same seam the online pipeline uses. +func TestOfflinePipelineHistoryScorer(t *testing.T) { + p := NewOfflinePipeline(PipelineOptions{}) + if p.HistoryScorer() == nil { + t.Fatal("HistoryScorer() = nil, want the scripted scorer") + } + if _, ok := p.HistoryScorer().(ScriptedScorer); !ok { + t.Errorf("HistoryScorer() = %T, want ScriptedScorer", p.HistoryScorer()) + } +} + +// TestRunPreflightOfflinePasses: the -check-compaction offline path runs all +// three real stages locally (no backend resolved, no key, no network) and +// passes by construction. The report names the scripted backend on stage 0. +func TestRunPreflightOfflinePasses(t *testing.T) { + results, ok := RunPreflightOffline(context.Background()) + if !ok { + t.Fatalf("RunPreflightOffline() ok = false, want all stages to pass:\n%s", FormatCheckReport(results, ok)) + } + if len(results) != 4 { + t.Fatalf("got %d results, want 4 (backend, questions, gate, expand)", len(results)) + } + for i, want := range []string{CheckStageBackend, CheckStageQuestions, CheckStageGate, CheckStageExpand} { + if results[i].Stage != want { + t.Errorf("results[%d].Stage = %q, want %q", i, results[i].Stage, want) + } + if !results[i].OK { + t.Errorf("results[%d] (%s) = FAIL: %s", i, results[i].Stage, results[i].Detail) + } + } + // Stage 0 says there is no endpoint and no key — the scripted scorer. + for _, want := range []string{`"offline"`, "none", "scripted", "no network"} { + if !strings.Contains(results[0].Detail, want) { + t.Errorf("backend detail = %q, want it to contain %q", results[0].Detail, want) + } + } + // The gate really relocated from the synthetic output, and expand named + // the round trip. + if !strings.Contains(results[2].Detail, "relocated") { + t.Errorf("gate detail = %q, want the relocation summary", results[2].Detail) + } + if !strings.Contains(results[3].Detail, "byte for byte") { + t.Errorf("expand detail = %q, want the round-trip summary", results[3].Detail) + } + + out := FormatCheckReport(results, true) + if !strings.Contains(out, "result: PASS (4/4 stages ok)") { + t.Errorf("report missing the PASS verdict:\n%s", out) + } +} + +// TestOfflineBackendName pins the one string the config resolver, main, and +// the docs all spell the same way. +func TestOfflineBackendName(t *testing.T) { + if OfflineBackendName != "offline" { + t.Errorf("OfflineBackendName = %q, want \"offline\"", OfflineBackendName) + } +} diff --git a/internal/compaction/pipeline.go b/internal/compaction/pipeline.go new file mode 100644 index 00000000..171b54fa --- /dev/null +++ b/internal/compaction/pipeline.go @@ -0,0 +1,341 @@ +package compaction + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "sync" + "time" + + "late/internal/common" +) + +// Scorer is the batch-scoring interface the Pipeline consumes: score one +// batch of items against one ongoing task. *DecisionClient (the System One +// backend client) is the production implementation; compaction.ScriptedScorer +// is the deterministic offline one (compaction-backend "offline" — demos and +// tests only). The same method set is session.HistoryScorer's, so a pipeline +// scores both the tool-output path and full-history compaction. +type Scorer interface { + ScoreBatch(ctx context.Context, task string, items map[string]Item) (map[string]float64, error) +} + +// The production scoring client is the reference Scorer. +var _ Scorer = (*DecisionClient)(nil) + +// Pipeline ties segmentation, scoring, and the shadow log together for the +// tool layer. Stage 1 (shadow-only) is ScoreToolOutput: it records decisions +// and returns scores without ever mutating agent behavior. Stage 2 +// (relocation, armed with EnableRelocation) additionally elides low-scoring +// segments from tool results and stores their originals for the expand tool. +type Pipeline struct { + // client scores every segment batch. Held as the Scorer interface, not + // the concrete *DecisionClient, so the deterministic offline scorer + // (NewOfflinePipeline) can drive the same pipeline without HTTP. + client Scorer + shadow *ShadowLog + maxSegChars int + // now is the clock for shadow-log timestamps; a var solely for tests. + now func() time.Time + + // Relocation state (stage 2). relocMu guards the armed store, the + // elision threshold, and the gate configuration: one pipeline is shared + // by the root agent and every subagent, whose tool calls run + // concurrently. The elide-id counter lives in the Store itself so the + // session's history compaction shares the same id space (Store.NextID). + // + // gate/gateSet hold the GateConfig applied via ApplyGateConfig; unset, + // the pipeline runs on DefaultGateConfig (see gate.go). + relocMu sync.Mutex + reloc *Store + threshold float64 + gate GateConfig + gateSet bool + + // Auth-poison state (the reference's JevAuthError policy): the moment + // scoring sees an auth-class error (401/403) the pipeline disables + // scoring for the rest of the session — a bad key will not heal — and + // emits ONE clear warning no matter how many tool calls trip over it + // (the sync.Once-style guard: authMu guards dead/reason/warned, so + // concurrent first failures produce exactly one note). warnTo is where + // that note goes; os.Stderr in production, swappable in tests. + authMu sync.Mutex + authDead bool + authReason string + authWarned bool + warnTo io.Writer + + // warnFn, when installed, receives the same one-time auth warning + // INSTEAD of warnTo: the TUI wiring installs it (SetWarningSink) so a + // mid-session poisoning surfaces as a toast instead of a raw stderr + // write painting over the alt-screen. nil (the default, and every + // headless/CLI flow) keeps the warnTo behavior. + warnFn func(msg string) +} + +// PipelineOptions tunes the pipeline; zero values are production defaults. +type PipelineOptions struct { + // MaxSegChars caps one segment's size in bytes (DefaultMaxSegChars when + // 0 or negative). + MaxSegChars int + // HTTPClient overrides the decision client's transport (tests inject + // fast/recorded transports here). Nil uses the stdlib default with a + // 30s per-attempt timeout. Only read by NewPipeline (the offline + // pipeline never sends requests). + HTTPClient *http.Client + // Shadow is the shadow log for NewOfflinePipeline, which has no backend + // parameter to hang one on. NewPipeline takes its shadow as its own + // third argument and ignores this field; nil (the zero value) disables + // logging exactly as online. + Shadow *ShadowLog +} + +// NewPipeline builds a shadow-only scoring pipeline. backend must be ready +// to call (ResolveBackend fills in the gateway's URL); apiKey overrides +// backend.APIKey when non-empty. shadow may be nil, which disables logging +// (scores still flow — useful for dry runs). +func NewPipeline(backend ResolvedBackend, apiKey string, shadow *ShadowLog, opts PipelineOptions) *Pipeline { + c := NewDecisionClient(backend, apiKey) + if opts.HTTPClient != nil { + c.http = opts.HTTPClient + } + return newPipelineWithScorer(c, shadow, opts.MaxSegChars) +} + +// newPipelineWithScorer is the shared constructor: one Scorer behind a +// pipeline with the default segment cap and clock. maxSegChars <= 0 means +// DefaultMaxSegChars. +func newPipelineWithScorer(scorer Scorer, shadow *ShadowLog, maxSegChars int) *Pipeline { + max := maxSegChars + if max <= 0 { + max = DefaultMaxSegChars + } + return &Pipeline{client: scorer, shadow: shadow, maxSegChars: max, now: time.Now, warnTo: os.Stderr} +} + +// noteAuthFailure records an auth rejection: compaction scoring is disabled +// for the rest of the session (the reference's JevAuthError policy — a bad +// or missing key will not heal within the session), and one clear warning is +// emitted, exactly once. The decision client poisons itself the same way; +// this is the pipeline-side half so the tool path stops calling entirely. +func (p *Pipeline) noteAuthFailure(reason string) { + if p == nil { + return + } + p.authMu.Lock() + defer p.authMu.Unlock() + if p.authDead { + return + } + p.authDead = true + p.authReason = reason + if !p.authWarned { + p.authWarned = true + // Live-console surface: the installed warning sink (TUI toast) when + // present, the warnTo writer (os.Stderr) otherwise — the exact + // pre-sink behavior for headless flows and tests. + if p.warnFn != nil { + p.warnFn(fmt.Sprintf("Warning: compaction scoring disabled for this session (%v)\n", reason)) + } else { + fmt.Fprintf(p.warnTo, "Warning: compaction scoring disabled for this session (%v)\n", reason) + } + // Durable record of the poisoning (the warning is ephemeral): + // best-effort, never fails the pipeline. + common.LogErrorf("compaction", "scoring disabled for this session (auth): %v", reason) + } +} + +// SetWarningSink installs fn as the live-console surface for the pipeline's +// one-time auth-poison warning: noteAuthFailure routes its note to fn instead +// of warnTo (os.Stderr), so a TUI session can surface the warning as a toast +// without raw text painting over the alt-screen. Passing nil removes the sink +// and restores the stderr fallback. The main() wiring installs it together +// with the retrieval-skip warning's diagSink; the -check-compaction +// preflight and every headless flow run with no sink installed and keep the +// stderr behavior. +func (p *Pipeline) SetWarningSink(fn func(msg string)) { + p.authMu.Lock() + defer p.authMu.Unlock() + p.warnFn = fn +} + +// DisableAuth is noteAuthFailure's exported form, for the Step 16 startup +// probe: the probe scores on its own throwaway client, so a typed auth +// rejection there would otherwise reach the live pipeline's client only on +// its first real scoring call. Calling this with the probe's reason applies +// the exact same session-disable + one-warning policy a live rejection takes +// (the reference's JevAuthError policy), before the first tool call can burn +// a doomed request. A nil pipeline is a no-op. +func (p *Pipeline) DisableAuth(reason string) { + p.noteAuthFailure(reason) +} + +// authDisabled reports whether scoring was disabled by an auth rejection, +// together with the reason recorded when it happened. +func (p *Pipeline) authDisabled() (bool, string) { + if p == nil { + return false, "" + } + p.authMu.Lock() + defer p.authMu.Unlock() + return p.authDead, p.authReason +} + +// HistoryScorer exposes the pipeline's scorer as the scorer for full-history +// compaction: session.CompactContext scores history segments against the +// ongoing task with the same scorer — and the same retry and fail-open +// contract — the tool-output path uses. A nil pipeline yields nil; callers +// treat that as "compaction unavailable". The concrete type behind the +// interface is the pipeline's decision client (the offline pipeline returns +// its ScriptedScorer); session.HistoryScorer is the same method set. +func (p *Pipeline) HistoryScorer() Scorer { + if p == nil { + return nil + } + return p.client +} + +// SegmentScores is the result of scoring one tool output. +type SegmentScores struct { + // Segments is the segmentation of the tool output. + Segments []Segment + // Scores maps segment ID → score in [0,1]; 1.0 also covers items that + // failed to score (fail-open). Always populated for every segment. + Scores map[string]float64 + // Errors lists scoring and shadow-log failures (each scoring failure is + // an *ItemScoreError keyed by segment ID). Non-fatal by contract. + Errors []error + // TaskHash is the digest logged alongside each decision. + TaskHash string +} + +// scoreTask derives the ongoing-task description for one tool output. Stage +// 1 has no ambient task to thread through (the tool layer is wired in the +// relocation stage), so the task is derived from the tool's name; the +// relocation stage can pass a richer task through then. +func scoreTask(toolName string) string { + return fmt.Sprintf("Preserve what the %s tool output contributed toward the ongoing task.", toolName) +} + +// ScoreToolOutput segments the tool output, scores every segment against the +// ongoing task, appends one shadow-log line per segment, and returns the +// per-segment scores. +// +// Nothing is elided or relocated here: in shadow mode every decision is +// recorded as "keep" so Replay() can quantify what a threshold would have +// elided; when relocation is armed (EnableRelocation) below-threshold scores +// are recorded as "elide" — the elision itself happens in CompactToolOutput. +// +// The error return is non-nil exactly when at least one segment failed to +// score (mirroring DecisionClient.ScoreBatch's joined item errors); the +// scores themselves are always usable thanks to fail-open. An auth-class +// error (the reference's JevAuthError, 401/403) additionally disables the +// pipeline's scoring for the rest of the session — one warning is logged, +// and CompactToolOutput stops calling the backend entirely. +func (p *Pipeline) ScoreToolOutput(ctx context.Context, toolName, output string) (SegmentScores, error) { + var out SegmentScores + if p == nil || p.client == nil { + return out, fmt.Errorf("compaction: pipeline has no decision client") + } + + task := scoreTask(toolName) + out.TaskHash = HashTask(task) + out.Segments = SegmentSegments(output, p.maxSegChars) + if len(out.Segments) == 0 { + return out, nil + } + + items := make(map[string]Item, len(out.Segments)) + for _, s := range out.Segments { + items[s.ID] = Item{Text: s.Text, Tokens: s.Tokens} + } + scores, err := p.client.ScoreBatch(ctx, task, items) + out.Scores = scores + if err != nil { + out.Errors = append(out.Errors, err) + var ce *Error + if errors.As(err, &ce) && ce.Kind == KindAuth { + // Auth is a session-level configuration failure: disable + // scoring (one warning) instead of failing every future tool + // call against a backend that will only say 401 again. The + // client poisons itself too, so even direct ScoreBatch users + // stop hitting the network. + p.noteAuthFailure(ce.Error()) + } + } + + // Shadow log: one line per segment. In shadow mode (stage 1) the decision + // is always keep. When relocation is armed (stage 2), the recorded + // decision reflects what CompactToolOutput does with this score: elide + // strictly below the segment's floor (the protected-kind floor when the + // kind has one, else the relocation threshold), with the same paragraph + // atomicity (AtomicElideDecisions — minimum sibling score for cut + // paragraphs) and protected-origin clamping (activate_skill at a 1.0 + // score floor) the elide path applies, so the log stays a faithful + // replay of the real decisions. Append failures are recorded but never + // fail the call — logging must not be able to break scoring. + if p.shadow != nil { + now := p.now() + relocStore, _ := p.relocationArmed() + gate := p.resolveGate() + source := OriginSourceToolPrefix + toolName + scoresByID := make([]float64, len(out.Segments)) + floors := make([]float64, len(out.Segments)) + for i, s := range out.Segments { + score, ok := out.Scores[s.ID] + if !ok { + score = keepScore + } + scoresByID[i] = protectedScore(source, score) + floors[i] = gate.floor(s.Kind) + } + elided := make([]bool, len(out.Segments)) + decide, decFloors := AtomicDecisionScores(out.Segments, scoresByID, floors) + for i := range out.Segments { + elided[i] = decide[i] < decFloors[i] + } + if relocStore == nil { + // Shadow mode (stage 1): relocation is not armed, so every + // decision is keep — the log records what actually happens. + for i := range elided { + elided[i] = false + } + } + for i, s := range out.Segments { + // The score the decision was made on: the origin-protected + // clamp (1.0) when one applies, the paragraph-minimum sibling + // score for pieces of a cut paragraph. Recording the decision's + // own binding score keeps Stats/Replay (score vs threshold) + // consistent with the recorded decision. + score := decide[i] + // The floor this segment's elide decision turns on: the + // protected-kind floor when the kind has one, else the + // relocation threshold in force (the paragraph minimum for + // grouped pieces — AtomicDecisionScores). Recorded with the + // entry so Stats, FalseNegativeRate, and ReplayTable can re-run + // the decision from score vs threshold without re-scoring (0 + // when no threshold is in force — never counts as elided). + floor := decFloors[i] + decision := DecisionKeep + if elided[i] { + decision = DecisionElide + } + if aerr := p.shadow.Append(ShadowEntry{ + TS: now, + TaskHash: out.TaskHash, + SegmentID: s.ID, + Tokens: s.Tokens, + Score: score, + Decision: decision, + Threshold: floor, + Kind: DecisionKindAdmit, + }); aerr != nil { + out.Errors = append(out.Errors, fmt.Errorf("shadow log append for %s: %w", s.ID, aerr)) + } + } + } + return out, err +} diff --git a/internal/compaction/pipeline_test.go b/internal/compaction/pipeline_test.go new file mode 100644 index 00000000..39174bc4 --- /dev/null +++ b/internal/compaction/pipeline_test.go @@ -0,0 +1,262 @@ +package compaction + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// fixedScoresHandler serves the same score map for every request. +func fixedScoresHandler(scores map[string]float64) func(int, capturedRequest) (int, string) { + return func(_ int, _ capturedRequest) (int, string) { + return http.StatusOK, answersBody(scores) + } +} + +// shrinkPipelineRetryCurve shrinks the pipeline's decision-client retry +// curve for a fast test. The pipeline holds its scorer behind the Scorer +// interface (so the offline pipeline can swap in the scripted scorer); +// production code never touches the concrete retry fields, tests do, hence +// the type assertion — a no-op for a pipeline over any other scorer. +func shrinkPipelineRetryCurve(p *Pipeline) { + if dc, ok := p.client.(*DecisionClient); ok { + dc.baseBackoff, dc.maxBackoff = time.Millisecond, time.Millisecond + } +} + +func TestPipeline_ScoreToolOutputEndToEnd(t *testing.T) { + scores := map[string]float64{"seg-1": 0.9, "seg-2": 0.1, "seg-3": 0.55} + d := newDecisionsServer(t, fixedScoresHandler(scores)) + + shadowPath := filepath.Join(t.TempDir(), "shadow.jsonl") + shadow, err := NewShadowLogAt(shadowPath) + if err != nil { + t.Fatal(err) + } + + backend := ResolvedBackend{Backend: Backend{Name: "test", URL: d.srv.URL, Model: "jev-latest"}, APIKey: "k"} + p := NewPipeline(backend, "k", shadow, PipelineOptions{}) + now := time.Unix(1700000000, 0).UTC() + p.now = func() time.Time { return now } + + output := "essential output\n\nfiller paragraph\n\nanother keeper" + got, err := p.ScoreToolOutput(context.Background(), "Bash", output) + if err != nil { + t.Fatalf("ScoreToolOutput() error = %v", err) + } + + // Segmentation flowed through. + if len(got.Segments) != 3 { + t.Fatalf("got %d segments, want 3", len(got.Segments)) + } + assertSegmentsInvariant(t, output, got.Segments) + + // Scores round-tripped. + for ref, want := range scores { + if got.Scores[ref] != want { + t.Errorf("Scores[%s] = %v, want %v", ref, got.Scores[ref], want) + } + } + if len(got.Errors) != 0 { + t.Errorf("Errors = %v, want none", got.Errors) + } + + // The shadow log recorded one keep decision per segment. + report, err := shadow.Replay(0.5) + if err != nil { + t.Fatalf("Replay() error = %v", err) + } + if report.Entries != 3 || report.UniqueSegments != 3 { + t.Fatalf("Replay() = %+v, want 3 entries over 3 segments", report) + } + if report.ElidedEntries != 1 || report.TokensElided != got.Segments[1].Tokens { + t.Errorf("Replay() = %+v, want only seg-2 (score 0.1) elided", report) + } + lines := readLines(t, shadowPath) + if len(lines) != 3 { + t.Fatalf("got %d shadow lines, want 3", len(lines)) + } + for _, line := range lines { + var e ShadowEntry + if err := json.Unmarshal([]byte(line), &e); err != nil { + t.Fatalf("shadow line invalid: %v", err) + } + if e.Decision != DecisionKeep { + t.Errorf("shadow decision = %q, want %q (shadow-only stage)", e.Decision, DecisionKeep) + } + // No gate was applied and relocation is not armed, so no elision + // threshold is in force: the entry records 0 (omitted in JSON) — + // Stats/FalseNegativeRate never count such an entry as elided. + if e.Threshold != 0 { + t.Errorf("shadow threshold = %v, want 0 (no gate in force)", e.Threshold) + } + if e.TaskHash == "" || e.TaskHash != got.TaskHash { + t.Errorf("shadow TaskHash = %q, want the pipeline's %q", e.TaskHash, got.TaskHash) + } + if !e.TS.Equal(now) { + t.Errorf("shadow TS = %v, want the pipeline clock %v", e.TS, now) + } + } +} + +// TestPipeline_FailOpenStillLogs: with the provider down, every segment +// fail-opens to 1.0 and the shadow log still records the decisions. +func TestPipeline_FailOpenStillLogs(t *testing.T) { + d := newDecisionsServer(t, func(_ int, _ capturedRequest) (int, string) { + return http.StatusServiceUnavailable, `{"error": {"message": "down"}}` + }) + shadow, err := NewShadowLogAt(filepath.Join(t.TempDir(), "shadow.jsonl")) + if err != nil { + t.Fatal(err) + } + + backend := ResolvedBackend{Backend: Backend{Name: "test", URL: d.srv.URL, Model: "jev-latest"}, APIKey: "k"} + p := NewPipeline(backend, "k", shadow, PipelineOptions{}) + // Shrink the client's retry curve for a fast test. + shrinkPipelineRetryCurve(p) + + output := strings.Repeat("a", 200) + "\n\n" + strings.Repeat("b", 200) + "\n\n" + strings.Repeat("c", 200) + got, err := p.ScoreToolOutput(context.Background(), "Bash", output) + if err == nil { + t.Fatal("ScoreToolOutput() error = nil, want the outage recorded") + } + if len(got.Segments) != 3 { + t.Fatalf("got %d segments, want 3", len(got.Segments)) + } + for _, s := range got.Segments { + if got.Scores[s.ID] != keepScore { + t.Errorf("Scores[%s] = %v, want %v (fail-open)", s.ID, got.Scores[s.ID], keepScore) + } + } + + report, err := shadow.Replay(0.9) + if err != nil { + t.Fatalf("Replay() error = %v", err) + } + if report.Entries != 3 || report.ElidedEntries != 0 { + t.Errorf("Replay() = %+v, want 3 kept entries (1.0 ≥ 0.9)", report) + } +} + +// TestPipeline_ShadowAppendFailureIsRecordedNotFatal: a broken log must not +// break scoring; the failures land in SegmentScores.Errors. +func TestPipeline_ShadowAppendFailureIsRecordedNotFatal(t *testing.T) { + d := newDecisionsServer(t, echoHandler) + shadow, err := NewShadowLogAt(filepath.Join(t.TempDir(), "logdir")) + if err != nil { + t.Fatal(err) + } + // Make the log path a directory so every append fails. + if err := os.MkdirAll(shadow.Path(), 0o700); err != nil { + t.Fatal(err) + } + + backend := ResolvedBackend{Backend: Backend{Name: "test", URL: d.srv.URL, Model: "jev-latest"}, APIKey: "k"} + p := NewPipeline(backend, "k", shadow, PipelineOptions{}) + got, err := p.ScoreToolOutput(context.Background(), "Bash", "one paragraph") + if err != nil { + t.Fatalf("ScoreToolOutput() error = %v, want scoring to succeed", err) + } + if len(got.Errors) == 0 { + t.Error("Errors is empty, want the append failures recorded") + } + found := false + for _, e := range got.Errors { + if strings.Contains(e.Error(), "shadow log append") { + found = true + } + } + if !found { + t.Errorf("Errors = %v, want a shadow-log append failure", got.Errors) + } +} + +func TestPipeline_EmptyOutputSkipsEverything(t *testing.T) { + var requests int + d := newDecisionsServer(t, func(_ int, req capturedRequest) (int, string) { + requests++ + return echoHandler(1, req) + }) + shadow, err := NewShadowLogAt(filepath.Join(t.TempDir(), "shadow.jsonl")) + if err != nil { + t.Fatal(err) + } + backend := ResolvedBackend{Backend: Backend{Name: "test", URL: d.srv.URL, Model: "jev-latest"}, APIKey: "k"} + p := NewPipeline(backend, "k", shadow, PipelineOptions{}) + + for _, output := range []string{"", "\n\n \n\n"} { + got, err := p.ScoreToolOutput(context.Background(), "Bash", output) + if err != nil { + t.Fatalf("ScoreToolOutput(%q) error = %v", output, err) + } + if len(got.Segments) != 0 || len(got.Scores) != 0 { + t.Errorf("ScoreToolOutput(%q) = %+v, want empty", output, got) + } + } + if requests != 0 { + t.Errorf("got %d requests, want 0 for empty outputs", requests) + } + report, err := shadow.Replay(0.5) + if err != nil { + t.Fatalf("Replay() error = %v", err) + } + if report.Entries != 0 { + t.Errorf("shadow log has %d entries, want 0", report.Entries) + } +} + +// TestPipeline_NilShadowScoresWithoutLogging covers the dry-run form. +func TestPipeline_NilShadowScoresWithoutLogging(t *testing.T) { + d := newDecisionsServer(t, echoHandler) + backend := ResolvedBackend{Backend: Backend{Name: "test", URL: d.srv.URL, Model: "jev-latest"}, APIKey: "k"} + p := NewPipeline(backend, "k", nil, PipelineOptions{}) + got, err := p.ScoreToolOutput(context.Background(), "Bash", "alpha\n\nbeta") + if err != nil { + t.Fatalf("ScoreToolOutput() error = %v", err) + } + if len(got.Scores) != 2 { + t.Errorf("got %d scores, want 2", len(got.Scores)) + } + if len(got.Errors) != 0 { + t.Errorf("Errors = %v, want none", got.Errors) + } +} + +// TestPipeline_CanceledContextFailsOpen: a canceled context must not wedge +// the caller — everything is kept, with the cancellation recorded. +func TestPipeline_CanceledContextFailsOpen(t *testing.T) { + d := newDecisionsServer(t, echoHandler) + backend := ResolvedBackend{Backend: Backend{Name: "test", URL: d.srv.URL, Model: "jev-latest"}, APIKey: "k"} + p := NewPipeline(backend, "k", nil, PipelineOptions{}) + shrinkPipelineRetryCurve(p) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + got, err := p.ScoreToolOutput(ctx, "Bash", "alpha\n\nbeta") + if err == nil { + t.Fatal("ScoreToolOutput() error = nil, want the cancellation recorded") + } + if !errors.Is(err, context.Canceled) { + t.Errorf("error = %v, want it to wrap context.Canceled", err) + } + for _, s := range got.Segments { + if got.Scores[s.ID] != keepScore { + t.Errorf("Scores[%s] = %v, want %v (fail-open)", s.ID, got.Scores[s.ID], keepScore) + } + } +} + +// TestScoreTask_DerivedTaskShape pins the derived task used while the tool +// layer is not yet wired. +func TestScoreTask_DerivedTaskShape(t *testing.T) { + task := scoreTask("Bash") + if !strings.Contains(task, "Bash") || !strings.Contains(task, "ongoing task") { + t.Errorf("scoreTask = %q, want it to name the tool and the ongoing task", task) + } +} diff --git a/internal/compaction/pipeline_warning_sink_test.go b/internal/compaction/pipeline_warning_sink_test.go new file mode 100644 index 00000000..162275b2 --- /dev/null +++ b/internal/compaction/pipeline_warning_sink_test.go @@ -0,0 +1,71 @@ +package compaction + +import ( + "io" + "strings" + "testing" +) + +// TestPipeline_WarningSinkReceivesAuthWarning pins the mid-session routing: +// with a warning sink installed (the TUI wiring), the one-time auth-poison +// warning goes to the sink — which renders it as a toast instead of a raw +// stderr write painting over the alt-screen — and warnTo stays clean. +func TestPipeline_WarningSinkReceivesAuthWarning(t *testing.T) { + var warnTo strings.Builder + p := &Pipeline{warnTo: &warnTo} + + var got []string + p.SetWarningSink(func(msg string) { got = append(got, msg) }) + defer p.SetWarningSink(nil) + + p.noteAuthFailure("401: bad key") + + if len(got) != 1 { + t.Fatalf("sink received %d messages (%q), want exactly the one-time warning", len(got), got) + } + want := "Warning: compaction scoring disabled for this session (401: bad key)\n" + if got[0] != want { + t.Fatalf("sink = %q, want %q", got[0], want) + } + if warnTo.Len() != 0 { + t.Fatalf("warnTo received %q with a sink installed, want nothing", warnTo.String()) + } + if !p.authDead || p.authReason != "401: bad key" { + t.Fatalf("poisoning state = (dead=%v, reason=%q), want the auth failure recorded", p.authDead, p.authReason) + } + + // The one-time guard: a second failure emits nothing further. + p.noteAuthFailure("401: bad key again") + if len(got) != 1 { + t.Fatalf("sink received %d messages after the second failure, want 1 (one-time warning)", len(got)) + } +} + +// TestPipeline_WarningSinkNilFallsBackToWarnTo pins the fallback: without a +// sink (headless CLI flows, tests), the warning goes to warnTo exactly as +// before the sink existed. +func TestPipeline_WarningSinkNilFallsBackToWarnTo(t *testing.T) { + var warnTo strings.Builder + p := &Pipeline{warnTo: &warnTo} + + p.noteAuthFailure("403: forbidden") + + want := "Warning: compaction scoring disabled for this session (403: forbidden)\n" + if warnTo.String() != want { + t.Fatalf("warnTo = %q, want the exact pre-sink line %q", warnTo.String(), want) + } +} + +// TestPipeline_WarningSinkDiscardCapable documents that any io.Writer-shaped +// fallback remains valid after SetWarningSink(nil): the tests swap warnTo +// freely (errors_test.go does), and the sink must not change that. +func TestPipeline_WarningSinkDiscardCapable(t *testing.T) { + p := &Pipeline{warnTo: io.Discard} + p.SetWarningSink(func(msg string) { t.Errorf("sink called %q with no auth failure", msg) }) + p.SetWarningSink(nil) + + // No panic, no sink call: only noteAuthFailure ever emits. + if p.authDead { + t.Fatal("a fresh pipeline must not start poisoned") + } +} diff --git a/internal/compaction/pointer_test.go b/internal/compaction/pointer_test.go new file mode 100644 index 00000000..be0c0d5a --- /dev/null +++ b/internal/compaction/pointer_test.go @@ -0,0 +1,326 @@ +package compaction + +import ( + "strings" + "testing" +) + +// TestContentID_ReferenceVectors pins ContentID to the reference algorithm +// (jev-compaction types.py content_id: sha256(salt+"\x00"+text) hex, first +// 8 chars, prefix+"<8hex>") with vectors computed by the reference +// implementation, plus the determinism contract: same input → same id, +// always; any input change (text or salt) → a different id. +func TestContentID_ReferenceVectors(t *testing.T) { + if got, want := ContentID("hello world", "", "r"), "r:4eccf346"; got != want { + t.Errorf("ContentID(hello world) = %q, want %q", got, want) + } + if got, want := ContentID("run text", "Bash", "r"), "r:74bf3504"; got != want { + t.Errorf("ContentID(run text, Bash) = %q, want %q", got, want) + } + + for _, tc := range []struct { + name string + text, salt, pre string + }{ + {name: "plain", text: "some text", salt: "", pre: "r"}, + {name: "salted", text: "some text", salt: "Bash", pre: "r"}, + {name: "other prefix", text: "some text", salt: "", pre: "s"}, + {name: "empty text", text: "", salt: "", pre: "r"}, + {name: "unicode", text: "héllo wörld ✓", salt: "s", pre: "r"}, + } { + t.Run(tc.name, func(t *testing.T) { + first := ContentID(tc.text, tc.salt, tc.pre) + if first != ContentID(tc.text, tc.salt, tc.pre) { + t.Errorf("ContentID is not deterministic: %q vs %q", first, ContentID(tc.text, tc.salt, tc.pre)) + } + if !strings.HasPrefix(first, tc.pre+":") { + t.Errorf("ContentID = %q, want the %q: prefix", first, tc.pre) + } + hex8 := strings.TrimPrefix(first, tc.pre+":") + if len(hex8) != 8 { + t.Errorf("ContentID = %q, want an 8-hex-char digest", first) + } + for _, r := range hex8 { + if !strings.ContainsRune("0123456789abcdef", r) { + t.Errorf("ContentID = %q, want lowercase hex", first) + } + } + if got := ContentID(tc.text, tc.salt+"x", tc.pre); tc.salt+"x" != tc.salt && got == first { + t.Errorf("a different salt must mint a different id: %q", got) + } + if got := ContentID(tc.text+"x", tc.salt, tc.pre); got == first { + t.Errorf("different text must mint a different id: %q", got) + } + }) + } +} + +// TestParsePointer parses the formats that must keep working: the reference +// format with a line range, the legacy "elide-" counter ids, pointers +// with the lines part missing, and summaries with escaped quotes and +// backslashes. +func TestParsePointer(t *testing.T) { + t.Run("reference format with lines", func(t *testing.T) { + p, ok := ParsePointer(`[[elided id=r:1a2b3c4d lines=3-9 tokens=364 "he said \"ok\" \\ now"]]`) + if !ok { + t.Fatal("ParsePointer() = false, want a parse") + } + if p.ID != "r:1a2b3c4d" { + t.Errorf("ID = %q, want r:1a2b3c4d", p.ID) + } + if p.Lines == nil || *p.Lines != [2]int{3, 9} { + t.Errorf("Lines = %v, want [3 9]", p.Lines) + } + if p.Tokens != 364 { + t.Errorf("Tokens = %d, want 364", p.Tokens) + } + if p.Summary != `he said "ok" \ now` { + t.Errorf("Summary = %q, want the unescaped text", p.Summary) + } + }) + + t.Run("legacy elide-N id", func(t *testing.T) { + // Legacy counter ids parse — the id charset allows them. The lines + // part, though, must use the reference's a-b range shape: the regex + // is ported exactly, so the pre-reference count form (lines=12) + // does not match at all (see the non-pointer cases below). + p, ok := ParsePointer(`[[elided id=elide-3 lines=12-31 tokens=310 "first sixty chars"]]`) + if !ok { + t.Fatal("ParsePointer() = false, want a parse") + } + if p.ID != "elide-3" { + t.Errorf("ID = %q, want elide-3", p.ID) + } + if p.Lines == nil || *p.Lines != [2]int{12, 31} { + t.Errorf("Lines = %v, want [12 31]", p.Lines) + } + }) + + t.Run("missing lines part", func(t *testing.T) { + p, ok := ParsePointer(`[[elided id=elide-7 tokens=12 "plain summary"]]`) + if !ok { + t.Fatal("ParsePointer() = false, want a parse") + } + if p.Lines != nil { + t.Errorf("Lines = %v, want nil", p.Lines) + } + if p.Summary != "plain summary" || p.Tokens != 12 { + t.Errorf("parsed %+v, want the plain summary shape", p) + } + }) + + t.Run("summary ending in an escaped backslash", func(t *testing.T) { + p, ok := ParsePointer(`[[elided id=r:abcdef01 lines=1-1 tokens=0 "trailing backslash \\\\"]]`) + if !ok { + t.Fatal("ParsePointer() = false, want a parse") + } + if p.Summary != `trailing backslash \\` { + t.Errorf("Summary = %q, want %q", p.Summary, `trailing backslash \\`) + } + }) + + t.Run("searches inside a longer line", func(t *testing.T) { + p, ok := ParsePointer(`noise before [[elided id=r:ffeeddcc tokens=5 "s"]] noise after`) + if !ok || p.ID != "r:ffeeddcc" { + t.Errorf("ParsePointer() = (%+v, %v), want the embedded pointer", p, ok) + } + }) + + t.Run("non-pointer lines do not parse", func(t *testing.T) { + for _, line := range []string{ + "", + "plain text", + "[[elided id=r:1a2b3c4d lines=1-2 \"no tokens\"]]", + `[[elided id=r:1a2b3c4d tokens=5 "unterminated`, + "[[elided id= tokens=5 \"no id\"]]", + // The exact regex port: the pre-reference count-form lines part + // (lines=) never matched the reference pattern either. + `[[elided id=elide-3 lines=12 tokens=310 "old count-form lines"]]`, + } { + if p, ok := ParsePointer(line); ok { + t.Errorf("ParsePointer(%q) = (%+v, true), want false", line, p) + } + } + }) +} + +// TestFormatPointerAndRoundTrip: FormatPointer output always parses back to +// the same Pointer — including quotes and backslashes in the summary — and +// is a single line. +func TestFormatPointerAndRoundTrip(t *testing.T) { + for _, p := range []Pointer{ + {ID: "r:1a2b3c4d", Lines: &[2]int{3, 9}, Tokens: 364, Summary: `he said "ok" \ now`}, + {ID: "elide-7", Tokens: 12, Summary: "plain summary"}, + {ID: "r:abcdef01", Lines: &[2]int{1, 1}, Summary: `trailing backslash \\`}, + {ID: "r:00000000", Lines: &[2]int{1, 400}, Summary: "tabs\tand spaces flatten elsewhere"}, + {ID: "r:ffffffff", Tokens: 1, Summary: ""}, + } { + line := FormatPointer(p) + if strings.Contains(line, "\n") { + t.Errorf("FormatPointer(%+v) = %q, want a single line", p, line) + } + got, ok := ParsePointer(line) + if !ok { + t.Fatalf("ParsePointer(%q) = false, want a parse", line) + } + if got.ID != p.ID || got.Tokens != p.Tokens || got.Summary != p.Summary { + t.Errorf("round trip of %+v gave %+v (line %q)", p, got, line) + } + if (got.Lines == nil) != (p.Lines == nil) { + t.Errorf("round trip lines nil-ness changed: %+v → %+v", p, got) + } + if p.Lines != nil && *got.Lines != *p.Lines { + t.Errorf("round trip lines = %v, want %v", *got.Lines, *p.Lines) + } + } +} + +// TestFindPointers: every pointer in order of appearance, non-pointers +// ignored. +func TestFindPointers(t *testing.T) { + text := strings.Join([]string{ + "kept prose", + FormatPointer(Pointer{ID: "r:aaaa0001", Lines: &[2]int{1, 2}, Tokens: 10, Summary: "first"}), + "more prose", + FormatPointer(Pointer{ID: "r:aaaa0002", Lines: &[2]int{7, 8}, Tokens: 20, Summary: `second "quoted"`}), + FormatPointer(Pointer{ID: "elide-9", Tokens: 3, Summary: "legacy"}), + }, "\n") + got := FindPointers(text) + if len(got) != 3 { + t.Fatalf("FindPointers() = %d pointers, want 3: %+v", len(got), got) + } + wantIDs := []string{"r:aaaa0001", "r:aaaa0002", "elide-9"} + for i, p := range got { + if p.ID != wantIDs[i] { + t.Errorf("pointer[%d].ID = %q, want %q", i, p.ID, wantIDs[i]) + } + } + if got := FindPointers("no pointers here\nnor here"); len(got) != 0 { + t.Errorf("FindPointers(pointerless) = %+v, want empty", got) + } +} + +// TestSummarise ports _summarise's contract: the first non-blank line, +// whitespace-flattened, cut at a word boundary within limit runes with a +// "…" suffix. +func TestSummarise(t *testing.T) { + for _, tc := range []struct { + name string + text string + limit int + want string + }{ + { + name: "short line returned whole", + text: "first line\nsecond line", + limit: 120, + want: "first line", + }, + { + name: "leading blank lines skipped", + text: "\n \n\t\nthe real first line", + limit: 120, + want: "the real first line", + }, + { + // The first RAW line flattens to "spaced out" (the reference's + // " ".join(line.split()) — " line" lives on the next raw line, + // so it is not part of the summary). + name: "whitespace flattened", + text: " spaced\t\tout \n line\nsecond", + limit: 120, + want: "spaced out", + }, + { + name: "word-boundary cut with ellipsis", + text: "aaaaaaaaaa bbbbbbbbbb cccccccccc dddddddddd", + limit: 25, + want: "aaaaaaaaaa bbbbbbbbbb…", + }, + { + name: "cut lands exactly on a space", + text: "aaaaaaaaaa bbbbbbbbbb cccccccccc dddddddddd", + limit: 22, + want: "aaaaaaaaaa bbbbbbbbbb…", + }, + { + name: "no space in window falls back to the raw cut", + text: strings.Repeat("x", 200), + limit: 120, + want: strings.Repeat("x", 120) + "…", + }, + { + name: "all blank text", + text: "\n \n", + limit: 120, + want: "", + }, + { + name: "empty text", + text: "", + limit: 120, + want: "", + }, + } { + t.Run(tc.name, func(t *testing.T) { + if got := Summarise(tc.text, tc.limit); got != tc.want { + t.Errorf("Summarise(%q, %d) = %q, want %q", tc.text, tc.limit, got, tc.want) + } + }) + } + + // The limit counts runes, not bytes. + multibyte := strings.Repeat("é", 150) // 300 bytes, 150 runes + if got := Summarise(multibyte, 120); got != strings.Repeat("é", 120)+"…" { + t.Errorf("Summarise(multibyte, 120) = %d runes, want 120 runes + ellipsis", len([]rune(got))-1) + } + if got := Summarise("one two three", 0); got != "…" { + t.Errorf("Summarise(_, 0) = %q, want the ellipsis-only cut", got) + } +} + +// TestReconstruct: every pointer whose record exists is substituted by its +// stored text (the pointer line AND its trailing newline are consumed, so +// the text lands exactly where the run was); unknown ids and nil stores +// leave the text untouched byte for byte. The fixture uses the exact shape +// admit renders: pointer lines follow text that carries its own trailing +// blank separator and are followed by exactly one newline. +func TestReconstruct(t *testing.T) { + store := NewStore() + store.Put("r:aaaa0001", "original one\n\nwith a trailing separator") + store.Put("elide-2", "legacy original") + + pOne := FormatPointer(Pointer{ID: "r:aaaa0001", Lines: &[2]int{2, 3}, Tokens: 9, Summary: "one"}) + pTwo := FormatPointer(Pointer{ID: "elide-2", Tokens: 4, Summary: "two"}) + pGone := FormatPointer(Pointer{ID: "r:ffff0000", Lines: &[2]int{9, 9}, Tokens: 1, Summary: "missing"}) + pAgain := FormatPointer(Pointer{ID: "r:aaaa0001", Tokens: 9, Summary: "one again"}) + + text := "before block\n\n" + pOne + "\n" + + "middle block\n\n" + pTwo + "\n" + pGone + "\n" + + "after block\n" + pAgain + + // Known ids: pointer + trailing newline → record text, landing exactly + // where the run stood. The unknown id stays as-is (its newline too). + want := "before block\n\n" + "original one\n\nwith a trailing separator" + + "middle block\n\n" + "legacy original" + pGone + "\n" + + "after block\n" + "original one\n\nwith a trailing separator" + if got := Reconstruct(text, store); got != want { + t.Errorf("Reconstruct() mismatch:\n got %q\nwant %q", got, want) + } + + // A pointer at EOF without a trailing newline is still substituted. + if got, want := Reconstruct("head block\n\n"+pTwo, store), "head block\n\nlegacy original"; got != want { + t.Errorf("Reconstruct(EOF pointer) = %q, want %q", got, want) + } + + // Pointerless text is returned byte-for-byte. + plain := "nothing to see\nmove along" + if got := Reconstruct(plain, store); got != plain { + t.Errorf("Reconstruct(pointerless) = %q, want the input unchanged", got) + } + + // A nil store leaves every pointer in place. + if got := Reconstruct(text, nil); got != text { + t.Errorf("Reconstruct(text, nil) must be the identity, got:\n%s", got) + } +} diff --git a/internal/compaction/providers.go b/internal/compaction/providers.go new file mode 100644 index 00000000..1ad06df3 --- /dev/null +++ b/internal/compaction/providers.go @@ -0,0 +1,288 @@ +// Package compaction is late's Go port of jev-compaction +// (github.com/Waxmell114514/jev-compaction, MIT): a System One "decision +// client" that scores how essential each segment of a tool output is for the +// ongoing task, so a later stage can elide the low-value tail of the context +// window instead of cramming everything in. +// +// Stage 1 (ScoreToolOutput) segments tool outputs, scores the segments +// through the System One decisions protocol, and appends one JSONL line per +// decision to a shadow log; Replay() turns the accumulated log into the +// numbers a threshold decision needs. Stage 2 (EnableRelocation + +// CompactToolOutput) additionally elides low-scoring segments into the +// persistent record Store and replaces each run with an [[elided …]] +// pointer line that the expand tool (internal/tool) resolves back — the +// executor consults the pipeline for oversized tool results before they +// enter history, and session.CompactContext reuses the same scorer, gate +// vocabulary, and store for full-history compaction. Shadow mode +// (compaction-mode "shadow") is still report-only: stage 2 only runs when +// relocation is armed. +// +// Fail-open contract: scoring is best-effort. Any item that cannot be scored +// (provider outage, bad answer, oversized item) comes back with a score of +// 1.0 ("keep") plus a recorded error, so a compaction decision can never be +// wrong because the scorer was down — at worst it degenerates to "keep +// everything". +package compaction + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "late/internal/pathutil" +) + +// Backend names accepted by --api / JEV_API and stored on Backend.Name. +const ( + BackendTypesafe = "typesafe" + BackendOpenRouter = "openrouter" + BackendGateway = "gateway" +) + +// Environment variables consulted by ResolveBackend. +const ( + // EnvJevAPI overrides backend auto-detection with an explicit backend + // name (the env form of the --api flag). + EnvJevAPI = "JEV_API" + // EnvJevGatewayURL holds the gateway backend's decisions endpoint; the + // gateway is the one backend whose URL is user-provided rather than + // compiled in. + EnvJevGatewayURL = "JEV_GATEWAY_URL" + // EnvJevGatewayKey is the gateway backend's API key variable. + EnvJevGatewayKey = "JEV_GATEWAY_API_KEY" +) + +// Backend is one System One decisions provider. The three known backends +// speak the same protocol; they differ only in endpoint, model id, and where +// the API key lives. +type Backend struct { + // Name is the stable identifier used by --api / JEV_API. + Name string + // URL is the absolute decisions endpoint. Empty for the gateway backend + // until ResolveBackend fills it in from EnvJevGatewayURL. + URL string + // Model is the decisions model id sent as the request's "model". + Model string + // KeyEnv is the environment variable checked first for the API key. + KeyEnv string + // KeyFile is the file name (under late's config dir) checked second: + // ~/.config/late/compaction-.key. + KeyFile string +} + +// KeySource says where a resolved API key came from. +type KeySource string + +const ( + KeySourceEnv KeySource = "env" + KeySourceFile KeySource = "file" +) + +// ResolvedBackend is a Backend ready to call: URL resolved (the gateway's +// comes from JEV_GATEWAY_URL) and API key found. +type ResolvedBackend struct { + Backend Backend + APIKey string + KeySource KeySource + // KeyPath is the key file consulted, "" when the key came from the env. + KeyPath string +} + +// backendSpec is the internal registry entry: a Backend plus the env variable +// that supplies a non-static URL (the gateway's endpoint is user-provided). +type backendSpec struct { + backend Backend + urlEnv string +} + +// backendOrder is the auto-detection precedence: typesafe first. +var backendOrder = []string{BackendTypesafe, BackendOpenRouter, BackendGateway} + +// backendSpecs returns the registry of known System One backends. The URLs, +// model ids, and key variables are pinned by the port and asserted in +// providers_test.go — change them there and here together. +func backendSpecs() map[string]backendSpec { + return map[string]backendSpec{ + BackendTypesafe: { + backend: Backend{ + Name: BackendTypesafe, + URL: "https://api.typesafe.ai/v1/systemone", + Model: "jev-latest", + KeyEnv: "TYPESAFE_API_KEY", + KeyFile: "compaction-typesafe.key", + }, + }, + BackendOpenRouter: { + backend: Backend{ + Name: BackendOpenRouter, + URL: "https://openrouter.ai/api/alpha/decisions", + Model: "~typesafe/jev-latest", + KeyEnv: "OPENROUTER_API_KEY", + KeyFile: "compaction-openrouter.key", + }, + }, + BackendGateway: { + backend: Backend{ + Name: BackendGateway, + Model: "jev-latest", + KeyEnv: EnvJevGatewayKey, + KeyFile: "compaction-gateway.key", + }, + urlEnv: EnvJevGatewayURL, + }, + } +} + +// MissingKeyError reports that the selected backend has no API key in any of +// the places late looks. It is typed so callers can errors.As it and print +// tailored guidance. +type MissingKeyError struct { + Backend string + KeyEnv string + // KeyFile is the absolute key-file path that was consulted ("" when the + // config dir itself was unavailable). + KeyFile string +} + +func (e *MissingKeyError) Error() string { + if e.KeyFile == "" { + return fmt.Sprintf( + "compaction: no API key for backend %q: set %s= in the environment (the user config dir is unavailable, so the key file cannot be used)", + e.Backend, e.KeyEnv) + } + return fmt.Sprintf( + "compaction: no API key for backend %q: set %s= in the environment, or write the key to %s", + e.Backend, e.KeyEnv, e.KeyFile) +} + +// UnknownBackendError reports an unrecognized --api / JEV_API name. +type UnknownBackendError struct { + Name string +} + +func (e *UnknownBackendError) Error() string { + return fmt.Sprintf( + "compaction: unknown System One backend %q (known backends: %s; or unset to auto-detect)", + e.Name, strings.Join(backendOrder, ", ")) +} + +// GatewayURLError reports a gateway selection without JEV_GATEWAY_URL: the +// gateway's endpoint is user-provided and there is nothing to call without it. +type GatewayURLError struct { + Backend string +} + +func (e *GatewayURLError) Error() string { + return fmt.Sprintf( + "compaction: backend %q needs an endpoint URL: set %s=https://your-gateway/decisions", + e.Backend, EnvJevGatewayURL) +} + +// NoBackendError reports that auto-detection found no fully usable backend. +type NoBackendError struct { + Detail string +} + +func (e *NoBackendError) Error() string { + return "compaction: no System One backend available (tried " + strings.Join(backendOrder, ", ") + "): " + e.Detail +} + +// ResolveBackend picks the System One backend for this run. +// +// Precedence: an explicit backend name (the --api param, name != "") beats +// the JEV_API env var, which beats auto-detection — the first backend in +// [typesafe, openrouter, gateway] that is fully usable (has a key and, for +// the gateway, a URL). env is the environment lookup (os.Getenv); it is a +// parameter so callers and tests can pin the environment. +// +// An explicitly selected backend never falls through to another one: a +// missing key or URL for the named backend is a typed error enumerating the +// options, not a silent switch. +func ResolveBackend(name string, env func(string) string) (ResolvedBackend, error) { + return resolveBackend(defaultKeyDir(), name, env) +} + +// ResolveBackendEnv is ResolveBackend against the process environment. +func ResolveBackendEnv(name string) (ResolvedBackend, error) { + return ResolveBackend(name, os.Getenv) +} + +// ResolveBackendIn is ResolveBackend with an explicit config dir holding the +// compaction-.key files (tests inject a temp dir). +func ResolveBackendIn(keyDir, name string, env func(string) string) (ResolvedBackend, error) { + return resolveBackend(keyDir, name, env) +} + +func resolveBackend(keyDir, name string, env func(string) string) (ResolvedBackend, error) { + if env == nil { + env = os.Getenv + } + specs := backendSpecs() + + pick := strings.TrimSpace(name) + if pick == "" { + pick = strings.TrimSpace(env(EnvJevAPI)) + } + if pick != "" { + spec, ok := specs[strings.ToLower(pick)] + if !ok { + return ResolvedBackend{}, &UnknownBackendError{Name: pick} + } + return resolveKey(spec, env, keyDir) + } + + // Auto-detect: the first fully usable backend in precedence order. A + // backend that is merely unconfigured (no key yet, or the gateway with + // no URL) is skipped and reported if nothing else pans out. + var attempts []string + for _, n := range backendOrder { + res, err := resolveKey(specs[n], env, keyDir) + if err == nil { + return res, nil + } + attempts = append(attempts, fmt.Sprintf("%s: %v", n, err)) + } + return ResolvedBackend{}, &NoBackendError{Detail: strings.Join(attempts, "; ")} +} + +// resolveKey resolves a backend spec into a callable ResolvedBackend: URL +// first (gateway only), then the API key from KeyEnv, then the key file. +func resolveKey(spec backendSpec, env func(string) string, keyDir string) (ResolvedBackend, error) { + b := spec.backend + if b.URL == "" && spec.urlEnv != "" { + u := strings.TrimSpace(env(spec.urlEnv)) + if u == "" { + return ResolvedBackend{}, &GatewayURLError{Backend: b.Name} + } + b.URL = strings.TrimSuffix(u, "/") + } + + keyPath := "" + if keyDir != "" && b.KeyFile != "" { + keyPath = filepath.Join(keyDir, b.KeyFile) + } + if k := strings.TrimSpace(env(b.KeyEnv)); k != "" { + return ResolvedBackend{Backend: b, APIKey: k, KeySource: KeySourceEnv}, nil + } + if keyPath != "" { + // The key file holds just the key; surrounding whitespace (a + // trailing newline from an editor) is tolerated. + if data, err := os.ReadFile(keyPath); err == nil { + if k := strings.TrimSpace(string(data)); k != "" { + return ResolvedBackend{Backend: b, APIKey: k, KeySource: KeySourceFile, KeyPath: keyPath}, nil + } + } + } + return ResolvedBackend{}, &MissingKeyError{Backend: b.Name, KeyEnv: b.KeyEnv, KeyFile: keyPath} +} + +// defaultKeyDir returns late's config dir (os.UserConfigDir()/late), where +// the compaction-.key files live; "" when it cannot be determined. +func defaultKeyDir() string { + dir, err := pathutil.LateConfigDir() + if err != nil { + return "" + } + return dir +} diff --git a/internal/compaction/providers_test.go b/internal/compaction/providers_test.go new file mode 100644 index 00000000..bbb2a9ce --- /dev/null +++ b/internal/compaction/providers_test.go @@ -0,0 +1,320 @@ +package compaction + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func envOf(kv map[string]string) func(string) string { + return func(k string) string { return kv[k] } +} + +// TestBackendRegistry_PinnedValues asserts the exact endpoints, model ids, +// and key variables the port pins for the three backends — including the +// user-required OpenRouter alpha decisions surface. +func TestBackendRegistry_PinnedValues(t *testing.T) { + specs := backendSpecs() + want := map[string]Backend{ + BackendTypesafe: { + Name: "typesafe", + URL: "https://api.typesafe.ai/v1/systemone", + Model: "jev-latest", + KeyEnv: "TYPESAFE_API_KEY", + KeyFile: "compaction-typesafe.key", + }, + BackendOpenRouter: { + Name: "openrouter", + URL: "https://openrouter.ai/api/alpha/decisions", + Model: "~typesafe/jev-latest", + KeyEnv: "OPENROUTER_API_KEY", + KeyFile: "compaction-openrouter.key", + }, + BackendGateway: { + Name: "gateway", + URL: "", // from JEV_GATEWAY_URL + Model: "jev-latest", + KeyEnv: "JEV_GATEWAY_API_KEY", + KeyFile: "compaction-gateway.key", + }, + } + for name, wb := range want { + got, ok := specs[name] + if !ok { + t.Fatalf("backend %q missing from registry", name) + } + if got.backend != wb { + t.Errorf("backend %q = %+v, want %+v", name, got.backend, wb) + } + if name == BackendGateway && got.urlEnv != EnvJevGatewayURL { + t.Errorf("gateway urlEnv = %q, want %q", got.urlEnv, EnvJevGatewayURL) + } + } + if len(specs) != 3 { + t.Errorf("registry has %d backends, want 3", len(specs)) + } +} + +// TestResolveBackend_Precedence walks the selection matrix: --api param > +// JEV_API > first backend with a key (typesafe first); explicit selections +// never fall through. +func TestResolveBackend_Precedence(t *testing.T) { + dir := t.TempDir() // no key files: env-only resolution + + t.Run("explicit name beats JEV_API and other keys", func(t *testing.T) { + env := envOf(map[string]string{ + "JEV_API": "openrouter", + "OPENROUTER_API_KEY": "or-key", + "TYPESAFE_API_KEY": "ts-key", + "JEV_GATEWAY_URL": "https://gw.example/decisions", + "JEV_GATEWAY_API_KEY": "gw-key", + }) + got, err := ResolveBackendIn(dir, "typesafe", env) + if err != nil { + t.Fatalf("ResolveBackendIn() error = %v", err) + } + if got.Backend.Name != BackendTypesafe { + t.Errorf("resolved %q, want typesafe", got.Backend.Name) + } + }) + + t.Run("JEV_API beats auto-detection", func(t *testing.T) { + env := envOf(map[string]string{ + "JEV_API": "openrouter", + "OPENROUTER_API_KEY": "or-key", + "TYPESAFE_API_KEY": "ts-key", + }) + got, err := ResolveBackendIn(dir, "", env) + if err != nil { + t.Fatalf("ResolveBackendIn() error = %v", err) + } + if got.Backend.Name != BackendOpenRouter { + t.Errorf("resolved %q, want openrouter", got.Backend.Name) + } + }) + + t.Run("auto-detection prefers typesafe", func(t *testing.T) { + env := envOf(map[string]string{ + "OPENROUTER_API_KEY": "or-key", + "TYPESAFE_API_KEY": "ts-key", + }) + got, err := ResolveBackendIn(dir, "", env) + if err != nil { + t.Fatalf("ResolveBackendIn() error = %v", err) + } + if got.Backend.Name != BackendTypesafe { + t.Errorf("resolved %q, want typesafe", got.Backend.Name) + } + }) + + t.Run("auto-detection skips keyless typesafe", func(t *testing.T) { + env := envOf(map[string]string{"OPENROUTER_API_KEY": "or-key"}) + got, err := ResolveBackendIn(dir, "", env) + if err != nil { + t.Fatalf("ResolveBackendIn() error = %v", err) + } + if got.Backend.Name != BackendOpenRouter { + t.Errorf("resolved %q, want openrouter", got.Backend.Name) + } + }) + + t.Run("auto-detection with nothing usable enumerates the gaps", func(t *testing.T) { + _, err := ResolveBackendIn(dir, "", envOf(nil)) + var noBackend *NoBackendError + if !errors.As(err, &noBackend) { + t.Fatalf("error = %v, want *NoBackendError", err) + } + for _, name := range backendOrder { + if !strings.Contains(err.Error(), name) { + t.Errorf("NoBackendError detail %q does not mention backend %q", err.Error(), name) + } + } + }) + + t.Run("explicit unknown name is a typed error", func(t *testing.T) { + _, err := ResolveBackendIn(dir, "acme", envOf(nil)) + var unknown *UnknownBackendError + if !errors.As(err, &unknown) { + t.Fatalf("error = %v, want *UnknownBackendError", err) + } + if !strings.Contains(err.Error(), "typesafe") { + t.Errorf("error %q should enumerate known backends", err.Error()) + } + }) + + t.Run("JEV_API unknown name is a typed error", func(t *testing.T) { + _, err := ResolveBackendIn(dir, "", envOf(map[string]string{"JEV_API": "acme"})) + var unknown *UnknownBackendError + if !errors.As(err, &unknown) { + t.Fatalf("error = %v, want *UnknownBackendError", err) + } + }) + + t.Run("named backend with missing key does not fall through", func(t *testing.T) { + env := envOf(map[string]string{"OPENROUTER_API_KEY": "or-key"}) + _, err := ResolveBackendIn(dir, "typesafe", env) + var missing *MissingKeyError + if !errors.As(err, &missing) { + t.Fatalf("error = %v, want *MissingKeyError", err) + } + if missing.Backend != BackendTypesafe || missing.KeyEnv != "TYPESAFE_API_KEY" { + t.Errorf("MissingKeyError = %+v, want typesafe/TYPESAFE_API_KEY", missing) + } + }) + + t.Run("backend names are case-insensitive", func(t *testing.T) { + env := envOf(map[string]string{"OPENROUTER_API_KEY": "or-key"}) + got, err := ResolveBackendIn(dir, "OpenRouter", env) + if err != nil { + t.Fatalf("ResolveBackendIn() error = %v", err) + } + if got.Backend.Name != BackendOpenRouter { + t.Errorf("resolved %q, want openrouter", got.Backend.Name) + } + }) +} + +// TestResolveBackend_GatewayURL covers the env-provided gateway endpoint. +func TestResolveBackend_GatewayURL(t *testing.T) { + dir := t.TempDir() + + t.Run("named gateway without URL is a typed error", func(t *testing.T) { + env := envOf(map[string]string{"JEV_GATEWAY_API_KEY": "gw-key"}) + _, err := ResolveBackendIn(dir, "gateway", env) + var urlErr *GatewayURLError + if !errors.As(err, &urlErr) { + t.Fatalf("error = %v, want *GatewayURLError", err) + } + if !strings.Contains(err.Error(), EnvJevGatewayURL) { + t.Errorf("error %q should mention %s", err.Error(), EnvJevGatewayURL) + } + }) + + t.Run("gateway URL comes from the env and is trimmed", func(t *testing.T) { + env := envOf(map[string]string{ + "JEV_GATEWAY_URL": "https://gw.example/decisions/", + "JEV_GATEWAY_API_KEY": "gw-key", + }) + got, err := ResolveBackendIn(dir, "gateway", env) + if err != nil { + t.Fatalf("ResolveBackendIn() error = %v", err) + } + if got.Backend.URL != "https://gw.example/decisions" { + t.Errorf("gateway URL = %q, want trailing slash trimmed", got.Backend.URL) + } + if got.Backend.Model != "jev-latest" { + t.Errorf("gateway model = %q, want jev-latest", got.Backend.Model) + } + }) + + t.Run("auto-detection picks a fully configured gateway", func(t *testing.T) { + env := envOf(map[string]string{ + "JEV_GATEWAY_URL": "https://gw.example/decisions", + "JEV_GATEWAY_API_KEY": "gw-key", + }) + got, err := ResolveBackendIn(dir, "", env) + if err != nil { + t.Fatalf("ResolveBackendIn() error = %v", err) + } + if got.Backend.Name != BackendGateway { + t.Errorf("resolved %q, want gateway", got.Backend.Name) + } + }) + + t.Run("auto-detection skips a keyless gateway", func(t *testing.T) { + // The gateway has its URL but no key anywhere: auto-detection takes + // the first backend with a resolvable key, so the gateway is skipped + // and, with nothing else configured, the gaps are enumerated. + env := envOf(map[string]string{"JEV_GATEWAY_URL": "https://gw.example/decisions"}) + _, err := ResolveBackendIn(dir, "", env) + var noBackend *NoBackendError + if !errors.As(err, &noBackend) { + t.Fatalf("error = %v, want *NoBackendError (the keyless gateway is not selected)", err) + } + if !strings.Contains(err.Error(), BackendGateway) || !strings.Contains(err.Error(), EnvJevGatewayKey) { + t.Errorf("detail %q should report the skipped gateway's missing %s", err.Error(), EnvJevGatewayKey) + } + }) + + t.Run("auto-detection falls through a URL'd keyless gateway to a keyed backend", func(t *testing.T) { + env := envOf(map[string]string{ + "JEV_GATEWAY_URL": "https://gw.example/decisions", + "OPENROUTER_API_KEY": "or-key", + }) + got, err := ResolveBackendIn(dir, "", env) + if err != nil { + t.Fatalf("ResolveBackendIn() error = %v", err) + } + if got.Backend.Name != BackendOpenRouter { + t.Errorf("resolved %q, want openrouter (the URL'd but keyless gateway is skipped)", got.Backend.Name) + } + }) +} + +// TestResolveBackend_KeyFileLookup covers the env → key file → error chain. +func TestResolveBackend_KeyFileLookup(t *testing.T) { + dir := t.TempDir() + + t.Run("key file is used when the env is empty", func(t *testing.T) { + path := filepath.Join(dir, "compaction-typesafe.key") + if err := os.WriteFile(path, []byte("file-key\n"), 0o600); err != nil { + t.Fatal(err) + } + got, err := ResolveBackendIn(dir, "typesafe", envOf(nil)) + if err != nil { + t.Fatalf("ResolveBackendIn() error = %v", err) + } + if got.APIKey != "file-key" { + t.Errorf("APIKey = %q, want file-key (trailing newline trimmed)", got.APIKey) + } + if got.KeySource != KeySourceFile { + t.Errorf("KeySource = %q, want file", got.KeySource) + } + if got.KeyPath != path { + t.Errorf("KeyPath = %q, want %q", got.KeyPath, path) + } + }) + + t.Run("env beats key file", func(t *testing.T) { + if err := os.WriteFile(filepath.Join(dir, "compaction-typesafe.key"), []byte("file-key"), 0o600); err != nil { + t.Fatal(err) + } + got, err := ResolveBackendIn(dir, "typesafe", envOf(map[string]string{"TYPESAFE_API_KEY": "env-key"})) + if err != nil { + t.Fatalf("ResolveBackendIn() error = %v", err) + } + if got.APIKey != "env-key" || got.KeySource != KeySourceEnv { + t.Errorf("got %q/%q, want env-key/env", got.APIKey, got.KeySource) + } + }) + + t.Run("whitespace-only key file counts as missing", func(t *testing.T) { + if err := os.WriteFile(filepath.Join(dir, "compaction-openrouter.key"), []byte(" \n\t\n"), 0o600); err != nil { + t.Fatal(err) + } + _, err := ResolveBackendIn(dir, "openrouter", envOf(nil)) + var missing *MissingKeyError + if !errors.As(err, &missing) { + t.Fatalf("error = %v, want *MissingKeyError", err) + } + }) +} + +// TestResolveBackend_MissingKeyErrorEnumeratesOptions checks that the typed +// error tells the user every place a key can live. +func TestResolveBackend_MissingKeyErrorEnumeratesOptions(t *testing.T) { + dir := t.TempDir() + _, err := ResolveBackendIn(dir, "typesafe", envOf(nil)) + var missing *MissingKeyError + if !errors.As(err, &missing) { + t.Fatalf("error = %v, want *MissingKeyError", err) + } + msg := err.Error() + for _, want := range []string{"TYPESAFE_API_KEY", filepath.Join(dir, "compaction-typesafe.key")} { + if !strings.Contains(msg, want) { + t.Errorf("MissingKeyError message %q does not mention %q", msg, want) + } + } +} diff --git a/internal/compaction/relocate.go b/internal/compaction/relocate.go new file mode 100644 index 00000000..d4b24a51 --- /dev/null +++ b/internal/compaction/relocate.go @@ -0,0 +1,525 @@ +package compaction + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "regexp" + "strconv" + "strings" + + "late/internal/common" +) + +// DecisionElide is the shadow-log decision recorded for a segment whose +// score fell strictly below the relocation threshold while relocation is +// armed (compaction-mode "enabled"). Shadow mode never records it: there +// every segment is DecisionKeep. +const DecisionElide = "elide" + +// DefaultRelocationThreshold is the score under which a segment is elided +// when compaction-mode is "enabled" and no explicit threshold was set. It +// comes from the upstream repo's own shadow-log replay data. +const DefaultRelocationThreshold = 0.35 + +// SummaryMaxChars is the reference summary_max_chars: how many characters of +// an elided run's first non-blank line its pointer carries, so the agent can +// guess whether the run is worth expanding without paying for it. +const SummaryMaxChars = 120 + +// contentIDPrefix is the reference's prefix for elided-run record ids: +// content-addressed ("r:<8hex>"), so the same run text always maps to the +// same id — and to the same store record. +const contentIDPrefix = "r" + +// Pointer id charset, ported from the reference _POINTER_RE. Content ids +// ("r:<8hex>") and legacy counter ids ("elide-") both fit it. +const pointerIDCharset = `[A-Za-z0-9:_.-]+` + +// pointerPattern is the reference _POINTER_RE, byte for byte: one +// [[elided …]] pointer with a mandatory id, an optional "lines=a-b" range, +// a mandatory token count, and a double-quoted summary in which backslash +// escapes are allowed. Used both to parse pointers and (plus the optional +// trailing newline) to substitute them back during Reconstruct. +const pointerPattern = `\[\[elided id=(?P` + pointerIDCharset + `)` + + `(?: lines=(?P\d+)-(?P\d+))?` + + ` tokens=(?P\d+)` + + ` "(?P(?:[^"\\]|\\.)*)"\]\]` + +var ( + pointerRe = regexp.MustCompile(pointerPattern) + pointerLineRe = regexp.MustCompile(pointerPattern + `\n?`) + // unescapeRe is the reference's summary unescape: re.sub(r"\\(.)", r"\1"). + unescapeRe = regexp.MustCompile(`\\(.)`) +) + +// ContentID derives the stable short id for a piece of content: the first 8 +// hex chars of sha256(salt + "\x00" + text), prefixed. Ported from the +// reference content_id (types.py). Same input → same id, always — which is +// what makes elided-run records content-addressed: re-compacting identical +// text cannot mint a second record for it. +func ContentID(text, salt, prefix string) string { + sum := sha256.Sum256([]byte(salt + "\x00" + text)) + return prefix + ":" + hex.EncodeToString(sum[:])[:8] +} + +// Pointer is the one-line stand-in left in context for relocated content +// (the reference types.py Pointer). Lines is the 1-based [first, last] line +// range of the elided run in the original output, or nil when unknown; the +// id names the run's record in the Store (content ids "r:<8hex>", or legacy +// counter ids "elide-", both parse). +type Pointer struct { + ID string + Lines *[2]int + Tokens int + Summary string +} + +// FormatPointer renders one pointer line — the exact format ParsePointer +// parses back. The summary is escaped (backslashes first, then quotes) so +// the result is always a single line the regex can recover it from. +func FormatPointer(p Pointer) string { + lines := "" + if p.Lines != nil { + lines = fmt.Sprintf(" lines=%d-%d", p.Lines[0], p.Lines[1]) + } + summary := strings.ReplaceAll(p.Summary, "\\", "\\\\") + summary = strings.ReplaceAll(summary, `"`, `\"`) + return fmt.Sprintf(`[[elided id=%s%s tokens=%d "%s"]]`, p.ID, lines, p.Tokens, summary) +} + +// ParsePointer parses one pointer line — the inverse of FormatPointer, +// ported from the reference parse_pointer. The second return is false when +// line carries no pointer at all. Legacy "elide-" ids parse too (the id +// charset allows them), and a missing lines part yields a nil Lines. +func ParsePointer(line string) (Pointer, bool) { + m := pointerRe.FindStringSubmatch(line) + if m == nil { + return Pointer{}, false + } + get := func(name string) string { return m[pointerRe.SubexpIndex(name)] } + p := Pointer{ + ID: get("id"), + Summary: unescapeSummary(get("summary")), + } + p.Tokens, _ = strconv.Atoi(get("tokens")) + if start, end := get("start"), get("end"); start != "" && end != "" { + a, _ := strconv.Atoi(start) + b, _ := strconv.Atoi(end) + p.Lines = &[2]int{a, b} + } + return p, true +} + +// FindPointers returns every pointer in text, in order of appearance +// (the reference find_pointers). +func FindPointers(text string) []Pointer { + matches := pointerRe.FindAllString(text, -1) + out := make([]Pointer, 0, len(matches)) + for _, match := range matches { + if p, ok := ParsePointer(match); ok { + out = append(out, p) + } + } + return out +} + +// unescapeSummary undoes FormatPointer's escaping: every backslash-escaped +// character collapses to the character itself (the reference's +// re.sub(r"\\(.)", r"\1", summary)). +func unescapeSummary(s string) string { + return unescapeRe.ReplaceAllString(s, "$1") +} + +// Summarise builds a pointer summary from run text (the reference +// _summarise): the first non-blank line, whitespace-flattened, cut at the +// last word boundary within limit runes with a "…" suffix. An empty text +// (or one with no non-blank line) summarises to "". +func Summarise(text string, limit int) string { + for _, rawLine := range strings.Split(text, "\n") { + line := strings.Join(strings.Fields(rawLine), " ") + if line == "" { + continue + } + runes := []rune(line) + if len(runes) <= limit { + return line + } + if limit <= 0 { + return "…" + } + cut := string(runes[:limit]) + // Word-boundary cut: drop the trailing partial word (rsplit(" ", 1)[0]), + // falling back to the raw cut when that leaves nothing. + if idx := strings.LastIndex(cut, " "); idx >= 0 { + cut = cut[:idx] + } + if cut == "" { + cut = string(runes[:limit]) + } + return cut + "…" + } + return "" +} + +// Reconstruct substitutes every pointer in text back with the original text +// it stands for — the byte-for-byte inverse of what admit/compaction +// produced, provided the records are still in the store (the reference +// reconstruct). Each match is the pointer line plus its trailing newline, so +// the substituted text lands exactly where the run was; a pointer whose id +// is unknown (record gone, foreign text) is left as-is. A nil store leaves +// every pointer in place. +func Reconstruct(text string, store *Store) string { + return pointerLineRe.ReplaceAllStringFunc(text, func(match string) string { + p, ok := ParsePointer(match) + if !ok { + return match // unreachable: the regex just matched + } + original, found := store.Get(p.ID) + if !found { + return match + } + return original + }) +} + +// ElidedRun is the shared flush_run product (the reference pipeline's +// flush_run): one run of consecutive elided segments reduced to the pieces +// both relocation surfaces need — the store record's fields, the pointer +// line, and the run's stats. The pipeline's tool-output path +// (CompactToolOutput) and the session's history walk +// (session.CompactContext) build their runs with BuildElidedRun, so the id +// scheme, the token/summary math, and the pointer format cannot drift +// between tool-output compaction and history compaction. +type ElidedRun struct { + // ID is the run's content id: ContentID(Text, salt, "r"). + ID string + // Text is the concatenated original run text — what the store keeps + // and what the expand tool returns. + Text string + // Tokens is the run's summed segment token count. + Tokens int + // Segments is the number of source segments the run grouped. + Segments int + // Summary is Summarise(Text, SummaryMaxChars) — the pointer's preview. + Summary string + // SegmentIDs lists the contributing segment ids in run order (the + // outcomes ledger's attribution). + SegmentIDs []string + // Pointer is the formatted [[elided …]] pointer line for the run, with + // no trailing newline; callers append their own separator. + Pointer string +} + +// BuildElidedRun reduces one run of consecutive elided segments (run is +// non-empty) to its record/pointer product. salt namespaces the content id +// (ContentID of the run text): the tool-output path salts with the tool +// name, the history walk salts with "" (per-surface content addressing). +// The pointer's lines come from the run's first and last segment line span +// in the original text. +func BuildElidedRun(run []Segment, salt string) ElidedRun { + var text strings.Builder + segIDs := make([]string, 0, len(run)) + for _, seg := range run { + text.WriteString(seg.Text) + segIDs = append(segIDs, seg.ID) + } + runText := text.String() + tokens := 0 + for _, seg := range run { + tokens += seg.Tokens + } + summary := Summarise(runText, SummaryMaxChars) + id := ContentID(runText, salt, contentIDPrefix) + pointer := FormatPointer(Pointer{ + ID: id, + Lines: &[2]int{run[0].LineStart, run[len(run)-1].LineEnd}, + Tokens: tokens, + Summary: summary, + }) + return ElidedRun{ + ID: id, + Text: runText, + Tokens: tokens, + Segments: len(run), + Summary: summary, + SegmentIDs: segIDs, + Pointer: pointer, + } +} + +// ElidedSegment is one RUN of consecutive segments removed from a tool +// result (or history message) by relocation. ID/Lines/Tokens/Summary +// describe the pointer that replaced the run; Text is the concatenated +// original the store keeps for the expand tool. +type ElidedSegment struct { + // ID is the run's content id ("r:<8hex>" — ContentID of the run text + // with the tool name as salt), so the same run stored twice collapses + // onto one record. + ID string + // Lines is the 1-based [first, last] line range the run occupied in the + // original output. + Lines [2]int + // Tokens is the run's summed token count. + Tokens int + // Segments is the number of source segments the run grouped. + Segments int + // Summary is Summarise(Text, SummaryMaxChars) — the pointer's preview. + Summary string + // Text is the original run text (what the expand tool returns). + Text string +} + +// CompactResult is the outcome of relocating one tool output. +type CompactResult struct { + // CompactText is the replacement tool result: kept segments in place, + // each run of elided segments replaced by one [[elided …]] pointer line. + // When nothing was elided this is the original output byte-for-byte. + CompactText string + // Elided carries the relocated runs (originals included) for the + // caller's Store, in order of appearance. Empty when nothing was elided. + Elided []ElidedSegment + // Tripwire is non-empty when the gate's safety tripwire fired and the + // scorer's elision decisions were discarded: TripwireMaxElideFraction + // means the scorer wanted to elide more than MaxElideFraction of the + // output's tokens, so it was distrusted and everything was kept. Empty + // means the decisions stood. + Tripwire string + // Disabled is non-empty when compaction scoring is off for the session: + // the decision backend rejected auth (401/403 — a bad or missing API + // key), so nothing can be scored and the output came back untouched. + // It carries the one-line reason; the warning itself is logged once on + // the pipeline (never per tool call). Empty means the decisions stood. + Disabled string +} + +// EnableRelocation arms stage 2 on the pipeline: from now on +// CompactToolOutput elides segments whose score is strictly below threshold +// into store and replaces each run of them with [[elided …]] pointers. +// threshold outside (0, 1] falls back to DefaultRelocationThreshold. Calling +// it again re-arms with the new values; passing a nil store disarms +// relocation (the pipeline degenerates to shadow-only scoring). +func (p *Pipeline) EnableRelocation(store *Store, threshold float64) { + if p == nil { + return + } + if threshold <= 0 || threshold > 1 { + threshold = DefaultRelocationThreshold + } + p.relocMu.Lock() + defer p.relocMu.Unlock() + p.reloc = store + p.threshold = threshold +} + +// relocationArmed reports the armed store (nil when relocation is off) and +// its threshold. +func (p *Pipeline) relocationArmed() (*Store, float64) { + if p == nil { + return nil, 0 + } + p.relocMu.Lock() + defer p.relocMu.Unlock() + return p.reloc, p.threshold +} + +// CompactToolOutput scores one tool output and, when relocation is armed, +// relocates every segment scoring strictly below its floor — the +// protected-kind floor when the segment's kind has one (the gate's +// ProtectedKinds), else the keep threshold: the segment's original goes into +// the armed store and its slot in the result is replaced by an [[elided …]] +// pointer line. The returned CompactText is the tool result that should +// enter history. +// +// Runs: consecutive below-floor segments are grouped into ONE run (the +// reference pipeline's flush_run pattern, shared with the history walk via +// BuildElidedRun) sharing a single record and a single pointer — the +// record's text is the concatenated run, its id is ContentID(runText, +// toolName, "r"), and the pointer carries the run's [first, last] line +// range in the original output. Pointer lines stand exactly where the runs +// stood, so Reconstruct(compacted, store) restores the original byte for +// byte. +// +// Two gate guards run before and after the per-segment decisions: +// +// - MinGateTokens: an output estimated below that many tokens is returned +// unchanged without scoring it (no backend call) — the round trip costs +// more than any possible elision saves. +// - The MaxElideFraction tripwire: when the scorer wants to elide more +// than that share of the output's tokens, it is distrusted and NOTHING +// is elided; the result reports Tripwire=TripwireMaxElideFraction and +// the shadow log records the override. +// +// Fail-open contract, extended to relocation: any scoring error (provider +// outage, bad answer, canceled context) means no reliable elision decision +// exists, so the original output is returned unchanged (Elided empty) along +// with the error. Compaction must never break a tool call. +// +// One exception to the retry storm: an auth-class failure (the reference's +// JevAuthError, 401/403) disables scoring for the rest of the session — the +// result reports Disabled with the reason, one warning is logged on the +// pipeline, and every later call returns the output untouched without +// touching the backend again. +func (p *Pipeline) CompactToolOutput(ctx context.Context, toolName, output string) (CompactResult, error) { + out := CompactResult{CompactText: output} + + // Auth-poisoned pipeline: scoring is off for the session. Return the + // output untouched without another doomed backend round trip — the + // one-time warning was emitted when the rejection was first seen. + if dead, reason := p.authDisabled(); dead { + out.Disabled = reason + return out, nil + } + + // Min-gate: below MinGateTokens the scoring round trip costs more than + // elision can possibly save — skip scoring entirely. + if min := p.minGateTokens(); min > 0 && common.EstimateTokenCount(output) < min { + return out, nil + } + + scores, err := p.ScoreToolOutput(ctx, toolName, output) + if err != nil { + // Fail-open: keep the whole output verbatim. An auth rejection + // additionally disabled scoring for the rest of the session — + // report that in the result so callers can tell the difference + // between a transient outage and compaction being switched off. + if dead, reason := p.authDisabled(); dead { + out.Disabled = reason + } + return out, err + } + + store, _ := p.relocationArmed() + if store == nil || len(scores.Segments) == 0 { + return out, nil + } + + // Decide per segment first (flags only), so the tripwire can still veto + // the whole batch before anything is stored or replaced. The origin for + // this tool's results decides protection: an activate_skill result is + // clamped to a 1.0 score floor before the floor comparison, so no gate + // setting can elide the skill instructions the agent was told to follow. + // Paragraph atomicity (AtomicElideDecisions): pieces cut from the same + // oversized paragraph share one decision, made on the minimum sibling + // score — a cut JSON blob is never partially elided into an unparseable + // remnant, and a paragraph whose pieces all stay above their floors is + // kept fully. + source := OriginSourceToolPrefix + toolName + gate := p.resolveGate() + scoresByID := make([]float64, len(scores.Segments)) + floors := make([]float64, len(scores.Segments)) + totalTokens := 0 + for i, seg := range scores.Segments { + score, ok := scores.Scores[seg.ID] + if !ok { + score = keepScore // defensive; ScoreToolOutput fills every id + } + scoresByID[i] = protectedScore(source, score) + floors[i] = gate.floor(seg.Kind) + totalTokens += seg.Tokens + } + elide := AtomicElideDecisions(scores.Segments, scoresByID, floors) + elidedTokens := 0 + for i, seg := range scores.Segments { + if elide[i] { + elidedTokens += seg.Tokens + } + } + + // TRIPWIRE: a scorer that wants to drop most of the output is wrong + // more often than not (and one broken score map could gut a tool result + // wholesale). Distrust it: elide nothing, say so in the result, and log + // the override. + if gate.cfg.MaxElideFraction > 0 && totalTokens > 0 && + float64(elidedTokens) > gate.cfg.MaxElideFraction*float64(totalTokens) { + for i := range elide { + elide[i] = false + } + out.Tripwire = TripwireMaxElideFraction + p.logTripwire(scores.TaskHash, totalTokens) + } + + var b strings.Builder + var run []Segment + + // flushRun relocates the accumulated run of consecutive elided segments + // (the reference pipeline's flush_run, via the shared BuildElidedRun): + // the concatenated run text goes into the store under its content id, + // and one pointer line — run line range, summed tokens, 120-char + // summary — takes the run's place in the output. + flushRun := func() { + if len(run) == 0 { + return + } + er := BuildElidedRun(run, toolName) + // The record carries what the reference attaches to every stored + // run (store.py Record): its origin — "tool:", the only + // surface this path knows — token count, pointer summary, and the + // contributing segment ids the outcomes ledger attributes back to. + // Turn stays 0: turn plumbing does not exist yet. + store.PutRecord(Record{ + ID: er.ID, + Text: er.Text, + Kind: RecordKindElidedSegment, + Origin: Origin{Source: OriginSourceToolPrefix + toolName}, + Tokens: er.Tokens, + Summary: er.Summary, + SegmentIDs: er.SegmentIDs, + }) + e := ElidedSegment{ + ID: er.ID, + Lines: [2]int{run[0].LineStart, run[len(run)-1].LineEnd}, + Tokens: er.Tokens, + Segments: er.Segments, + Summary: er.Summary, + Text: er.Text, + } + out.Elided = append(out.Elided, e) + b.WriteString(er.Pointer) + b.WriteString("\n") + run = run[:0] + } + + for i, seg := range scores.Segments { + if !elide[i] { + flushRun() + b.WriteString(seg.Text) + continue + } + run = append(run, seg) + } + flushRun() + + if len(out.Elided) == 0 { + // Nothing to relocate: return the original byte-for-byte. (The kept + // segments alone would drop leading blank bytes, which belong to no + // segment.) + out.Elided = nil + out.CompactText = output + return out, nil + } + + out.CompactText = b.String() + return out, nil +} + +// CompactToolResult adapts CompactToolOutput to the executor's +// ToolResultCompactor interface: it returns the compacted result with a +// trailer telling the agent how many segments were elided and how to get +// them back, or the original result unchanged when nothing was elided or +// scoring failed. +func (p *Pipeline) CompactToolResult(ctx context.Context, toolName, result string) string { + compacted, err := p.CompactToolOutput(ctx, toolName, result) + if err != nil || len(compacted.Elided) == 0 { + return result + } + ids := make([]string, 0, len(compacted.Elided)) + elidedSegments := 0 + for _, e := range compacted.Elided { + elidedSegments += e.Segments + ids = append(ids, e.ID) + } + return fmt.Sprintf("%s\n\n%d segments elided — use the expand tool with the elided ids to retrieve originals (%s).", + compacted.CompactText, elidedSegments, strings.Join(ids, ", ")) +} diff --git a/internal/compaction/relocate_test.go b/internal/compaction/relocate_test.go new file mode 100644 index 00000000..7389a4de --- /dev/null +++ b/internal/compaction/relocate_test.go @@ -0,0 +1,662 @@ +package compaction + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "path/filepath" + "regexp" + "strings" + "sync" + "testing" +) + +// relocationOutput builds a three-paragraph tool output whose segments stay +// separate (each well over defaultMinSegChars, well under +// DefaultMaxSegChars). Every paragraph carries its marker after the first +// 100 characters, so a pointer's 120-char summary can show the marker's +// first characters but never the whole paragraph body (its distinctive +// tail). +func relocationOutput() (output, keeper1, filler, keeper2 string) { + keeper1 = strings.Repeat("k", 100) + "KEEPER-ONE-MARK" + strings.Repeat("1", 40) + filler = strings.Repeat("f", 100) + "FILLER-SECRET-MARK" + strings.Repeat("2", 40) + keeper2 = strings.Repeat("j", 100) + "KEEPER-TWO-MARK" + strings.Repeat("3", 40) + return keeper1 + "\n\n" + filler + "\n\n" + keeper2, keeper1, filler, keeper2 +} + +// boundaryScores scores seg-1 exactly at the threshold, seg-2 below it, and +// seg-3 above it. +func boundaryScores(threshold float64) map[string]float64 { + return map[string]float64{ + "seg-1": threshold, // exactly at: stays (strictly-below comparison) + "seg-2": 0.1, // below: elided + "seg-3": 0.9, // above: stays + } +} + +// applyTestGate applies a gate config sized for the small relocation +// fixtures: they are a few hundred bytes (a few dozen tokens), far below the +// reference DefaultGateConfig's 400-token min-gate, so the token gate is +// disabled and the keep threshold pinned explicitly. maxElide caps the +// elide-fraction tripwire (1 disables it, since elided tokens can never +// exceed the total), so fixtures that elide everything still exercise the +// pointer path they were written for. +func applyTestGate(p *Pipeline, keepThreshold, maxElide float64) { + p.ApplyGateConfig(GateConfig{ + KeepThreshold: keepThreshold, + MinGateTokens: 0, + MaxElideFraction: maxElide, + }) +} + +// TestPipeline_EnableRelocationThresholdBoundary pins the relocation +// contract: the segment scoring exactly at the threshold stays, the one +// below it is elided into the store under a content id, the high scorer +// stays, and the compact text keeps the kept segments in place with the +// pointer standing where the run stood — so Reconstruct is byte-for-byte. +func TestPipeline_EnableRelocationThresholdBoundary(t *testing.T) { + const threshold = 0.35 + d := newDecisionsServer(t, fixedScoresHandler(boundaryScores(threshold))) + store := NewStore() + + p := NewPipeline(ResolvedBackend{Backend: Backend{Name: "test", URL: d.srv.URL, Model: "jev-latest"}, APIKey: "k"}, "k", nil, PipelineOptions{}) + p.EnableRelocation(store, threshold) + applyTestGate(p, threshold, 0.7) + + output, keeper1, filler, keeper2 := relocationOutput() + got, err := p.CompactToolOutput(context.Background(), "Bash", output) + if err != nil { + t.Fatalf("CompactToolOutput() error = %v", err) + } + + // The stored original is the full segment span (a segment absorbs its + // trailing blank-line separator, so spans tile the input). + segs := SegmentSegments(output, 0) + if len(segs) != 3 { + t.Fatalf("SegmentSegments() = %d segments, want 3", len(segs)) + } + fillerSpan := segs[1].Text + + if len(got.Elided) != 1 { + t.Fatalf("Elided = %d segments, want exactly the below-threshold one", len(got.Elided)) + } + e := got.Elided[0] + // Content-addressed id: ContentID of the run text, salted with the tool + // name, "r"-prefixed — 8 lowercase hex chars. + if want := ContentID(fillerSpan, "Bash", "r"); e.ID != want { + t.Errorf("elided id = %q, want the content id %q", e.ID, want) + } + if !regexp.MustCompile(`^r:[0-9a-f]{8}$`).MatchString(e.ID) { + t.Errorf("elided id = %q, want the r:<8hex> shape", e.ID) + } + if e.Text != fillerSpan { + t.Errorf("elided text = %q, want the filler segment span %q", e.Text, fillerSpan) + } + if !strings.Contains(e.Text, filler) { + t.Errorf("elided text %q lost the filler paragraph", e.Text) + } + // Lines: the filler occupies original line 3, plus its trailing blank + // separator on line 4. + if e.Lines != [2]int{3, 4} { + t.Errorf("elided lines = %v, want [3 4]", e.Lines) + } + if e.Segments != 1 { + t.Errorf("elided segments = %d, want 1", e.Segments) + } + if want := Summarise(fillerSpan, SummaryMaxChars); e.Summary != want { + t.Errorf("elided summary = %q, want %q", e.Summary, want) + } + if e.Summary == "" || strings.ContainsAny(e.Summary, "\n\r\t") { + t.Errorf("elided summary = %q, want a flat one-liner", e.Summary) + } + // The summary is a 120-char cut plus the ellipsis rune. + if len([]rune(e.Summary)) > SummaryMaxChars+1 { + t.Errorf("elided summary = %d runes, want ≤%d (cut + ellipsis)", + len([]rune(e.Summary)), SummaryMaxChars+1) + } + + // The store round-trips the original, keyed by the content id. + if text, ok := store.Get(e.ID); !ok || text != fillerSpan { + t.Errorf("store.Get(%s) = (%q, %v), want the filler original", e.ID, text, ok) + } + if store.Len() != 1 { + t.Errorf("store.Len() = %d, want exactly the one elided record", store.Len()) + } + + // Compact text: the kept segments in place, the pointer standing where + // the run stood (after keeper-1's span, before keeper-2's). + if !strings.HasPrefix(got.CompactText, keeper1+"\n\n") { + t.Errorf("CompactText must start with the first kept segment, got:\n%s", got.CompactText) + } + if !strings.Contains(got.CompactText, keeper1) || !strings.Contains(got.CompactText, keeper2) { + t.Errorf("CompactText lost a high-scoring segment:\n%s", got.CompactText) + } + // The full filler span (its 2-filled tail reaches past the 120-char + // summary) must not appear; the summary legitimately shows the marker's + // first characters. + if strings.Contains(got.CompactText, filler) || strings.Contains(got.CompactText, strings.Repeat("2", 40)) { + t.Errorf("CompactText leaked the elided segment's original:\n%s", got.CompactText) + } + pointerRe := regexp.MustCompile(`^\[\[elided id=r:[0-9a-f]{8} lines=3-4 tokens=[0-9]+ ".+"\]\]$`) + pointerLine := "" + pointerIdx := -1 + for i, line := range strings.Split(got.CompactText, "\n") { + if strings.Contains(line, "[[elided ") { + pointerLine = line + pointerIdx = i + break + } + } + if !pointerRe.MatchString(pointerLine) { + t.Errorf("CompactText pointer line %q not in the pinned reference format", pointerLine) + } + if lastLine := got.CompactText[strings.LastIndexByte(got.CompactText, '\n')+1:]; lastLine != keeper2 { + t.Errorf("CompactText must end with the last kept segment, got %q", lastLine) + } + if pointerIdx != 2 { // keeper1's line, the blank separator, then the pointer + t.Errorf("pointer line index = %d, want 2 (in place of the elided run)", pointerIdx) + } + + // The byte-for-byte inverse: Reconstruct restores the original output. + if restored := Reconstruct(got.CompactText, store); restored != output { + t.Errorf("Reconstruct(compacted) mismatch:\n got %q\nwant %q", restored, output) + } +} + +// TestPipeline_CompactOutputAtAndAboveThresholdKeepsOriginalBytes: nothing +// below the threshold means the result passes through byte-for-byte (never +// reconstructed, which would drop leading blank bytes). +func TestPipeline_CompactOutputNothingElidedKeepsOriginalBytes(t *testing.T) { + // Every segment scores 0.9, so nothing is elided. + high := newDecisionsServer(t, func(_ int, req capturedRequest) (int, string) { + scores := make(map[string]float64, len(req.Req.Questions)) + for ref := range req.Req.Questions { + scores[ref] = 0.9 + } + return http.StatusOK, answersBody(scores) + }) + store := NewStore() + p := NewPipeline(ResolvedBackend{Backend: Backend{Name: "test", URL: high.srv.URL, Model: "jev-latest"}, APIKey: "k"}, "k", nil, PipelineOptions{}) + p.EnableRelocation(store, 0.35) + applyTestGate(p, 0.35, 0.7) + + output := strings.Repeat("keep me\n\n", 40) + got, err := p.CompactToolOutput(context.Background(), "Bash", output) + if err != nil { + t.Fatalf("CompactToolOutput() error = %v", err) + } + if got.CompactText != output { + t.Error("CompactText must equal the original byte-for-byte when nothing is elided") + } + if len(got.Elided) != 0 { + t.Errorf("Elided = %v, want empty", got.Elided) + } + if _, ok := store.Get("elide-1"); ok { + t.Error("store must stay empty when nothing is elided") + } +} + +// TestPipeline_CompactOutputAllElided: every segment below the threshold +// leaves only pointer lines behind — and the two consecutive elided +// segments form ONE run with a single record and pointer (the reference +// flush_run pattern). The gate's tripwire is disabled for this fixture: +// eliding everything is 100% of tokens, past the 0.7 default — the +// tripwire's own behavior is covered in gate_test.go. +func TestPipeline_CompactOutputAllElided(t *testing.T) { + d := newDecisionsServer(t, fixedScoresHandler(map[string]float64{"seg-1": 0.1, "seg-2": 0.2})) + store := NewStore() + p := NewPipeline(ResolvedBackend{Backend: Backend{Name: "test", URL: d.srv.URL, Model: "jev-latest"}, APIKey: "k"}, "k", nil, PipelineOptions{}) + p.EnableRelocation(store, 0.35) + applyTestGate(p, 0.35, 1) + + output := strings.Repeat("x", 120) + "\n\n" + strings.Repeat("y", 120) + got, err := p.CompactToolOutput(context.Background(), "Bash", output) + if err != nil { + t.Fatalf("CompactToolOutput() error = %v", err) + } + if got.Tripwire != "" { + t.Errorf("Tripwire = %q, want empty (tripwire disabled for this fixture)", got.Tripwire) + } + if len(got.Elided) != 1 { + t.Fatalf("Elided = %d runs, want 1 (both segments are consecutive)", len(got.Elided)) + } + run := got.Elided[0] + if run.Segments != 2 { + t.Errorf("run.Segments = %d, want the two grouped segments", run.Segments) + } + if run.Text != output { + t.Errorf("run text = %q, want both segment spans concatenated", run.Text) + } + if run.Lines != [2]int{1, 3} { // x-run, blank separator, y-run + t.Errorf("run lines = %v, want [1 3]", run.Lines) + } + if run.Tokens != SegmentSegments(output, 0)[0].Tokens+SegmentSegments(output, 0)[1].Tokens { + t.Errorf("run tokens = %d, want the summed segment tokens", run.Tokens) + } + // The first line (exactly 120 x's) IS the summary — but the second + // segment's y-run must be gone. + if strings.Contains(got.CompactText, strings.Repeat("y", 120)) { + t.Error("CompactText kept an elided segment's text") + } + // One pointer, one record. + if strings.Count(got.CompactText, "[[elided ") != 1 { + t.Errorf("CompactText must carry exactly one pointer:\n%s", got.CompactText) + } + if store.Len() != 1 { + t.Errorf("store.Len() = %d, want the single run record", store.Len()) + } + if text, ok := store.Get(run.ID); !ok || text != output { + t.Errorf("store.Get(%s) = (%q, %v), want the whole run", run.ID, text, ok) + } + // The pointer replaced the whole output: reconstruct restores it. + if restored := Reconstruct(got.CompactText, store); restored != output { + t.Errorf("Reconstruct(compacted) = %q, want the original output", restored) + } +} + +// TestPipeline_CompactOutputFailOpenKeepsOriginal: any scoring error means +// the original result comes back unchanged (never break a tool call over +// compaction). A 400 is non-retryable so this fails fast; the 503 case +// shrinks the retry curve. +func TestPipeline_CompactOutputFailOpenKeepsOriginal(t *testing.T) { + output, _, _, _ := relocationOutput() + for _, tc := range []struct { + name string + status int + shrink bool + wantErr bool + }{ + {name: "permanent 400", status: http.StatusBadRequest}, + {name: "transient 503", status: http.StatusServiceUnavailable, shrink: true, wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + d := newDecisionsServer(t, func(_ int, _ capturedRequest) (int, string) { + return tc.status, `{"error": {"message": "down"}}` + }) + store := NewStore() + p := NewPipeline(ResolvedBackend{Backend: Backend{Name: "test", URL: d.srv.URL, Model: "jev-latest"}, APIKey: "k"}, "k", nil, PipelineOptions{}) + if tc.shrink { + shrinkPipelineRetryCurve(p) + } + p.EnableRelocation(store, 0.35) + applyTestGate(p, 0.35, 0.7) + + got, err := p.CompactToolOutput(context.Background(), "Bash", output) + if tc.wantErr && err == nil { + t.Fatal("CompactToolOutput() error = nil, want the outage recorded") + } + if got.CompactText != output { + t.Errorf("fail-open must return the original text unchanged, got:\n%s", got.CompactText) + } + if len(got.Elided) != 0 { + t.Errorf("Elided = %v, want empty on fail-open", got.Elided) + } + if store.Len() != 0 { + t.Error("store must stay empty on fail-open") + } + }) + } +} + +// TestPipeline_CompactWithoutRelocationIsShadow: an un-armed pipeline scores +// and logs but never mutates the result or the store — the shadow contract. +func TestPipeline_CompactWithoutRelocationIsShadow(t *testing.T) { + d := newDecisionsServer(t, fixedScoresHandler(map[string]float64{"seg-1": 0.01, "seg-2": 0.02})) + store := NewStore() + p := NewPipeline(ResolvedBackend{Backend: Backend{Name: "test", URL: d.srv.URL, Model: "jev-latest"}, APIKey: "k"}, "k", nil, PipelineOptions{}) + applyTestGate(p, 0.35, 0.7) + + output := strings.Repeat("a", 120) + "\n\n" + strings.Repeat("b", 120) + got, err := p.CompactToolOutput(context.Background(), "Bash", output) + if err != nil { + t.Fatalf("CompactToolOutput() error = %v", err) + } + if got.CompactText != output || len(got.Elided) != 0 { + t.Errorf("shadow-only pipeline must not mutate the result, got %+v", got) + } + // Arming with a nil store disarms relocation again. + p.EnableRelocation(nil, 0.35) + got, err = p.CompactToolOutput(context.Background(), "Bash", output) + if err != nil || got.CompactText != output { + t.Errorf("disarmed pipeline must not mutate the result, got %+v, err %v", got, err) + } + if store.Len() != 0 { + t.Error("store must stay empty without relocation") + } +} + +// TestPipeline_RelocationShadowLogDecisions: the shadow log records "elide" +// for below-threshold segments only when relocation is armed; shadow mode +// keeps recording "keep" for everything. +func TestPipeline_RelocationShadowLogDecisions(t *testing.T) { + newArmed := func(armed bool) (*Pipeline, *ShadowLog) { + d := newDecisionsServer(t, fixedScoresHandler(boundaryScores(0.35))) + shadow, err := NewShadowLogAt(filepath.Join(t.TempDir(), "shadow.jsonl")) + if err != nil { + t.Fatal(err) + } + p := NewPipeline(ResolvedBackend{Backend: Backend{Name: "test", URL: d.srv.URL, Model: "jev-latest"}, APIKey: "k"}, "k", shadow, PipelineOptions{}) + applyTestGate(p, 0.35, 0.7) + if armed { + p.EnableRelocation(NewStore(), 0.35) + } + return p, shadow + } + + output, _, _, _ := relocationOutput() + + t.Run("armed records elide", func(t *testing.T) { + p, shadow := newArmed(true) + if _, err := p.CompactToolOutput(context.Background(), "Bash", output); err != nil { + t.Fatalf("CompactToolOutput() error = %v", err) + } + report, err := shadow.Replay(0.35) + if err != nil { + t.Fatalf("Replay() error = %v", err) + } + if report.Entries != 3 || report.ElidedEntries != 1 { + t.Fatalf("Replay() = %+v, want 3 entries with 1 elided", report) + } + // Step 13: every decision entry carries the gate floor it was made + // against (all fixtures are text segments, so the floor is the keep + // threshold), letting replay re-decide from score vs threshold. + lines := readLines(t, shadow.Path()) + if len(lines) != 3 { + t.Fatalf("got %d shadow lines, want 3", len(lines)) + } + for _, line := range lines { + var e ShadowEntry + if err := json.Unmarshal([]byte(line), &e); err != nil { + t.Fatalf("shadow line invalid: %v", err) + } + if e.Threshold != 0.35 { + t.Errorf("decision for %s threshold = %v, want the gate floor 0.35", e.SegmentID, e.Threshold) + } + } + }) + + t.Run("shadow records keep", func(t *testing.T) { + p, shadow := newArmed(false) + if _, err := p.CompactToolOutput(context.Background(), "Bash", output); err != nil { + t.Fatalf("CompactToolOutput() error = %v", err) + } + report, err := shadow.Replay(0.35) + if err != nil { + t.Fatalf("Replay() error = %v", err) + } + if report.Entries != 3 || report.ElidedEntries != 1 { + t.Fatalf("Replay() = %+v, want 3 entries with 1 below threshold", report) + } + }) +} + +// TestPipeline_ContentIDsDeterministicAcrossCalls: pointer ids are +// content-addressed, so the same run text mints the same id (and reuses the +// same store record) across calls, while different texts mint different +// ids. The legacy counter stays untouched for old pointers. +func TestPipeline_ContentIDsDeterministicAcrossCalls(t *testing.T) { + d := newDecisionsServer(t, echoHandler) // seg-N scores 0.01*N: all below 0.35 + store := NewStore() + p := NewPipeline(ResolvedBackend{Backend: Backend{Name: "test", URL: d.srv.URL, Model: "jev-latest"}, APIKey: "k"}, "k", nil, PipelineOptions{}) + p.EnableRelocation(store, 0.35) + applyTestGate(p, 0.35, 1) + + ctx := context.Background() + output := strings.Repeat("a", 120) + "\n\n" + strings.Repeat("b", 120) + first, err := p.CompactToolOutput(ctx, "Bash", output) + if err != nil { + t.Fatalf("first CompactToolOutput() error = %v", err) + } + again, err := p.CompactToolOutput(ctx, "Bash", output) + if err != nil { + t.Fatalf("second CompactToolOutput() error = %v", err) + } + other, err := p.CompactToolOutput(ctx, "Bash", strings.Repeat("c", 120)+"\n\n"+strings.Repeat("d", 120)) + if err != nil { + t.Fatalf("third CompactToolOutput() error = %v", err) + } + + // Two consecutive segments per output: one run each. + if len(first.Elided) != 1 || len(again.Elided) != 1 || len(other.Elided) != 1 { + t.Fatalf("Elided runs = %d/%d/%d, want one run per output", + len(first.Elided), len(again.Elided), len(other.Elided)) + } + + // Same run text → same id, always: re-compaction reuses the record. + if first.Elided[0].ID != again.Elided[0].ID { + t.Errorf("same run text minted different ids: %q vs %q", first.Elided[0].ID, again.Elided[0].ID) + } + if first.Elided[0].ID != ContentID(output, "Bash", "r") { + t.Errorf("run id = %q, want ContentID(runText, salt=toolName)", first.Elided[0].ID) + } + // Different text → different id. + if first.Elided[0].ID == other.Elided[0].ID { + t.Errorf("different run texts shared the id %q", first.Elided[0].ID) + } + // All runs are retrievable, and idempotent Put kept exactly two records. + for _, e := range []ElidedSegment{first.Elided[0], other.Elided[0]} { + if _, ok := store.Get(e.ID); !ok { + t.Errorf("store missing %s", e.ID) + } + } + if store.Len() != 2 { + t.Errorf("store.Len() = %d, want 2 (identical content shares one record)", store.Len()) + } +} + +// TestPipeline_CompactToolResultTrailer pins the executor-facing string +// form: compact text plus the expand-tool trailer, original on fail-open. +func TestPipeline_CompactToolResultTrailer(t *testing.T) { + output, keeper1, filler, keeper2 := relocationOutput() + d := newDecisionsServer(t, fixedScoresHandler(boundaryScores(0.35))) + store := NewStore() + p := NewPipeline(ResolvedBackend{Backend: Backend{Name: "test", URL: d.srv.URL, Model: "jev-latest"}, APIKey: "k"}, "k", nil, PipelineOptions{}) + p.EnableRelocation(store, 0.35) + applyTestGate(p, 0.35, 0.7) + + got := p.CompactToolResult(context.Background(), "Bash", output) + // The trailer names the run's content id. + segs := SegmentSegments(output, 0) + runID := ContentID(segs[1].Text, "Bash", "r") + for _, want := range []string{ + keeper1, keeper2, + "1 segments elided — use the expand tool with the elided ids to retrieve originals (" + runID + ").", + } { + if !strings.Contains(got, want) { + t.Errorf("CompactToolResult() missing %q:\n%s", want, got) + } + } + if strings.Contains(got, filler) || strings.Contains(got, strings.Repeat("2", 40)) { + t.Errorf("CompactToolResult() leaked the elided original:\n%s", got) + } + + // Nothing elided → byte-identical, no trailer. + high := newDecisionsServer(t, fixedScoresHandler(map[string]float64{"seg-1": 0.9, "seg-2": 0.9, "seg-3": 0.9})) + p2 := NewPipeline(ResolvedBackend{Backend: Backend{Name: "test", URL: high.srv.URL, Model: "jev-latest"}, APIKey: "k"}, "k", nil, PipelineOptions{}) + p2.EnableRelocation(store, 0.35) + applyTestGate(p2, 0.35, 0.7) + if got := p2.CompactToolResult(context.Background(), "Bash", output); got != output { + t.Errorf("CompactToolResult() with nothing elided must return the original, got:\n%s", got) + } +} + +// TestPipeline_EnableRelocationClampsThreshold: out-of-range thresholds fall +// back to DefaultRelocationThreshold instead of eliding everything or +// nothing by accident. +func TestPipeline_EnableRelocationClampsThreshold(t *testing.T) { + d := newDecisionsServer(t, fixedScoresHandler(map[string]float64{"seg-1": 0.3})) + output := strings.Repeat("a", 120) + + for _, bad := range []float64{0, -1, 1.5} { + store := NewStore() + p := NewPipeline(ResolvedBackend{Backend: Backend{Name: "test", URL: d.srv.URL, Model: "jev-latest"}, APIKey: "k"}, "k", nil, PipelineOptions{}) + p.EnableRelocation(store, bad) + // The invalid keep threshold clamps to "unset" (the armed threshold + // stays in force) and the tripwire is disabled so the single + // all-eliding fixture elides normally. + applyTestGate(p, bad, 1) + got, err := p.CompactToolOutput(context.Background(), "Bash", output) + if err != nil { + t.Fatalf("threshold %v: CompactToolOutput() error = %v", bad, err) + } + // Every out-of-range threshold clamps to DefaultRelocationThreshold, + // under which the 0.3-scoring segment is elided (a raw threshold of + // 0 would keep everything; 1.5 would be nonsense). + if len(got.Elided) != 1 { + t.Errorf("threshold %v: Elided = %d, want the default-threshold elision", bad, len(got.Elided)) + } + } +} + +// TestStore_GetRoundTripAndMisses covers the store's read side and Put's +// idempotency: an existing id keeps its first record, so re-storing +// identical content never duplicates or overwrites (content ids hash the +// text, so two records under one id would be indistinguishable anyway). +func TestStore_GetRoundTripAndMisses(t *testing.T) { + var nilStore *Store + if _, ok := nilStore.Get("elide-1"); ok { + t.Error("nil store must report a miss") + } + if nilStore.Len() != 0 { + t.Error("nil store must be empty") + } + + s := NewStore() + if _, ok := s.Get("elide-1"); ok { + t.Error("empty store must report a miss") + } + s.Put("elide-1", "original text") + if text, ok := s.Get("elide-1"); !ok || text != "original text" { + t.Errorf("Get(elide-1) = (%q, %v), want the stored original", text, ok) + } + s.Put("elide-1", "replaced") + if text, _ := s.Get("elide-1"); text != "original text" { + t.Errorf("Get(elide-1) = %q, want the first record kept (idempotent Put)", text) + } + if s.Len() != 1 { + t.Errorf("Len() = %d, want 1 after an idempotent re-put", s.Len()) + } + // Legacy counter ids still mint (backward compatibility of old stores). + if got := s.NextID(); got != "elide-1" { + t.Errorf("NextID() = %q, want elide-1", got) + } + if got := s.NextID(); got != "elide-2" { + t.Errorf("NextID() = %q, want elide-2", got) + } + s.Put("elide-2", "legacy original") + if text, ok := s.Get("elide-2"); !ok || text != "legacy original" { + t.Errorf("Get(elide-2) = (%q, %v), want the legacy original", text, ok) + } +} + +// TestPipeline_CompactReconstructRoundTrip is the Step 11 round-trip pin: +// an output whose segments carry quotes, backslashes, and newlines goes +// through compact (enabled mode, stub scorer) and comes back through +// Reconstruct BYTE FOR BYTE — the pointer lines (with their escaped +// summaries) are exact inverses of the runs they replaced. +func TestPipeline_CompactReconstructRoundTrip(t *testing.T) { + // Five paragraphs: keep, elide, keep, elide, keep. The elided ones start + // with quotes and backslashes (exercising summary escaping) and span + // multiple lines (exercising stored newlines); every paragraph clears + // the 80-byte tiny-paragraph floor and stays under the segment cap. + pad := func(n int) string { return strings.Repeat("pad ", n) } + para1 := `intro "quoted" and \backslash\ padding ` + pad(13) + para2 := `secret one starts "with quotes" and \ a backslash` + "\n" + + "secret one line two\nsecret one line three\n" + pad(6) + para3 := `middle keeper "with quotes" too ` + pad(12) + para4 := `secret two \ ends its first line oddly` + "\n" + + "secret two line two\n" + pad(8) + para5 := `final keeper "quoted end" ` + pad(14) + output := para1 + "\n\n" + para2 + "\n\n" + para3 + "\n\n" + para4 + "\n\n" + para5 + + segs := SegmentSegments(output, 0) + if len(segs) != 5 { + t.Fatalf("SegmentSegments() = %d segments, want 5", len(segs)) + } + + d := newDecisionsServer(t, fixedScoresHandler(map[string]float64{ + "seg-1": 0.9, "seg-2": 0.05, "seg-3": 0.9, "seg-4": 0.05, "seg-5": 0.9, + })) + store := NewStore() + p := NewPipeline(ResolvedBackend{Backend: Backend{Name: "test", URL: d.srv.URL, Model: "jev-latest"}, APIKey: "k"}, "k", nil, PipelineOptions{}) + p.EnableRelocation(store, 0.35) + applyTestGate(p, 0.35, 1) + + got, err := p.CompactToolOutput(context.Background(), "Bash", output) + if err != nil { + t.Fatalf("CompactToolOutput() error = %v", err) + } + if len(got.Elided) != 2 { + t.Fatalf("Elided = %d runs, want exactly the two secret paragraphs", len(got.Elided)) + } + + // Each run's record holds its multi-line original verbatim. + for i, e := range got.Elided { + want := segs[2*i+1].Text + if e.Text != want { + t.Errorf("run %d text = %q, want the segment span %q", i, e.Text, want) + } + stored, ok := store.Get(e.ID) + if !ok || stored != want { + t.Errorf("store.Get(%s) = (%q, %v), want the verbatim multi-line original", e.ID, stored, ok) + } + } + if got.Elided[0].ID == got.Elided[1].ID { + t.Errorf("distinct runs shared the id %q", got.Elided[0].ID) + } + if store.Len() != 2 { + t.Errorf("store.Len() = %d, want 2", store.Len()) + } + + // The compact text names both pointers with escaped summaries and hides + // the secrets' bodies; the kept paragraphs (quotes and all) survive. + if strings.Contains(got.CompactText, "secret one line two") || + strings.Contains(got.CompactText, "secret two line two") { + t.Errorf("CompactText leaked an elided run's body:\n%s", got.CompactText) + } + for _, keeper := range []string{para1, para3, para5} { + if !strings.Contains(got.CompactText, keeper) { + t.Errorf("CompactText lost a kept paragraph:\n%s", got.CompactText) + } + } + pointers := FindPointers(got.CompactText) + if len(pointers) != 2 { + t.Fatalf("FindPointers(compacted) = %d, want the 2 pointers back", len(pointers)) + } + for i, p := range pointers { + if p.ID != got.Elided[i].ID { + t.Errorf("pointer[%d].ID = %q, want the run's id %q", i, p.ID, got.Elided[i].ID) + } + if p.Lines == nil || *p.Lines != got.Elided[i].Lines { + t.Errorf("pointer[%d] lines = %v, want the run's %v", i, p.Lines, got.Elided[i].Lines) + } + } + + // THE guarantee: reconstruct is the byte-for-byte inverse. + if restored := Reconstruct(got.CompactText, store); restored != output { + t.Errorf("Reconstruct(compacted) is not byte-for-byte:\n got %q\nwant %q", restored, output) + } +} + +// TestStore_ConcurrentAccess exercises the mutex under -race: one pipeline +// is shared by all agents, whose tool calls run concurrently. +func TestStore_ConcurrentAccess(t *testing.T) { + s := NewStore() + var wg sync.WaitGroup + for i := 1; i <= 64; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + id := fmt.Sprintf("elide-%d", n) + s.Put(id, strings.Repeat("x", n)) + if text, ok := s.Get(id); !ok || len(text) != n { + t.Errorf("Get(%s) = (%d chars, %v), want %d chars", id, len(text), ok, n) + } + }(i) + } + wg.Wait() + if _, ok := s.Get("elide-65"); ok { + t.Error("Get(missing id) must report a miss") + } +} diff --git a/internal/compaction/retrieve.go b/internal/compaction/retrieve.go new file mode 100644 index 00000000..761ec20b --- /dev/null +++ b/internal/compaction/retrieve.go @@ -0,0 +1,379 @@ +package compaction + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" +) + +// Retrieval read side (implementation_plan.md Step 17) — the port of the +// reference pipeline.py retrieve(): score the STORE's digest entries (cheap +// per-record summaries, capped by a token budget) against the current task, +// take the top-k entries scoring at or above the threshold, log one +// kind=retrieve decision per scored entry, and return the full Records so +// the caller can put them into the WORK AREA of the next request — never +// the frozen prefix. +// +// Documented deviation from the reference: the reference asks +// RETRIEVE_QUESTION, a typed Noul question carrying its own instructions and +// true/false criteria, while the task digest travels in state.task. The Go +// DecisionClient speaks the reference's question format (instructions plus +// true/false criteria — noulQuestionFor in client.go) but carries ONE fixed +// question, the ADMIT_QUESTION port below, with no per-call question +// plumbing, so the retrieve question's text is folded into the task slot +// instead (RetrieveTask): the constants here carry the reference wording +// verbatim, and the request then scores "relevant to ". The +// semantics are equivalent — a high score means the stored item is relevant +// to the task right now. +// +// A second deviation, also deliberate: the reference's score_items fails +// open with keep-scores on ANY Jev error, so an outage retrieves (and +// injects) arbitrary records at 1.0. That is the right fail-open direction +// for elision (never lose information) and the wrong one for retrieval +// (never stuff the work area with records nobody ranked): this port returns +// no records and logs no decisions when scoring failed — the read side is +// aborted for the turn, not decided against garbage. Auth-class failures +// additionally disable the pipeline's scoring for the rest of the session +// (the same one-warning policy every other scoring path takes). + +// RetrieveQuestionInstructions is the reference RETRIEVE_QUESTION +// instructions string (pipeline.py), verbatim. See the RetrieveTask +// documentation for how it reaches the scorer in this port. +const RetrieveQuestionInstructions = "Is this stored item relevant to the task described in `task` right now? " + + "Answer true if the next step is likely to need it. Answer false if it belongs " + + "to unrelated work, or is superseded by something more recent." + +// RetrieveQuestionTrue is the reference RETRIEVE_QUESTION true-criteria +// string (pipeline.py), verbatim. +const RetrieveQuestionTrue = "The item is relevant to the current step." + +// RetrieveQuestionFalse is the reference RETRIEVE_QUESTION false-criteria +// string (pipeline.py), verbatim. +const RetrieveQuestionFalse = "The item is not relevant right now." + +// AdmitQuestionInstructions is the ONE question this port's DecisionClient +// asks about every item (noulQuestionFor in client.go, the reference's admit +// end of score_items). Like the reference, the question never embeds the +// task: the task digest travels in state.task. +// +// The wording sharpens the reference ADMIT_QUESTION's boundary while keeping +// its conservative spirit ("answer false ONLY if it is noise"): real-run +// replay showed the reference wording scoring dense work content 0.4–0.89 — +// almost nothing elided — because "facts, identifiers, errors, results" also +// describes the boilerplate progress logs that embed those words. The rewrite +// pins the NOISE side to what it actually is (progress output, +// confirmations, repeated boilerplate, large repetitive dumps whose key +// facts are retained nearby or re-derivable) and the ESSENTIAL side to the +// concrete facts a later step may have to refer back to, and tells the +// scorer explicitly that verbose intermediate logs are noise even when they +// mention relevant words while their final results/summaries are essential. +const AdmitQuestionInstructions = "Will this item still be needed later in the task described in `task`? " + + "Answer false if it is NOISE: progress output, success or progress confirmations, repeated " + + "boilerplate, or a large repetitive dump (verbose intermediate logs, build or test output, " + + "file listings) whose key facts — file paths, commands, error messages, final results — are " + + "retained in the surrounding kept content or can be re-derived by rerunning the step. " + + "Verbose intermediate logs are noise even when they mention relevant words; the final result " + + "or summary of such a log is essential. " + + "Answer true only if it is ESSENTIAL: it contains concrete facts a later step may have to " + + "refer back to — file paths, commands and their outcomes, error messages, decisions, user " + + "preferences, todo state, numbers or results, or the key fields of an API response — that " + + "are not retained elsewhere and cannot be re-derived." + +// AdmitQuestionTrue is the admit question's true-criterion: what a "true" +// answer asserts about the item. +const AdmitQuestionTrue = "The item carries concrete facts a later step may need — paths, commands, " + + "errors, decisions, results, or key response fields — that are not retained elsewhere and " + + "cannot be re-derived." + +// AdmitQuestionFalse is the admit question's false-criterion: what a "false" +// answer asserts about the item. +const AdmitQuestionFalse = "The item is progress noise, a confirmation, repeated boilerplate, or a " + + "verbose dump whose useful facts are retained nearby or re-derivable — eliding it loses " + + "nothing a later step cannot recover." + +// RetrieveActionInjected and RetrieveActionSkipped are the actions recorded +// on kind=retrieve decision entries (the reference's "injected"/"skipped"): +// the entry's record was put into the work area, or it was scored below the +// retrieval threshold and left in the store. +const ( + RetrieveActionInjected = "injected" + RetrieveActionSkipped = "skipped" +) + +// Production defaults for Retrieve (the reference retrieve() signature: +// k=5, budget_tokens=24_000, threshold=0.5). A non-positive k or budget and +// a threshold outside (0, 1] fall back to these. +const ( + DefaultRetrieveK = 5 + DefaultRetrieveBudgetTokens = 24_000 + DefaultRetrieveThreshold = 0.5 +) + +// RetrieveTask composes the scoring task for one retrieval from the +// caller's task digest. The reference sends RETRIEVE_QUESTION as the +// question and task_digest as the state task; this port's fixed question +// envelope embeds the task, so the framing travels with it: the composed +// string carries the reference question's instructions and true/false +// criteria, then the task digest itself. Deterministic per digest, so +// HashTask of the result groups a digest's retrieval decisions. +func RetrieveTask(taskDigest string) string { + var b strings.Builder + b.WriteString(RetrieveQuestionInstructions) + b.WriteString(" (true: ") + b.WriteString(RetrieveQuestionTrue) + b.WriteString(" false: ") + b.WriteString(RetrieveQuestionFalse) + b.WriteString(") Task: ") + b.WriteString(taskDigest) + return b.String() +} + +// DigestEntry is the cheap view of one stored Record (the reference +// types.py DigestEntry): small enough that hundreds fit in one scoring +// state. Summary is what gets scored; Tokens is the record's own token +// count — the budget packs by it (conservative: the summaries actually sent +// are far smaller than the runs they stand for). +type DigestEntry struct { + ID string + Summary string + Kind string + Tokens int + CreatedTurn int +} + +// Digest builds the retrieval digest: one cheap entry per record, packed +// under budgetTokens. Port of the reference store's digest(budget_tokens) +// (the plan's Step 17 spec — the vendored reference trimmed store.py, so +// the wording there is the source of truth): +// +// - One entry per record. Summary is the record's pointer summary; a +// record without one falls back to a truncated first line +// (Summarise of the record text at the pointer-summary limit). +// - Drop oldest-first when over budget: entries start in first-appearance +// (oldest first) order and the oldest are dropped until the token sum +// fits, so a store larger than the budget keeps its NEWEST records — +// the ones least likely to be superseded. A non-positive budget keeps +// nothing (nothing fits a non-positive budget); Retrieve normalizes +// its budget before calling. +// - Largest-last: the returned entries are ordered ascending by tokens +// (stable — equal sizes keep their first-appearance order), so the big +// summaries sit at the end of the digest. Scoring batches by id, so +// this order is presentation-only; it is pinned by test anyway. +func (s *Store) Digest(budgetTokens int) []DigestEntry { + if s == nil { + return nil + } + records := s.Records() // first-appearance order: oldest first + entries := make([]DigestEntry, 0, len(records)) + for _, rec := range records { + summary := rec.Summary + if summary == "" { + // No pointer summary (a record written before summaries were + // threaded, or by a writer that skipped it): fall back to a + // truncated first line of the original. + summary = Summarise(rec.Text, SummaryMaxChars) + } + entries = append(entries, DigestEntry{ + ID: rec.ID, + Summary: summary, + Kind: rec.Kind, + Tokens: rec.Tokens, + CreatedTurn: rec.CreatedTurn, + }) + } + + // Drop oldest-first until the token sum fits the budget. + total := 0 + for _, e := range entries { + total += e.Tokens + } + drop := 0 + for total > budgetTokens && drop < len(entries) { + total -= entries[drop].Tokens + drop++ + } + entries = entries[drop:] + + // Largest-last ordering (stable, so equal sizes keep first-appearance + // order). + sort.SliceStable(entries, func(i, j int) bool { return entries[i].Tokens < entries[j].Tokens }) + return entries +} + +// RetrievedContextHeader is the first line of the work-area block the +// caller renders from Retrieve's records (session.InjectRetrieved). +const RetrievedContextHeader = "Retrieved context (scored relevant to the current task):" + +// RetrievedBlock renders the records as the single context block injected +// into the work area: the header line, then each record's full text +// separated by a "---" rule. Records with empty text are skipped; a +// selection with no text at all renders as an empty string (nothing to +// inject). +func RetrievedBlock(records []Record) string { + var b strings.Builder + b.WriteString(RetrievedContextHeader) + wrote := false + for _, rec := range records { + if rec.Text == "" { + continue + } + if wrote { + b.WriteString("\n---\n") + } else { + b.WriteString("\n") + } + b.WriteString(rec.Text) + wrote = true + } + if !wrote { + return "" + } + return b.String() +} + +// Retrieve scores the store's digest against taskDigest and returns the +// top-k records — the port of the reference pipeline.py retrieve(). k, the +// budget, and the threshold fall back to DefaultRetrieveK, +// DefaultRetrieveBudgetTokens, and DefaultRetrieveThreshold when +// non-positive (or, for the threshold, outside (0, 1]). +// +// The pipeline's own decision log (shadow, when attached) receives one +// kind=retrieve decision entry per scored entry — Action (and Decision) +// "injected" for records that made the cut, "skipped" for the rest — with +// the retrieval threshold recorded so replay tooling can re-run the +// selection from the log. Shadow-log append failures are swallowed: +// logging is best-effort by contract and must not break retrieval (and a +// logging failure must not be reportable as a scoring error — that would +// cancel a selection that already happened). +// +// The error return is non-nil exactly when scoring failed; NO records and +// NO decisions are returned or logged then (see the package-level deviation +// note). A nil store is an empty digest (nil, nil); a nil pipeline (or one +// without a client) is an error, like every other pipeline entry point. +func (p *Pipeline) Retrieve(ctx context.Context, taskDigest string, store *Store, k int, budgetTokens int, threshold float64) ([]Record, error) { + if p == nil || p.client == nil { + return nil, fmt.Errorf("compaction: pipeline has no decision client") + } + if store == nil { + return nil, nil + } + // Zero values are production defaults (the reference retrieve + // signature's k=5, budget_tokens=24_000, threshold=0.5). + if k <= 0 { + k = DefaultRetrieveK + } + if budgetTokens <= 0 { + budgetTokens = DefaultRetrieveBudgetTokens + } + if threshold <= 0 || threshold > 1 { + threshold = DefaultRetrieveThreshold + } + + // Auth-poisoned pipeline: scoring is off for the session (the one-time + // warning was emitted when the rejection was first seen). Retrieval is + // silently off — no doomed request, no records, no decisions. + if dead, _ := p.authDisabled(); dead { + return nil, nil + } + + entries := store.Digest(budgetTokens) + if len(entries) == 0 { + return nil, nil + } + + task := RetrieveTask(taskDigest) + items := make(map[string]Item, len(entries)) + for _, e := range entries { + items[e.ID] = Item{Text: e.Summary, Tokens: e.Tokens} + } + scores, err := p.client.ScoreBatch(ctx, task, items) + if err != nil { + var ce *Error + if errors.As(err, &ce) && ce.Kind == KindAuth { + // The backend refused the credentials (401/403 — the reference's + // JevAuthError): disable scoring for the session exactly like + // the other scoring paths, and stop the retrieval here. Nothing + // was ranked, so nothing is logged. + p.noteAuthFailure(ce.Error()) + return nil, err + } + // Any other scoring failure: the returned scores are (partially) + // fail-open keep-scores whose only safe use is "keep everything" — + // the elision direction. Retrieval's mirror-image safe direction is + // "inject nothing": an unreliable ranking must not stuff the work + // area with arbitrary records. Abort the read side for this turn. + return nil, err + } + + type scoredEntry struct { + entry DigestEntry + score float64 + } + order := make([]scoredEntry, 0, len(entries)) + for _, e := range entries { + score, ok := scores[e.ID] + if !ok { + score = keepScore // defensive; ScoreBatch fills every id + } + order = append(order, scoredEntry{entry: e, score: score}) + } + // Rank: score descending; ties break by ascending record id so the + // selection is deterministic (map iteration order is not). + sort.Slice(order, func(i, j int) bool { + if order[i].score != order[j].score { + return order[i].score > order[j].score + } + return order[i].entry.ID < order[j].entry.ID + }) + + chosen := make(map[string]bool, k) + records := make([]Record, 0, k) + for _, r := range order { + if len(records) == k { + break + } + if r.score < threshold { + continue + } + rec, ok := store.GetRecord(r.entry.ID) + if !ok { + continue // vanished between Digest and now; skip + } + chosen[r.entry.ID] = true + records = append(records, *rec) + } + + // Shadow log: one kind=retrieve decision per scored entry, with the + // selection threshold recorded so the injected/skipped split can be + // replayed from the log without re-scoring. Append failures are + // swallowed (see the doc comment). + if p.shadow != nil { + now := p.now() + taskHash := HashTask(task) + for _, r := range order { + action := RetrieveActionSkipped + if chosen[r.entry.ID] { + action = RetrieveActionInjected + } + // Decision carries the action too: it is what a reader that + // ignores the newer Action field sees, and "injected"/"skipped" + // is the honest value for a retrieve decision (the empty + // default would be rewritten to "keep", admit vocabulary). + _ = p.shadow.Append(ShadowEntry{ + TS: now, + TaskHash: taskHash, + SegmentID: r.entry.ID, + Tokens: r.entry.Tokens, + Score: r.score, + Decision: action, + Type: EntryTypeDecision, + Kind: DecisionKindRetrieve, + Action: action, + Threshold: threshold, + }) + } + } + return records, nil +} diff --git a/internal/compaction/retrieve_test.go b/internal/compaction/retrieve_test.go new file mode 100644 index 00000000..f4d97bd5 --- /dev/null +++ b/internal/compaction/retrieve_test.go @@ -0,0 +1,508 @@ +package compaction + +import ( + "context" + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestStoreDigest_BudgetDropsOldest pins the budget packing: entries start +// in first-appearance (oldest first) order, the oldest are dropped until +// the token sum fits, and the survivors come back largest-last. +func TestStoreDigest_BudgetDropsOldest(t *testing.T) { + s := NewStore() + s.PutRecord(Record{ID: "r:old", Text: "old run", Tokens: 100, CreatedTurn: 1}) + s.PutRecord(Record{ID: "r:mid", Text: "mid run", Tokens: 50, CreatedTurn: 2}) + s.PutRecord(Record{ID: "r:new", Text: "new run", Tokens: 200, CreatedTurn: 3}) + + // 350 total tokens: a 300-token budget drops the oldest record; the + // survivors come back largest-last (50 before 200). + got := s.Digest(300) + if len(got) != 2 { + t.Fatalf("Digest(300) returned %d entries, want 2: %+v", len(got), got) + } + if got[0].ID != "r:mid" || got[1].ID != "r:new" { + t.Errorf("Digest(300) = [%s, %s], want [r:mid, r:new] (oldest dropped, largest last)", + got[0].ID, got[1].ID) + } + + // An exact budget keeps everything. + if got := s.Digest(350); len(got) != 3 { + t.Fatalf("Digest(350) returned %d entries, want 3 (exact budget)", len(got)) + } + + // A budget smaller than any single record keeps nothing. + if got := s.Digest(10); len(got) != 0 { + t.Fatalf("Digest(10) returned %d entries, want 0", len(got)) + } + + // A nil store is an empty digest, not a panic. + if got := (*Store)(nil).Digest(1000); got != nil { + t.Fatalf("nil store Digest = %+v, want nil", got) + } +} + +// TestStoreDigest_SummaryFallback: the record's pointer summary is the +// digest's summary; a record without one falls back to a truncated first +// line of the original text. +func TestStoreDigest_SummaryFallback(t *testing.T) { + s := NewStore() + s.PutRecord(Record{ID: "r:1", Text: "first line\nsecond line", Summary: "explicit summary", Tokens: 10}) + s.PutRecord(Record{ID: "r:2", Text: " \n the real first line \n more text", Tokens: 10}) + + got := s.Digest(1000) + if len(got) != 2 { + t.Fatalf("Digest returned %d entries, want 2", len(got)) + } + byID := map[string]DigestEntry{} + for _, e := range got { + byID[e.ID] = e + } + if byID["r:1"].Summary != "explicit summary" { + t.Errorf("r:1 summary = %q, want the record's own pointer summary", byID["r:1"].Summary) + } + if byID["r:2"].Summary != "the real first line" { + t.Errorf("r:2 summary = %q, want the flattened first non-blank line", byID["r:2"].Summary) + } + if byID["r:1"].Kind != RecordKindElidedSegment { + t.Errorf("r:1 kind = %q, want the stored default %q", byID["r:1"].Kind, RecordKindElidedSegment) + } + if byID["r:1"].CreatedTurn != 0 { + t.Errorf("r:1 created_turn = %d, want 0 (no turn plumbing yet)", byID["r:1"].CreatedTurn) + } +} + +// TestStoreDigest_LargestLastTieOrder: equal-size entries keep their +// first-appearance order (the ordering sort is stable). +func TestStoreDigest_LargestLastTieOrder(t *testing.T) { + s := NewStore() + s.PutRecord(Record{ID: "r:a", Text: "a", Tokens: 7}) + s.PutRecord(Record{ID: "r:b", Text: "b", Tokens: 3}) + s.PutRecord(Record{ID: "r:c", Text: "c", Tokens: 7}) + + got := s.Digest(1000) + want := []string{"r:b", "r:a", "r:c"} // 3 first, then the two 7s in insertion order + if len(got) != len(want) { + t.Fatalf("Digest returned %d entries, want %d", len(got), len(want)) + } + for i, id := range want { + if got[i].ID != id { + t.Errorf("Digest[%d].ID = %s, want %s", i, got[i].ID, id) + } + } +} + +// retrieveTestPipeline builds a pipeline over a scripted decisions server +// with a temp shadow log and a fixed clock. +func retrieveTestPipeline(t *testing.T, handler func(int, capturedRequest) (int, string)) (*Pipeline, *ShadowLog) { + t.Helper() + d := newDecisionsServer(t, handler) + shadow, err := NewShadowLogAt(filepath.Join(t.TempDir(), "shadow.jsonl")) + if err != nil { + t.Fatal(err) + } + backend := ResolvedBackend{Backend: Backend{Name: "test", URL: d.srv.URL, Model: "jev-latest"}, APIKey: "k"} + p := NewPipeline(backend, "k", shadow, PipelineOptions{}) + now := time.Unix(1700000000, 0).UTC() + p.now = func() time.Time { return now } + return p, shadow +} + +// shadowLines reads the log's JSONL lines, treating a not-yet-created file +// (nothing has ever been appended) as zero lines — readLines fatals on the +// missing file, and "nothing was logged" assertions are exactly about that. +func shadowLines(t *testing.T, l *ShadowLog) []string { + t.Helper() + if _, err := os.Stat(l.Path()); err != nil { + if os.IsNotExist(err) { + return nil + } + t.Fatalf("stat %s: %v", l.Path(), err) + } + return readLines(t, l.Path()) +} + +func retrieveTestStore() *Store { + s := NewStore() + s.PutRecord(Record{ID: "r:a", Text: "original text a", Summary: "a summary", Tokens: 10}) + s.PutRecord(Record{ID: "r:b", Text: "original text b", Summary: "b summary", Tokens: 20}) + s.PutRecord(Record{ID: "r:c", Text: "original text c", Summary: "c summary", Tokens: 30}) + s.PutRecord(Record{ID: "r:d", Text: "original text d", Summary: "d summary", Tokens: 40}) + return s +} + +// TestRetrieve_TopKThresholdTiesAndDecisions pins the core selection: rank +// by score descending, ties by ascending id, take the top k at or above the +// threshold, and log one kind=retrieve decision per scored entry with the +// injected/skipped action. +func TestRetrieve_TopKThresholdTiesAndDecisions(t *testing.T) { + p, shadow := retrieveTestPipeline(t, fixedScoresHandler(map[string]float64{ + "r:a": 0.9, "r:b": 0.9, "r:c": 0.6, "r:d": 0.4, + })) + + records, err := p.Retrieve(context.Background(), "fix the login bug", retrieveTestStore(), 2, 1000, 0.5) + if err != nil { + t.Fatalf("Retrieve() error = %v", err) + } + // a and b tie at 0.9 — ascending ids break the tie; c (0.6) overflows + // k=2; d (0.4) is below the threshold. + if len(records) != 2 { + t.Fatalf("Retrieve returned %d records, want 2", len(records)) + } + if records[0].ID != "r:a" || records[1].ID != "r:b" { + t.Errorf("Retrieve = [%s, %s], want [r:a, r:b] (tie broken by id)", records[0].ID, records[1].ID) + } + if records[0].Text != "original text a" { + t.Errorf("record text = %q, want the FULL original from the store", records[0].Text) + } + + // One kind=retrieve decision per scored entry (all four, not just the + // chosen two). + lines := shadowLines(t, shadow) + if len(lines) != 4 { + t.Fatalf("shadow log has %d lines, want 4 (one decision per scored entry)", len(lines)) + } + wantActions := map[string]string{ + "r:a": RetrieveActionInjected, + "r:b": RetrieveActionInjected, + "r:c": RetrieveActionSkipped, + "r:d": RetrieveActionSkipped, + } + for _, line := range lines { + var e ShadowEntry + if err := json.Unmarshal([]byte(line), &e); err != nil { + t.Fatalf("shadow line invalid: %v", err) + } + if e.Type != EntryTypeDecision { + t.Errorf("type = %q, want %q", e.Type, EntryTypeDecision) + } + if e.Kind != DecisionKindRetrieve { + t.Errorf("kind = %q, want %q", e.Kind, DecisionKindRetrieve) + } + wantAction := wantActions[e.SegmentID] + if wantAction == "" { + t.Errorf("unexpected decision for %q", e.SegmentID) + continue + } + if e.Action != wantAction { + t.Errorf("decision %s action = %q, want %q", e.SegmentID, e.Action, wantAction) + } + if e.Decision != wantAction { + t.Errorf("decision %s decision field = %q, want %q (mirrors the action)", e.SegmentID, e.Decision, wantAction) + } + if e.Threshold != 0.5 { + t.Errorf("decision %s threshold = %v, want 0.5", e.SegmentID, e.Threshold) + } + if e.Score != scoreOf("fix the login bug", e.SegmentID, map[string]float64{ + "r:a": 0.9, "r:b": 0.9, "r:c": 0.6, "r:d": 0.4, + }) { + t.Errorf("decision %s score = %v, want the scripted score", e.SegmentID, e.Score) + } + if e.Tokens <= 0 { + t.Errorf("decision %s tokens = %d, want the record's token count", e.SegmentID, e.Tokens) + } + if e.TaskHash == "" { + t.Errorf("decision %s has an empty task_hash", e.SegmentID) + } + } +} + +// scoreOf looks up a scripted score by id so the table above reads plainly. +func scoreOf(_ string, id string, scores map[string]float64) float64 { + return scores[id] +} + +// TestRetrieve_TaskFramingReachesTheWire: the scoring request's task slot +// carries the ported RETRIEVE_QUESTION wording and the caller's task digest, +// and the items are the digest summaries. +func TestRetrieve_TaskFramingReachesTheWire(t *testing.T) { + var captured capturedRequest + var capturedOnce bool + p, _ := retrieveTestPipeline(t, func(_ int, req capturedRequest) (int, string) { + if !capturedOnce { + captured, capturedOnce = req, true + } + return http.StatusOK, answersBody(map[string]float64{"r:a": 0.9}) + }) + + store := NewStore() + store.PutRecord(Record{ID: "r:a", Text: "text a", Summary: "summary a", Tokens: 5}) + + if _, err := p.Retrieve(context.Background(), "the digest of the task", store, 0, 0, 0); err != nil { + t.Fatalf("Retrieve() error = %v", err) + } + if !capturedOnce { + t.Fatal("the decisions server never received a request") + } + task := captured.Req.State.Task + for _, want := range []string{ + "Is this stored item relevant to the task described in `task` right now?", + "Answer false if it belongs to unrelated work", + "The item is relevant to the current step.", + "The item is not relevant right now.", + "the digest of the task", + } { + if !strings.Contains(task, want) { + t.Errorf("scoring task %q does not contain %q", task, want) + } + } + var scored *stateItem + for i := range captured.Req.State.Items { + if captured.Req.State.Items[i].Ref == "r:a" { + scored = &captured.Req.State.Items[i] + } + } + if scored == nil { + t.Fatal("the request carried no state.items entry for r:a") + } + if scored.Text != "summary a" { + t.Errorf("scored item text = %q, want the digest SUMMARY, not the original", scored.Text) + } +} + +// TestRetrieve_EmptyStoreMakesNoRequest: nothing to score, nothing logged, +// no backend round trip. +func TestRetrieve_EmptyStoreMakesNoRequest(t *testing.T) { + var requests int + p, shadow := retrieveTestPipeline(t, func(_ int, req capturedRequest) (int, string) { + requests++ + return echoHandler(1, req) + }) + + records, err := p.Retrieve(context.Background(), "any task", NewStore(), 0, 0, 0) + if err != nil { + t.Fatalf("Retrieve() error = %v", err) + } + if records != nil { + t.Errorf("Retrieve = %+v, want nil for an empty store", records) + } + if requests != 0 { + t.Errorf("got %d decisions requests, want 0 for an empty store", requests) + } + if lines := shadowLines(t, shadow); len(lines) != 0 { + t.Errorf("shadow log has %d lines, want 0", len(lines)) + } +} + +// TestRetrieve_DefaultsApply: k=0, budget=0, and threshold=0 fall back to +// the reference defaults (5, 24k, 0.5). +func TestRetrieve_DefaultsApply(t *testing.T) { + // Every ref scores a clean 1.0 (a fixed map would leave the unlisted + // refs unanswered — an error, and a failed retrieval injects nothing). + p, _ := retrieveTestPipeline(t, func(_ int, req capturedRequest) (int, string) { + scores := make(map[string]float64, len(req.Req.Questions)) + for ref := range req.Req.Questions { + scores[ref] = 1.0 + } + return http.StatusOK, answersBody(scores) + }) + + store := NewStore() + for i := 1; i <= 7; i++ { + id := "r:0" + string(rune('0'+i)) + store.PutRecord(Record{ID: id, Text: "text", Summary: "s", Tokens: 10}) + } + + // All seven tie at 1.0, ties break by ascending id, and the default + // k=5 caps the selection. + records, err := p.Retrieve(context.Background(), "task", store, 0, 0, 0) + if err != nil { + t.Fatalf("Retrieve() error = %v", err) + } + if len(records) != DefaultRetrieveK { + t.Fatalf("Retrieve returned %d records, want the default k=%d", len(records), DefaultRetrieveK) + } + for i, want := range []string{"r:01", "r:02", "r:03", "r:04", "r:05"} { + if records[i].ID != want { + t.Errorf("records[%d].ID = %s, want %s (ties broken by ascending id)", i, records[i].ID, want) + } + } +} + +// TestRetrieve_NilInputs: a nil store is an empty digest; a nil pipeline is +// an error like every other pipeline entry point. +func TestRetrieve_NilInputs(t *testing.T) { + p, _ := retrieveTestPipeline(t, echoHandler) + records, err := p.Retrieve(context.Background(), "task", nil, 0, 0, 0) + if err != nil { + t.Fatalf("Retrieve(nil store) error = %v, want (nil, nil)", err) + } + if records != nil { + t.Errorf("Retrieve(nil store) = %+v, want nil", records) + } + + var noPipeline *Pipeline + if _, err := noPipeline.Retrieve(context.Background(), "task", NewStore(), 0, 0, 0); err == nil { + t.Error("Retrieve on a nil pipeline error = nil, want an error") + } +} + +// TestRetrieve_AuthDisabledIsSilentlyOff: a pipeline disabled by an earlier +// auth rejection retrieves nothing, touches no backend, and logs nothing. +func TestRetrieve_AuthDisabledIsSilentlyOff(t *testing.T) { + var requests int + p, shadow := retrieveTestPipeline(t, func(_ int, req capturedRequest) (int, string) { + requests++ + return echoHandler(1, req) + }) + p.warnTo = &strings.Builder{} // DisableAuth's one-time warning must not hit stderr in tests + p.DisableAuth("decisions API error (401): bad key") + + records, err := p.Retrieve(context.Background(), "task", retrieveTestStore(), 0, 0, 0) + if err != nil { + t.Fatalf("Retrieve() error = %v, want (nil, nil) while auth-disabled", err) + } + if records != nil { + t.Errorf("Retrieve = %+v, want nil while auth-disabled", records) + } + if requests != 0 { + t.Errorf("got %d requests, want 0 while auth-disabled", requests) + } + if lines := shadowLines(t, shadow); len(lines) != 0 { + t.Errorf("shadow log has %d lines, want 0 while auth-disabled", len(lines)) + } +} + +// TestRetrieve_LiveAuthErrorDisablesSession: an auth rejection during a +// retrieval disables the pipeline's scoring for the session (the shared +// one-warning policy), returns no records, and logs no decisions. +func TestRetrieve_LiveAuthErrorDisablesSession(t *testing.T) { + p, shadow := retrieveTestPipeline(t, func(_ int, _ capturedRequest) (int, string) { + return http.StatusUnauthorized, `{"error": {"message": "bad key"}}` + }) + p.warnTo = &strings.Builder{} // the one-time auth warning must not hit stderr in tests + + records, err := p.Retrieve(context.Background(), "task", retrieveTestStore(), 0, 0, 0) + if err == nil { + t.Fatal("Retrieve() error = nil, want the auth-class failure surfaced") + } + if records != nil { + t.Errorf("Retrieve = %+v, want nil after an auth rejection", records) + } + if lines := shadowLines(t, shadow); len(lines) != 0 { + t.Errorf("shadow log has %d lines, want 0 (nothing was ranked)", len(lines)) + } + // The rejection disabled scoring for the session: a second retrieval is + // silently off. + if _, err := p.Retrieve(context.Background(), "task", retrieveTestStore(), 0, 0, 0); err != nil { + t.Errorf("second Retrieve() error = %v, want the silently-off (nil, nil) path", err) + } +} + +// TestRetrieve_ScoringErrorAbortsTheReadSide: a non-auth scoring failure +// returns no records and logs no decisions — the fail-open direction for +// retrieval is "inject nothing", and an aborted retrieval made no decision +// worth logging. +func TestRetrieve_ScoringErrorAbortsTheReadSide(t *testing.T) { + p, shadow := retrieveTestPipeline(t, func(_ int, _ capturedRequest) (int, string) { + return http.StatusServiceUnavailable, `{"error": {"message": "down"}}` + }) + // Shrink the client's retry curve for a fast test. + shrinkPipelineRetryCurve(p) + + records, err := p.Retrieve(context.Background(), "task", retrieveTestStore(), 0, 0, 0) + if err == nil { + t.Fatal("Retrieve() error = nil, want the outage surfaced") + } + if records != nil { + t.Errorf("Retrieve = %+v, want nil on a scoring failure", records) + } + if lines := shadowLines(t, shadow); len(lines) != 0 { + t.Errorf("shadow log has %d lines, want 0 (the retrieval was aborted, not decided)", len(lines)) + } +} + +// TestRetrieveDecisionsAreNotElideDecisions: kind=retrieve decisions must +// stay out of the elide math — the replay table, the Stats counters, and +// the false-negative ledger model elision, not relevance. +func TestRetrieveDecisionsAreNotElideDecisions(t *testing.T) { + p, shadow := retrieveTestPipeline(t, fixedScoresHandler(map[string]float64{ + "r:a": 0.9, "r:b": 0.1, "r:c": 0.8, "r:d": 0.2, + })) + if _, err := p.Retrieve(context.Background(), "task", retrieveTestStore(), 5, 1000, 0.5); err != nil { + t.Fatalf("Retrieve() error = %v", err) + } + + report, err := shadow.Replay(0.1) + if err != nil { + t.Fatalf("Replay() error = %v", err) + } + if report.Entries != 0 || report.ElidedEntries != 0 { + t.Errorf("Replay() = %+v, want zero entries (retrieve decisions are not elide decisions)", report) + } + + st, err := shadow.Stats() + if err != nil { + t.Fatalf("Stats() error = %v", err) + } + if st.Decisions != 0 || st.ElidedDecisions != 0 { + t.Errorf("Stats() = %+v, want zero decisions (retrieve decisions excluded)", st) + } + + rows, err := shadow.ReplayTable([]float64{0.1}) + if err != nil { + t.Fatalf("ReplayTable() error = %v", err) + } + if rows[0].Kept != 0 || rows[0].Relocated != 0 { + t.Errorf("ReplayTable() = %+v, want an empty row for retrieve-only logs", rows[0]) + } + + // The false-negative ledger stays clean too: a mixed log with one real + // elide decision (later expanded) and one below-threshold retrieve + // decision reports a rate over the ELIDES only — the skipped retrieval + // must not dilute it. + shadow2, err := NewShadowLogAt(filepath.Join(t.TempDir(), "mixed.jsonl")) + if err != nil { + t.Fatal(err) + } + now := time.Unix(1700000000, 0).UTC() + if err := shadow2.Append(ShadowEntry{ + TS: now, SegmentID: "seg-1", Score: 0.1, Decision: DecisionElide, + Threshold: 0.35, Type: EntryTypeDecision, Kind: DecisionKindAdmit, + }); err != nil { + t.Fatal(err) + } + // A retrieve decision whose score sits below its own threshold: without + // the guard it would join the elided set (and never be expanded, + // halving the rate). + if err := shadow2.Append(ShadowEntry{ + TS: now, SegmentID: "r:zz", Score: 0.2, Decision: RetrieveActionSkipped, + Threshold: 0.5, Type: EntryTypeDecision, Kind: DecisionKindRetrieve, + }); err != nil { + t.Fatal(err) + } + if err := shadow2.Append(ShadowEntry{TS: now, Type: EntryTypeExpand, ItemID: "seg-1"}); err != nil { + t.Fatal(err) + } + fnr, err := shadow2.FalseNegativeRate() + if err != nil { + t.Fatalf("FalseNegativeRate() error = %v", err) + } + if fnr != 1.0 { + t.Errorf("FalseNegativeRate() = %v, want 1.0 (the one elided segment was expanded; the skipped retrieval must not dilute)", fnr) + } +} + +// TestRetrievedBlockShape pins the work-area block rendering. +func TestRetrievedBlockShape(t *testing.T) { + records := []Record{ + {ID: "r:a", Text: "first original"}, + {ID: "r:b", Text: "second original"}, + } + got := RetrievedBlock(records) + want := "Retrieved context (scored relevant to the current task):\nfirst original\n---\nsecond original" + if got != want { + t.Errorf("RetrievedBlock =\n%q\nwant\n%q", got, want) + } + if RetrievedBlock(nil) != "" { + t.Error("RetrievedBlock(nil) = non-empty, want empty") + } + if got := RetrievedBlock([]Record{{ID: "r:x", Text: ""}}); got != "" { + t.Errorf("RetrievedBlock of text-less records = %q, want empty", got) + } +} diff --git a/internal/compaction/segment.go b/internal/compaction/segment.go new file mode 100644 index 00000000..8048ad4d --- /dev/null +++ b/internal/compaction/segment.go @@ -0,0 +1,514 @@ +package compaction + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + "unicode/utf8" + + "late/internal/common" +) + +// DefaultMaxSegChars caps one segment's size in bytes. It mirrors the +// jev-compaction default: large enough to keep a paragraph's context together +// for the scorer, small enough that one giant tool dump does not become one +// giant undifferentiated segment. +const DefaultMaxSegChars = 1200 + +// defaultMinSegChars is the size under which a paragraph is "tiny": a tiny +// paragraph is folded into an adjacent larger paragraph instead of being +// scored on its own, so a stray one-liner next to substantial output does +// not become its own segment. Runs of small paragraphs stay separate. +const defaultMinSegChars = 80 + +// Segment is one scored piece of a tool output. +// +// StartByte/EndByte are byte offsets into the ORIGINAL string (EndByte +// exclusive) with the invariant original[StartByte:EndByte] == Text. Each +// span includes the paragraph's trailing blank-line separator when one +// follows it, so the spans tile the input: concatenating the kept segments' +// Text reproduces the original output byte-for-byte minus the elided spans. +// (Blank bytes before the first paragraph belong to no segment.) +type Segment struct { + ID string + Text string + StartByte int + EndByte int + Tokens int + // Kind is the segment's content classification (classifyKind), computed + // at segmentation time. The gate consults it for protected-kind floors: + // stacktrace and diff segments are only elided below their own, much + // lower, floor. + Kind SegmentKind + // LineStart/LineEnd are the 1-based [first, last] line numbers the + // segment's span occupies in the segmented string (the reference's + // line_span) — the numbers a pointer for this segment carries. Computed + // at segmentation time from the byte offsets; 0/0 never occurs for + // segments from SegmentSegments. + LineStart int + LineEnd int + // Group is the 1-based identity of the merged paragraph span this + // segment was cut from: when splitOversized cuts one oversized + // paragraph into several pieces, every piece carries the same Group, so + // the elide decision can keep the paragraph ATOMIC — pieces of one + // paragraph must share a single elide decision (AtomicElideDecisions / + // AtomicDecisionScores), because eliding one piece of a cut paragraph + // corrupts the whole (a JSON blob split mid-structure and partially + // elided is unparseable). Ids are assigned per SegmentSegments call and + // every decision is computed over one call's segments (one tool output, + // one history message), so groups from different calls never mix. 0 + // means ungrouped — segments built outside SegmentSegments (hand-built + // test fixtures) carry no grouping and behave exactly as before this + // field existed: each stands alone. + Group int +} + +// SegmentKind classifies a segment's content so the gate can treat kinds +// differently (the reference pipeline's protected_kinds). The zero value is +// meaningless — SegmentSegments always sets one of the Kind* constants. +type SegmentKind string + +const ( + // KindText is prose: anything that is none of the more specific kinds. + KindText SegmentKind = "text" + // KindJSON is a parseable JSON document (object or array). + KindJSON SegmentKind = "json" + // KindLog is timestamped/severity-prefixed log output. + KindLog SegmentKind = "log" + // KindStacktrace is a panic or exception trace. Protected: traces are + // dense in signal and cheap in tokens, so they survive almost any score. + KindStacktrace SegmentKind = "stacktrace" + // KindTable is pipe-delimited tabular data. + KindTable SegmentKind = "table" + // KindCode is a fenced code block. + KindCode SegmentKind = "code" + // KindDiff is unified-diff output. Protected: a dropped hunk silently + // corrupts everything built on top of it. + KindDiff SegmentKind = "diff" +) + +// Classification patterns. Compiled once; all anchored per trimmed line so +// leading indentation (Java's "\tat com...") and CRLF endings never hide a +// match. +var ( + // stackFrameLineRe matches V8/Java-style frames: "at pkg.File(Tool.go:42)" + // and "at com.example.Foo.bar(Foo.java:99)". + stackFrameLineRe = regexp.MustCompile(`^at .+\(.+:\d+\)`) + // goroutineHeaderRe matches Go panic headers: "goroutine 1 [running]:" + // and "goroutine 17 [signal SIGSEGV: ...]". + goroutineHeaderRe = regexp.MustCompile(`^goroutine \d+ \[`) + // iso8601LogLineRe matches a leading ISO-8601-ish timestamp, with a "T" + // or space separator: "2024-01-02T15:04:05Z", "2024-01-02 15:04:05,123". + iso8601LogLineRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}`) + // bracketedTimeLogLineRe matches syslog-style prefixes: "[12:34:56]". + bracketedTimeLogLineRe = regexp.MustCompile(`^\[\d{1,2}:\d{2}:\d{2}\]`) + // logLevelLineRe matches a leading severity word: "ERROR ", "WARN:", + // "INFO", "DEBUG ...". + logLevelLineRe = regexp.MustCompile(`^(WARN|ERROR|INFO|DEBUG)\b`) +) + +// classifyKind guesses a segment's content kind with cheap, deterministic +// heuristics, most specific first (ported from the reference pipeline): +// +// 1. a fenced ``` code block → code (the fence wins over the fenced +// content's own appearance, which may look like logs or diffs), +// 2. `diff --git` / `+++ ` / `@@ -` lines → diff, +// 3. "Traceback (most recent call last)", exception names, "at f(x.go:1)" +// frames, or "goroutine N [...]" headers → stacktrace, +// 4. trimmed text opening with { or [ that parses as JSON → json (the +// validity check keeps "[TODO] fix the parser" prose out), +// 5. a majority of lines with timestamp or severity prefixes → log, +// 6. a majority of lines pipe rows with one shared column count → table, +// 7. anything else → text. +func classifyKind(text string) SegmentKind { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return KindText + } + lines := strings.Split(trimmed, "\n") + + for _, line := range lines { + if strings.HasPrefix(strings.TrimSpace(line), "```") { + return KindCode + } + } + for _, line := range lines { + t := strings.TrimSpace(line) + if strings.HasPrefix(t, "diff --git") || strings.HasPrefix(t, "+++ ") || strings.HasPrefix(t, "@@ -") { + return KindDiff + } + } + for _, line := range lines { + t := strings.TrimSpace(line) + if strings.Contains(t, "Traceback (most recent call last)") || + strings.Contains(t, "Exception") || + stackFrameLineRe.MatchString(t) || + goroutineHeaderRe.MatchString(t) { + return KindStacktrace + } + } + if trimmed[0] == '{' || trimmed[0] == '[' { + if json.Valid([]byte(trimmed)) { + return KindJSON + } + } + if lineMajority(lines, looksLikeLogLine) { + return KindLog + } + if looksLikeTable(lines) { + return KindTable + } + return KindText +} + +// looksLikeLogLine reports whether one line carries a log timestamp or +// severity prefix. +func looksLikeLogLine(line string) bool { + t := strings.TrimSpace(line) + return iso8601LogLineRe.MatchString(t) || + bracketedTimeLogLineRe.MatchString(t) || + logLevelLineRe.MatchString(t) +} + +// lineMajority reports whether strictly more than half of the non-blank +// lines satisfy match. A majority (not a single hit) keeps one timestamp +// mention inside prose from reclassifying the paragraph around it. +func lineMajority(lines []string, match func(string) bool) bool { + total, hits := 0, 0 + for _, line := range lines { + t := strings.TrimSpace(line) + if t == "" { + continue + } + total++ + if match(t) { + hits++ + } + } + return total > 0 && hits*2 > total +} + +// looksLikeTable reports whether a majority of the non-blank lines are table +// rows — lines containing "|" with one shared column count — and at least two +// such rows exist (a single line mentioning a pipe is prose, not a table). +func looksLikeTable(lines []string) bool { + total, rows, columns := 0, 0, -1 + for _, line := range lines { + t := strings.TrimSpace(line) + if t == "" { + continue + } + total++ + if !strings.Contains(t, "|") { + continue + } + cols := strings.Count(t, "|") + if columns >= 0 && cols != columns { + return false // inconsistent columns: not a table + } + columns = cols + rows++ + } + return rows >= 2 && rows*2 > total +} + +// SegmentSegments splits toolOutput into segments at paragraph boundaries +// (blank lines), merging tiny paragraphs and capping each segment at +// maxSegChars bytes (DefaultMaxSegChars when maxSegChars <= 0). It returns +// nil for empty or whitespace-only output. +func SegmentSegments(toolOutput string, maxSegChars int) []Segment { + if maxSegChars <= 0 { + maxSegChars = DefaultMaxSegChars + } + paras := splitParagraphs(toolOutput) + if len(paras) == 0 { + return nil + } + + var segs []Segment + n := 0 + group := 0 + // newlines counts the '\n' bytes in toolOutput[:cursor]; pieces arrive + // in offset order, so line numbers come from one linear pass. (The + // reference carries the same information as line_span.) + cursor, newlines := 0, 0 + for _, sp := range mergeParagraphs(paras, toolOutput, maxSegChars) { + // One group per merged paragraph span: every piece splitOversized + // cuts from this span shares the id, so the elide decision can treat + // the paragraph as one atomic unit. + group++ + for _, piece := range splitOversized(toolOutput, sp, maxSegChars) { + n++ + text := toolOutput[piece.start:piece.end] + newlines += strings.Count(toolOutput[cursor:piece.start], "\n") + lineStart := newlines + 1 + newlines += strings.Count(toolOutput[piece.start:piece.end], "\n") + // The last byte of the span terminates its line when it is a + // newline; either way the span ends on that line. + lineEnd := newlines + 1 + if toolOutput[piece.end-1] == '\n' { + lineEnd-- + } + cursor = piece.end + segs = append(segs, Segment{ + ID: fmt.Sprintf("seg-%d", n), + Text: text, + StartByte: piece.start, + EndByte: piece.end, + Tokens: common.EstimateTokenCount(text), + Kind: classifyKind(text), + LineStart: lineStart, + LineEnd: lineEnd, + Group: group, + }) + } + } + return segs +} + +// span is a half-open byte range [start, end) into the segmented string. +type span struct { + start int + end int +} + +// splitParagraphs cuts s into paragraphs: maximal runs of non-blank lines. +// A blank line is a line that is empty or whitespace-only (\r\n tolerant). +// The span of a paragraph starts at its first content byte and ends after +// the blank-line separator that follows it (or at EOF for trailing +// whitespace), so consecutive spans tile the string from the first content +// byte onward. +func splitParagraphs(s string) []span { + var out []span + i, n := 0, len(s) + for i < n { + // Skip blank lines (this also skips leading blanks, which belong to + // no paragraph). + i = skipBlankLines(s, i) + if i >= n { + break + } + start := i + // Consume content lines until a blank line or EOF. + for i < n { + lineEnd, next := lineBounds(s, i) + if strings.TrimSpace(s[i:lineEnd]) == "" { + break + } + i = next + } + // Absorb the trailing blank-line separator into this paragraph's + // span so the spans tile the input. + end := i + for i < n { + lineEnd, next := lineBounds(s, i) + if strings.TrimSpace(s[i:lineEnd]) != "" { + break + } + i = next + end = i + } + out = append(out, span{start, end}) + } + return out +} + +// lineBounds returns the end (exclusive, before the '\n') and the start of +// the following line for the line beginning at i. +func lineBounds(s string, i int) (lineEnd, next int) { + if j := strings.IndexByte(s[i:], '\n'); j >= 0 { + return i + j, i + j + 1 + } + return len(s), len(s) +} + +// skipBlankLines advances i past blank lines and returns the new offset. +func skipBlankLines(s string, i int) int { + for i < len(s) { + lineEnd, next := lineBounds(s, i) + if strings.TrimSpace(s[i:lineEnd]) != "" { + return i + } + i = next + } + return i +} + +// mergeParagraphs folds tiny paragraphs into their large neighbors: adjacent +// paragraphs merge when exactly one of them is tiny (smaller than +// defaultMinSegChars), so a stray one-liner next to substantial content does +// not become its own scored segment, while runs of small paragraphs — and +// runs of large ones — keep their separate identities. Merges chain greedily +// from each paragraph and are only taken while the combined span still fits +// maxSegChars. Lengths are measured in bytes including the absorbed +// separators. +func mergeParagraphs(paras []span, s string, maxSegChars int) []span { + if len(paras) <= 1 { + return paras + } + tiny := func(sp span) bool { return sp.end-sp.start < defaultMinSegChars } + merged := make([]span, 0, len(paras)) + i := 0 + for i < len(paras) { + // Grow the group starting at i across adjacent paragraphs: each step + // straddles a tiny/large boundary and must keep the combined span + // within the cap. + j := i + 1 + for j < len(paras) && + tiny(paras[j-1]) != tiny(paras[j]) && + len(s[paras[i].start:paras[j].end]) <= maxSegChars { + j++ + } + merged = append(merged, span{paras[i].start, paras[j-1].end}) + i = j + } + return merged +} + +// splitOversized cuts a merged span longer than maxSegChars into pieces of at +// most maxSegChars bytes, cutting at the last newline inside the window when +// one exists and otherwise at a rune-safe hard boundary. +func splitOversized(s string, sp span, maxSegChars int) []span { + if sp.end-sp.start <= maxSegChars { + return []span{sp} + } + var out []span + start := sp.start + for start < sp.end { + if sp.end-start <= maxSegChars { + out = append(out, span{start, sp.end}) + break + } + window := start + maxSegChars + cut := window + if idx := strings.LastIndexByte(s[start:window], '\n'); idx >= 0 { + // Keep the newline with the left piece. + cut = start + idx + 1 + } else { + // Hard cut that never splits a multi-byte rune. + for cut > start && !utf8.RuneStart(s[cut]) { + cut-- + } + } + if cut <= start { + cut = start + maxSegChars // paranoia; cannot happen + } + out = append(out, span{start, cut}) + start = cut + } + return out +} + +// AtomicElideDecisions computes per-segment elide decisions with paragraph +// atomicity: pieces cut from the same oversized paragraph (equal nonzero +// Group — the pieces splitOversized produced) share ONE decision, made on +// the paragraph's MINIMUM sibling score against the MINIMUM sibling floor — +// unless a sibling sits at the unelidable score ceiling, which keeps the +// whole paragraph (see AtomicDecisionScores). See AtomicDecisionScores for +// the exact decision inputs; this returns just the flags. A mismatched +// input triple yields nil (a caller bug). +func AtomicElideDecisions(segs []Segment, scores, floors []float64) []bool { + decide, decFloors := AtomicDecisionScores(segs, scores, floors) + if decide == nil { + return nil + } + elide := make([]bool, len(segs)) + for i := range segs { + elide[i] = decide[i] < decFloors[i] + } + return elide +} + +// AtomicDecisionScores returns, per segment, the score and floor its elide +// decision turns on, with paragraph atomicity: segments cut from the same +// oversized paragraph (equal nonzero Group — the pieces splitOversized +// produced) decide as ONE unit, on the paragraph's MINIMUM sibling score +// against the MINIMUM sibling floor. +// +// Why: oversized single paragraphs are hard-cut by splitOversized into +// multiple segment pieces; eliding ONE piece of a cut paragraph corrupts the +// whole — a JSON blob split mid-structure and partially elided is +// unparseable, and a prose paragraph loses its middle. So a cut paragraph is +// decided as a unit: one low sibling (the minimum sibling score is the +// binding one) elides the WHOLE paragraph — the run's original is stored +// whole and Reconstruct restores it byte for byte — while siblings above +// their floor keep the paragraph fully. +// +// scores[i] is segment i's score (the caller applies any origin protection +// first), floors[i] its elide floor (the gate's protected-kind floor when +// the kind has one, else the keep threshold; a flat threshold for callers +// without a gate). The returned slices are fresh; ungrouped segments +// (Group 0) decide on their own score and floor. For groups whose floors are +// all equal — the overwhelmingly common case, pieces of one paragraph +// sharing their content kind — the decision reduces exactly to +// min-score < floor. The decision scores returned here are also what the +// shadow log records, so score-vs-threshold replay reproduces the recorded +// decisions. A mismatched input triple yields (nil, nil) — a caller bug. +// +// Unelidable pin: protection overrides the minimum. A sibling whose score +// sits at unelidableScore (1.0) — a protected origin's clamped score +// (protectedScore), or the scorer's fail-open keep score — can never be +// elided at any gate setting, and the paragraph decides as one unit, so a +// group holding such a sibling is PINNED to keep: every piece's decision +// score becomes unelidableScore (its floor stays the group minimum). The +// naive alternative — letting the min-floor rule carry protection — would +// invert it: min(1.0, 0.35) = 0.35 drops the protection floor and the +// protected piece would elide with its low-scoring sibling. Pinning the +// decision score (not the floor) is what keeps the shadow log and every +// replay read consistent: 1.0 is never strictly below a floor in [0, 1], so +// recorded decisions, Stats, FalseNegativeRate, and ReplayTable all +// reproduce the keep a mutating run would make. +func AtomicDecisionScores(segs []Segment, scores, floors []float64) (decide, decFloors []float64) { + if len(segs) != len(scores) || len(segs) != len(floors) { + return nil, nil // defensive: a mismatched triple is a caller bug + } + decide = append([]float64(nil), scores...) + decFloors = append([]float64(nil), floors...) + + // Group reduce: minimum score and minimum floor per paragraph, plus the + // unelidable pin — any sibling at the unelidable score ceiling (a + // protected origin's clamped score, or the fail-open keep score) pins + // the whole group to keep. + type minima struct{ score, floor float64 } + groups := make(map[int]*minima) + pinned := make(map[int]bool) + for i, seg := range segs { + if seg.Group <= 0 { + continue + } + if scores[i] >= unelidableScore { + pinned[seg.Group] = true + } + m, ok := groups[seg.Group] + if !ok { + m = &minima{score: decide[i], floor: decFloors[i]} + groups[seg.Group] = m + continue + } + if decide[i] < m.score { + m.score = decide[i] + } + if decFloors[i] < m.floor { + m.floor = decFloors[i] + } + } + for i, seg := range segs { + if seg.Group <= 0 { + continue + } + m := groups[seg.Group] + decide[i] = m.score + decFloors[i] = m.floor + if pinned[seg.Group] { + // Protection is absolute and the paragraph decides as one + // unit, so the unit keeps. Recording the ceiling as the + // decision score — never the raw group minimum — keeps every + // score-vs-floor read (the recorded decision, Stats, + // FalseNegativeRate, ReplayTable) reproducing the keep a + // mutating run would make. + decide[i] = unelidableScore + } + } + return decide, decFloors +} diff --git a/internal/compaction/segment_test.go b/internal/compaction/segment_test.go new file mode 100644 index 00000000..17a3fe17 --- /dev/null +++ b/internal/compaction/segment_test.go @@ -0,0 +1,423 @@ +package compaction + +import ( + "fmt" + "strings" + "testing" +) + +// assertSegmentsInvariant checks the Segment contract: original[s.StartByte: +// s.EndByte] == s.Text for every segment, plus tokens, ID numbering, and the +// 1-based line span recomputed from the byte offsets. +func assertSegmentsInvariant(t *testing.T, original string, segs []Segment) { + t.Helper() + for i, s := range segs { + if got := original[s.StartByte:s.EndByte]; got != s.Text { + t.Errorf("segment %d (%s): original[%d:%d] = %q, want Text %q", i, s.ID, s.StartByte, s.EndByte, got, s.Text) + } + if s.EndByte < s.StartByte { + t.Errorf("segment %d (%s): EndByte %d < StartByte %d", i, s.ID, s.EndByte, s.StartByte) + } + if s.Tokens <= 0 { + t.Errorf("segment %d (%s): Tokens = %d, want > 0 for non-empty text", i, s.ID, s.Tokens) + } + if want := fmt.Sprintf("seg-%d", i+1); s.ID != want { + t.Errorf("segment %d ID = %q, want %q", i, s.ID, want) + } + if want := 1 + strings.Count(original[:s.StartByte], "\n"); s.LineStart != want { + t.Errorf("segment %d (%s): LineStart = %d, want %d (recomputed from StartByte)", i, s.ID, s.LineStart, want) + } + wantEnd := 1 + strings.Count(original[:s.EndByte], "\n") + if s.EndByte > 0 && original[s.EndByte-1] == '\n' { + wantEnd-- + } + if s.LineEnd != wantEnd { + t.Errorf("segment %d (%s): LineEnd = %d, want %d (recomputed from EndByte)", i, s.ID, s.LineEnd, wantEnd) + } + if s.LineStart > s.LineEnd { + t.Errorf("segment %d (%s): LineStart %d > LineEnd %d", i, s.ID, s.LineStart, s.LineEnd) + } + } + for i := 1; i < len(segs); i++ { + if segs[i].StartByte < segs[i-1].EndByte { + t.Errorf("segment %d starts at %d before segment %d ends at %d (overlapping spans)", + i, segs[i].StartByte, i-1, segs[i-1].EndByte) + } + } +} + +func TestSegmentSegments_ParagraphSplitting(t *testing.T) { + out := "first paragraph\n\nsecond paragraph\n\nthird paragraph" + segs := SegmentSegments(out, 0) + if len(segs) != 3 { + t.Fatalf("got %d segments, want 3: %+v", len(segs), segs) + } + for i, want := range []string{"first paragraph\n\n", "second paragraph\n\n", "third paragraph"} { + if segs[i].Text != want { + t.Errorf("segment %d Text = %q, want %q (trailing separator included)", i, segs[i].Text, want) + } + } + assertSegmentsInvariant(t, out, segs) + + // The spans tile the input from the first content byte: concatenating + // every segment reproduces the output. + var joined strings.Builder + for _, s := range segs { + joined.WriteString(s.Text) + } + if joined.String() != out { + t.Errorf("concatenated segments = %q, want original %q", joined.String(), out) + } +} + +// TestSegmentSegments_LineSpans pins the 1-based line spans the pointer +// format needs (the reference's line_span): each segment covers the lines +// its span occupies, blank-line separators included. +func TestSegmentSegments_LineSpans(t *testing.T) { + out := "first paragraph\n\nsecond paragraph\n\nthird paragraph" + segs := SegmentSegments(out, 0) + if len(segs) != 3 { + t.Fatalf("got %d segments, want 3", len(segs)) + } + for i, want := range [][2]int{{1, 2}, {3, 4}, {5, 5}} { + if got := [2]int{segs[i].LineStart, segs[i].LineEnd}; got != want { + t.Errorf("segment %d line span = %v, want %v", i, got, want) + } + } + assertSegmentsInvariant(t, out, segs) + + // A hard rune-safe cut mid-line (no newline inside the cap window) still + // lands both pieces on the same line. + long := strings.Repeat("a", 1500) + segs = SegmentSegments(long, 0) + if len(segs) < 2 { + t.Fatalf("got %d segments, want the oversized split", len(segs)) + } + for i, s := range segs { + if got := [2]int{s.LineStart, s.LineEnd}; got != [2]int{1, 1} { + t.Errorf("piece %d line span = %v, want [1 1] (single-line output)", i, got) + } + } + assertSegmentsInvariant(t, long, segs) +} + +func TestSegmentSegments_TinyParagraphsMerged(t *testing.T) { + // A tiny paragraph between two larger ones is absorbed forward. + out := strings.Repeat("x", 100) + "\n\ntiny\n\n" + strings.Repeat("y", 100) + segs := SegmentSegments(out, 0) + if len(segs) != 1 { + t.Fatalf("got %d segments, want 1 (tiny paragraph merged forward)", len(segs)) + } + + // A trailing tiny paragraph is folded backward into the previous one. + out = strings.Repeat("x", 100) + "\n\ntiny" + segs = SegmentSegments(out, 0) + if len(segs) != 1 { + t.Fatalf("got %d segments, want 1 (trailing tiny paragraph folded back)", len(segs)) + } + if segs[0].Text != out { + t.Errorf("merged Text = %q, want the full output %q", segs[0].Text, out) + } + assertSegmentsInvariant(t, out, segs) +} + +func TestSegmentSegments_TinyParagraphNotMergedWhenTooBig(t *testing.T) { + // Merging would blow the cap, so the paragraphs stay separate: 1195 + // content bytes + the 2-byte separator + the 6-byte tiny paragraph + // exceeds the 1200-byte cap. + out := strings.Repeat("x", 1195) + "\n\ntiny" + segs := SegmentSegments(out, 0) + if len(segs) != 2 { + t.Fatalf("got %d segments, want 2 (merge would exceed the cap)", len(segs)) + } + assertSegmentsInvariant(t, out, segs) +} + +func TestSegmentSegments_SizeCapSplitsLargeParagraphs(t *testing.T) { + // One giant paragraph with newlines inside: splits at newlines within + // the 1200-byte window. + var lines []string + for i := 0; i < 40; i++ { + lines = append(lines, strings.Repeat(fmt.Sprint(i%10), 100)) + } + out := strings.Join(lines, "\n") // 40*100 + 39 = 4039 bytes + segs := SegmentSegments(out, 0) + if len(segs) < 3 { + t.Fatalf("got %d segments, want ≥3 for a %d-byte paragraph under the %d-byte cap", + len(segs), len(out), DefaultMaxSegChars) + } + for i, s := range segs { + if len(s.Text) > DefaultMaxSegChars { + t.Errorf("segment %d is %d bytes, want ≤%d", i, len(s.Text), DefaultMaxSegChars) + } + } + assertSegmentsInvariant(t, out, segs) + + // Rune-safe hard splitting: a paragraph with no newlines at all. + out = strings.Repeat("é", 3000) // 6000 bytes of 2-byte runes + segs = SegmentSegments(out, 0) + if len(segs) != 5 { // 6000 bytes / 1200 = 5 exactly (rune-aligned cap) + t.Fatalf("got %d segments, want 5", len(segs)) + } + var joined strings.Builder + for _, s := range segs { + if len(s.Text) > DefaultMaxSegChars { + t.Errorf("segment %s is %d bytes, want ≤%d", s.ID, len(s.Text), DefaultMaxSegChars) + } + joined.WriteString(s.Text) + } + if joined.String() != out { + t.Error("hard-split pieces do not reproduce the original output") + } + assertSegmentsInvariant(t, out, segs) +} + +func TestSegmentSegments_ExplicitMaxSegChars(t *testing.T) { + out := strings.Repeat("a", 500) + if segs := SegmentSegments(out, 0); len(segs) != 1 { + t.Fatalf("got %d segments, want 1 under the default cap", len(segs)) + } + if segs := SegmentSegments(out, 200); len(segs) != 3 { + t.Fatalf("got %d segments, want 3 under a 200-byte cap", len(segs)) + } +} + +func TestSegmentSegments_DefaultCapMatchesConstant(t *testing.T) { + out := strings.Repeat("a", 3000) + a := SegmentSegments(out, 0) + b := SegmentSegments(out, DefaultMaxSegChars) + if len(a) != len(b) { + t.Fatalf("maxSegChars 0 gave %d segments, explicit %d gave %d — 0 must mean the default", + len(a), DefaultMaxSegChars, len(b)) + } + for i := range a { + if a[i].Text != b[i].Text { + t.Errorf("segment %d differs between 0 and the default cap", i) + } + } +} + +func TestSegmentSegments_EmptyAndWhitespace(t *testing.T) { + for _, in := range []string{"", "\n\n\n", " \n\t\n \n"} { + if segs := SegmentSegments(in, 0); segs != nil { + t.Errorf("SegmentSegments(%q) = %+v, want nil", in, segs) + } + } +} + +func TestSegmentSegments_LeadingBlanksAndCRLF(t *testing.T) { + // Leading blank lines belong to no segment; CRLF blank lines still + // separate paragraphs. + out := "\n\n\r\nfirst\r\n\n\r\nsecond\n" + segs := SegmentSegments(out, 0) + if len(segs) != 2 { + t.Fatalf("got %d segments, want 2: %+v", len(segs), segs) + } + if segs[0].Text != "first\r\n\n\r\n" { + t.Errorf("segment 0 Text = %q, want %q", segs[0].Text, "first\r\n\n\r\n") + } + if segs[1].Text != "second\n" { + t.Errorf("segment 1 Text = %q, want %q", segs[1].Text, "second\n") + } + assertSegmentsInvariant(t, out, segs) +} + +func TestSegmentSegments_WhitespaceOnlyLinesDoNotSplitParagraphs(t *testing.T) { + // A line holding only spaces is blank (a separator), not content. + out := "alpha\n \nbeta" + segs := SegmentSegments(out, 0) + if len(segs) != 2 { + t.Fatalf("got %d segments, want 2 (whitespace-only line is a separator)", len(segs)) + } + assertSegmentsInvariant(t, out, segs) +} + +func TestSegmentSegments_MultiByteOffsets(t *testing.T) { + // Byte offsets must stay byte-based (not rune-based) with multibyte + // content before the split point. + out := "日本語のテキスト\n\nsecond paragraph with ascii" + segs := SegmentSegments(out, 0) + if len(segs) != 2 { + t.Fatalf("got %d segments, want 2", len(segs)) + } + assertSegmentsInvariant(t, out, segs) + if segs[0].StartByte != 0 || segs[0].EndByte != len("日本語のテキスト\n\n") { + t.Errorf("segment 0 span = [%d,%d), want [0,%d)", segs[0].StartByte, segs[0].EndByte, len("日本語のテキスト\n\n")) + } +} + +// TestClassifyKind pins the kind heuristics, most specific first. Each case +// is one deterministic classification of a whole segment's text. +func TestClassifyKind(t *testing.T) { + cases := []struct { + name string + text string + want SegmentKind + }{ + {name: "empty is text", text: "", want: KindText}, + {name: "prose is text", text: "The parser accepts three input formats and normalizes them before scoring.", want: KindText}, + { + name: "fenced code block is code", + text: "```go\nfmt.Println(\"hi\")\n```", + want: KindCode, + }, + { + name: "fence beats diff markers inside it", + text: "```diff\n+++ b/x.go\n@@ -1 +1 @@\n```", + want: KindCode, + }, + { + name: "diff header is diff", + text: "diff --git a/x.go b/x.go\nindex 1234..5678 100644\n--- a/x.go\n+++ b/x.go", + want: KindDiff, + }, + { + name: "plus-plus prefix is diff", + text: "+++ b/internal/x.go\n@@ -1 +1 @@", + want: KindDiff, + }, + { + name: "hunk header is diff", + text: "@@ -12,7 +12,9 @@ func main() {", + want: KindDiff, + }, + { + name: "go panic header is stacktrace", + text: "goroutine 1 [running]:\nmain.main()\n\t/home/dev/app/main.go:42 +0x1a4", + want: KindStacktrace, + }, + { + name: "python traceback is stacktrace", + text: "Traceback (most recent call last):\n File \"x.py\", line 3, in ", + want: KindStacktrace, + }, + { + name: "java frame with leading tab is stacktrace", + text: "\tat com.example.Foo.bar(Foo.java:99)\nat com.example.Foo.baz(Foo.java:10)", + want: KindStacktrace, + }, + { + name: "exception name is stacktrace", + text: "java.lang.NullPointerException: cannot invoke method on null", + want: KindStacktrace, + }, + { + name: "json object is json", + text: `{"ok": true, "items": [1, 2, 3]}`, + want: KindJSON, + }, + { + name: "json array is json", + text: `[{"id": 1}, {"id": 2}]`, + want: KindJSON, + }, + { + name: "bracketed prose is not json", + text: "[TODO] fix the parser before the next release", + want: KindText, + }, + { + name: "broken json is not json", + text: `{"ok": true, "trailing":`, + want: KindText, + }, + { + name: "iso timestamps are log", + text: "2024-01-02T15:04:05Z INFO boot\n2024-01-02T15:04:06Z ERROR fail\n2024-01-02T15:04:07Z INFO ready", + want: KindLog, + }, + { + name: "space-separated timestamps are log", + text: "2024-01-02 15:04:05 starting import\n2024-01-02 15:04:06 import done", + want: KindLog, + }, + { + name: "bracketed times are log", + text: "[12:00:00] boot ok\n[12:00:01] ready\n[12:00:02] done", + want: KindLog, + }, + { + name: "severity prefixes are log", + text: "WARN disk nearly full\nERROR write failed\nINFO retrying", + want: KindLog, + }, + { + name: "one timestamp mention in prose is not a log", + text: "The deploy finished at 2024-01-02 15:04:05 sharp, and everyone celebrated.", + want: KindText, + }, + { + name: "pipe rows with consistent columns are table", + text: "name | count\nalpha | 2\nbeta | 3", + want: KindTable, + }, + { + name: "inconsistent columns are not a table", + text: "name | count\nalpha | 2 | extra\nbeta | 3", + want: KindText, + }, + { + name: "a single pipe line is not a table", + text: "alpha | beta", + want: KindText, + }, + { + name: "prose mentioning a pipe is not a table", + text: "Use a | sparingly.\nOr not at all.\nReally, don't.", + want: KindText, + }, + { + name: "diff beats exception-looking content", + text: "+++ b/x.py\n@@ -1 +1 @@\n-raise Exception('x')", + want: KindDiff, + }, + { + name: "stacktrace beats table", + text: "goroutine 1 [running]:\nmain.a()\nmain.b() | frame", + want: KindStacktrace, + }, + { + name: "log beats table", + text: "2024-01-02T15:04:05Z INFO a | b\n2024-01-02T15:04:06Z INFO c | d", + want: KindLog, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := classifyKind(tc.text); got != tc.want { + t.Errorf("classifyKind(%q) = %q, want %q", tc.text, got, tc.want) + } + }) + } +} + +// TestSegmentSegments_KindClassification checks that segmentation stamps +// every segment with its kind while leaving the byte-offset contract +// untouched. +func TestSegmentSegments_KindClassification(t *testing.T) { + prose := strings.Repeat("plain prose. ", 8) // 104 chars: over the tiny floor + var tableRows []string + for i := 0; i < 6; i++ { + tableRows = append(tableRows, fmt.Sprintf("item-%02d | count-%02d | note", i, i)) + } + table := strings.Join(tableRows, "\n") + stack := "goroutine 1 [running]:\nmain.main()\n\t/home/dev/app/main.go:42 +0x1a4\nexit status 2" + // Over the 80-char tiny-paragraph floor so it stays its own segment. + diff := "diff --git a/main.go b/main.go\nindex 1234..5678 100644\n--- a/main.go\n+++ b/main.go\n@@ -1,3 +1,4 @@" + // Both over the 80-char tiny-paragraph floor so each stays its own segment. + js := `{"ok": true, "items": [1, 2, 3], "note": "padding padding padding padding padding"}` + + out := strings.Join([]string{prose, table, stack, diff, js}, "\n\n") + segs := SegmentSegments(out, 0) + if len(segs) != 5 { + t.Fatalf("got %d segments, want 5: %+v", len(segs), segs) + } + wantKinds := []SegmentKind{KindText, KindTable, KindStacktrace, KindDiff, KindJSON} + for i, want := range wantKinds { + if segs[i].Kind != want { + t.Errorf("segment %d Kind = %q, want %q (text %q)", i, segs[i].Kind, want, segs[i].Text) + } + } + assertSegmentsInvariant(t, out, segs) +} diff --git a/internal/compaction/shadow.go b/internal/compaction/shadow.go new file mode 100644 index 00000000..3bcd23f3 --- /dev/null +++ b/internal/compaction/shadow.go @@ -0,0 +1,529 @@ +package compaction + +import ( + "bufio" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "late/internal/pathutil" +) + +// DecisionKeep is the only decision the shadow-only stage records: every +// scored segment is kept; elision comes with the relocation stage. +const DecisionKeep = "keep" + +// Entry types (ShadowEntry.Type). An empty Type is the legacy spelling of +// EntryTypeDecision: every line written before the field existed is a +// per-segment decision. +const ( + EntryTypeDecision = "decision" + EntryTypeHistoryRun = "history-run" + // EntryTypeTripwire marks a gate-tripwire override: the scorer wanted to + // elide past GateConfig.MaxElideFraction, so nothing was elided. The + // entry carries Action "tripwire" and the output's total token count; + // it is not a per-segment decision and Replay skips it. + EntryTypeTripwire = "tripwire" + // EntryTypeExpand marks an expand outcome: a stored record whose original + // the agent fetched back through the expand tool. Every expand is a + // recorded false negative of the elision decision that relocated the + // record's content — the input to FalseNegativeRate and the replay + // table's still-missed column. One outcome names the record id (ItemID); + // one outcome per contributing segment id accompanies it (the reference + // pipeline.py expand() attribution). + EntryTypeExpand = "expand" + // EntryTypeHit marks a hit outcome: a record whose content was used + // after retrieval (the reference store's mark_hits). ItemID names the + // record or segment the hit attributes to. + EntryTypeHit = "hit" +) + +// DecisionKindAdmit and DecisionKindRetrieve are the ShadowEntry.Kind +// decision classes (the reference shadow.py DecisionKind): "admit" — the +// per-segment elision decisions the scoring pipeline records — and +// "retrieve" — the store read side's injected/skipped decisions (Step 17). +const ( + DecisionKindAdmit = "admit" + DecisionKindRetrieve = "retrieve" +) + +// ShadowEntry is one JSONL line in the shadow log: a single scored segment +// at a single decision point — an outcome line (Type "expand"/"hit") naming +// the record or segment it attributes to via ItemID — or, with Type +// "history-run", a whole history-compaction run's summary (then only TS, +// TaskHash and Run carry data). The raw task text never reaches the log — +// only its TaskHash digest does. +type ShadowEntry struct { + TS time.Time `json:"ts"` + TaskHash string `json:"task_hash"` + SegmentID string `json:"segment_id"` + Tokens int `json:"tokens"` + Score float64 `json:"score"` + Decision string `json:"decision"` + // Type is the entry kind; empty means a per-segment decision (legacy + // lines predate the field). + Type string `json:"type,omitempty"` + // Action is the machine-readable action carried by non-decision entries + // ("tripwire" on Type "tripwire") and by kind=retrieve decisions + // ("injected"/"skipped"). Admit decisions leave it empty — their + // Decision field carries the action. Additive: older logs simply + // lack the field. + Action string `json:"action,omitempty"` + // Kind is the decision class: "admit" for the per-segment elision + // decisions the pipeline makes, "retrieve" for the store read side's + // decisions (Step 17). Empty on legacy lines (written before the field + // existed); readers treat empty as "admit". Additive: older logs simply + // lack the field. + Kind string `json:"kind,omitempty"` + // Threshold is the score floor the decision was made against — the gate + // floor in force at decision time (the protected-kind floor for + // protected kinds, else the relocation threshold). Stats, + // FalseNegativeRate and ReplayTable re-run the recorded decision from + // score vs threshold without re-scoring. Additive: legacy lines predate + // the field (0 = no threshold was in force). + Threshold float64 `json:"threshold,omitempty"` + // ItemID is the record or segment id an OUTCOME entry (Type "expand" or + // "hit") attributes to; decision entries carry SegmentID instead. + // Additive. + ItemID string `json:"item_id,omitempty"` + // Turn is the conversation turn the outcome happened in; 0 while turn + // plumbing does not exist anywhere in the port. Additive. + Turn int `json:"turn,omitempty"` + // Run carries the run totals for Type "history-run" entries. + Run *RunSummary `json:"run,omitempty"` +} + +// isDecision reports whether the entry is a per-segment decision: an +// explicit Type "decision" or empty Type (the legacy spelling). Outcomes, +// tripwire overrides, and run summaries are not. +func (e ShadowEntry) isDecision() bool { + return e.Type == "" || e.Type == EntryTypeDecision +} + +// isElideDecision reports whether the entry is a per-segment ELISION +// decision — the kind the replay table, the Stats elide counters, and the +// false-negative ledger are built on. Retrieve decisions (Kind "retrieve") +// are decisions too, but their score means "relevant to the task right +// now", not "essential enough to keep": counting them as elides would pollute +// the false-negative rate with records the read side skipped on purpose and +// would replay injected/skipped through kept/elided vocabulary. The Go +// table models the elide axis only (the reference's _counterfactual maps +// retrieve kinds onto injected/skipped — a separate axis here). Empty Kind +// is admit: every legacy line behaves exactly as before this field existed. +func (e ShadowEntry) isElideDecision() bool { + return e.isDecision() && e.Kind != DecisionKindRetrieve +} + +// RunSummary is the per-run totals of one history-compaction pass — the +// numbers the TUI status line reports, persisted so runs are auditable +// alongside the per-segment decisions they produced. +type RunSummary struct { + Shadow bool `json:"shadow"` + Scanned int `json:"scanned"` + Scored int `json:"scored"` + Elided int `json:"elided"` + TokensBefore int `json:"tokens_before"` + TokensAfter int `json:"tokens_after"` + TokensSaved int `json:"tokens_saved"` + Err string `json:"error,omitempty"` +} + +// ReplayReport summarizes what WOULD have been elided at a score threshold. +// The counts are entry-based; miss-risk — whether eliding a segment would +// actually have lost information the agent needed — is computable from the +// expand/hit outcome lines once they exist: see FalseNegativeRate and +// ReplayTable (whose StillMissed column is built on them). +type ReplayReport struct { + Threshold float64 `json:"threshold"` + Entries int `json:"entries"` // decision lines read + UniqueSegments int `json:"unique_segments"` // distinct segment_ids + ElidedEntries int `json:"elided_entries"` // decisions scoring below the threshold + ElidedSegments int `json:"elided_segments"` // distinct segment_ids elided at least once + TokensTotal int `json:"tokens_total"` // tokens across all entries + TokensElided int `json:"tokens_elided"` // tokens on elided entries + MalformedLines int `json:"malformed_lines"` // lines that failed to parse +} + +// HashTask returns the short SHA-256 hex digest used as task_hash in the +// shadow log (16 hex chars — enough to group decisions by task without +// leaking the task text). +func HashTask(task string) string { + sum := sha256.Sum256([]byte(task)) + return hex.EncodeToString(sum[:8]) +} + +// ShadowLog is a JSONL appender for decision records. Appends are +// goroutine-safe (mutex) and crash-atomic per line (one Write call on an +// O_APPEND descriptor, so concurrent late processes interleave whole lines). +type ShadowLog struct { + path string + mu sync.Mutex +} + +// DefaultShadowPath returns the shadow log location: +// ~/.local/share/late/compaction-shadow.jsonl, resolved through +// pathutil.LateDataDir (Windows keeps everything under the config dir). +func DefaultShadowPath() (string, error) { + dir, err := pathutil.LateDataDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "compaction-shadow.jsonl"), nil +} + +// NewShadowLog opens (creating parent directories 0700) the default shadow +// log at DefaultShadowPath. +func NewShadowLog() (*ShadowLog, error) { + p, err := DefaultShadowPath() + if err != nil { + return nil, err + } + return NewShadowLogAt(p) +} + +// NewShadowLogAt opens the shadow log at path, creating parent directories +// with 0700 (the log file itself is created 0600 on first append). +func NewShadowLogAt(path string) (*ShadowLog, error) { + if path == "" { + return nil, fmt.Errorf("compaction: shadow log path is empty") + } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("compaction: create shadow log dir %s: %w", dir, err) + } + return &ShadowLog{path: path}, nil +} + +// Path returns the log file path. +func (l *ShadowLog) Path() string { return l.path } + +// Append writes one shadow-log line. Zero fields are defaulted: a zero TS +// becomes time.Now() and a decision entry's empty Decision becomes +// DecisionKeep — outcomes (Type "expand"/"hit") and run summaries carry no +// decision and are never defaulted. The entry is serialized first so a +// marshal failure cannot leave a torn line behind. +func (l *ShadowLog) Append(e ShadowEntry) error { + if e.Decision == "" && e.isDecision() { + e.Decision = DecisionKeep + } + if e.TS.IsZero() { + e.TS = time.Now() + } + line, err := json.Marshal(e) + if err != nil { + return fmt.Errorf("compaction: encode shadow entry: %w", err) + } + line = append(line, '\n') + + l.mu.Lock() + defer l.mu.Unlock() + f, err := os.OpenFile(l.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return fmt.Errorf("compaction: open shadow log %s: %w", l.path, err) + } + defer f.Close() + if _, err := f.Write(line); err != nil { + return fmt.Errorf("compaction: append shadow log %s: %w", l.path, err) + } + return nil +} + +// AppendRun writes one history-compaction run summary line (Type +// "history-run") carrying run's totals grouped under taskHash. It goes +// through the same crash-atomic append path as decisions; Replay skips +// these lines so run summaries never count as decisions. +func (l *ShadowLog) AppendRun(taskHash string, run RunSummary) error { + return l.Append(ShadowEntry{ + TaskHash: taskHash, + Type: EntryTypeHistoryRun, + Run: &run, + }) +} + +// AppendOutcome writes one outcome line (Type "expand" or "hit") naming the +// item it attributes to: the record id itself for the record-level outcome, +// plus one outcome per contributing segment id — the reference pipeline.py +// expand() attribution that ties every expand back to the score decisions +// that caused the elision. kind must be one of the two outcome types; +// turn is the conversation turn (0 while turn plumbing does not exist). +// Outcomes go through the same crash-atomic append path as decisions; +// FalseNegativeRate and the replay table's still-missed column are built on +// them. +func (l *ShadowLog) AppendOutcome(kind, itemID string, turn int) error { + if kind != EntryTypeExpand && kind != EntryTypeHit { + return fmt.Errorf("compaction: unknown outcome kind %q", kind) + } + if itemID == "" { + return fmt.Errorf("compaction: outcome needs an item id") + } + return l.Append(ShadowEntry{ + Type: kind, + ItemID: itemID, + Turn: turn, + }) +} + +// readEntries parses the whole log into entries in file order — appends are +// serialized whole lines, so file order is chronological. Blank lines are +// skipped; malformed lines (the torn residue of a crash mid-append) are +// counted in the second return value and skipped. A missing log file is +// (nil, 0, nil): nothing has been scored yet, not an error. +func (l *ShadowLog) readEntries() ([]ShadowEntry, int, error) { + l.mu.Lock() + defer l.mu.Unlock() + f, err := os.Open(l.path) + if err != nil { + if os.IsNotExist(err) { + return nil, 0, nil + } + return nil, 0, fmt.Errorf("compaction: open shadow log %s: %w", l.path, err) + } + defer f.Close() + + var ( + entries []ShadowEntry + malformed int + ) + sc := bufio.NewScanner(f) + // Segment IDs are short but the lines carry no payload text; a 4 MiB + // cap is purely defensive against a corrupted or hostile log. + sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + var e ShadowEntry + if err := json.Unmarshal([]byte(line), &e); err != nil { + malformed++ + continue + } + entries = append(entries, e) + } + if err := sc.Err(); err != nil { + return nil, malformed, fmt.Errorf("compaction: read shadow log %s: %w", l.path, err) + } + return entries, malformed, nil +} + +// Replay reads the whole log and reports what WOULD be elided at threshold: +// every decision whose score is strictly below threshold counts as elided. +// History-run summaries, tripwire records, and outcome lines are not +// decisions — they carry no segment score — so they are skipped, not counted +// as malformed. A missing log file is an empty report, not an error (nothing +// has been scored yet). +func (l *ShadowLog) Replay(threshold float64) (ReplayReport, error) { + entries, malformed, err := l.readEntries() + if err != nil { + return ReplayReport{}, err + } + + report := ReplayReport{Threshold: threshold, MalformedLines: malformed} + seen := make(map[string]bool) + elidedSeen := make(map[string]bool) + for _, e := range entries { + if !e.isElideDecision() { + // Non-elide entries — history-run summaries, tripwire records, + // expand/hit outcomes, and kind=retrieve decisions — are not + // elision decisions: counting them would inflate + // Entries/TokensTotal and mint a "" unique segment (or, for + // retrieve decisions, replay "skipped" as "elided"). + continue + } + report.Entries++ + report.TokensTotal += e.Tokens + if !seen[e.SegmentID] { + seen[e.SegmentID] = true + report.UniqueSegments++ + } + if e.Score < threshold { + report.ElidedEntries++ + report.TokensElided += e.Tokens + if !elidedSeen[e.SegmentID] { + elidedSeen[e.SegmentID] = true + report.ElidedSegments++ + } + } + } + return report, nil +} + +// Stats summarizes a shadow log's decisions and outcomes: how many +// per-segment decisions were recorded, how many of those the log itself +// vouches were — or, in shadow mode, would have been — elided at the +// threshold recorded with them, and how many distinct items carry expand +// and hit outcomes. A missing log file is a zero Stats, not an error. +type Stats struct { + // Decisions is the number of per-segment decision entries. + Decisions int `json:"decisions"` + // ElidedDecisions is the number of decision entries whose score is + // strictly below the threshold recorded with them. Legacy entries + // without a recorded threshold (0 = none in force) can never count: no + // score is below an unknown floor. + ElidedDecisions int `json:"elided_decisions"` + // Expands is the number of distinct item ids with an expand outcome. + Expands int `json:"expands"` + // Hits is the number of distinct item ids with a hit outcome. + Hits int `json:"hits"` +} + +// Stats reads the whole log and counts decisions and outcomes. Outcome +// entries (Type "expand"/"hit") never count as decisions, and outcome item +// ids are counted distinct — one record expanded five times is one expand. +func (l *ShadowLog) Stats() (Stats, error) { + entries, _, err := l.readEntries() + if err != nil { + return Stats{}, err + } + var st Stats + expanded := make(map[string]bool) + hit := make(map[string]bool) + for _, e := range entries { + switch { + case e.isElideDecision(): + // Elide decisions only: retrieve decisions (Step 17) carry a + // relevance score with injected/skipped actions — they are + // tracked through the raw log, not through the elide counters. + st.Decisions++ + if e.Threshold > 0 && e.Score < e.Threshold { + st.ElidedDecisions++ + } + case e.Type == EntryTypeExpand && e.ItemID != "": + expanded[e.ItemID] = true + case e.Type == EntryTypeHit && e.ItemID != "": + hit[e.ItemID] = true + } + } + st.Expands = len(expanded) + st.Hits = len(hit) + return st, nil +} + +// FalseNegativeRate reports the share of elided segments the agent later had +// to expand — every expand is a recorded false negative of the elision +// decision that relocated its content (the reference's outcome ledger). +// +// Precisely: the numerator counts the DISTINCT segment ids with a decision +// entry whose score is strictly below the threshold recorded with it AND a +// later expand outcome naming that id (later = appended after the decision +// line); the denominator counts the DISTINCT segment ids elided at their own +// recorded threshold. 0 when nothing was elided — an empty ledger is not an +// error. Legacy entries without a recorded threshold never count as elided. +func (l *ShadowLog) FalseNegativeRate() (float64, error) { + entries, _, err := l.readEntries() + if err != nil { + return 0, err + } + elided := make(map[string]bool) + for _, e := range entries { + if e.isElideDecision() && e.SegmentID != "" && elidedAtOwnThreshold(e) { + elided[e.SegmentID] = true + } + } + if len(elided) == 0 { + return 0, nil + } + missed := stillMissedIDs(entries, elidedAtOwnThreshold) + return float64(len(missed)) / float64(len(elided)), nil +} + +// elidedAtOwnThreshold is the elision predicate on a decision entry's own +// recorded data: score strictly below the threshold recorded with it. An +// entry without a recorded threshold (legacy lines, or no threshold in +// force) is never elided — an unknown floor vouches for nothing. +func elidedAtOwnThreshold(e ShadowEntry) bool { + return e.Threshold > 0 && e.Score < e.Threshold +} + +// stillMissedIDs returns the distinct segment ids the elide predicate +// selects — a "would relocate" set — that ALSO have a later expand outcome +// naming them: content the predicate would have removed that the agent +// demonstrably had to fetch back. Expand outcomes may name a record id +// (the record-level outcome) instead of a segment id; record ids never +// match a segment id here, so only the per-segment attribution links. +func stillMissedIDs(entries []ShadowEntry, elided func(ShadowEntry) bool) map[string]bool { + // lastExpandAt maps an item id to the LAST line index of an expand + // outcome naming it (the scan runs in ascending order, so the final + // assignment is the maximum). + lastExpandAt := make(map[string]int) + for i, e := range entries { + if e.Type == EntryTypeExpand && e.ItemID != "" { + lastExpandAt[e.ItemID] = i + } + } + missed := make(map[string]bool) + for i, e := range entries { + if !e.isElideDecision() || e.SegmentID == "" || !elided(e) { + continue + } + if j, ok := lastExpandAt[e.SegmentID]; ok && j > i { + missed[e.SegmentID] = true + } + } + return missed +} + +// ReplayRow is one threshold's replay of the recorded decisions — what the +// gate WOULD have done at that threshold, re-decided from the recorded +// scores without re-running the scorer (the reference's replay table; that +// is the point of replay: the same run, re-decided at other settings). +type ReplayRow struct { + // Threshold is the replayed keep threshold. + Threshold float64 `json:"threshold"` + // Kept is the number of decision entries whose score is at or above the + // threshold — they would have stayed in the output. + Kept int `json:"kept"` + // Relocated is the number of decision entries whose score is strictly + // below the threshold — they would have been elided. Entries, not + // distinct segments: a segment re-scored twice counts twice. + Relocated int `json:"relocated"` + // TokensSaved is the total token count of the relocated entries — the + // tokens the threshold would have saved. + TokensSaved int `json:"tokens_saved"` + // StillMissed is the number of DISTINCT relocated segment ids that have + // a later expand outcome: content the threshold would have removed that + // the agent demonstrably needed back. + StillMissed int `json:"still_missed"` +} + +// ReplayTable replays the log's recorded scores at every given threshold, +// returning one row per threshold sorted ascending. Each entry is re-decided +// from its own recorded score against the GIVEN threshold — the entry's own +// recorded threshold plays no part here. A missing log file yields a zero +// row per threshold, not an error. +func (l *ShadowLog) ReplayTable(thresholds []float64) ([]ReplayRow, error) { + entries, _, err := l.readEntries() + if err != nil { + return nil, err + } + rows := make([]ReplayRow, 0, len(thresholds)) + for _, th := range thresholds { + row := ReplayRow{Threshold: th} + for _, e := range entries { + if !e.isElideDecision() { + // See isElideDecision: the table replays the elide axis; + // retrieve decisions and outcomes never count. + continue + } + if e.Score < th { + row.Relocated++ + row.TokensSaved += e.Tokens + } else { + row.Kept++ + } + } + row.StillMissed = len(stillMissedIDs(entries, func(e ShadowEntry) bool { + return e.Score < th + })) + rows = append(rows, row) + } + sort.Slice(rows, func(i, j int) bool { return rows[i].Threshold < rows[j].Threshold }) + return rows, nil +} diff --git a/internal/compaction/shadow_test.go b/internal/compaction/shadow_test.go new file mode 100644 index 00000000..f2d02950 --- /dev/null +++ b/internal/compaction/shadow_test.go @@ -0,0 +1,610 @@ +package compaction + +import ( + "bufio" + "encoding/json" + "fmt" + "math" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +func readLines(t *testing.T, path string) []string { + t.Helper() + f, err := os.Open(path) + if err != nil { + t.Fatalf("open %s: %v", path, err) + } + defer f.Close() + var lines []string + sc := bufio.NewScanner(f) + for sc.Scan() { + if strings.TrimSpace(sc.Text()) != "" { + lines = append(lines, sc.Text()) + } + } + if err := sc.Err(); err != nil { + t.Fatalf("scan %s: %v", path, err) + } + return lines +} + +func TestShadowLog_AppendAndReadBack(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "shadow.jsonl") + l, err := NewShadowLogAt(path) + if err != nil { + t.Fatalf("NewShadowLogAt() error = %v", err) + } + if l.Path() != path { + t.Errorf("Path() = %q, want %q", l.Path(), path) + } + + ts := time.Unix(1700000000, 0).UTC() + entries := []ShadowEntry{ + {TS: ts, TaskHash: "abc123", SegmentID: "seg-1", Tokens: 120, Score: 0.42, Decision: DecisionKeep}, + {SegmentID: "seg-2", Tokens: 30, Score: 0.9}, // defaults: TS and Decision + } + for i, e := range entries { + if err := l.Append(e); err != nil { + t.Fatalf("Append(%d) error = %v", i, err) + } + } + + lines := readLines(t, path) + if len(lines) != 2 { + t.Fatalf("got %d lines, want 2", len(lines)) + } + var got []ShadowEntry + for i, line := range lines { + var e ShadowEntry + if err := json.Unmarshal([]byte(line), &e); err != nil { + t.Fatalf("line %d is not valid JSON: %v", i, err) + } + got = append(got, e) + } + if got[0] != entries[0] { + t.Errorf("entry 0 = %+v, want %+v", got[0], entries[0]) + } + if got[1].TS.IsZero() { + t.Error("entry 1 TS was not defaulted to now") + } + if got[1].Decision != DecisionKeep { + t.Errorf("entry 1 Decision = %q, want %q", got[1].Decision, DecisionKeep) + } + + // The created directory must be private (0700). + if info, err := os.Stat(filepath.Dir(path)); err != nil { + t.Fatalf("stat dir: %v", err) + } else if perm := info.Mode().Perm(); perm != 0o700 { + t.Errorf("dir perms = %o, want 700", perm) + } +} + +func TestShadowLog_ReplayMath(t *testing.T) { + l, err := NewShadowLogAt(filepath.Join(t.TempDir(), "shadow.jsonl")) + if err != nil { + t.Fatal(err) + } + entries := []ShadowEntry{ + {SegmentID: "seg-keep", Tokens: 50, Score: 0.9}, + {SegmentID: "seg-drop", Tokens: 100, Score: 0.2}, + {SegmentID: "seg-drop", Tokens: 100, Score: 0.3}, // same segment re-scored + {SegmentID: "seg-mid", Tokens: 25, Score: 0.5}, // exactly at threshold: kept + } + for _, e := range entries { + if err := l.Append(e); err != nil { + t.Fatalf("Append() error = %v", err) + } + } + + report, err := l.Replay(0.5) + if err != nil { + t.Fatalf("Replay() error = %v", err) + } + want := ReplayReport{ + Threshold: 0.5, + Entries: 4, + UniqueSegments: 3, + ElidedEntries: 2, // both seg-drop decisions (0.2, 0.3 < 0.5) + ElidedSegments: 1, + TokensTotal: 275, + TokensElided: 200, + } + if report != want { + t.Errorf("Replay() = %+v, want %+v", report, want) + } +} + +func TestShadowLog_AppendRun(t *testing.T) { + path := filepath.Join(t.TempDir(), "shadow.jsonl") + l, err := NewShadowLogAt(path) + if err != nil { + t.Fatal(err) + } + run := RunSummary{ + Shadow: true, + Scanned: 6, + Scored: 5, + Elided: 3, + TokensBefore: 1000, + TokensAfter: 700, + TokensSaved: 300, + } + if err := l.AppendRun("task-hash", run); err != nil { + t.Fatalf("AppendRun() error = %v", err) + } + + lines := readLines(t, path) + if len(lines) != 1 { + t.Fatalf("got %d lines, want 1", len(lines)) + } + var e ShadowEntry + if err := json.Unmarshal([]byte(lines[0]), &e); err != nil { + t.Fatalf("run line is not valid JSON: %v (%q)", err, lines[0]) + } + if e.Type != EntryTypeHistoryRun { + t.Errorf("Type = %q, want %q", e.Type, EntryTypeHistoryRun) + } + if e.TaskHash != "task-hash" { + t.Errorf("TaskHash = %q, want task-hash", e.TaskHash) + } + if e.TS.IsZero() { + t.Error("run entry TS was not defaulted to now") + } + if e.SegmentID != "" || e.Decision != "" { + t.Errorf("run entry must carry no per-segment fields, got segment_id=%q decision=%q", e.SegmentID, e.Decision) + } + if e.Run == nil { + t.Fatal("Run is nil") + } + if *e.Run != run { + t.Errorf("Run = %+v, want %+v", *e.Run, run) + } +} + +// TestShadowLog_AppendOutcome: outcomes append as first-class lines — +// Type/ItemID/Turn persisted, TS defaulted, no decision defaulted in — and +// unknown kinds or empty item ids are rejected before anything is written. +func TestShadowLog_AppendOutcome(t *testing.T) { + path := filepath.Join(t.TempDir(), "shadow.jsonl") + l, err := NewShadowLogAt(path) + if err != nil { + t.Fatal(err) + } + if err := l.AppendOutcome(EntryTypeExpand, "r:1a2b3c4d", 0); err != nil { + t.Fatalf("AppendOutcome(expand) error = %v", err) + } + if err := l.AppendOutcome(EntryTypeHit, "seg-2", 7); err != nil { + t.Fatalf("AppendOutcome(hit) error = %v", err) + } + if err := l.AppendOutcome("bogus", "seg-1", 0); err == nil { + t.Error("AppendOutcome(unknown kind) error = nil, want an error") + } + if err := l.AppendOutcome(EntryTypeExpand, "", 0); err == nil { + t.Error("AppendOutcome(empty item id) error = nil, want an error") + } + + lines := readLines(t, path) + if len(lines) != 2 { + t.Fatalf("got %d lines, want 2 (rejected outcomes must not write)", len(lines)) + } + var first, second ShadowEntry + if err := json.Unmarshal([]byte(lines[0]), &first); err != nil { + t.Fatalf("outcome line invalid: %v (%q)", err, lines[0]) + } + if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { + t.Fatalf("outcome line invalid: %v (%q)", err, lines[1]) + } + if first.Type != EntryTypeExpand || first.ItemID != "r:1a2b3c4d" { + t.Errorf("first outcome = type %q item %q, want expand/r:1a2b3c4d", first.Type, first.ItemID) + } + if first.Turn != 0 { + t.Errorf("first outcome Turn = %d, want 0", first.Turn) + } + if second.Type != EntryTypeHit || second.ItemID != "seg-2" || second.Turn != 7 { + t.Errorf("second outcome = type %q item %q turn %d, want hit/seg-2/7", second.Type, second.ItemID, second.Turn) + } + // Outcomes are not decisions: no SegmentID, no defaulted Decision. + for i, e := range []ShadowEntry{first, second} { + if e.SegmentID != "" { + t.Errorf("outcome %d carries segment_id %q, want none (outcomes name ItemID)", i, e.SegmentID) + } + if e.Decision != "" { + t.Errorf("outcome %d carries decision %q, want none", i, e.Decision) + } + if e.TS.IsZero() { + t.Errorf("outcome %d TS was not defaulted to now", i) + } + } +} + +// TestShadowLog_Stats: decisions count, elided-at-own-threshold counts, +// distinct outcome item ids, and non-decision entries skipped. +func TestShadowLog_Stats(t *testing.T) { + l, err := NewShadowLogAt(filepath.Join(t.TempDir(), "shadow.jsonl")) + if err != nil { + t.Fatal(err) + } + entries := []ShadowEntry{ + {SegmentID: "seg-a", Tokens: 10, Score: 0.1, Threshold: 0.35}, // elided at own threshold + {SegmentID: "seg-b", Tokens: 20, Score: 0.2, Threshold: 0.35}, // elided at own threshold + {SegmentID: "seg-c", Tokens: 30, Score: 0.9, Threshold: 0.35}, // kept + {SegmentID: "seg-legacy", Score: 0.1}, // legacy: no threshold, never elided + {SegmentID: "seg-d", Tokens: 5, Score: 0.5, Threshold: 0}, // 0 threshold = none in force + {SegmentID: "seg-e", Tokens: 5, Score: 0.5, Threshold: 0.5}, // exactly at: kept + } + for _, e := range entries { + if err := l.Append(e); err != nil { + t.Fatalf("Append(%s) error = %v", e.SegmentID, err) + } + } + // Non-decision lines must not count as decisions. + if err := l.AppendRun("task", RunSummary{Scanned: 1}); err != nil { + t.Fatalf("AppendRun() error = %v", err) + } + if err := l.Append(ShadowEntry{Type: EntryTypeTripwire, Action: TripwireAction, Tokens: 99}); err != nil { + t.Fatalf("Append(tripwire) error = %v", err) + } + // Outcomes: r:1 twice (one distinct), seg-a once (hit), seg-b once. + for _, oc := range []struct { + kind, id string + }{{EntryTypeExpand, "r:1"}, {EntryTypeExpand, "r:1"}, {EntryTypeHit, "seg-a"}, {EntryTypeExpand, "seg-b"}} { + if err := l.AppendOutcome(oc.kind, oc.id, 0); err != nil { + t.Fatalf("AppendOutcome(%s, %s) error = %v", oc.kind, oc.id, err) + } + } + + st, err := l.Stats() + if err != nil { + t.Fatalf("Stats() error = %v", err) + } + want := Stats{Decisions: 6, ElidedDecisions: 2, Expands: 2, Hits: 1} + if st != want { + t.Errorf("Stats() = %+v, want %+v", st, want) + } +} + +// TestShadowLog_FalseNegativeRate: the rate counts distinct segments elided +// at their own recorded threshold that got a LATER expand outcome — an +// expand recorded before the elision does not link. +func TestShadowLog_FalseNegativeRate(t *testing.T) { + l, err := NewShadowLogAt(filepath.Join(t.TempDir(), "shadow.jsonl")) + if err != nil { + t.Fatal(err) + } + // seg-a: elided, then expanded → missed. + if err := l.Append(ShadowEntry{SegmentID: "seg-a", Score: 0.1, Threshold: 0.35}); err != nil { + t.Fatal(err) + } + if err := l.AppendOutcome(EntryTypeExpand, "seg-a", 0); err != nil { + t.Fatal(err) + } + // seg-b: elided, never expanded → denominator only. + if err := l.Append(ShadowEntry{SegmentID: "seg-b", Score: 0.2, Threshold: 0.35}); err != nil { + t.Fatal(err) + } + // seg-c: expanded BEFORE the elision decision → not "later", not missed. + if err := l.AppendOutcome(EntryTypeExpand, "seg-c", 0); err != nil { + t.Fatal(err) + } + if err := l.Append(ShadowEntry{SegmentID: "seg-c", Score: 0.05, Threshold: 0.35}); err != nil { + t.Fatal(err) + } + // seg-d: kept (score above threshold) and expanded — irrelevant either way. + if err := l.Append(ShadowEntry{SegmentID: "seg-d", Score: 0.9, Threshold: 0.35}); err != nil { + t.Fatal(err) + } + if err := l.AppendOutcome(EntryTypeExpand, "seg-d", 0); err != nil { + t.Fatal(err) + } + // A record-level outcome (r:…) must not link to any segment id. + if err := l.AppendOutcome(EntryTypeExpand, "r:abcd1234", 0); err != nil { + t.Fatal(err) + } + + got, err := l.FalseNegativeRate() + if err != nil { + t.Fatalf("FalseNegativeRate() error = %v", err) + } + want := 1.0 / 3.0 // seg-a missed of {seg-a, seg-b, seg-c} elided + if math.Abs(got-want) > 1e-9 { + t.Errorf("FalseNegativeRate() = %v, want %v", got, want) + } +} + +// TestShadowLog_FalseNegativeRateEmptyLedger: no elided decisions (empty +// log, missing file, or only kept entries) is a 0 rate, not an error. +func TestShadowLog_FalseNegativeRateEmptyLedger(t *testing.T) { + t.Run("missing file", func(t *testing.T) { + l, err := NewShadowLogAt(filepath.Join(t.TempDir(), "never-written.jsonl")) + if err != nil { + t.Fatal(err) + } + got, err := l.FalseNegativeRate() + if err != nil || got != 0 { + t.Errorf("FalseNegativeRate() = (%v, %v), want (0, nil)", got, err) + } + }) + t.Run("only kept decisions", func(t *testing.T) { + l, err := NewShadowLogAt(filepath.Join(t.TempDir(), "shadow.jsonl")) + if err != nil { + t.Fatal(err) + } + if err := l.Append(ShadowEntry{SegmentID: "seg-a", Score: 0.9, Threshold: 0.35}); err != nil { + t.Fatal(err) + } + got, err := l.FalseNegativeRate() + if err != nil || got != 0 { + t.Errorf("FalseNegativeRate() = (%v, %v), want (0, nil)", got, err) + } + }) +} + +// TestShadowLog_ReplayTable: each row re-decides every recorded entry from +// its OWN score against the GIVEN threshold (never the entry's recorded +// one), sums relocated tokens, counts distinct later-expanded segments as +// still missed, and sorts rows by threshold ascending. +func TestShadowLog_ReplayTable(t *testing.T) { + l, err := NewShadowLogAt(filepath.Join(t.TempDir(), "shadow.jsonl")) + if err != nil { + t.Fatal(err) + } + entries := []ShadowEntry{ + {SegmentID: "seg-a", Tokens: 10, Score: 0.9, Threshold: 0.35}, + {SegmentID: "seg-b", Tokens: 20, Score: 0.2, Threshold: 0.35}, + {SegmentID: "seg-b", Tokens: 20, Score: 0.3, Threshold: 0.35}, // re-scored + {SegmentID: "seg-c", Tokens: 5, Score: 0.5, Threshold: 0.35}, + } + for _, e := range entries { + if err := l.Append(e); err != nil { + t.Fatalf("Append(%s) error = %v", e.SegmentID, err) + } + } + // seg-b was later expanded: still missed at every threshold that + // relocates either of its entries. + if err := l.AppendOutcome(EntryTypeExpand, "seg-b", 0); err != nil { + t.Fatal(err) + } + // Deliberately unsorted: the table must come back ascending. + rows, err := l.ReplayTable([]float64{0.35, 0.1, 0.25}) + if err != nil { + t.Fatalf("ReplayTable() error = %v", err) + } + want := []ReplayRow{ + {Threshold: 0.1, Kept: 4, Relocated: 0, TokensSaved: 0, StillMissed: 0}, + {Threshold: 0.25, Kept: 3, Relocated: 1, TokensSaved: 20, StillMissed: 1}, + {Threshold: 0.35, Kept: 2, Relocated: 2, TokensSaved: 40, StillMissed: 1}, + } + if len(rows) != len(want) { + t.Fatalf("ReplayTable() returned %d rows, want %d", len(rows), len(want)) + } + for i, row := range rows { + if row != want[i] { + t.Errorf("ReplayTable()[%d] = %+v, want %+v", i, row, want[i]) + } + } +} + +// TestShadowLog_ReplayTableMissingFileIsEmpty: a missing log yields a zero +// row per threshold, not an error. +func TestShadowLog_ReplayTableMissingFileIsEmpty(t *testing.T) { + l, err := NewShadowLogAt(filepath.Join(t.TempDir(), "never-written.jsonl")) + if err != nil { + t.Fatal(err) + } + rows, err := l.ReplayTable([]float64{0.35, 0.5}) + if err != nil { + t.Fatalf("ReplayTable() error = %v", err) + } + want := []ReplayRow{{Threshold: 0.35}, {Threshold: 0.5}} + if len(rows) != len(want) { + t.Fatalf("ReplayTable() returned %d rows, want %d", len(rows), len(want)) + } + for i, row := range rows { + if row != want[i] { + t.Errorf("ReplayTable()[%d] = %+v, want %+v", i, row, want[i]) + } + } +} + +func TestShadowLog_ReplaySkipsHistoryRunEntries(t *testing.T) { + path := filepath.Join(t.TempDir(), "shadow.jsonl") + l, err := NewShadowLogAt(path) + if err != nil { + t.Fatal(err) + } + // A real log mixes per-segment decisions with run summary lines; only + // the decisions may count toward the replay math. + for _, e := range []ShadowEntry{ + {SegmentID: "seg-keep", Tokens: 50, Score: 0.9}, + {SegmentID: "seg-drop", Tokens: 100, Score: 0.2}, + } { + if err := l.Append(e); err != nil { + t.Fatalf("Append() error = %v", err) + } + } + if err := l.AppendRun("task-hash", RunSummary{Scanned: 4, Scored: 4, Elided: 1, TokensBefore: 500, TokensAfter: 400, TokensSaved: 100}); err != nil { + t.Fatalf("AppendRun() error = %v", err) + } + if err := l.Append(ShadowEntry{SegmentID: "seg-drop", Tokens: 100, Score: 0.3}); err != nil { + t.Fatalf("Append() error = %v", err) + } + + report, err := l.Replay(0.5) + if err != nil { + t.Fatalf("Replay() error = %v", err) + } + want := ReplayReport{ + Threshold: 0.5, + Entries: 3, // the run summary line must not count + UniqueSegments: 2, + ElidedEntries: 2, // both seg-drop decisions (0.2, 0.3 < 0.5) + ElidedSegments: 1, + TokensTotal: 250, + TokensElided: 200, + } + if report != want { + t.Errorf("Replay() = %+v, want %+v", report, want) + } + if report.MalformedLines != 0 { + t.Errorf("MalformedLines = %d, want 0 (run summaries are skipped, not malformed)", report.MalformedLines) + } +} + +func TestShadowLog_ReplayMissingFileIsEmpty(t *testing.T) { + l, err := NewShadowLogAt(filepath.Join(t.TempDir(), "never-written.jsonl")) + if err != nil { + t.Fatal(err) + } + report, err := l.Replay(0.5) + if err != nil { + t.Fatalf("Replay() error = %v (a missing log is empty, not an error)", err) + } + if report.Entries != 0 || report.Threshold != 0.5 { + t.Errorf("Replay() = %+v, want an empty report at threshold 0.5", report) + } +} + +func TestShadowLog_ReplaySkipsMalformedLines(t *testing.T) { + path := filepath.Join(t.TempDir(), "shadow.jsonl") + l, err := NewShadowLogAt(path) + if err != nil { + t.Fatal(err) + } + if err := l.Append(ShadowEntry{SegmentID: "seg-1", Tokens: 10, Score: 0.1}); err != nil { + t.Fatal(err) + } + f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString("this is not json\n\n"); err != nil { + t.Fatal(err) + } + f.Close() + if err := l.Append(ShadowEntry{SegmentID: "seg-2", Tokens: 20, Score: 0.9}); err != nil { + t.Fatal(err) + } + + report, err := l.Replay(0.5) + if err != nil { + t.Fatalf("Replay() error = %v", err) + } + if report.MalformedLines != 1 { + t.Errorf("MalformedLines = %d, want 1", report.MalformedLines) + } + if report.Entries != 2 || report.ElidedEntries != 1 { + t.Errorf("Replay() = %+v, want 2 entries with 1 elided", report) + } +} + +func TestShadowLog_ConcurrentAppends(t *testing.T) { + path := filepath.Join(t.TempDir(), "shadow.jsonl") + l, err := NewShadowLogAt(path) + if err != nil { + t.Fatal(err) + } + + const ( + goroutines = 20 + perWorker = 10 + ) + var wg sync.WaitGroup + for g := 0; g < goroutines; g++ { + wg.Add(1) + go func(g int) { + defer wg.Done() + for i := 0; i < perWorker; i++ { + e := ShadowEntry{ + SegmentID: fmt.Sprintf("seg-%d-%d", g, i), + Tokens: 1, + Score: 0.5, + } + if err := l.Append(e); err != nil { + t.Errorf("Append() error = %v", err) + return + } + } + }(g) + } + wg.Wait() + + lines := readLines(t, path) + if len(lines) != goroutines*perWorker { + t.Fatalf("got %d lines, want %d — appends were lost or torn", len(lines), goroutines*perWorker) + } + seen := make(map[string]bool, len(lines)) + for i, line := range lines { + var e ShadowEntry + if err := json.Unmarshal([]byte(line), &e); err != nil { + t.Fatalf("line %d torn or invalid: %v (%q)", i, err, line) + } + if seen[e.SegmentID] { + t.Errorf("duplicate entry for %s — a write was interleaved", e.SegmentID) + } + seen[e.SegmentID] = true + } +} + +func TestShadowLog_AppendFailureOnUnwritablePath(t *testing.T) { + dir := t.TempDir() + blocker := filepath.Join(dir, "not-a-dir") + if err := os.WriteFile(blocker, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := NewShadowLogAt(filepath.Join(blocker, "shadow.jsonl")); err == nil { + t.Error("NewShadowLogAt() under a file path error = nil, want a mkdir failure") + } + + // A log whose path is a directory makes every append fail. + l, err := NewShadowLogAt(filepath.Join(dir, "sub")) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(l.Path(), 0o700); err != nil { + t.Fatal(err) + } + if err := l.Append(ShadowEntry{SegmentID: "seg-1"}); err == nil { + t.Error("Append() to a directory error = nil, want a failure") + } +} + +func TestShadowLog_EmptyPathRejected(t *testing.T) { + if _, err := NewShadowLogAt(""); err == nil { + t.Error("NewShadowLogAt(\"\") error = nil, want an error") + } +} + +func TestDefaultShadowPath(t *testing.T) { + p, err := DefaultShadowPath() + if err != nil { + t.Skipf("no home dir available: %v", err) + } + if !strings.HasSuffix(p, filepath.Join(".local", "share", "late", "compaction-shadow.jsonl")) { + t.Errorf("DefaultShadowPath() = %q, want it under ~/.local/share/late/compaction-shadow.jsonl", p) + } +} + +func TestHashTask(t *testing.T) { + a := HashTask("write the parser") + b := HashTask("write the parser") + c := HashTask("write the parser!") + if a != b { + t.Errorf("HashTask is not deterministic: %q vs %q", a, b) + } + if a == c { + t.Error("HashTask collided on different inputs") + } + if len(a) != 16 { + t.Errorf("HashTask length = %d, want 16 hex chars", len(a)) + } + // Distinct tasks must hash differently from their raw text (the point + // of hashing is that the raw text is not logged). + if strings.Contains(a, "parser") { + t.Error("HashTask leaked raw task text") + } +} diff --git a/internal/compaction/skill_preserved_test.go b/internal/compaction/skill_preserved_test.go new file mode 100644 index 00000000..3cfec78b --- /dev/null +++ b/internal/compaction/skill_preserved_test.go @@ -0,0 +1,82 @@ +package compaction + +import ( + "context" + "strings" + "testing" +) + +// TestPipeline_ActivateSkillResultPreserved pins the activated-skills +// protection: a tool result produced by activate_skill is the skill's +// instructions — what the agent was told to follow — and must NEVER be +// elided, whatever the scorer answers. The gate clamps the result's scores +// up to the 1.0 protected-origin floor, so even a scorer that scores +// everything 0.0 keeps the result verbatim; the same output from an +// unprotected tool elides as usual. +func TestPipeline_ActivateSkillResultPreserved(t *testing.T) { + const threshold = 0.35 + output, keeper1, filler, keeper2 := relocationOutput() + + // Stub scorer scoring 0.0: without protection everything would elide. + d := newDecisionsServer(t, fixedScoresHandler(map[string]float64{"seg-1": 0.0, "seg-2": 0.0, "seg-3": 0.0})) + + t.Run("activate_skill result is kept verbatim at score 0.0", func(t *testing.T) { + store := NewStore() + p := NewPipeline(ResolvedBackend{Backend: Backend{Name: "test", URL: d.srv.URL, Model: "jev-latest"}, APIKey: "k"}, "k", nil, PipelineOptions{}) + p.EnableRelocation(store, threshold) + applyTestGate(p, threshold, 1) + + got, err := p.CompactToolOutput(context.Background(), SkillToolName, output) + if err != nil { + t.Fatalf("CompactToolOutput() error = %v", err) + } + if got.CompactText != output { + t.Errorf("the activate_skill result was mutated:\n got %q\nwant %q", truncateForTest(got.CompactText), truncateForTest(output)) + } + if len(got.Elided) != 0 { + t.Errorf("the activate_skill result was elided into %d runs", len(got.Elided)) + } + if store.Len() != 0 { + t.Error("nothing from a protected tool result should be stored") + } + if !strings.Contains(got.CompactText, keeper1) || !strings.Contains(got.CompactText, filler) || !strings.Contains(got.CompactText, keeper2) { + t.Error("the protected result lost content") + } + }) + + t.Run("the same output from an unprotected tool elides", func(t *testing.T) { + store := NewStore() + p := NewPipeline(ResolvedBackend{Backend: Backend{Name: "test", URL: d.srv.URL, Model: "jev-latest"}, APIKey: "k"}, "k", nil, PipelineOptions{}) + p.EnableRelocation(store, threshold) + applyTestGate(p, threshold, 1) + + got, err := p.CompactToolOutput(context.Background(), "Bash", output) + if err != nil { + t.Fatalf("CompactToolOutput() error = %v", err) + } + if len(got.Elided) != 1 { + t.Fatalf("control run: Elided = %d, want 1 (all three segments score 0.0)", len(got.Elided)) + } + if got.CompactText == output { + t.Error("control run: expected elision") + } + }) + + t.Run("ProtectedTool and origin floor agree", func(t *testing.T) { + if !ProtectedTool(SkillToolName) { + t.Error("ProtectedTool(activate_skill) = false, want true") + } + if ProtectedTool("Bash") || ProtectedTool("expand") { + t.Error("ProtectedTool = true for an unprotected tool") + } + if f, ok := originScoreFloor(OriginSourceSkillTool); !ok || f != 1.0 { + t.Errorf("originScoreFloor(%q) = (%v, %v), want (1.0, true)", OriginSourceSkillTool, f, ok) + } + if got := protectedScore(OriginSourceSkillTool, 0.0); got != 1.0 { + t.Errorf("protectedScore clamped %v, want 1.0", got) + } + if got := protectedScore(OriginSourceToolPrefix+"Bash", 0.0); got != 0.0 { + t.Errorf("unprotected origin score changed: %v", got) + } + }) +} diff --git a/internal/compaction/store.go b/internal/compaction/store.go new file mode 100644 index 00000000..b6327f3e --- /dev/null +++ b/internal/compaction/store.go @@ -0,0 +1,507 @@ +package compaction + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + + "late/internal/common" + "late/internal/pathutil" +) + +// RecordKindElidedSegment is the Kind of every record the elide paths +// write (reference store.py parity): a run of segments relocated out of a +// tool output or a history message. +const RecordKindElidedSegment = "elided_segment" + +// OriginSourceToolPrefix prefixes the Origin.Source of records relocated +// from tool outputs: "tool:" + the tool's registry name (the pipeline's +// CompactToolOutput is the only writer on that path and always knows the +// tool it is compacting for). +const OriginSourceToolPrefix = "tool:" + +// OriginSourceHistory is the Origin.Source of records relocated from +// session history by session.CompactContext: the walk scores whole +// messages and has no finer-grained ref to attribute the run to. +const OriginSourceHistory = "history" + +// Origin names where a stored record's content came from (the reference +// store.py Origin). Turn is the conversation turn the content was produced +// in — turn plumbing does not exist anywhere in the port yet, so every +// writer leaves it 0 for now; the field is here so threading a turn +// through later is a one-line change per writer, not a schema migration. +type Origin struct { + // Source is the producing surface: "tool:" for tool outputs, + // "history" for history-compaction runs. + Source string `json:"source"` + // Ref is an optional pointer into the source (a tool-call id, a + // message index); empty when the writer has nothing stable to name. + Ref string `json:"ref,omitempty"` + // Turn is the conversation turn the content was produced in; 0 until + // turn plumbing exists (see the type doc). + Turn int `json:"turn"` +} + +// Record is one entry of the elided-original store (the reference +// store.py Record): the full description of a relocated run, not just its +// text. ID is the pointer id the [[elided …]] line carries — +// content-addressed ("r:<8hex>", ContentID of the run text) for runs this +// pipeline produced, or a legacy "elide-" counter id. The counters +// (ExpandCount/HitCount) are the expand-tool usage ledger Step 13's +// outcomes build on. +type Record struct { + ID string `json:"id"` + Text string `json:"text"` + Kind string `json:"kind,omitempty"` + Origin Origin `json:"origin"` + Tokens int `json:"tokens"` + CreatedTurn int `json:"created_turn"` + Summary string `json:"summary,omitempty"` + SegmentIDs []string `json:"segment_ids,omitempty"` + ExpandCount int `json:"expand_count"` + HitCount int `json:"hit_count"` +} + +// Store holds the original text of elided runs, keyed by pointer id, so the +// expand tool can retrieve what compaction removed. It is safe for +// concurrent use: the root agent and every subagent share one store. +// +// Records: the store keeps full Records — text, kind, origin, token count, +// pointer summary, contributing segment ids, and the expand/hit counters — +// not just strings. Get still returns the record's text so the legacy read +// side (tool.ExpandStore, session.ElideStore, Reconstruct) works unchanged; +// GetRecord exposes the rest (Step 13 outcomes and Step 17 retrieval read +// it). An empty Kind defaults to RecordKindElidedSegment when stored. +// +// Persistence: NewStore() is purely in-memory (tests, shadow mode — nothing +// stored there anyway). OpenStore(path) backs the store with an append-only +// JSONL file (0600, parent directories 0700): every Put/PutRecord that +// creates a record and every Touch that bumps a counter appends ONE line — +// a single Write call on an O_APPEND descriptor while holding the mutex, so +// a reader (including another late process) only ever sees whole lines, +// exactly like ShadowLog.Append. Loading is last-writer-wins per id: Touch +// re-appends the whole record rather than a delta line (a few wasted bytes +// per touch in exchange for one append path), and content-addressed ids +// make duplicate puts the exception. Lines that fail to parse are skipped, +// so a torn trailing line — the only damage a crash mid-append can leave — +// cannot poison the records written before it (OpenStore also terminates a +// torn tail so the next append cannot weld itself onto it). +// +// Ids are content-addressed for runs produced by this pipeline +// (ContentID, "r:<8hex>"); the legacy counter (NextID, "elide-") remains +// only so pointers minted by older builds still resolve — on a reloaded +// store the counter resumes past every legacy id already on disk, keeping +// the minted keys distinct. Put is idempotent: storing under an id that +// already exists keeps the first record and appends nothing — two +// compactions of identical text share one record instead of duplicating it, +// and legacy ids, being unique per NextID call, never collide anyway. +// +// Persistence is best-effort by contract: an append failure leaves the +// in-memory record intact (the session keeps working; only cross-restart +// persistence for that record is lost) and is not reported — the no-error +// shape matches Put's existing signature, and open-time failures are where +// callers warn and degrade to the in-memory store. The backing file is +// deliberately never fsynced and no descriptor is held between appends +// (each persist opens, writes, and closes): a process crash can lose at +// most the records of the appends still in the OS page cache, and the +// torn-tail repair below covers the mid-line residue. Durability against a +// machine crash is accepted debt for an append-only JSONL log of +// re-retrievable originals — exactly the trade ShadowLog.Append makes. +type Store struct { + mu sync.Mutex + records map[string]*Record + // order lists the ids in first-appearance order, so Records() is + // deterministic (map iteration is not; last-writer-wins loads keep the + // first position). + order []string + // next backs the legacy NextID counter. + next int + // path is the JSONL backing file; "" is in-memory only. + path string + // shadow is the outcome log the expand tool attributes expand outcomes + // through (Step 13): wiring attaches the session's shadow log with + // WithShadowLog, and the expand tool — the only expand path — reads it + // back to append one outcome per record id and per contributing segment + // id. nil (the default) disables outcome logging: the expand tool skips + // it silently. + shadow *ShadowLog +} + +// NewStore returns an empty in-memory original-text store. +func NewStore() *Store { + return &Store{records: make(map[string]*Record)} +} + +// DefaultStorePath returns the record store location: +// ~/.local/share/late/compaction-store.jsonl, resolved through +// pathutil.LateDataDir (mirroring DefaultShadowPath; Windows keeps everything +// under the config dir). +func DefaultStorePath() (string, error) { + dir, err := pathutil.LateDataDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "compaction-store.jsonl"), nil +} + +// OpenStore opens the record store at path, loading previously persisted +// records (last-writer-wins per id) so [[elided …]] pointers in resumed +// sessions still resolve after a restart. The parent directory is created +// 0700 if missing; the file itself is created 0600 on first append. A +// missing file is an empty store, not an error (nothing persisted yet); an +// empty path keeps the store in-memory (NewStore). Callers that cannot +// afford a failure degrade to NewStore() with a warning. +func OpenStore(path string) (*Store, error) { + if path == "" { + return NewStore(), nil + } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("compaction: create store dir %s: %w", dir, err) + } + s := NewStore() + s.path = path + s.repairTornTail() + if err := s.load(); err != nil { + return nil, err + } + return s, nil +} + +// Path returns the backing file path ("" when the store is in-memory). +func (s *Store) Path() string { + if s == nil { + return "" + } + return s.path +} + +// WithShadowLog attaches l as the store's outcome log and returns the store +// for call-site chaining: the expand tool reads it back through ShadowLog +// and appends one "expand" outcome per record id and per contributing +// segment id on every retrieval — the reference pipeline.py expand() +// attribution that ShadowLog.FalseNegativeRate and the replay table's +// still-missed column are built on. A nil log (or a nil store) is accepted +// and simply disables outcome logging, mirroring how a nil shadow log +// disables decision logging on the pipeline. Wiring calls this once at +// startup, before any agent can run a tool call. +func (s *Store) WithShadowLog(l *ShadowLog) *Store { + if s == nil { + return s + } + s.mu.Lock() + defer s.mu.Unlock() + s.shadow = l + return s +} + +// ShadowLog returns the store's attached outcome log, or nil when none was +// set (the expand tool skips outcome logging then). +func (s *Store) ShadowLog() *ShadowLog { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + return s.shadow +} + +// Get returns the original text stored for id — the record's Text field, so +// the pre-Record call sites (the expand tool, Reconstruct, the session's +// ElideStore read side) keep working unchanged. A nil store — or an unknown +// id — reports ("", false); the expand tool turns the miss into an +// "unknown elided id" error result. +func (s *Store) Get(id string) (string, bool) { + rec, ok := s.GetRecord(id) + if !ok { + return "", false + } + return rec.Text, true +} + +// GetRecord returns a snapshot of the record stored for id. The returned +// pointer names a copy (including the segment-id slice), so callers can +// read it without racing Touch's counter updates. A nil store — or an +// unknown id — reports (nil, false). +func (s *Store) GetRecord(id string) (*Record, bool) { + if s == nil { + return nil, false + } + s.mu.Lock() + defer s.mu.Unlock() + rec, ok := s.records[id] + if !ok { + return nil, false + } + snap := *rec + snap.SegmentIDs = cloneStrings(rec.SegmentIDs) + return &snap, true +} + +// Records returns snapshots of every record in first-appearance order (a +// nil store yields nil). Like GetRecord, each pointer names a copy safe to +// read without the lock. +func (s *Store) Records() []*Record { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + out := make([]*Record, 0, len(s.order)) + for _, id := range s.order { + rec, ok := s.records[id] + if !ok { + continue // records and order move together; defensive only + } + snap := *rec + snap.SegmentIDs = cloneStrings(rec.SegmentIDs) + out = append(out, &snap) + } + return out +} + +// Put stores text under id. Idempotent: an existing id keeps its first +// record (see the Store doc). A nil store is a no-op. The synthesized +// record carries the default kind, an estimated token count, and the +// pointer summary — Put is the metadata-free legacy entry point; writers +// that know more (the pipeline and the history walk) use PutRecord. An +// empty id or empty text is a no-op: an idless record is unreachable through +// any pointer, and an empty original has nothing to reconstruct — storing +// either would only pollute the digest and the record order. +func (s *Store) Put(id, text string) { + if s == nil || id == "" || text == "" { + return + } + s.PutRecord(Record{ + ID: id, + Text: text, + Tokens: common.EstimateTokenCount(text), + Summary: Summarise(text, SummaryMaxChars), + }) +} + +// PutRecord stores rec under rec.ID — the origin-threaded write side. The +// pipeline's CompactToolOutput writes Origin{Source: "tool:"} with +// the run's token count, summary, and contributing segment ids; the +// session's history walk writes Origin{Source: "history"}. Idempotent like +// Put: an existing id keeps its first record and appends nothing. A record +// with an empty id or empty text is a no-op (see Put). A nil store is a +// no-op. +func (s *Store) PutRecord(rec Record) { + if s == nil || rec.ID == "" || rec.Text == "" { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if stored, inserted := s.upsertLocked(rec, false); inserted { + s.persistLocked(*stored) + } +} + +// Touch bumps an existing record's counters — expand when the expand tool +// retrieved the original, hit when the content was used after retrieval +// (the reference store's touch; Step 13's expand outcomes build on these). +// It reports whether the record exists; a nil store or unknown id is a +// no-op. With both flags false it is a pure existence check. The updated +// record is re-appended whole when the store is file-backed +// (last-writer-wins on load, so counters survive restarts) — the simplest +// crash-atomic update, documented on the Store type. +func (s *Store) Touch(id string, expand, hit bool) bool { + if s == nil { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + rec, ok := s.records[id] + if !ok { + return false + } + if expand { + rec.ExpandCount++ + } + if hit { + rec.HitCount++ + } + if expand || hit { + s.persistLocked(*rec) + } + return true +} + +// Len reports how many records the store holds (tests and diagnostics). +func (s *Store) Len() int { + if s == nil { + return 0 + } + s.mu.Lock() + defer s.mu.Unlock() + return len(s.records) +} + +// NextID mints the next legacy elide-pointer id ("elide-", 1-based). +// New pointers are content-addressed and never mint ids; the counter exists +// only so pre-content-id stores keep minting distinct legacy keys — on a +// reloaded file-backed store it resumes past every legacy id already on +// disk. A nil store returns "". +func (s *Store) NextID() string { + if s == nil { + return "" + } + s.mu.Lock() + defer s.mu.Unlock() + s.next++ + return fmt.Sprintf("elide-%d", s.next) +} + +// upsertLocked inserts rec when its id is new (copying it, with its +// segment ids, into the store and returning the stored record and true) or +// — with lastWriterWins — replaces the existing record in place (keeping +// its order position; returning false, nothing new to persist). Without +// lastWriterWins an existing id keeps its first record (Put idempotency). +// Caller holds mu. +func (s *Store) upsertLocked(rec Record, lastWriterWins bool) (*Record, bool) { + if existing, ok := s.records[rec.ID]; ok { + if !lastWriterWins { + return existing, false + } + *existing = rec + return existing, false + } + if rec.Kind == "" { + rec.Kind = RecordKindElidedSegment + } + stored := rec + stored.SegmentIDs = cloneStrings(rec.SegmentIDs) + s.records[rec.ID] = &stored + s.order = append(s.order, rec.ID) + return &stored, true +} + +// persistLocked appends rec as one JSON line to the backing file. Caller +// holds mu. One marshal, one OpenFile (O_CREATE|O_WRONLY|O_APPEND, 0600), +// one Write of the whole line — the ShadowLog.Append crash-atomic shape: +// concurrent processes interleave whole lines, and a crash leaves either a +// complete line or a torn trailing one that loads skip. +func (s *Store) persistLocked(rec Record) { + if s.path == "" { + return + } + line, err := json.Marshal(rec) + if err != nil { + return // Record fields are all JSON-native; defensive only + } + line = append(line, '\n') + f, err := os.OpenFile(s.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return + } + defer f.Close() + _, _ = f.Write(line) +} + +// repairTornTail terminates a torn trailing line — the residue of a crash +// mid-append — with a newline, so the next append starts on a fresh line +// instead of welding a good record onto the unparsable tail (the torn bytes +// stay on their own skipped line). A no-op when the file ends cleanly. +func (s *Store) repairTornTail() { + f, err := os.OpenFile(s.path, os.O_RDWR, 0o600) + if err != nil { + return // missing file: nothing to repair + } + defer f.Close() + st, err := f.Stat() + if err != nil || st.Size() == 0 { + return + } + last := make([]byte, 1) + if _, err := f.ReadAt(last, st.Size()-1); err != nil { + return + } + if last[0] != '\n' { + if _, err := f.WriteAt([]byte{'\n'}, st.Size()); err != nil { + return + } + } +} + +// load reads the backing file into memory: one JSON record per line, +// last-writer-wins per id (Touch re-appends whole records, so a later line +// carries newer counters). Malformed or id-less lines are skipped — a torn +// trailing line must not poison the records before it. Legacy "elide-" +// ids advance the NextID counter so minted keys stay distinct from loaded +// ones. +func (s *Store) load() error { + f, err := os.Open(s.path) + if err != nil { + if os.IsNotExist(err) { + return nil // first run: nothing persisted yet + } + return fmt.Errorf("compaction: open store %s: %w", s.path, err) + } + defer f.Close() + + // Records carry full run text (a giant tool output is a legal record), + // so lines have no fixed bound; bufio.Reader grows per line as needed + // and the last line may lack its newline (EOF returns it whole). + r := bufio.NewReaderSize(f, 64*1024) + for { + line, rerr := r.ReadString('\n') + if rec, ok := parseRecordLine(line); ok { + s.mu.Lock() + s.upsertLocked(rec, true) + if n, ok := legacyElideNumber(rec.ID); ok && n > s.next { + s.next = n + } + s.mu.Unlock() + } + if rerr != nil { + if errors.Is(rerr, io.EOF) { + return nil + } + return fmt.Errorf("compaction: read store %s: %w", s.path, rerr) + } + } +} + +// parseRecordLine decodes one JSONL record line; blank and malformed lines +// (torn appends) report false. +func parseRecordLine(line string) (Record, bool) { + line = strings.TrimSpace(line) + if line == "" { + return Record{}, false + } + var rec Record + if err := json.Unmarshal([]byte(line), &rec); err != nil || rec.ID == "" { + return Record{}, false + } + return rec, true +} + +// legacyElideNumber parses the counter of a legacy "elide-" id; false +// for content ids and anything else. +func legacyElideNumber(id string) (int, bool) { + rest, ok := strings.CutPrefix(id, "elide-") + if !ok { + return 0, false + } + n, err := strconv.Atoi(rest) + if err != nil || n < 0 { + return 0, false + } + return n, true +} + +// cloneStrings copies a string slice (nil stays nil). +func cloneStrings(in []string) []string { + if in == nil { + return nil + } + return append([]string(nil), in...) +} diff --git a/internal/compaction/store_test.go b/internal/compaction/store_test.go new file mode 100644 index 00000000..fcf989b0 --- /dev/null +++ b/internal/compaction/store_test.go @@ -0,0 +1,409 @@ +package compaction + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "sync" + "testing" +) + +// openFileStore opens a file-backed store under t.TempDir() and fails the +// test on error. +func openFileStore(t *testing.T, name string) (*Store, string) { + t.Helper() + path := filepath.Join(t.TempDir(), name) + s, err := OpenStore(path) + if err != nil { + t.Fatalf("OpenStore(%s) error = %v", path, err) + } + return s, path +} + +// storeLines reads a JSONL file and returns its non-blank lines. +func storeLines(t *testing.T, path string) []string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read store %s: %v", path, err) + } + var lines []string + for _, line := range strings.Split(string(data), "\n") { + if strings.TrimSpace(line) != "" { + lines = append(lines, line) + } + } + return lines +} + +// sampleRecord builds a fully-populated record whose text exercises JSON +// escaping (quotes, backslashes, newlines). +func sampleRecord(id string) Record { + return Record{ + ID: id, + Text: "line one \"quoted\" \\slash\nline two", + Kind: RecordKindElidedSegment, + Origin: Origin{Source: "tool:Bash", Ref: "call_9", Turn: 0}, + Tokens: 42, + Summary: `line one "quoted" \slash …`, + SegmentIDs: []string{"seg-2", "seg-3"}, + } +} + +// TestStore_PersistReloadRoundTrip is the Step 12 core pin: a record put +// into a file-backed store comes back byte-identical — full Record, not +// just text — from a freshly reopened store, and the legacy Get view still +// answers with the text. +func TestStore_PersistReloadRoundTrip(t *testing.T) { + s, path := openFileStore(t, "store.jsonl") + want := sampleRecord("r:aaaa1111") + s.PutRecord(want) + legacy := "legacy original text" + s.Put("elide-3", legacy) + + reopened, err := OpenStore(path) + if err != nil { + t.Fatalf("reopen OpenStore(%s) error = %v", path, err) + } + got, ok := reopened.GetRecord("r:aaaa1111") + if !ok { + t.Fatalf("reopened store must hold a record for r:aaaa1111") + } + if !reflect.DeepEqual(got, &want) { + t.Errorf("reopened record = %+v, want %+v", got, &want) + } + if text, ok := reopened.Get("r:aaaa1111"); !ok || text != want.Text { + t.Errorf("reopened Get = (%q, %v), want the record text", text, ok) + } + if text, ok := reopened.Get("elide-3"); !ok || text != legacy { + t.Errorf("reopened Get(elide-3) = (%q, %v), want the legacy original", text, ok) + } + if reopened.Len() != 2 { + t.Errorf("reopened Len() = %d, want 2", reopened.Len()) + } + // Records() reports first-appearance order and the same snapshots. + recs := reopened.Records() + if len(recs) != 2 || recs[0].ID != "r:aaaa1111" || recs[1].ID != "elide-3" { + t.Errorf("Records() ids = %v, want [r:aaaa1111 elide-3]", recIDs(recs)) + } + if !reflect.DeepEqual(recs[0], got) { + t.Errorf("Records()[0] = %+v, want the same snapshot as GetRecord", recs[0]) + } +} + +// TestStore_PutDefaultsAndLegacyView: a record written through the legacy +// string Put is stored as a full record — default kind, estimated token +// count, pointer summary — so even legacy-written ids carry metadata. +func TestStore_PutDefaultsAndLegacyView(t *testing.T) { + s, _ := openFileStore(t, "store.jsonl") + s.Put("elide-7", "some original run text") + + rec, ok := s.GetRecord("elide-7") + if !ok { + t.Fatal("Put must create a readable record") + } + if rec.Text != "some original run text" { + t.Errorf("Text = %q, want the stored original", rec.Text) + } + if rec.Kind != RecordKindElidedSegment { + t.Errorf("Kind = %q, want the default %q", rec.Kind, RecordKindElidedSegment) + } + if rec.Tokens <= 0 { + t.Errorf("Tokens = %d, want the estimated count", rec.Tokens) + } + if want := Summarise("some original run text", SummaryMaxChars); rec.Summary != want { + t.Errorf("Summary = %q, want %q", rec.Summary, want) + } +} + +// TestStore_PutIdempotentKeepsFirstRecord: re-storing under a stored id +// keeps the first record, appends no second line, and reload sees the +// first content — content ids make duplicate puts the norm, and the file +// must not grow for them. +func TestStore_PutIdempotentKeepsFirstRecord(t *testing.T) { + s, path := openFileStore(t, "store.jsonl") + first := sampleRecord("r:aaaa2222") + s.PutRecord(first) + // Same id, different everything: must be ignored wholesale. + dup := first + dup.Text = "second content" + dup.Tokens = 999 + dup.Origin = Origin{Source: "history"} + s.PutRecord(dup) + s.Put("r:aaaa2222", "third content") + + if text, _ := s.Get("r:aaaa2222"); text != first.Text { + t.Errorf("Get = %q, want the first record's text kept", text) + } + if s.Len() != 1 { + t.Errorf("Len() = %d, want 1", s.Len()) + } + lines := storeLines(t, path) + if len(lines) != 1 { + t.Fatalf("store file has %d lines, want 1 (no line for an idempotent re-put)", len(lines)) + } + reopened, err := OpenStore(path) + if err != nil { + t.Fatalf("reopen error = %v", err) + } + got, ok := reopened.GetRecord("r:aaaa2222") + if !ok || !reflect.DeepEqual(got, &first) { + t.Errorf("reopened record = %+v (%v), want the first record %+v", got, ok, &first) + } +} + +// TestStore_TouchCountersSurviveReload: Touch bumps the expand/hit +// counters in memory, persists them (last-writer-wins full-record lines), +// leaves unknown ids and flag-less touches alone, and the counters survive +// a reopen. +func TestStore_TouchCountersSurviveReload(t *testing.T) { + s, path := openFileStore(t, "store.jsonl") + s.PutRecord(sampleRecord("r:aaaa3333")) + id := "r:aaaa3333" + if !s.Touch(id, true, false) || !s.Touch(id, true, false) || !s.Touch(id, false, true) { + t.Fatal("Touch on a stored record must report found") + } + if rec, _ := s.GetRecord(id); rec.ExpandCount != 2 || rec.HitCount != 1 { + t.Errorf("counters = (expand %d, hit %d), want (2, 1)", rec.ExpandCount, rec.HitCount) + } + if s.Touch("r:unknown", true, true) { + t.Error("Touch on an unknown id must report not-found") + } + before := len(storeLines(t, path)) + if !s.Touch(id, false, false) { + t.Error("flag-less Touch on a stored record reports found") + } + if len(storeLines(t, path)) != before { + t.Error("a flag-less Touch must not append a line") + } + + reopened, err := OpenStore(path) + if err != nil { + t.Fatalf("reopen error = %v", err) + } + rec, ok := reopened.GetRecord(id) + if !ok { + t.Fatal("reopened store must hold the touched record") + } + if rec.ExpandCount != 2 || rec.HitCount != 1 { + t.Errorf("reopened counters = (expand %d, hit %d), want (2, 1)", rec.ExpandCount, rec.HitCount) + } + if rec.Tokens != 42 { + t.Errorf("reopened Tokens = %d, want the untouched 42", rec.Tokens) + } +} + +// TestStore_NextIDResumesPastLoadedLegacyIDs: the legacy counter restarts +// past every elide-N id already on disk, so minted keys stay distinct from +// loaded ones across restarts. +func TestStore_NextIDResumesPastLoadedLegacyIDs(t *testing.T) { + s, path := openFileStore(t, "store.jsonl") + s.Put("elide-1", "one") + s.Put("elide-3", "three") + + reopened, err := OpenStore(path) + if err != nil { + t.Fatalf("reopen error = %v", err) + } + if got := reopened.NextID(); got != "elide-4" { + t.Errorf("NextID() = %q, want elide-4 (resumed past the loaded elide-3)", got) + } + // In-memory stores keep the 1-based counter. + if got := NewStore().NextID(); got != "elide-1" { + t.Errorf("fresh NextID() = %q, want elide-1", got) + } +} + +// TestStore_ConcurrentPutsWriteWholeLines: concurrent puts on a file-backed +// store are race-free (run under -race), every persisted line is complete +// JSON (crash-atomic per line), and a reopen sees every record. +func TestStore_ConcurrentPutsWriteWholeLines(t *testing.T) { + s, path := openFileStore(t, "store.jsonl") + const n = 32 + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + go func(k int) { + defer wg.Done() + rec := sampleRecord(contentIDFor(k)) + s.PutRecord(rec) + // Concurrent reads race the writes; both are mutex-guarded. + s.Get(rec.ID) + s.GetRecord(rec.ID) + }(i) + } + wg.Wait() + if s.Len() != n { + t.Errorf("Len() = %d, want %d", s.Len(), n) + } + for i, line := range storeLines(t, path) { + var rec Record + if err := json.Unmarshal([]byte(line), &rec); err != nil || rec.ID == "" { + t.Fatalf("store line %d is not a complete JSON record: %v (%q)", i+1, err, line) + } + } + + reopened, err := OpenStore(path) + if err != nil { + t.Fatalf("reopen error = %v", err) + } + for k := 0; k < n; k++ { + rec, ok := reopened.GetRecord(contentIDFor(k)) + if !ok { + t.Errorf("reopened store lost record %d", k) + } else if rec.Tokens != 42 { + t.Errorf("record %d tokens = %d, want 42", k, rec.Tokens) + } + } +} + +// contentIDFor mints a distinct id per concurrent writer. +func contentIDFor(k int) string { + return fmt.Sprintf("r:conc%04d", k) +} + +// TestStore_TornTrailingLineSkippedAndRepaired: a crash mid-append leaves +// a torn trailing line; loading skips it, the reopen repairs the framing, +// and records appended afterwards stay readable instead of welding onto +// the tail. +func TestStore_TornTrailingLineSkippedAndRepaired(t *testing.T) { + s, path := openFileStore(t, "store.jsonl") + s.PutRecord(sampleRecord("r:aaaa4444")) + s.Put("elide-5", "second record") + + // Simulate the crash: a torn record line without its newline. + f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(`{"id":"r:torn","text":"cut o`); err != nil { + t.Fatal(err) + } + f.Close() + + reopened, err := OpenStore(path) + if err != nil { + t.Fatalf("reopen with torn tail error = %v", err) + } + if _, ok := reopened.GetRecord("r:torn"); ok { + t.Error("the torn line must not load as a record") + } + if reopened.Len() != 2 { + t.Errorf("reopened Len() = %d, want the 2 intact records", reopened.Len()) + } + // The repaired framing keeps the next append on its own line. + reopened.Put("r:aaaa5555", "third record") + + again, err := OpenStore(path) + if err != nil { + t.Fatalf("reopen after append error = %v", err) + } + if text, ok := again.Get("r:aaaa5555"); !ok || text != "third record" { + t.Errorf("Get(r:aaaa5555) = (%q, %v), want the post-repair record", text, ok) + } + if again.Len() != 3 { + t.Errorf("Len() = %d, want 3", again.Len()) + } +} + +// TestStore_OpenMissingFileAndPerms: opening a store whose file does not +// exist yet is an empty store (not an error); the first append creates the +// file 0600 and OpenStore created the parent directories 0700. +func TestStore_OpenMissingFileAndPerms(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permission bits do not apply on Windows") + } + path := filepath.Join(t.TempDir(), "nested", "deeper", "store.jsonl") + s, err := OpenStore(path) + if err != nil { + t.Fatalf("OpenStore on a missing file error = %v, want an empty store", err) + } + if s.Len() != 0 { + t.Errorf("fresh store Len() = %d, want 0", s.Len()) + } + if _, ok := s.Get("r:anything"); ok { + t.Error("fresh store must report misses") + } + s.Put("r:first", "text") + st, err := os.Stat(path) + if err != nil { + t.Fatalf("store file not created on first append: %v", err) + } + if got := st.Mode().Perm(); got != 0o600 { + t.Errorf("store file mode = %o, want 600", got) + } + dirSt, err := os.Stat(filepath.Dir(path)) + if err != nil { + t.Fatalf("store dir missing: %v", err) + } + if got := dirSt.Mode().Perm(); got != 0o700 { + t.Errorf("store dir mode = %o, want 700", got) + } +} + +// TestStore_InMemoryAndEmptyPath: NewStore() and OpenStore("") are purely +// in-memory (no path, nothing persisted) — the tests/legacy contract. +func TestStore_InMemoryAndEmptyPath(t *testing.T) { + s := NewStore() + if s.Path() != "" { + t.Errorf("NewStore().Path() = %q, want empty", s.Path()) + } + s.Put("r:mem", "in-memory") + if text, ok := s.Get("r:mem"); !ok || text != "in-memory" { + t.Errorf("Get = (%q, %v), want the in-memory text", text, ok) + } + + empty, err := OpenStore("") + if err != nil { + t.Fatalf("OpenStore(\"\") error = %v, want the in-memory store", err) + } + if empty.Path() != "" { + t.Errorf("OpenStore(\"\").Path() = %q, want empty", empty.Path()) + } + empty.Put("r:mem2", "also in-memory") + if empty.Len() != 1 { + t.Errorf("Len() = %d, want 1", empty.Len()) + } +} + +// TestStore_NilStoreNoOps pins the nil-store contract: every method is a +// safe no-op with zero-value results. +func TestStore_NilStoreNoOps(t *testing.T) { + var s *Store + if text, ok := s.Get("r:x"); text != "" || ok { + t.Error("nil Get must report a miss") + } + if rec, ok := s.GetRecord("r:x"); rec != nil || ok { + t.Error("nil GetRecord must report a miss") + } + if recs := s.Records(); recs != nil { + t.Error("nil Records must be nil") + } + if s.Len() != 0 { + t.Error("nil Len must be 0") + } + if id := s.NextID(); id != "" { + t.Error("nil NextID must be empty") + } + if p := s.Path(); p != "" { + t.Error("nil Path must be empty") + } + s.Put("r:x", "text") // must not panic + s.PutRecord(Record{ID: "r:x", Text: "text"}) // must not panic + if s.Touch("r:x", true, true) { + t.Error("nil Touch must report not-found") + } +} + +// recIDs maps records to their ids (test helper). +func recIDs(recs []*Record) []string { + out := make([]string, 0, len(recs)) + for _, r := range recs { + out = append(out, r.ID) + } + return out +} diff --git a/internal/compaction/validation_test.go b/internal/compaction/validation_test.go new file mode 100644 index 00000000..cc95046b --- /dev/null +++ b/internal/compaction/validation_test.go @@ -0,0 +1,34 @@ +package compaction + +import "testing" + +// TestStore_PutRejectsEmptyIDOrText pins the write-side validation: a record +// without an id is unreachable through any pointer and a record without text +// has nothing to reconstruct — both are no-ops that leave no trace in the +// records, the first-appearance order, or the digest. +func TestStore_PutRejectsEmptyIDOrText(t *testing.T) { + s := NewStore() + + s.Put("", "orphan text") + if got := s.Len(); got != 0 { + t.Errorf("Put with an empty id stored a record (Len = %d)", got) + } + s.Put("id-1", "") + if got := s.Len(); got != 0 { + t.Errorf("Put with empty text stored a record (Len = %d)", got) + } + s.PutRecord(Record{ID: "", Text: "orphan text"}) + s.PutRecord(Record{ID: "id-1", Text: ""}) + if got := s.Len(); got != 0 { + t.Errorf("PutRecord with an empty id or text stored a record (Len = %d)", got) + } + if entries := s.Digest(24_000); len(entries) != 0 { + t.Errorf("Digest after rejected puts = %d entries, want 0", len(entries)) + } + + // Valid writes are untouched by the validation. + s.Put("id-2", "real original") + if text, ok := s.Get("id-2"); !ok || text != "real original" { + t.Errorf("Get(id-2) = (%q, %v), want the stored original", text, ok) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 4b0463fc..ee69b756 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,6 +39,16 @@ type ModelSetting struct { URL string `json:"url"` Key string `json:"key"` Model string `json:"model"` + + // JevAutocompactPercent is the per-model override of the global + // autocompact trigger: different models have different context sizes, so + // the same "compact at N% of the window" level is not right for every + // model. It reuses the top-level jev-autocompact-percent key name inside + // the entry. 0/unset = use the global; values 1-100 are honored and win + // over the global for every agent whose agent_models entry routes to + // this model (see Config.AutocompactPercentForAgent); out-of-range + // values warn at startup and are ignored (see Config.AutocompactWarnings). + JevAutocompactPercent int `json:"jev-autocompact-percent,omitempty"` } // Reference returns the stable value stored in agent_models. Model is retained @@ -50,11 +60,86 @@ func (m ModelSetting) Reference() string { return m.Model } +// AutocompactPercentOverride reports the entry's per-model autocompact +// trigger override. The second return is true only for a valid percentage +// (1-100): 0/unset means "no override — use the global", and an out-of-range +// value is ignored with a startup warning (Config.AutocompactWarnings), so +// both resolve to the global threshold. +func (m ModelSetting) AutocompactPercentOverride() (int, bool) { + if m.JevAutocompactPercent >= 1 && m.JevAutocompactPercent <= 100 { + return m.JevAutocompactPercent, true + } + return 0, false +} + const ( configDirPerm os.FileMode = 0o700 configFilePerm os.FileMode = 0o600 ) +// DefaultCompactionThresholdPercent is the context-usage percentage at which +// compaction happens when config.json does not set +// compaction-threshold-percent (or sets an invalid value). +const DefaultCompactionThresholdPercent = 80 + +// Compaction modes (staged rollout of the jev-compaction port). The +// effective mode is resolved by ResolveCompactionMode: an explicitly set +// --compaction-mode flag (validated in main) > config.json compaction-mode > +// DefaultCompactionMode. +const ( + // CompactionModeOff disables compaction entirely: no scoring, no shadow + // log, no relocation. + CompactionModeOff = "off" + // CompactionModeShadow scores tool outputs and appends to the shadow log + // without changing any tool result (the stage-1 behavior). + CompactionModeShadow = "shadow" + // CompactionModeEnabled additionally relocates low-scoring segments out + // of tool results (with [[elided …]] pointers plus the expand tool to + // retrieve the originals). + CompactionModeEnabled = "enabled" +) + +// DefaultCompactionMode is the compaction-mode default: score and shadow-log +// only, never change agent behavior. +const DefaultCompactionMode = CompactionModeShadow + +// DefaultJevAutocompactPercent is the context-usage percentage at which the +// JEV auto-compaction trigger fires when config.json does not set +// jev-autocompact-percent (or sets an invalid value). +const DefaultJevAutocompactPercent = 99 + +// DefaultCompactionMaxElidePercent is the elide-fraction tripwire: when the +// compaction scorer wants to elide more than this percentage of a tool +// output's tokens, it is distrusted and nothing is elided (reference: +// jev-compaction pipeline.py max_elide_fraction=0.7). Applied when +// config.json does not set compaction-max-elide-percent (or sets an +// invalid value). +const DefaultCompactionMaxElidePercent = 70 + +// DefaultCompactionThreshold mirrors compaction.DefaultRelocationThreshold — +// the score strictly below which segments are elided (re-declared here so +// the config package stays free of a compaction import). It is the fallback +// when neither the -compaction-threshold flag nor config.json +// compaction-threshold provides a valid value. +const DefaultCompactionThreshold = 0.35 + +// DefaultCompactionProtectedFloorPercent is the score floor (as a +// percentage) under which protected segment kinds (stacktrace, diff) may be +// elided: at any higher score they are kept even below the normal +// threshold (reference: pipeline.py protected_floor=0.05). Applied when +// config.json does not set compaction-protected-floor (or sets an invalid +// value). +const DefaultCompactionProtectedFloorPercent = 5 + +// CompactionBackendOffline is the compaction-backend value that selects the +// deterministic offline scripted scorer (compaction.ScriptedScorer): the +// whole compaction flow runs locally with no API key and no network. It +// exists for demos and tests — the scripted scores are a content hash, not a +// judgment of essentialness — and must never become a production default. +// It mirrors compaction.OfflineBackendName (re-declared here so the config +// package stays free of a compaction import). +const CompactionBackendOffline = "offline" + // Config represents the application configuration. type Config struct { EnabledTools map[string]bool `json:"enabled_tools"` @@ -86,6 +171,87 @@ type Config struct { Theme string `json:"theme,omitempty"` Models []ModelSetting `json:"models,omitempty"` AgentModels map[string]string `json:"agent_models,omitempty"` + + // CompactionThresholdPercent is the context-usage percentage at which + // compaction should trigger (surfaced by the TUI info bar as the + // remaining headroom). 0 means DefaultCompactionThresholdPercent; + // values outside 1-100 are invalid and resolve back to the default + // with a warning (see ResolveCompactionThreshold). + CompactionThresholdPercent int `json:"compaction-threshold-percent,omitempty"` + + // CompactionMode selects the staged rollout stage of the tool-output + // compaction port: CompactionModeOff, CompactionModeShadow (default: + // score + shadow log only), or CompactionModeEnabled (also relocate + // low-scoring segments and register the expand tool). Set via config + // file; the --compaction-mode CLI flag overrides it. Invalid values + // warn and fall back to DefaultCompactionMode (see + // ResolveCompactionMode). + CompactionMode string `json:"compaction-mode,omitempty"` + + // JevAutocompact enables the automatic full-history context compaction + // (the /jev-compact-context flow): when the focused agent's context + // usage crosses JevAutocompactPercent of the context window, the TUI + // runs one compaction pass. Default false. + JevAutocompact bool `json:"jev-autocompact,omitempty"` + + // JevAutocompactPercent is that threshold percentage. 0 (unset) means + // DefaultJevAutocompactPercent; values outside 1-100 are invalid and + // resolve back to the default with a warning (see ResolveAutocompact). + JevAutocompactPercent int `json:"jev-autocompact-percent,omitempty"` + + // CompactionMaxElidePercent is the compaction gate's elide-fraction + // tripwire as a percentage: when the scorer wants to elide more than + // this share of a tool output's tokens, the scorer is distrusted and + // NOTHING is elided (the tripwire is recorded in the result and the + // shadow log). 0 (unset) means DefaultCompactionMaxElidePercent; + // values outside 1-100 are invalid and resolve back to the default with + // a warning (see ResolveCompactionMaxElidePercent). The tripwire cannot + // be disabled from config.json (100 still trips on an over-100% claim, + // i.e. never — set it to 100 for the closest thing to off). + CompactionMaxElidePercent int `json:"compaction-max-elide-percent,omitempty"` + + // CompactionThreshold is the elision score threshold: the score + // strictly below which tool-output segments (and full-history + // segments, for /jev-compact-context) are elided when compaction-mode + // is "enabled". 0 (unset) means the -compaction-threshold flag, or the + // DefaultCompactionThreshold (0.35) when the flag is not passed; values + // outside (0,1] are invalid and resolve back to the default with a + // warning (see ResolveCompactionScoreThreshold). NOTE: this is the + // per-segment SCORE cutoff — compaction-threshold-percent above is a + // different knob (the context-usage level the info bar reports + // headroom for), and jev-autocompact-percent is a third one (the + // context-usage level that fires the auto-trigger). + CompactionThreshold float64 `json:"compaction-threshold,omitempty"` + + // CompactionProtectedFloor is the score floor, as a percentage, under + // which protected segment kinds (stacktrace, diff) may be elided: at + // any higher score they are kept even below the normal threshold. + // 0 (unset) means DefaultCompactionProtectedFloorPercent; values + // outside 1-100 are invalid and resolve back to the default with a + // warning (see ResolveCompactionProtectedFloor). + CompactionProtectedFloor int `json:"compaction-protected-floor,omitempty"` + + // CompactionBackend selects where compaction scores come from. The only + // value today is CompactionBackendOffline ("offline"): the deterministic + // scripted scorer — no API key, no network, demos and tests only (its + // scores are content hashes, not judgments). Empty (unset) keeps the + // env-based backend resolution (JEV_API / auto-detection) that + // compaction.ResolveBackendEnv performs. A set value WINS over the env + // — the config entry is the explicit statement about where scoring + // happens, so JEV_API is only consulted when this entry is absent. + // Invalid values warn and fall back to the env-based resolution (see + // ResolveCompactionBackend). + CompactionBackend string `json:"compaction-backend,omitempty"` + + // CompactionRetrieval enables the retrieve() read side of the compaction + // record store (Step 17): before every stream request the store's digest + // summaries are scored against the current task and the top-k relevant + // records are appended to the outgoing request's work area (never the + // frozen prefix, never persisted to history). Default false. It needs a + // record store to read, which only fills when compaction-mode is + // "enabled" — ResolveCompactionRetrieval warns about the inert + // combinations. + CompactionRetrieval bool `json:"compaction-retrieval,omitempty"` } func defaultConfig() Config { @@ -147,6 +313,17 @@ func LoadConfig() (*Config, error) { return &fallback, err } + // Unknown keys inside models[] entries are fatal, located errors: the + // typed decode would silently drop a hand-edited typo there, and the + // per-model settings (including the per-model jev-autocompact-percent + // override) are exactly where hand edits go wrong. See + // models_entry_keys.go; value-range problems keep the warn-and-fall-back + // path (AutocompactWarnings). + if err := checkModelsEntryKeys(configPath, content); err != nil { + fallback := defaultConfig() + return &fallback, err + } + if cfg.EnabledTools == nil { cfg.EnabledTools = defaultConfig().EnabledTools } else { @@ -292,6 +469,212 @@ func ResolvePermissionMode(cfg *Config, askFlag, unsupervisedFlag bool) (mode st return PermissionModeAskForUserApproval, "", nil } +// ResolveCompactionThreshold returns the effective compaction threshold +// percentage and a warning string, mirroring ResolvePermissionMode's +// invalid-value pattern: 0 (unset) means DefaultCompactionThresholdPercent, +// values in 1-100 are honored as-is, and anything else falls back to the +// default with a warning. +func ResolveCompactionThreshold(cfg *Config) (threshold int, warning string) { + if cfg == nil { + return DefaultCompactionThresholdPercent, "" + } + if cfg.CompactionThresholdPercent >= 1 && cfg.CompactionThresholdPercent <= 100 { + return cfg.CompactionThresholdPercent, "" + } + if cfg.CompactionThresholdPercent == 0 { + return DefaultCompactionThresholdPercent, "" + } + return DefaultCompactionThresholdPercent, + fmt.Sprintf("ignoring invalid config.json compaction-threshold-percent %d; using %d", + cfg.CompactionThresholdPercent, DefaultCompactionThresholdPercent) +} + +// IsValidCompactionMode reports whether mode is one of the accepted +// compaction-mode values (off, shadow, enabled). +func IsValidCompactionMode(mode string) bool { + switch mode { + case CompactionModeOff, CompactionModeShadow, CompactionModeEnabled: + return true + } + return false +} + +// ResolveCompactionMode returns the effective compaction mode from +// config.json and a warning string, mirroring ResolveCompactionThreshold's +// invalid-value pattern: empty (unset) means DefaultCompactionMode, valid +// values are honored as-is, and anything else falls back to the default with +// a warning. The --compaction-mode CLI flag overrides the resolved value and +// is validated with IsValidCompactionMode by the caller (main). +func ResolveCompactionMode(cfg *Config) (mode string, warning string) { + if cfg == nil || cfg.CompactionMode == "" { + return DefaultCompactionMode, "" + } + if IsValidCompactionMode(cfg.CompactionMode) { + return cfg.CompactionMode, "" + } + return DefaultCompactionMode, + fmt.Sprintf("ignoring invalid config.json compaction-mode %q; using %q", + cfg.CompactionMode, DefaultCompactionMode) +} + +// ResolveAutocompact returns the effective JEV auto-compaction switch and +// threshold percentage, mirroring ResolveCompactionThreshold's invalid-value +// pattern: percent 0 (unset) means DefaultJevAutocompactPercent, values in +// 1-100 are honored as-is, and anything else falls back to the default with +// a warning. The switch is a plain boolean: absent (false) means the +// auto-trigger never fires. +func ResolveAutocompact(cfg *Config) (enabled bool, percent int, warning string) { + if cfg == nil { + return false, DefaultJevAutocompactPercent, "" + } + percent = DefaultJevAutocompactPercent + switch { + case cfg.JevAutocompactPercent >= 1 && cfg.JevAutocompactPercent <= 100: + percent = cfg.JevAutocompactPercent + case cfg.JevAutocompactPercent == 0: + // Unset: keep the default. + default: + warning = fmt.Sprintf("ignoring invalid config.json jev-autocompact-percent %d; using %d", + cfg.JevAutocompactPercent, DefaultJevAutocompactPercent) + } + return cfg.JevAutocompact, percent, warning +} + +// ResolveCompactionScoreThreshold resolves the elision score threshold: the +// score strictly below which segments are elided when compaction-mode is +// "enabled" (also the /jev-compact-context walk's keep threshold). It is a +// DIFFERENT knob from compaction-threshold-percent (the context-usage level +// the info bar reports headroom for) and from jev-autocompact-percent (the +// context-usage level that fires the auto-trigger). +// +// Precedence: an explicitly passed -compaction-threshold flag > config.json +// compaction-threshold > DefaultCompactionThreshold (0.35). The caller +// passes flagValue = 0 when the flag was NOT on the command line (the +// flag.Visit detection in main) — flag values are indistinguishable from +// defaults through the flag package alone, and 0 is itself invalid, so it +// doubles cleanly as the "not set" sentinel. Valid range is (0,1] for both +// sources: an explicitly passed but out-of-range flag warns and falls +// through to the config entry (or the default), and an out-of-range config +// value warns and falls back to the default. +func ResolveCompactionScoreThreshold(cfg *Config, flagValue float64) (threshold float64, warning string) { + if flagValue > 0 && flagValue <= 1 { + // Explicit, valid flag wins over everything. + return flagValue, "" + } + if cfg != nil && cfg.CompactionThreshold > 0 && cfg.CompactionThreshold <= 1 { + threshold = cfg.CompactionThreshold + } else { + threshold = DefaultCompactionThreshold + } + switch { + case flagValue != 0: + // Explicit but out of range: the caller meant to set it, so say so. + warning = fmt.Sprintf("ignoring invalid -compaction-threshold %v; using %v", flagValue, threshold) + case cfg != nil && (cfg.CompactionThreshold < 0 || cfg.CompactionThreshold > 1): + // Present but invalid. 0 is the silent unset (see the Config doc); + // anything else outside (0,1] warns. + warning = fmt.Sprintf("ignoring invalid config.json compaction-threshold %v; using %v", cfg.CompactionThreshold, threshold) + } + return threshold, warning +} + +// ResolveCompactionMaxElidePercent returns the effective compaction +// elide-fraction tripwire percentage and a warning string, mirroring +// ResolveCompactionThreshold's invalid-value pattern: 0 (unset) means +// DefaultCompactionMaxElidePercent, values in 1-100 are honored as-is, and +// anything else falls back to the default with a warning. +func ResolveCompactionMaxElidePercent(cfg *Config) (percent int, warning string) { + if cfg == nil { + return DefaultCompactionMaxElidePercent, "" + } + if cfg.CompactionMaxElidePercent >= 1 && cfg.CompactionMaxElidePercent <= 100 { + return cfg.CompactionMaxElidePercent, "" + } + if cfg.CompactionMaxElidePercent == 0 { + return DefaultCompactionMaxElidePercent, "" + } + return DefaultCompactionMaxElidePercent, + fmt.Sprintf("ignoring invalid config.json compaction-max-elide-percent %d; using %d", + cfg.CompactionMaxElidePercent, DefaultCompactionMaxElidePercent) +} + +// ResolveCompactionProtectedFloor returns the effective score floor (as a +// percentage) under which protected segment kinds may be elided, mirroring +// ResolveCompactionThreshold's invalid-value pattern: 0 (unset) means +// DefaultCompactionProtectedFloorPercent, values in 1-100 are honored +// as-is, and anything else falls back to the default with a warning. +func ResolveCompactionProtectedFloor(cfg *Config) (percent int, warning string) { + if cfg == nil { + return DefaultCompactionProtectedFloorPercent, "" + } + if cfg.CompactionProtectedFloor >= 1 && cfg.CompactionProtectedFloor <= 100 { + return cfg.CompactionProtectedFloor, "" + } + if cfg.CompactionProtectedFloor == 0 { + return DefaultCompactionProtectedFloorPercent, "" + } + return DefaultCompactionProtectedFloorPercent, + fmt.Sprintf("ignoring invalid config.json compaction-protected-floor %d; using %d", + cfg.CompactionProtectedFloor, DefaultCompactionProtectedFloorPercent) +} + +// IsValidCompactionBackend reports whether backend is one of the accepted +// compaction-backend values (offline). +func IsValidCompactionBackend(backend string) bool { + return backend == CompactionBackendOffline +} + +// ResolveCompactionBackend returns the effective compaction-backend value +// from config.json plus a warning, mirroring ResolveCompactionMode's +// invalid-value pattern. Precedence: a set config value WINS — scoring goes +// exactly where the user pointed it, and the env-based backend resolution +// (JEV_API / auto-detection inside compaction.ResolveBackendEnv) is only +// consulted when this entry is ABSENT. So: +// +// - ("", "") — unset (or a nil config): the caller resolves a backend +// from the environment as before. +// - ("offline", "") — the offline scripted scorer; the caller builds the +// offline pipeline and never resolves a backend or needs a key. +// - ("", warning) — an unrecognized value: the config entry is ignored +// with the warning, and the caller falls back to the env-based +// resolution. +// +// The warning names the config key so the user can fix the typo in +// config.json rather than guess which entry was rejected. +func ResolveCompactionBackend(cfg *Config) (backend string, warning string) { + if cfg == nil || cfg.CompactionBackend == "" { + return "", "" + } + if IsValidCompactionBackend(cfg.CompactionBackend) { + return cfg.CompactionBackend, "" + } + return "", fmt.Sprintf("ignoring invalid config.json compaction-backend %q (known values: %s); resolving the backend from the environment", + cfg.CompactionBackend, CompactionBackendOffline) +} + +// ResolveCompactionRetrieval returns whether the compaction store's +// retrieve() read side is enabled, mirroring ResolveAutocompact's resolver +// shape (value plus warning). The switch is a plain boolean defaulting to +// false: absent means retrieval never runs. A bool config entry has no +// invalid VALUE — a wrong-typed config.json value fails the whole parse — +// so the warning return carries +// the one invalid COMBINATION instead: retrieval enabled while +// compaction-mode is not "enabled". The record store only fills when +// relocation runs (mode "enabled"), so in shadow or off mode retrieval +// would score an eternally empty store and inject nothing; the resolver +// still honors the switch (harmless no-op) and lets the warning explain. +func ResolveCompactionRetrieval(cfg *Config) (enabled bool, warning string) { + if cfg == nil || !cfg.CompactionRetrieval { + return false, "" + } + mode, _ := ResolveCompactionMode(cfg) + if mode != CompactionModeEnabled { + return true, fmt.Sprintf("config.json compaction-retrieval has no effect while compaction-mode is %q: the record store only fills in %q mode", + mode, CompactionModeEnabled) + } + return true, "" +} + func nonEmptyEnv(lookup EnvLookup, key string) (string, bool) { if lookup == nil { return "", false @@ -360,6 +743,57 @@ func (cfg *Config) GetModelForAgent(agentType string) (ModelSetting, bool) { return ModelSetting{}, false } +// AutocompactWarnings returns one warning per models[] entry whose +// jev-autocompact-percent value parses (otherwise the strict parse aborts) +// but is outside the valid 1-100 range. The global threshold applies for that +// model until the entry is fixed, exactly like an out-of-range global +// jev-autocompact-percent warns and falls back to the default. The warnings +// name the model entry (its id, or its model name when no id is set) and the +// bad value so the user can locate the offending line. A nil config has no +// entries and returns nil. +func (cfg *Config) AutocompactWarnings() []string { + if cfg == nil { + return nil + } + var warnings []string + for _, m := range cfg.Models { + v := m.JevAutocompactPercent + if v == 0 || (v >= 1 && v <= 100) { + continue + } + name := m.ID + if name == "" { + name = m.Model + } + warnings = append(warnings, fmt.Sprintf( + "ignoring invalid config.json models[%s] jev-autocompact-percent %d; using the global jev-autocompact-percent for this model", + name, v)) + } + return warnings +} + +// AutocompactPercentForAgent resolves the effective JEV auto-compaction +// trigger percentage for one agent type. Resolution order: the +// agent_models-routed model entry's per-model override (when valid, 1-100) > +// the global jev-autocompact-percent (already validated and normalized by +// ResolveAutocompact) > DefaultJevAutocompactPercent, which the caller passes +// as the global. A nil config or an agent type with no model entry just uses +// the global. The percent keys off the agent's own model because that is +// whose context window fills. +func (cfg *Config) AutocompactPercentForAgent(agentType string, global int) int { + if cfg == nil || agentType == "" { + return global + } + setting, ok := cfg.GetModelForAgent(agentType) + if !ok { + return global + } + if override, ok := setting.AutocompactPercentOverride(); ok { + return override + } + return global +} + // SaveConfig atomically writes the configuration back to config.json. func SaveConfig(cfg *Config) error { lateConfigDir, err := pathutil.LateConfigDir() diff --git a/internal/config/config_offline_test.go b/internal/config/config_offline_test.go new file mode 100644 index 00000000..f97f21d2 --- /dev/null +++ b/internal/config/config_offline_test.go @@ -0,0 +1,61 @@ +package config + +import ( + "strings" + "testing" +) + +// TestResolveCompactionBackend pins the Step 18 resolver: unset (or nil) +// config means "resolve from the environment as before", the "offline" value +// selects the scripted scorer, and an invalid value warns (naming the config +// key) while falling back to the env-based resolution. Precedence: a set +// config value WINS over the environment — JEV_API / auto-detection are only +// consulted when the entry is absent. +func TestResolveCompactionBackend(t *testing.T) { + cases := []struct { + name string + cfg *Config + wantBackend string + wantWarning bool + }{ + {"nil config", nil, "", false}, + {"zero config", &Config{}, "", false}, + {"explicit empty", &Config{CompactionBackend: ""}, "", false}, + {"offline", &Config{CompactionBackend: CompactionBackendOffline}, CompactionBackendOffline, false}, + {"invalid value", &Config{CompactionBackend: "typesafe"}, "", true}, + {"wrong case", &Config{CompactionBackend: "Offline"}, "", true}, + {"garbage", &Config{CompactionBackend: "bogus"}, "", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + backend, warning := ResolveCompactionBackend(tc.cfg) + if backend != tc.wantBackend { + t.Errorf("ResolveCompactionBackend() backend = %q, want %q", backend, tc.wantBackend) + } + if (warning != "") != tc.wantWarning { + t.Errorf("ResolveCompactionBackend() warning = %q, wantWarning = %v", warning, tc.wantWarning) + } + if tc.wantWarning { + if !strings.Contains(warning, "compaction-backend") { + t.Errorf("warning %q does not name the config key", warning) + } + if !strings.Contains(warning, CompactionBackendOffline) { + t.Errorf("warning %q does not name the only valid value", warning) + } + } + }) + } +} + +// TestIsValidCompactionBackend pins the accepted set: exactly "offline" +// (strict equality — compaction-mode validates the same way). +func TestIsValidCompactionBackend(t *testing.T) { + if !IsValidCompactionBackend(CompactionBackendOffline) { + t.Errorf("IsValidCompactionBackend(%q) = false, want true", CompactionBackendOffline) + } + for _, invalid := range []string{"", "gateway", "OFFLINE", " offline", "scripted"} { + if IsValidCompactionBackend(invalid) { + t.Errorf("IsValidCompactionBackend(%q) = true, want false", invalid) + } + } +} diff --git a/internal/config/config_retrieval_test.go b/internal/config/config_retrieval_test.go new file mode 100644 index 00000000..b12dbf8a --- /dev/null +++ b/internal/config/config_retrieval_test.go @@ -0,0 +1,47 @@ +package config + +import ( + "strings" + "testing" +) + +// TestResolveCompactionRetrieval pins the Step 17 resolver: default false, +// honored when set, and a warning for the invalid COMBINATION — retrieval +// enabled while compaction-mode is not "enabled" (the record store only +// fills when relocation runs, so retrieval could never inject anything). +func TestResolveCompactionRetrieval(t *testing.T) { + cases := []struct { + name string + cfg *Config + wantEnabled bool + wantWarning bool + }{ + {"nil config", nil, false, false}, + {"zero config", &Config{}, false, false}, + {"explicit false", &Config{CompactionRetrieval: false, CompactionMode: CompactionModeEnabled}, false, false}, + {"enabled mode", &Config{CompactionRetrieval: true, CompactionMode: CompactionModeEnabled}, true, false}, + {"off mode warns", &Config{CompactionRetrieval: true, CompactionMode: CompactionModeOff}, true, true}, + {"shadow mode warns", &Config{CompactionRetrieval: true, CompactionMode: CompactionModeShadow}, true, true}, + {"unset mode (shadow default) warns", &Config{CompactionRetrieval: true}, true, true}, + {"invalid mode falls back to shadow and warns", &Config{CompactionRetrieval: true, CompactionMode: "bogus"}, true, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + enabled, warning := ResolveCompactionRetrieval(tc.cfg) + if enabled != tc.wantEnabled { + t.Errorf("ResolveCompactionRetrieval() enabled = %v, want %v", enabled, tc.wantEnabled) + } + if (warning != "") != tc.wantWarning { + t.Errorf("ResolveCompactionRetrieval() warning = %q, wantWarning = %v", warning, tc.wantWarning) + } + if tc.wantWarning { + if !strings.Contains(warning, "compaction-retrieval") { + t.Errorf("warning %q does not name the config key", warning) + } + if !strings.Contains(warning, CompactionModeEnabled) { + t.Errorf("warning %q does not name the mode that makes retrieval effective", warning) + } + } + }) + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5d4f8c11..0b4a0fd6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -811,6 +811,429 @@ func TestResolvePermissionMode(t *testing.T) { } } +func TestResolveCompactionThreshold(t *testing.T) { + cases := []struct { + name string + cfg *Config + want int + wantWarning []string + }{ + { + name: "nil config uses default", + cfg: nil, + want: DefaultCompactionThresholdPercent, + }, + { + name: "unset uses default", + cfg: &Config{}, + want: DefaultCompactionThresholdPercent, + }, + { + name: "valid value honored", + cfg: &Config{CompactionThresholdPercent: 65}, + want: 65, + }, + { + name: "one is valid", + cfg: &Config{CompactionThresholdPercent: 1}, + want: 1, + }, + { + name: "hundred is valid", + cfg: &Config{CompactionThresholdPercent: 100}, + want: 100, + }, + { + name: "negative value invalid", + cfg: &Config{CompactionThresholdPercent: -5}, + want: DefaultCompactionThresholdPercent, + wantWarning: []string{"invalid", "-5"}, + }, + { + name: "over hundred invalid", + cfg: &Config{CompactionThresholdPercent: 250}, + want: DefaultCompactionThresholdPercent, + wantWarning: []string{"invalid", "250"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, warning := ResolveCompactionThreshold(tc.cfg) + if got != tc.want { + t.Fatalf("ResolveCompactionThreshold() = %d, want %d", got, tc.want) + } + if len(tc.wantWarning) == 0 { + if warning != "" { + t.Fatalf("warning = %q, want empty", warning) + } + return + } + if warning == "" { + t.Fatal("warning is empty, want a warning") + } + for _, substring := range tc.wantWarning { + if !strings.Contains(warning, substring) { + t.Fatalf("warning = %q, want it to contain %q", warning, substring) + } + } + }) + } +} + +// TestResolveCompactionMode mirrors TestResolveCompactionThreshold: the +// staged rollout modes validate to off|shadow|enabled, empty means the +// default (shadow), and anything else warns and falls back to the default. +func TestResolveCompactionMode(t *testing.T) { + cases := []struct { + name string + cfg *Config + want string + wantWarning []string + }{ + { + name: "nil config uses default", + cfg: nil, + want: DefaultCompactionMode, + }, + { + name: "unset uses default", + cfg: &Config{}, + want: DefaultCompactionMode, + }, + { + name: "off honored", + cfg: &Config{CompactionMode: CompactionModeOff}, + want: CompactionModeOff, + }, + { + name: "shadow honored", + cfg: &Config{CompactionMode: CompactionModeShadow}, + want: CompactionModeShadow, + }, + { + name: "enabled honored", + cfg: &Config{CompactionMode: CompactionModeEnabled}, + want: CompactionModeEnabled, + }, + { + name: "invalid value warns and falls back", + cfg: &Config{CompactionMode: "aggressive"}, + want: DefaultCompactionMode, + wantWarning: []string{"invalid", "aggressive", DefaultCompactionMode}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, warning := ResolveCompactionMode(tc.cfg) + if got != tc.want { + t.Fatalf("ResolveCompactionMode() = %q, want %q", got, tc.want) + } + if len(tc.wantWarning) == 0 { + if warning != "" { + t.Fatalf("warning = %q, want empty", warning) + } + return + } + if warning == "" { + t.Fatal("warning is empty, want a warning") + } + for _, substring := range tc.wantWarning { + if !strings.Contains(warning, substring) { + t.Fatalf("warning = %q, want it to contain %q", warning, substring) + } + } + }) + } + + for _, valid := range []string{CompactionModeOff, CompactionModeShadow, CompactionModeEnabled} { + if !IsValidCompactionMode(valid) { + t.Errorf("IsValidCompactionMode(%q) = false, want true", valid) + } + } + for _, invalid := range []string{"", "Aggressive", "shadow ", "elided"} { + if IsValidCompactionMode(invalid) { + t.Errorf("IsValidCompactionMode(%q) = true, want false", invalid) + } + } +} + +// TestResolveCompactionMaxElidePercent mirrors TestResolveCompactionThreshold +// for the elide-fraction tripwire knob: 0 (unset) means the reference default, +// 1-100 are honored, anything else warns and falls back. +func TestResolveCompactionMaxElidePercent(t *testing.T) { + cases := []struct { + name string + cfg *Config + want int + wantWarning []string + }{ + { + name: "nil config uses default", + cfg: nil, + want: DefaultCompactionMaxElidePercent, + }, + { + name: "unset uses default", + cfg: &Config{}, + want: DefaultCompactionMaxElidePercent, + }, + { + name: "valid value honored", + cfg: &Config{CompactionMaxElidePercent: 50}, + want: 50, + }, + { + name: "one is valid", + cfg: &Config{CompactionMaxElidePercent: 1}, + want: 1, + }, + { + name: "hundred is valid", + cfg: &Config{CompactionMaxElidePercent: 100}, + want: 100, + }, + { + name: "negative value invalid", + cfg: &Config{CompactionMaxElidePercent: -10}, + want: DefaultCompactionMaxElidePercent, + wantWarning: []string{"invalid", "-10"}, + }, + { + name: "over hundred invalid", + cfg: &Config{CompactionMaxElidePercent: 101}, + want: DefaultCompactionMaxElidePercent, + wantWarning: []string{"invalid", "101"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, warning := ResolveCompactionMaxElidePercent(tc.cfg) + if got != tc.want { + t.Fatalf("ResolveCompactionMaxElidePercent() = %d, want %d", got, tc.want) + } + assertResolverWarning(t, warning, tc.wantWarning) + }) + } +} + +// TestResolveCompactionProtectedFloor mirrors TestResolveCompactionThreshold +// for the protected-kind floor knob: 0 (unset) means the reference default, +// 1-100 are honored, anything else warns and falls back. +func TestResolveCompactionProtectedFloor(t *testing.T) { + cases := []struct { + name string + cfg *Config + want int + wantWarning []string + }{ + { + name: "nil config uses default", + cfg: nil, + want: DefaultCompactionProtectedFloorPercent, + }, + { + name: "unset uses default", + cfg: &Config{}, + want: DefaultCompactionProtectedFloorPercent, + }, + { + name: "valid value honored", + cfg: &Config{CompactionProtectedFloor: 20}, + want: 20, + }, + { + name: "one is valid", + cfg: &Config{CompactionProtectedFloor: 1}, + want: 1, + }, + { + name: "hundred is valid", + cfg: &Config{CompactionProtectedFloor: 100}, + want: 100, + }, + { + name: "negative value invalid", + cfg: &Config{CompactionProtectedFloor: -3}, + want: DefaultCompactionProtectedFloorPercent, + wantWarning: []string{"invalid", "-3"}, + }, + { + name: "over hundred invalid", + cfg: &Config{CompactionProtectedFloor: 250}, + want: DefaultCompactionProtectedFloorPercent, + wantWarning: []string{"invalid", "250"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, warning := ResolveCompactionProtectedFloor(tc.cfg) + if got != tc.want { + t.Fatalf("ResolveCompactionProtectedFloor() = %d, want %d", got, tc.want) + } + assertResolverWarning(t, warning, tc.wantWarning) + }) + } +} + +// assertResolverWarning checks a resolver's warning against the expected +// substrings (empty means the warning must be empty too). +func assertResolverWarning(t *testing.T, warning string, wantSubstrings []string) { + t.Helper() + if len(wantSubstrings) == 0 { + if warning != "" { + t.Fatalf("warning = %q, want empty", warning) + } + return + } + if warning == "" { + t.Fatal("warning is empty, want a warning") + } + for _, substring := range wantSubstrings { + if !strings.Contains(warning, substring) { + t.Fatalf("warning = %q, want it to contain %q", warning, substring) + } + } +} + +// TestLoadConfig_CompactionMode covers the config-file path: valid modes +// parse through, invalid ones survive loading so ResolveCompactionMode can +// warn and fall back to the default. +func TestLoadConfig_CompactionMode(t *testing.T) { + t.Run("valid mode parses", func(t *testing.T) { + configRoot := t.TempDir() + setUserConfigEnv(t, configRoot) + configPath := lateConfigPath(t) + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, []byte(`{"enabled_tools": {"bash": true}, "compaction-mode": "enabled"}`), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if cfg.CompactionMode != CompactionModeEnabled { + t.Fatalf("CompactionMode = %q, want %q", cfg.CompactionMode, CompactionModeEnabled) + } + }) + + t.Run("invalid mode warns via resolver", func(t *testing.T) { + configRoot := t.TempDir() + setUserConfigEnv(t, configRoot) + configPath := lateConfigPath(t) + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, []byte(`{"enabled_tools": {"bash": true}, "compaction-mode": "yolo"}`), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + mode, warning := ResolveCompactionMode(cfg) + if mode != DefaultCompactionMode { + t.Fatalf("resolved mode = %q, want %q", mode, DefaultCompactionMode) + } + if !strings.Contains(warning, "yolo") || !strings.Contains(warning, DefaultCompactionMode) { + t.Fatalf("warning = %q, want it to name the invalid value and the fallback", warning) + } + }) +} + +// TestConfig_CompactionModeJSONRoundTrip: the field marshals under its +// config key and stays omitted when unset. +func TestConfig_CompactionModeJSONRoundTrip(t *testing.T) { + original := Config{CompactionMode: CompactionModeEnabled} + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + var decoded Config + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if decoded.CompactionMode != CompactionModeEnabled { + t.Fatalf("CompactionMode after round trip = %q, want %q", decoded.CompactionMode, CompactionModeEnabled) + } + + emptyData, err := json.Marshal(Config{}) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + var raw map[string]any + if err := json.Unmarshal(emptyData, &raw); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if _, ok := raw["compaction-mode"]; ok { + t.Fatalf("empty config should not marshal a compaction-mode key, got %s", emptyData) + } +} + +func TestLoadConfig_ParsesCompactionThresholdPercent(t *testing.T) { + configRoot := t.TempDir() + setUserConfigEnv(t, configRoot) + configPath := lateConfigPath(t) + + if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { + t.Fatal(err) + } + content := `{ + "enabled_tools": {"bash": true}, + "compaction-threshold-percent": 65 + }` + if err := os.WriteFile(configPath, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if cfg.CompactionThresholdPercent != 65 { + t.Fatalf("CompactionThresholdPercent = %d, want 65", cfg.CompactionThresholdPercent) + } +} + +func TestConfig_CompactionThresholdPercentJSONRoundTrip(t *testing.T) { + original := Config{CompactionThresholdPercent: 65} + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + + var decoded Config + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if decoded.CompactionThresholdPercent != 65 { + t.Fatalf("CompactionThresholdPercent after round trip = %d, want 65", decoded.CompactionThresholdPercent) + } + + // Zero values must not emit keys (omitempty), keeping config.json clean + // for users who never touched the new settings. + emptyData, err := json.Marshal(Config{}) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + var raw map[string]any + if err := json.Unmarshal(emptyData, &raw); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + for _, key := range []string{"compaction-threshold-percent"} { + if _, ok := raw[key]; ok { + t.Fatalf("empty config should not marshal a %s key, got %s", key, emptyData) + } + } +} + func TestConfig_PermissionModeJSONRoundTrip(t *testing.T) { original := Config{PermissionMode: PermissionModeUnsupervised} data, err := json.Marshal(original) @@ -838,3 +1261,264 @@ func TestConfig_PermissionModeJSONRoundTrip(t *testing.T) { t.Fatalf("empty config should not marshal a permission-mode key, got %s", emptyData) } } + +// TestResolveAutocompact mirrors TestResolveCompactionThreshold: the switch +// is a plain boolean (absent = disabled) and the percentage validates 1-100, +// with 0 (unset) meaning the default and anything else warning and falling +// back to the default. +func TestResolveAutocompact(t *testing.T) { + cases := []struct { + name string + cfg *Config + wantEnabled bool + wantPercent int + wantWarningParts []string + }{ + { + name: "nil config uses defaults", + cfg: nil, + wantEnabled: false, + wantPercent: DefaultJevAutocompactPercent, + }, + { + name: "unset uses defaults", + cfg: &Config{}, + wantEnabled: false, + wantPercent: DefaultJevAutocompactPercent, + }, + { + name: "enabled with default percent", + cfg: &Config{JevAutocompact: true}, + wantEnabled: true, + wantPercent: DefaultJevAutocompactPercent, + }, + { + name: "valid percent honored", + cfg: &Config{JevAutocompact: true, JevAutocompactPercent: 90}, + wantEnabled: true, + wantPercent: 90, + }, + { + name: "one is valid", + cfg: &Config{JevAutocompactPercent: 1}, + wantEnabled: false, + wantPercent: 1, + }, + { + name: "hundred is valid", + cfg: &Config{JevAutocompactPercent: 100}, + wantEnabled: false, + wantPercent: 100, + }, + { + name: "negative percent invalid", + cfg: &Config{JevAutocompact: true, JevAutocompactPercent: -5}, + wantEnabled: true, + wantPercent: DefaultJevAutocompactPercent, + wantWarningParts: []string{"invalid", "-5"}, + }, + { + name: "over hundred percent invalid", + cfg: &Config{JevAutocompactPercent: 250}, + wantEnabled: false, + wantPercent: DefaultJevAutocompactPercent, + wantWarningParts: []string{"invalid", "250"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotEnabled, gotPercent, warning := ResolveAutocompact(tc.cfg) + if gotEnabled != tc.wantEnabled { + t.Fatalf("ResolveAutocompact() enabled = %v, want %v", gotEnabled, tc.wantEnabled) + } + if gotPercent != tc.wantPercent { + t.Fatalf("ResolveAutocompact() percent = %d, want %d", gotPercent, tc.wantPercent) + } + if len(tc.wantWarningParts) == 0 { + if warning != "" { + t.Fatalf("warning = %q, want empty", warning) + } + return + } + if warning == "" { + t.Fatal("warning is empty, want a warning") + } + for _, substring := range tc.wantWarningParts { + if !strings.Contains(warning, substring) { + t.Fatalf("warning = %q, want it to contain %q", warning, substring) + } + } + }) + } +} + +func TestConfig_AutocompactJSONRoundTrip(t *testing.T) { + original := Config{JevAutocompact: true, JevAutocompactPercent: 90} + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + + var decoded Config + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if !decoded.JevAutocompact { + t.Fatal("JevAutocompact after round trip = false, want true") + } + if decoded.JevAutocompactPercent != 90 { + t.Fatalf("JevAutocompactPercent after round trip = %d, want 90", decoded.JevAutocompactPercent) + } + + // Zero values must not emit keys (omitempty), keeping config.json clean + // for users who never touched the new settings. + emptyData, err := json.Marshal(Config{}) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + var raw map[string]any + if err := json.Unmarshal(emptyData, &raw); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + for _, key := range []string{"jev-autocompact", "jev-autocompact-percent"} { + if _, ok := raw[key]; ok { + t.Fatalf("empty config should not marshal a %s key, got %s", key, emptyData) + } + } +} + +// TestModelSetting_AutocompactPercentOverride pins the per-model override +// accessor: 1-100 is the override, 0/unset (and anything out of range) means +// "no override — use the global". +func TestModelSetting_AutocompactPercentOverride(t *testing.T) { + cases := []struct { + name string + setting ModelSetting + wantValue int + wantOK bool + }{ + {name: "unset is not an override", setting: ModelSetting{}, wantValue: 0, wantOK: false}, + {name: "one is the smallest override", setting: ModelSetting{JevAutocompactPercent: 1}, wantValue: 1, wantOK: true}, + {name: "fifty-five honored", setting: ModelSetting{JevAutocompactPercent: 55}, wantValue: 55, wantOK: true}, + {name: "hundred is the largest override", setting: ModelSetting{JevAutocompactPercent: 100}, wantValue: 100, wantOK: true}, + {name: "negative is ignored", setting: ModelSetting{JevAutocompactPercent: -5}, wantValue: 0, wantOK: false}, + {name: "over hundred is ignored", setting: ModelSetting{JevAutocompactPercent: 250}, wantValue: 0, wantOK: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := tc.setting.AutocompactPercentOverride() + if ok != tc.wantOK || got != tc.wantValue { + t.Fatalf("AutocompactPercentOverride() = (%d, %v), want (%d, %v)", got, ok, tc.wantValue, tc.wantOK) + } + }) + } +} + +// TestConfig_AutocompactPercentForAgent pins the resolution order for the +// JEV auto-compaction trigger: the agent's model entry override (1-100) > +// the global jev-autocompact-percent > nothing else (the caller passes the +// already-normalized global). The lookup is Config.GetModelForAgent, so +// agent_models routing (stable id or legacy model name) decides which entry +// applies. +func TestConfig_AutocompactPercentForAgent(t *testing.T) { + cfg := &Config{ + JevAutocompactPercent: 99, // the global threshold + Models: []ModelSetting{ + {ID: "small-ctx", URL: "http://a:8080", Key: "k", Model: "model-a", JevAutocompactPercent: 55}, + {ID: "huge-ctx", URL: "http://b:8080", Key: "k", Model: "model-b", JevAutocompactPercent: 100}, + {ID: "no-override", URL: "http://c:8080", Key: "k", Model: "model-c"}, + {ID: "bad-override", URL: "http://d:8080", Key: "k", Model: "model-d", JevAutocompactPercent: 400}, + }, + AgentModels: map[string]string{ + "orchestrator": "no-override", + "researcher": "small-ctx", + "coder": "huge-ctx", + "reviewer": "bad-override", + "legacy": "model-a", // legacy name-based routing still resolves + }, + } + + cases := []struct { + agentType string + want int + }{ + {"researcher", 55}, // valid per-model override wins + {"coder", 100}, // boundary values are honored too + {"orchestrator", 99}, // no override: the global applies + {"reviewer", 99}, // out-of-range override is ignored: the global applies + {"legacy", 55}, // name-based agent_models routing resolves the entry + {"unrouted", 99}, // agent type with no agent_models entry: the global + } + for _, tc := range cases { + t.Run(tc.agentType, func(t *testing.T) { + if got := cfg.AutocompactPercentForAgent(tc.agentType, 99); got != tc.want { + t.Fatalf("AutocompactPercentForAgent(%q, 99) = %d, want %d", tc.agentType, got, tc.want) + } + }) + } + + // A different global flows through whenever no override applies. + if got := cfg.AutocompactPercentForAgent("researcher", 70); got != 55 { + t.Fatalf("override must win over a non-default global: got %d, want 55", got) + } + if got := cfg.AutocompactPercentForAgent("orchestrator", 70); got != 70 { + t.Fatalf("no override must pass the global through: got %d, want 70", got) + } +} + +// TestConfig_AutocompactPercentForAgentNilSafe pins the nil-receiver and +// degenerate-input guards: a nil config, a config without the models +// sections, and an empty agent type all fall back to the global. +func TestConfig_AutocompactPercentForAgentNilSafe(t *testing.T) { + if got := (*Config)(nil).AutocompactPercentForAgent("orchestrator", 99); got != 99 { + t.Fatalf("nil config = %d, want the global 99", got) + } + if got := (&Config{}).AutocompactPercentForAgent("orchestrator", 99); got != 99 { + t.Fatalf("empty config = %d, want the global 99", got) + } + cfg := &Config{Models: []ModelSetting{{ID: "m", JevAutocompactPercent: 55}}} + if got := cfg.AutocompactPercentForAgent("orchestrator", 99); got != 99 { + t.Fatalf("no agent_models routing = %d, want the global 99", got) + } + if got := cfg.AutocompactPercentForAgent("", 99); got != 99 { + t.Fatalf("empty agent type = %d, want the global 99", got) + } +} + +// TestConfig_AutocompactWarnings pins the startup warning path for +// out-of-range per-model jev-autocompact-percent values: one warning per bad +// entry, naming the model (id, else model name) and the value, and saying +// the global applies. Valid and unset values never warn. +func TestConfig_AutocompactWarnings(t *testing.T) { + cfg := &Config{ + Models: []ModelSetting{ + {ID: "good", Model: "model-good", JevAutocompactPercent: 55}, + {ID: "too-low", Model: "model-low", JevAutocompactPercent: -3}, + {Model: "no-id-bad", JevAutocompactPercent: 101}, + {ID: "unset", Model: "model-unset"}, + }, + } + warnings := cfg.AutocompactWarnings() + if len(warnings) != 2 { + t.Fatalf("AutocompactWarnings() = %#v, want exactly 2 warnings", warnings) + } + first := warnings[0] + for _, substring := range []string{"too-low", "-3", "global"} { + if !strings.Contains(first, substring) { + t.Fatalf("warning %q does not mention %q", first, substring) + } + } + if !strings.Contains(warnings[1], "no-id-bad") || !strings.Contains(warnings[1], "101") { + t.Fatalf("second warning %q must name the model (no id set) and the bad value 101", warnings[1]) + } + + if got := (&Config{}).AutocompactWarnings(); got != nil { + t.Fatalf("config without models = %#v, want nil", got) + } + if got := (*Config)(nil).AutocompactWarnings(); got != nil { + t.Fatalf("nil config = %#v, want nil", got) + } +} + + diff --git a/internal/config/config_threshold_test.go b/internal/config/config_threshold_test.go new file mode 100644 index 00000000..6a6dab87 --- /dev/null +++ b/internal/config/config_threshold_test.go @@ -0,0 +1,106 @@ +package config + +import "testing" + +// TestResolveCompactionScoreThreshold is the resolver table for the elision +// score threshold knob: precedence is explicit flag > config.json entry > +// the 0.35 default; anything out of (0,1] warns and falls back to the next +// source. flagValue = 0 models "flag not explicitly passed" (the flag.Visit +// sentinel main() uses — an explicit 0 is itself invalid, so the sentinel +// and the degenerate case resolve to the same fallback). +func TestResolveCompactionScoreThreshold(t *testing.T) { + cases := []struct { + name string + cfg *Config + flagValue float64 + want float64 + wantWarning []string + }{ + // Default tier. + { + name: "nil config, no flag, default", + cfg: nil, + flagValue: 0, + want: DefaultCompactionThreshold, + }, + { + name: "unset config, no flag, default", + cfg: &Config{}, + flagValue: 0, + want: DefaultCompactionThreshold, + }, + + // Config tier. + { + name: "config wins when the flag is not passed", + cfg: &Config{CompactionThreshold: 0.65}, + flagValue: 0, + want: 0.65, + }, + { + name: "config 1.0 is valid", + cfg: &Config{CompactionThreshold: 1}, + flagValue: 0, + want: 1, + }, + { + name: "config over 1 invalid, warns, default", + cfg: &Config{CompactionThreshold: 1.5}, + flagValue: 0, + want: DefaultCompactionThreshold, + wantWarning: []string{"invalid", "config.json", "1.5"}, + }, + { + name: "config negative invalid, warns, default", + cfg: &Config{CompactionThreshold: -0.2}, + flagValue: 0, + want: DefaultCompactionThreshold, + wantWarning: []string{"invalid", "config.json", "-0.2"}, + }, + + // Flag tier. + { + name: "explicit flag wins over config", + cfg: &Config{CompactionThreshold: 0.65}, + flagValue: 0.8, + want: 0.8, + }, + { + name: "explicit flag wins over the default", + cfg: &Config{}, + flagValue: 0.5, + want: 0.5, + }, + { + name: "explicit flag over 1 warns and falls to config", + cfg: &Config{CompactionThreshold: 0.65}, + flagValue: 2, + want: 0.65, + wantWarning: []string{"invalid", "-compaction-threshold", "2"}, + }, + { + name: "explicit flag over 1 with no config falls to default", + cfg: &Config{}, + flagValue: 2, + want: DefaultCompactionThreshold, + wantWarning: []string{"invalid", "-compaction-threshold", "2"}, + }, + { + name: "explicit negative flag warns and falls to default", + cfg: &Config{}, + flagValue: -1, + want: DefaultCompactionThreshold, + wantWarning: []string{"invalid", "-compaction-threshold", "-1"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, warning := ResolveCompactionScoreThreshold(tc.cfg, tc.flagValue) + if got != tc.want { + t.Fatalf("ResolveCompactionScoreThreshold(%v, %v) = %v, want %v", tc.cfg, tc.flagValue, got, tc.want) + } + assertResolverWarning(t, warning, tc.wantWarning) + }) + } +} diff --git a/internal/config/docs_test.go b/internal/config/docs_test.go new file mode 100644 index 00000000..d56a7333 --- /dev/null +++ b/internal/config/docs_test.go @@ -0,0 +1,232 @@ +package config + +import ( + "os" + "reflect" + "regexp" + "sort" + "strings" + "testing" +) + +// The config reference doc (docs/config-reference.md) claims to be the +// COMPLETE config.json reference. These tests keep that claim honest against +// the Config struct itself: +// +// - every documented key (a table row whose first cell is exactly one +// backticked word) must be a Config json tag found via reflection — a +// documented key that is not a struct field would be silently dropped by +// the decoder and never take effect; +// - every valid Config json tag must be documented, so a new struct field +// without a doc row fails the test instead of silently shipping +// undocumented; +// - every valid ModelSetting json tag (the keys inside one models[] entry) +// must be documented in the "models entries" nested schema, and every +// entry key documented in that section must be a ModelSetting tag — the +// same key set knownModelEntryKeys feeds the models[] key walk, so a +// typo'd or missing entry key fails here first; +// - the doc's required sections must stay present. +// +// Other nested entries (enabled_tools."bash", …) are intentionally +// documented outside that row shape — only top-level config.json keys are +// backticked single tokens in a first cell. + +const configReferenceDocPath = "../../docs/config-reference.md" + +// docKeyRowRe matches a reference-table row whose first cell is exactly one +// backticked key: `| `key` | ... |`. +var docKeyRowRe = regexp.MustCompile(`^\|\s*` + "`" + `([^` + "`" + `]+)` + "`" + `\s*\|`) + +// requiredDocSections are the headings the reference promises at the top. +var requiredDocSections = []string{ + "## Key reference", + "### Context compaction", + "## Nested schemas", +} + +// modelsSchemaHeading is the nested-schema section that documents the keys +// of one models[] entry (ModelSetting). Its bulleted `key` (type …) items +// are guarded against the struct's tags. +const modelsSchemaHeading = "### `models` entries" + +// modelsSchemaItemRe matches one bulleted models-entry key description: +// `* `key` (…) — description`. +var modelsSchemaItemRe = regexp.MustCompile(`^\*\s*` + "`" + `([^` + "`" + `]+)` + "`" + `\s*\(`) + +// structJSONTags reflects over Config's json tags and returns the set of +// top-level config.json keys the struct accepts. Runtime-only fields +// (json:"-") are excluded. +func structJSONTags(t *testing.T) map[string]bool { + t.Helper() + tags := make(map[string]bool) + typ := reflect.TypeOf(Config{}) + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + tag, _, _ := strings.Cut(field.Tag.Get("json"), ",") + switch tag { + case "-": + // Runtime-only field: not a config.json key. + case "": + t.Fatalf("Config field %s has no json tag; every field must declare one", field.Name) + default: + tags[tag] = true + } + } + return tags +} + +// modelSettingJSONTags reflects over ModelSetting's json tags and returns the +// set of keys one models[] entry accepts — the same set the +// models[] key walk (knownModelEntryKeys) validates against. +func modelSettingJSONTags(t *testing.T) map[string]bool { + t.Helper() + tags := make(map[string]bool) + typ := reflect.TypeOf(ModelSetting{}) + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + tag, _, _ := strings.Cut(field.Tag.Get("json"), ",") + switch tag { + case "-": + // Runtime-only field: not a config.json key. + case "": + t.Fatalf("ModelSetting field %s has no json tag; every field must declare one", field.Name) + default: + tags[tag] = true + } + } + return tags +} + +// modelSettingDocSection extracts the lines of the "### `models` entries" +// nested-schema section (up to the next heading). +func modelSettingDocSection(t *testing.T, lines []string) []string { + t.Helper() + start := -1 + for i, line := range lines { + if strings.TrimSpace(line) == modelsSchemaHeading { + start = i + 1 + break + } + } + if start == -1 { + t.Fatalf("%s is missing the %q section — the models[] entry schema must be documented", configReferenceDocPath, modelsSchemaHeading) + } + for end := start; end < len(lines); end++ { + if strings.HasPrefix(lines[end], "## ") || strings.HasPrefix(lines[end], "### ") { + return lines[start:end] + } + } + return lines[start:] +} + +func TestConfigReferenceDocKeysAreValid(t *testing.T) { + lines := readConfigReferenceDocLines(t) + + fromStruct := structJSONTags(t) + + documented := map[string]bool{} + for lineNo, line := range lines { + m := docKeyRowRe.FindStringSubmatch(line) + if m == nil { + continue + } + key := m[1] + if !fromStruct[key] { + t.Errorf("%s:%d: table row documents %q, which is NOT a Config json tag — the decoder would silently drop it", configReferenceDocPath, lineNo, key) + continue + } + if documented[key] { + t.Errorf("%s:%d: key %q is documented in more than one table row", configReferenceDocPath, lineNo, key) + } + documented[key] = true + } + + var missing []string + for tag := range fromStruct { + if !documented[tag] { + missing = append(missing, tag) + } + } + sort.Strings(missing) + for _, tag := range missing { + t.Errorf("Config json tag %q is valid but has no `| `%s` |` table row in %s — the reference must be complete", tag, tag, configReferenceDocPath) + } + + t.Logf("%s documents %d/%d Config json tags", configReferenceDocPath, len(documented), len(fromStruct)) +} + +// TestConfigReferenceDocModelsEntryKeysAreValid keeps the "### `models` +// entries" nested schema honest against ModelSetting's json tags — the same +// set the models[] key walk (knownModelEntryKeys) enforces: every documented +// entry key must be a ModelSetting tag, and every tag must be documented in +// that section, so a new per-model key without a doc bullet fails here. +func TestConfigReferenceDocModelsEntryKeysAreValid(t *testing.T) { + lines := readConfigReferenceDocLines(t) + section := modelSettingDocSection(t, lines) + + fromStruct := modelSettingJSONTags(t) + fromParser := knownModelEntryKeys // the models[] key walk + + for tag := range fromStruct { + if !fromParser[tag] { + t.Errorf("ModelSetting json tag %q is missing from knownModelEntryKeys; the walk would reject configs using it inside a models entry", tag) + } + } + + documented := map[string]bool{} + for lineNo, line := range section { + m := modelsSchemaItemRe.FindStringSubmatch(line) + if m == nil { + continue + } + key := m[1] + if !fromStruct[key] { + t.Errorf("%s:%d: models schema documents entry key %q, which is NOT a ModelSetting json tag — the walk would reject it as an unknown entry key", configReferenceDocPath, lineNo, key) + continue + } + if documented[key] { + t.Errorf("%s:%d: models entry key %q is documented more than once", configReferenceDocPath, lineNo, key) + } + documented[key] = true + } + + var missing []string + for tag := range fromStruct { + if !documented[tag] { + missing = append(missing, tag) + } + } + sort.Strings(missing) + for _, tag := range missing { + t.Errorf("ModelSetting json tag %q is valid but has no `* `%s` (` bullet in the %q section of %s — the reference must be complete", tag, tag, modelsSchemaHeading, configReferenceDocPath) + } + + t.Logf("%s documents %d/%d models entry keys", configReferenceDocPath, len(documented), len(fromStruct)) +} + +func TestConfigReferenceDocRequiredSections(t *testing.T) { + lines := readConfigReferenceDocLines(t) + seen := map[string]bool{} + for _, line := range lines { + if strings.HasPrefix(line, "#") { + seen[strings.TrimRight(line, " \t")] = true + } + } + for _, heading := range requiredDocSections { + if !seen[heading] { + t.Errorf("%s is missing the required %q section", configReferenceDocPath, heading) + } + } +} + +// readConfigReferenceDocLines reads the reference doc relative to this +// package's directory (Go tests run with the package dir as the working +// directory) and returns its lines. +func readConfigReferenceDocLines(t *testing.T) []string { + t.Helper() + data, err := os.ReadFile(configReferenceDocPath) + if err != nil { + t.Fatalf("read %s: %v", configReferenceDocPath, err) + } + return strings.Split(string(data), "\n") +} diff --git a/internal/config/models_entry_keys.go b/internal/config/models_entry_keys.go new file mode 100644 index 00000000..590ab81b --- /dev/null +++ b/internal/config/models_entry_keys.go @@ -0,0 +1,244 @@ +package config + +import ( + "bytes" + "encoding/json" + "fmt" + "sort" + "strings" + "unicode/utf8" +) + +// The models[] entry key walk: unknown keys inside a models[] entry are +// fatal, located, did-you-mean errors. encoding/json alone would silently +// drop a hand-edited typo there (the typed decode has no useful position for +// nested fields), and the top-level decode on this build ignores unknown +// keys — so the walk is enforced where it matters most: the per-model +// settings inside every models[] entry, including the per-model +// jev-autocompact-percent override. Value-RANGE problems (e.g. a +// jev-autocompact-percent of 400) stay in the warn-and-fall-back path +// (Config.AutocompactWarnings), consistent with the global key's warning +// pattern; strictness here covers key NAMES only. +// +// This file carries the minimal private versions of the strict-parser +// helpers the walk needs (known-key set, positioned error rendering, +// did-you-mean suggestion): the full positioned strict parser for every +// top-level key belongs to the config PR. + +// knownModelEntryKeys is the set of valid keys inside one models[] entry, +// mirrored from ModelSetting's json tags (kept in sync by docs_test.go's +// reflection guard on the documented schema). +var knownModelEntryKeys = map[string]bool{ + "id": true, + "url": true, + "key": true, + "model": true, + "jev-autocompact-percent": true, +} + +// maxSuggestionDistance is the largest Levenshtein distance still considered +// "reasonably similar" for a did-you-mean suggestion. +const maxSuggestionDistance = 3 + +// checkModelsEntryKeys walks the raw models[] array in content and validates +// every object's keys against the known ModelSetting key set: an unknown key +// inside an entry is a positioned did-you-mean error naming the entry. Each +// nested key's exact byte range within the document is recovered from the +// Decoder's InputOffset, so the reported line/column is the key's position in +// the user's editor — encoding/json does not report positions for nested +// fields itself. Syntax problems are NOT reported here (the typed decode +// already ran and owns them); the walk only adds the key-name check. +// agent_models needs no equivalent pass: it is a map[string]string whose keys +// are agent roles (data, never schema), so it has no unknown-key concept. +func checkModelsEntryKeys(path string, content []byte) error { + dec := json.NewDecoder(bytes.NewReader(content)) + tok, err := dec.Token() + if err != nil { + // Syntax error: the typed decode already reported it. + return nil + } + if delim, isDelim := tok.(json.Delim); !isDelim || delim != '{' { + // Not an object: the typed decode owns that error. + return nil + } + // Last "models" value wins (encoding/json's duplicate-key semantics: the + // last occurrence is what the typed decode keeps, so validating it + // matches what survives). + var modelsRaw json.RawMessage + modelsStart := -1 + for dec.More() { + keyTok, err := dec.Token() + if err != nil { + return nil + } + key, _ := keyTok.(string) + var raw json.RawMessage + if err := dec.Decode(&raw); err != nil { + return nil + } + if key == "models" { + modelsRaw = raw + // The value's first byte lives len(raw) bytes before the + // offset just past it. + modelsStart = int(dec.InputOffset()) - len(raw) + } + } + if modelsRaw == nil { + return nil + } + return walkModelsArray(path, content, modelsRaw, modelsStart) +} + +// walkModelsArray re-parses the raw models value with a Decoder so each +// entry's exact byte range within the document is known, then validates each +// entry object's keys. A non-array models value is the typed decode's error, +// not this walk's. +func walkModelsArray(path string, content, modelsRaw []byte, modelsStart int) error { + dec := json.NewDecoder(bytes.NewReader(modelsRaw)) + tok, err := dec.Token() + if err != nil { + return nil + } + if delim, isDelim := tok.(json.Delim); !isDelim || delim != '[' { + return nil + } + for dec.More() { + var raw json.RawMessage + if err := dec.Decode(&raw); err != nil { + return nil + } + entryStart := modelsStart + int(dec.InputOffset()) - len(raw) + if err := checkOneModelsEntry(path, content, raw, entryStart); err != nil { + return err + } + } + return nil +} + +// modelsEntryLabel derives the human name of one raw models[] entry for +// error messages: its id when set, else its model name, else the generic +// "entry" — the same identifier AutocompactWarnings uses. +func modelsEntryLabel(raw json.RawMessage) string { + var m ModelSetting + if err := json.Unmarshal(raw, &m); err == nil { + if m.ID != "" { + return m.ID + } + if m.Model != "" { + return m.Model + } + } + return "entry" +} + +// checkOneModelsEntry validates the keys of one raw models[] entry object +// whose first byte sits at entryStart in the document. A nested key's +// absolute offset is entryStart + (offset of the key within raw); the key +// token's InputOffset sits just past its closing quote, so the opening quote +// is len(key)+2 bytes back. Null entries (and non-object values, whose +// handling the typed decode owns) carry no keys to validate. +func checkOneModelsEntry(path string, content, raw []byte, entryStart int) error { + if string(raw) == "null" { + return nil + } + dec := json.NewDecoder(bytes.NewReader(raw)) + tok, err := dec.Token() + if err != nil { + return nil + } + if delim, isDelim := tok.(json.Delim); !isDelim || delim != '{' { + return nil + } + for dec.More() { + tok, err := dec.Token() + if err != nil { + return nil + } + key, isString := tok.(string) + if !isString { + // Unreachable in valid JSON: object keys are strings. + return nil + } + keyStart := entryStart + int(dec.InputOffset()) - len(key) - 2 + if !knownModelEntryKeys[key] { + return unknownModelEntryKeyError(path, content, key, keyStart, modelsEntryLabel(raw)) + } + // Values need no key-level validation here: the typed decode's + // errors cover wrong types, and an out-of-range + // jev-autocompact-percent value warns (Config.AutocompactWarnings), + // the same as the global key. + var skip json.RawMessage + if err := dec.Decode(&skip); err != nil { + return nil + } + } + return nil +} + +// unknownModelEntryKeyError renders the located error for an unknown key +// inside a models[] entry: a did-you-mean suggestion against the known entry +// keys when reasonably similar, otherwise the full sorted list — with the +// message naming the entry. +func unknownModelEntryKeyError(path string, content []byte, key string, keyStart int, label string) error { + names := make([]string, 0, len(knownModelEntryKeys)) + for name := range knownModelEntryKeys { + names = append(names, name) + } + before := string(content[:keyStart]) + line := 1 + strings.Count(before, "\n") + lineStart := strings.LastIndex(before, "\n") + 1 + column := utf8.RuneCountInString(before[lineStart:]) + 1 + if suggestion := closestMatch(key, names); suggestion != "" { + return fmt.Errorf("error in %s at line %d, column %d: models[%s] entry %q is not a valid entry key. Did you mean %q?", + path, line, column, label, key, suggestion) + } + sort.Strings(names) + return fmt.Errorf("error in %s at line %d, column %d: models[%s] entry %q is not a valid entry key. Valid entry keys are: %s", + path, line, column, label, key, strings.Join(names, ", ")) +} + +// closestMatch returns the candidate with the smallest Levenshtein distance +// to target when that distance is at most maxSuggestionDistance; ties resolve +// to the alphabetically first candidate (the candidates are walked sorted). +func closestMatch(target string, candidates []string) string { + sorted := append([]string(nil), candidates...) + sort.Strings(sorted) + best, bestDistance := "", maxSuggestionDistance+1 + for _, candidate := range sorted { + if distance := levenshtein(target, candidate); distance < bestDistance { + best, bestDistance = candidate, distance + } + } + if bestDistance > maxSuggestionDistance { + return "" + } + return best +} + +// levenshtein is the standard edit distance over runes. +func levenshtein(a, b string) int { + ar, br := []rune(a), []rune(b) + if len(ar) == 0 { + return len(br) + } + if len(br) == 0 { + return len(ar) + } + prev := make([]int, len(br)+1) + curr := make([]int, len(br)+1) + for j := range prev { + prev[j] = j + } + for i := 1; i <= len(ar); i++ { + curr[0] = i + for j := 1; j <= len(br); j++ { + cost := 1 + if ar[i-1] == br[j-1] { + cost = 0 + } + curr[j] = min(min(curr[j-1]+1, prev[j]+1), prev[j-1]+cost) + } + prev, curr = curr, prev + } + return prev[len(br)] +} diff --git a/internal/config/models_entry_keys_test.go b/internal/config/models_entry_keys_test.go new file mode 100644 index 00000000..bcc0afe5 --- /dev/null +++ b/internal/config/models_entry_keys_test.go @@ -0,0 +1,208 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "unicode/utf8" +) + +// lineOf returns the 1-based line of the first occurrence of needle in +// content (mirrors the positioned-error tests' expectations: the walk reports +// the key's editor line). +func lineOf(content, needle string) int { + byteIdx := strings.Index(content, needle) + if byteIdx < 0 { + return -1 + } + return strings.Count(content[:byteIdx], "\n") + 1 +} + +// runeColumn returns the 1-based rune column of the first occurrence of +// needle in content (columns count runes, so UTF-8 content reports the +// position an editor shows). +func runeColumn(content, needle string) int { + byteIdx := strings.Index(content, needle) + if byteIdx < 0 { + return -1 + } + lineStart := strings.LastIndex(content[:byteIdx], "\n") + 1 + return utf8.RuneCountInString(content[lineStart:byteIdx]) + 1 +} + +// parseForModelsWalk is the test entry point for the models[] key walk: the +// typed decode plus the walk, the same pair LoadConfig runs. +func parseForModelsWalk(t *testing.T, path, content string) (*Config, error) { + t.Helper() + var cfg Config + if err := json.Unmarshal([]byte(content), &cfg); err != nil { + return nil, err + } + return &cfg, checkModelsEntryKeys(path, []byte(content)) +} + +// TestModelsEntryKeyUnknownIsRejected pins the models[] walk: an unknown key +// inside an entry is a located did-you-mean error naming the entry — the +// strictness the plain decode cannot provide. "urll" is a realistic typo of +// the known entry key "url". +func TestModelsEntryKeyUnknownIsRejected(t *testing.T) { + content := `{"models": [{"url": "http://p:8080", "key": "k", "model": "m", "urll": "typo"}]}` + _, err := parseForModelsWalk(t, "/x/config.json", content) + if err == nil { + t.Fatal("expected an unknown nested entry error") + } + want := `error in /x/config.json at line 1, column ` + strconv.Itoa(runeColumn(content, `"urll"`)) + + `: models[m] entry "urll" is not a valid entry key. Did you mean "url"?` + if err.Error() != want { + t.Fatalf("error = %q\nwant %q", err.Error(), want) + } +} + +// TestModelsEntryKeyExactMessage pins the multi-line rendering: the reported +// line/column is the unknown key's position in the user's editor, and the +// entry is named by its id. +func TestModelsEntryKeyExactMessage(t *testing.T) { + content := `{ + "models": [ + { + "id": "local", + "urll": "http://localhost:8080", + "key": "", + "model": "qwen3.6-35b-a3b" + } + ] +}` + _, err := parseForModelsWalk(t, "/Users/u/config.json", content) + if err == nil { + t.Fatalf("parseForModelsWalk() = _, nil, want an unknown models-entry-key error") + } + want := `error in /Users/u/config.json at line ` + strconv.Itoa(lineOf(content, `"urll"`)) + + `, column ` + strconv.Itoa(runeColumn(content, `"urll"`)) + + `: models[local] entry "urll" is not a valid entry key. Did you mean "url"?` + if err.Error() != want { + t.Fatalf("error = %q\nwant %q", err.Error(), want) + } +} + +// TestModelsEntryKeySuggestsAutocompactKey pins the underscore typo of the +// per-model key: the walk knows "jev-autocompact-percent" (and only that +// spelling), so a hand-edited "jev-autocompact_percent" inside an entry +// suggests the kebab-case key. +func TestModelsEntryKeySuggestsAutocompactKey(t *testing.T) { + content := `{ + "models": [ + { + "id": "frontier", + "url": "https://api.deepseek.com", + "key": "sk-x", + "model": "deepseek-flash", + "jev-autocompact_percent": 55 + } + ] +}` + _, err := parseForModelsWalk(t, "/x/config.json", content) + if err == nil { + t.Fatal("expected an unknown models-entry-key error") + } + want := `error in /x/config.json at line ` + strconv.Itoa(lineOf(content, `"jev-autocompact_percent"`)) + + `, column ` + strconv.Itoa(runeColumn(content, `"jev-autocompact_percent"`)) + + `: models[frontier] entry "jev-autocompact_percent" is not a valid entry key. Did you mean "jev-autocompact-percent"?` + if err.Error() != want { + t.Fatalf("error = %q\nwant %q", err.Error(), want) + } +} + +// TestModelsEntryKeyNoSuggestionListsValidKeys pins the no-reasonable- +// suggestion branch of the walk: the full sorted valid-entry-keys list, +// still positioned at the offending key and naming the entry. +func TestModelsEntryKeyNoSuggestionListsValidKeys(t *testing.T) { + content := `{"models": [{"id": "prov", "zzzzzzz": 1}]}` + _, err := parseForModelsWalk(t, "/x/config.json", content) + if err == nil { + t.Fatal("expected an unknown models-entry-key error") + } + want := `error in /x/config.json at line ` + strconv.Itoa(lineOf(content, `"zzzzzzz"`)) + + `, column ` + strconv.Itoa(runeColumn(content, `"zzzzzzz"`)) + + `: models[prov] entry "zzzzzzz" is not a valid entry key. Valid entry keys are: id, jev-autocompact-percent, key, model, url` + if err.Error() != want { + t.Fatalf("error = %q\nwant %q", err.Error(), want) + } +} + +// TestModelsEntryKeysAccepted pins the happy path of the walk: every known +// entry key — including the per-model jev-autocompact-percent override — +// parses in every entry, and the values survive the decode. (Out-of-range +// VALUES keep the warning path; strictness here covers key names only.) +func TestModelsEntryKeysAccepted(t *testing.T) { + content := `{ + "models": [ + {"id": "a", "url": "http://a:8080", "key": "ka", "model": "ma", "jev-autocompact-percent": 55}, + {"id": "b", "url": "http://b:8080", "key": "", "model": "mb"} + ] +}` + cfg, err := parseForModelsWalk(t, "/x/config.json", content) + if err != nil { + t.Fatalf("parseForModelsWalk() error = %v", err) + } + if len(cfg.Models) != 2 { + t.Fatalf("models = %#v, want 2 entries", cfg.Models) + } + if cfg.Models[0].JevAutocompactPercent != 55 { + t.Fatalf("per-model override did not survive: %#v", cfg.Models[0]) + } + if got, ok := cfg.Models[0].AutocompactPercentOverride(); !ok || got != 55 { + t.Fatalf("AutocompactPercentOverride() = (%d, %v), want (55, true)", got, ok) + } + if _, ok := cfg.Models[1].AutocompactPercentOverride(); ok { + t.Fatal("an entry without the key must not report an override") + } +} + +// TestCheckModelsEntryKeysToleratesNonArrayModels pins the boundary: a +// non-array models value is the typed decode's error, not the walk's; the +// walk must not crash on it (LoadConfig reports the decode error first). +func TestCheckModelsEntryKeysToleratesNonArrayModels(t *testing.T) { + if err := checkModelsEntryKeys("/x/config.json", []byte(`{"models": {"id": "x"}}`)); err != nil { + t.Fatalf("non-array models must not be the walk's error, got %v", err) + } + if err := checkModelsEntryKeys("/x/config.json", []byte(`{"models": null}`)); err != nil { + t.Fatalf("null models must not error, got %v", err) + } + if err := checkModelsEntryKeys("/x/config.json", []byte(`{"other": 1}`)); err != nil { + t.Fatalf("no models key must not error, got %v", err) + } + if err := checkModelsEntryKeys("/x/config.json", []byte(`not json at all`)); err != nil { + t.Fatalf("syntax errors are the typed decode's job, got %v", err) + } +} + +// TestLoadConfigRejectsUnknownModelsEntryKey pins the end-to-end wiring: +// LoadConfig surfaces the walk's located error and a fallback config (the +// same shape as a decode error). +func TestLoadConfigRejectsUnknownModelsEntryKey(t *testing.T) { + configRoot := t.TempDir() + setUserConfigEnv(t, configRoot) + configPath := lateConfigPath(t) + if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { + t.Fatalf("MkdirAll(config dir) error = %v", err) + } + content := `{"models": [{"id": "a", "url": "http://a:8080", "key": "", "model": "ma", "jev-autocompact_percent": 55}]}` + if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile(config.json) error = %v", err) + } + + cfg, err := LoadConfig() + if err == nil { + t.Fatalf("LoadConfig() = %#v, want an unknown models-entry-key error", cfg) + } + if cfg == nil { + t.Fatal("expected a fallback config alongside the error") + } + if !strings.Contains(err.Error(), `entry "jev-autocompact_percent" is not a valid entry key`) || + !strings.Contains(err.Error(), `Did you mean "jev-autocompact-percent"?`) { + t.Fatalf("error = %q, want the located did-you-mean message", err.Error()) + } +} diff --git a/internal/executor/compaction_test.go b/internal/executor/compaction_test.go new file mode 100644 index 00000000..90b1479c --- /dev/null +++ b/internal/executor/compaction_test.go @@ -0,0 +1,448 @@ +package executor + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "late/internal/client" + "late/internal/compaction" + "late/internal/session" + "late/internal/tool" +) + +// largeDumpTool is a fake tool whose result is configurable: big enough to +// cross MinCompactToolResultChars, multi-paragraph so it segments, with one +// SECRET marker per paragraph placed past the first 60 characters (a +// pointer's preview can therefore never leak it). +type largeDumpTool struct { + name string + output string +} + +func (t largeDumpTool) Name() string { + if t.name == "" { + return "large_dump" + } + return t.name +} +func (t largeDumpTool) Description() string { return "Emits a large multi-paragraph output." } +func (t largeDumpTool) Parameters() json.RawMessage { + return json.RawMessage(`{"type":"object","properties":{}}`) +} +func (t largeDumpTool) RequiresConfirmation(json.RawMessage) bool { return false } +func (t largeDumpTool) CallString(json.RawMessage) string { return "Dumping..." } +func (t largeDumpTool) Execute(context.Context, json.RawMessage) (string, error) { + return t.output, nil +} + +// largeDumpOutput builds `paras` paragraphs of ~600 chars each (over the +// 80-char tiny-paragraph floor, under the 1200-byte segment cap, so each +// paragraph is exactly one segment). +func largeDumpOutput(paras int) string { + var b strings.Builder + for i := 1; i <= paras; i++ { + b.WriteString(strings.Repeat(fmt.Sprintf("p%d ", i), 200)) + b.WriteString(fmt.Sprintf("PARA-%d-SECRET", i)) + b.WriteString("\n\n") + } + return strings.TrimSuffix(b.String(), "\n\n") +} + +// stubDecisionsServer is a minimal System One decisions endpoint: every +// request is answered with a score per asked ref, decided by scoreFor. The +// returned counter counts served requests. +func stubDecisionsServer(t *testing.T, scoreFor func(ref string) float64) (*httptest.Server, *int32) { + t.Helper() + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + t.Errorf("read decisions request body: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + var req struct { + Questions map[string]json.RawMessage `json:"questions"` + } + if err := json.Unmarshal(body, &req); err != nil { + t.Errorf("decode decisions request body: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + answers := make(map[string]any, len(req.Questions)) + for ref := range req.Questions { + answers[ref] = map[string]any{"type": "noul", "noul": scoreFor(ref)} + } + resp, err := json.Marshal(map[string]any{"answers": answers}) + if err != nil { + t.Errorf("marshal decisions response: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(resp) + })) + t.Cleanup(srv.Close) + return srv, &hits +} + +// evenBelowOddAbove scores even-numbered segments below the 0.35 threshold +// and odd-numbered ones above it, so relocation has both kinds to work with. +func evenBelowOddAbove(ref string) float64 { + var n int + if _, err := fmt.Sscanf(ref, "seg-%d", &n); err == nil && n%2 == 0 { + return 0.05 + } + return 0.9 +} + +// newCompactionSession builds a session with the dump and expand tools +// registered, mirroring main()'s enabled-mode wiring. +func newCompactionSession(t *testing.T, store *compaction.Store) *session.Session { + t.Helper() + c := client.NewClient(client.Config{BaseURL: "http://localhost:0"}) + sess := session.New(c, filepath.Join(t.TempDir(), "history.json"), nil, "", true) + sess.Registry.Register(largeDumpTool{output: largeDumpOutput(8)}) + sess.Registry.Register(largeDumpTool{name: "small_dump", output: "tiny result"}) + sess.Registry.Register(tool.ExpandTool{Store: store}) + return sess +} + +// callTool runs one tool call through ExecuteToolCalls and returns the tool +// result message that entered history. +func callTool(t *testing.T, sess *session.Session, id, name, args string) string { + t.Helper() + err := ExecuteToolCalls(context.Background(), sess, []client.ToolCall{ + {ID: id, Function: client.FunctionCall{Name: name, Arguments: args}}, + }, nil) + if err != nil { + t.Fatalf("ExecuteToolCalls(%s) error = %v", name, err) + } + last := sess.History[len(sess.History)-1] + if last.Role != "tool" || last.ToolCallID != id { + t.Fatalf("history tail = role %q id %q, want the tool result for %s", last.Role, last.ToolCallID, id) + } + return last.Content.String() +} + +func hits(t *testing.T, counter *int32) int { + t.Helper() + return int(atomic.LoadInt32(counter)) +} + +// TestExecuteToolCalls_CompactionEnabledRelocatesAndExpandRetrieves is the +// end-to-end enabled-mode check: an oversized tool result enters history +// compacted (pointers + kept segments + trailer), the elided original never +// leaks into history, and the expand tool retrieves it from the store. +func TestExecuteToolCalls_CompactionEnabledRelocatesAndExpandRetrieves(t *testing.T) { + srv, counter := stubDecisionsServer(t, evenBelowOddAbove) + store := compaction.NewStore() + pipeline := compaction.NewPipeline( + compaction.ResolvedBackend{Backend: compaction.Backend{Name: "test", URL: srv.URL, Model: "jev-latest"}, APIKey: "k"}, + "k", nil, compaction.PipelineOptions{}) + pipeline.EnableRelocation(store, 0.35) + SetToolResultCompactor(pipeline) + t.Cleanup(func() { SetToolResultCompactor(nil) }) + + sess := newCompactionSession(t, store) + output := largeDumpOutput(8) + if len(output) <= MinCompactToolResultChars { + t.Fatalf("test output is only %d chars, must exceed the %d-char threshold", len(output), MinCompactToolResultChars) + } + + dumped := callTool(t, sess, "call_1", "large_dump", "{}") + // Four non-adjacent paragraphs score below the threshold: four runs, four + // content-addressed pointers (r:<8hex>) with line ranges. + if got := strings.Count(dumped, "[[elided id=r:"); got != 4 { + t.Errorf("history tool result carries %d content-id pointers, want 4:\n%s", got, dumped) + } + if !strings.Contains(dumped, " lines=") { + t.Errorf("history tool result pointers missing the line ranges:\n%s", dumped) + } + if !strings.Contains(dumped, "PARA-1-SECRET") || !strings.Contains(dumped, "PARA-3-SECRET") { + t.Errorf("history tool result lost a high-scoring segment:\n%s", dumped) + } + if strings.Contains(dumped, "PARA-2-SECRET") || strings.Contains(dumped, "PARA-4-SECRET") { + t.Errorf("history tool result leaked an elided original:\n%s", dumped) + } + if !strings.Contains(dumped, "4 segments elided — use the expand tool with the elided ids to retrieve originals") { + t.Errorf("history tool result missing the expand trailer:\n%s", dumped) + } + + // The elided original sits in the store under its content id — derived + // exactly as the pipeline does: ContentID(runText, salt=toolName, "r"). + segs := compaction.SegmentSegments(output, 0) + seg2ID := compaction.ContentID(segs[1].Text, "large_dump", "r") + if _, ok := store.Get(seg2ID); !ok { + t.Errorf("store must hold seg-2 under its content id %s", seg2ID) + } + + // The expand tool retrieves the elided original from the shared store — + // and its own result must not be re-compacted (no new scoring requests). + before := hits(t, counter) + expanded := callTool(t, sess, "call_2", "expand", `{"id":"`+seg2ID+`"}`) + if hits(t, counter) != before { + t.Errorf("expand result was re-compacted: %d new scoring requests", hits(t, counter)-before) + } + // seg-2 is paragraph 2 plus its trailing blank-line separator. + want := segs[1].Text + if expanded != want { + t.Errorf("expand(%s) = %q, want the stored original %q", seg2ID, truncateForTest(expanded), truncateForTest(want)) + } + + // The byte-for-byte inverse: reconstructing the history result (trailer + // stripped) restores the tool's raw output. evenBelowOddAbove elides the + // even segments: seg-2, seg-4, seg-6, seg-8 — none adjacent, so four + // single-segment runs in segment order. + ids := []string{ + compaction.ContentID(segs[1].Text, "large_dump", "r"), + compaction.ContentID(segs[3].Text, "large_dump", "r"), + compaction.ContentID(segs[5].Text, "large_dump", "r"), + compaction.ContentID(segs[7].Text, "large_dump", "r"), + } + trailer := "\n\n4 segments elided — use the expand tool with the elided ids to retrieve originals (" + strings.Join(ids, ", ") + ")." + compacted := strings.TrimSuffix(dumped, trailer) + if compacted == dumped { + t.Fatalf("history result missing the expand trailer with the elided ids:\n%s", dumped) + } + if restored := compaction.Reconstruct(compacted, store); restored != output { + t.Errorf("Reconstruct(history result) is not byte-for-byte:\n got %q\nwant %q", truncateForTest(restored), truncateForTest(output)) + } + + // Unknown ids produce the documented error result — legacy id shapes too. + unknown := callTool(t, sess, "call_3", "expand", `{"id":"elide-999"}`) + if !strings.Contains(unknown, "unknown elided id") { + t.Errorf("expand(unknown id) = %q, want the unknown-id error result", unknown) + } +} + +// TestExecuteToolCalls_CompactionFileBackedStoreSurvivesReopen is the Step +// 12 end-to-end: enabled mode with the store backed by a real JSONL file. +// The elided original is retrievable in-session, carries its full record +// metadata (tool origin, kind, segment ids, summary, tokens), and — the gap +// this step closes — is still retrievable from a freshly reopened store, +// so [[elided …]] pointers in a persisted session no longer dangle after a +// restart. +func TestExecuteToolCalls_CompactionFileBackedStoreSurvivesReopen(t *testing.T) { + srv, _ := stubDecisionsServer(t, evenBelowOddAbove) + storePath := filepath.Join(t.TempDir(), "compaction-store.jsonl") + store, err := compaction.OpenStore(storePath) + if err != nil { + t.Fatalf("OpenStore() error = %v", err) + } + pipeline := compaction.NewPipeline( + compaction.ResolvedBackend{Backend: compaction.Backend{Name: "test", URL: srv.URL, Model: "jev-latest"}, APIKey: "k"}, + "k", nil, compaction.PipelineOptions{}) + pipeline.EnableRelocation(store, 0.35) + SetToolResultCompactor(pipeline) + t.Cleanup(func() { SetToolResultCompactor(nil) }) + + sess := newCompactionSession(t, store) + output := largeDumpOutput(8) + callTool(t, sess, "call_1", "large_dump", "{}") + + segs := compaction.SegmentSegments(output, 0) + seg2ID := compaction.ContentID(segs[1].Text, "large_dump", "r") + + // The record carries the pipeline's metadata, not just the text. + rec, ok := store.GetRecord(seg2ID) + if !ok { + t.Fatalf("store must hold a record for %s", seg2ID) + } + if rec.Kind != compaction.RecordKindElidedSegment { + t.Errorf("Kind = %q, want %q", rec.Kind, compaction.RecordKindElidedSegment) + } + if rec.Origin != (compaction.Origin{Source: "tool:large_dump", Ref: "", Turn: 0}) { + t.Errorf("Origin = %+v, want {tool:large_dump 0}", rec.Origin) + } + if len(rec.SegmentIDs) != 1 || rec.SegmentIDs[0] != "seg-2" { + t.Errorf("SegmentIDs = %v, want [seg-2]", rec.SegmentIDs) + } + if rec.Tokens != segs[1].Tokens { + t.Errorf("Tokens = %d, want %d", rec.Tokens, segs[1].Tokens) + } + if want := compaction.Summarise(segs[1].Text, compaction.SummaryMaxChars); rec.Summary != want { + t.Errorf("Summary = %q, want the pointer summary %q", rec.Summary, want) + } + + // Touch the expand counter the way the expand path will (Step 13). + if !store.Touch(seg2ID, true, false) { + t.Fatal("Touch on a stored record must report found") + } + + // REOPEN — the restart/resume case every in-memory build failed: the + // persisted record must resolve for both the raw read side and the + // expand tool wired against the reopened store. + reopened, err := compaction.OpenStore(storePath) + if err != nil { + t.Fatalf("reopen OpenStore() error = %v", err) + } + if text, ok := reopened.Get(seg2ID); !ok || text != segs[1].Text { + t.Errorf("reopened Get(%s) = (%q, %v), want the stored original", seg2ID, truncateForTest(text), ok) + } + rec2, ok := reopened.GetRecord(seg2ID) + if !ok { + t.Fatalf("reopened store must hold the record for %s", seg2ID) + } + if rec2.ExpandCount != 1 { + t.Errorf("reopened ExpandCount = %d, want 1 (the touch survives reload)", rec2.ExpandCount) + } + if rec2.Origin != rec.Origin || rec2.Kind != rec.Kind || rec2.Tokens != rec.Tokens || rec2.Summary != rec.Summary || + len(rec2.SegmentIDs) != 1 || rec2.SegmentIDs[0] != "seg-2" { + t.Errorf("reopened record = %+v, want the original metadata %+v", rec2, rec) + } + + sess2 := newCompactionSession(t, reopened) + expanded := callTool(t, sess2, "call_3", "expand", `{"id":"`+seg2ID+`"}`) + if expanded != segs[1].Text { + t.Errorf("expand after reopen = %q, want the stored original %q", truncateForTest(expanded), truncateForTest(segs[1].Text)) + } +} + +// TestExecuteToolCalls_CompactionShadowMode: shadow mode scores and logs but +// the history result is byte-identical to the tool's output. +func TestExecuteToolCalls_CompactionShadowMode(t *testing.T) { + srv, counter := stubDecisionsServer(t, evenBelowOddAbove) + shadowPath := filepath.Join(t.TempDir(), "shadow.jsonl") + shadow, err := compaction.NewShadowLogAt(shadowPath) + if err != nil { + t.Fatal(err) + } + pipeline := compaction.NewPipeline( + compaction.ResolvedBackend{Backend: compaction.Backend{Name: "test", URL: srv.URL, Model: "jev-latest"}, APIKey: "k"}, + "k", shadow, compaction.PipelineOptions{}) + SetToolResultCompactor(pipeline) + t.Cleanup(func() { SetToolResultCompactor(nil) }) + + sess := newCompactionSession(t, compaction.NewStore()) + output := largeDumpOutput(8) + got := callTool(t, sess, "call_1", "large_dump", "{}") + if got != output { + t.Error("shadow mode must leave the history tool result unchanged") + } + + // The shadow log recorded one decision per segment. + data, err := os.ReadFile(shadowPath) + if err != nil { + t.Fatalf("shadow log missing: %v", err) + } + lines := strings.Count(strings.TrimSpace(string(data)), "\n") + 1 + if lines != 8 { + t.Errorf("shadow log has %d lines, want one per segment (8)", lines) + } + if hits(t, counter) == 0 { + t.Error("shadow mode must still score through the decisions backend") + } +} + +// TestExecuteToolCalls_CompactionOff: no compactor installed → the result +// passes through untouched and the decisions backend is never contacted. +func TestExecuteToolCalls_CompactionOff(t *testing.T) { + _, counter := stubDecisionsServer(t, evenBelowOddAbove) // server live, but must never be hit + SetToolResultCompactor(nil) + t.Cleanup(func() { SetToolResultCompactor(nil) }) + + sess := newCompactionSession(t, compaction.NewStore()) + output := largeDumpOutput(8) + got := callTool(t, sess, "call_1", "large_dump", "{}") + if got != output { + t.Error("compaction off must leave the history tool result unchanged") + } + if n := hits(t, counter); n != 0 { + t.Errorf("compaction off must not score, got %d decisions requests", n) + } +} + +// TestExecuteToolCalls_CompactionFailOpenAndSmallResults: a broken backend +// must return the original result (never break a tool call over +// compaction), and results under the size threshold must not be scored at +// all. The 400 status is non-retryable, so the broken-backend case is fast. +func TestExecuteToolCalls_CompactionFailOpenAndSmallResults(t *testing.T) { + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, `{"error": {"message": "down"}}`) + })) + t.Cleanup(srv.Close) + + store := compaction.NewStore() + pipeline := compaction.NewPipeline( + compaction.ResolvedBackend{Backend: compaction.Backend{Name: "test", URL: srv.URL, Model: "jev-latest"}, APIKey: "k"}, + "k", nil, compaction.PipelineOptions{}) + pipeline.EnableRelocation(store, 0.35) + SetToolResultCompactor(pipeline) + t.Cleanup(func() { SetToolResultCompactor(nil) }) + + sess := newCompactionSession(t, store) + output := largeDumpOutput(8) + got := callTool(t, sess, "call_1", "large_dump", "{}") + if got != output { + t.Errorf("fail-open must keep the original result, got:\n%s", truncateForTest(got)) + } + if store.Len() != 0 { + t.Error("fail-open must not store elided originals") + } + + // A small result is never offered to the compactor. + callTool(t, sess, "call_2", "small_dump", "{}") + if n := atomic.LoadInt32(&hits); n != 1 { + t.Errorf("scoring requests = %d, want exactly the single (failed) oversized-result attempt", n) + } +} + +// TestMaybeCompactToolResult_Guards pins the executor-side guards without a +// backend: nil compactor, the size threshold, and the expand-tool exemption. +func TestMaybeCompactToolResult_Guards(t *testing.T) { + SetToolResultCompactor(nil) + t.Cleanup(func() { SetToolResultCompactor(nil) }) + + big := strings.Repeat("x", MinCompactToolResultChars+1) + if got := maybeCompactToolResult(context.Background(), "large_dump", big); got != big { + t.Error("nil compactor must pass results through untouched") + } + + calls := 0 + SetToolResultCompactor(compactorFunc(func(ctx context.Context, toolName, result string) string { + calls++ + return "compacted" + })) + + if got := maybeCompactToolResult(context.Background(), "large_dump", strings.Repeat("x", MinCompactToolResultChars)); got != strings.Repeat("x", MinCompactToolResultChars) { + t.Error("results at the size threshold must not be compacted") + } + if got := maybeCompactToolResult(context.Background(), tool.ExpandToolName, big); got != big { + t.Error("expand results must be exempt from re-compaction") + } + if got := maybeCompactToolResult(context.Background(), "large_dump", big); got != "compacted" { + t.Errorf("oversized results must reach the compactor, got %q", truncateForTest(got)) + } + if calls != 1 { + t.Errorf("compactor invoked %d times, want 1", calls) + } +} + +// compactorFunc adapts a function to ToolResultCompactor (tests only). +type compactorFunc func(ctx context.Context, toolName, result string) string + +func (f compactorFunc) CompactToolResult(ctx context.Context, toolName, result string) string { + return f(ctx, toolName, result) +} + +func truncateForTest(s string) string { + if len(s) <= 120 { + return s + } + return s[:117] + "..." +} diff --git a/internal/executor/executor.go b/internal/executor/executor.go index c836ab83..f6aa52b7 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -68,6 +68,50 @@ func (a *StreamAccumulator) Reset() { // --- Tool Execution --- +// MinCompactToolResultChars is the tool-result size above which +// ExecuteToolCalls offers the result to the compaction stage (staged +// rollout stage 2 of the jev-compaction port). Smaller results enter +// history untouched: scoring them costs latency and their segments are +// rarely worth eliding. +const MinCompactToolResultChars = 4000 + +// ToolResultCompactor is the compaction stage consulted by ExecuteToolCalls +// before a tool result enters history. Implemented by *compaction.Pipeline +// (CompactToolResult). Install it with SetToolResultCompactor when +// compaction-mode is shadow or enabled; leave it unset for off. +type ToolResultCompactor interface { + CompactToolResult(ctx context.Context, toolName, result string) string +} + +var ( + toolResultCompactorMu sync.RWMutex + toolResultCompactor ToolResultCompactor +) + +// SetToolResultCompactor installs c as the process-wide compaction stage for +// ExecuteToolCalls — the root agent and every subagent share it, mirroring +// the shared pipeline it wraps. Pass nil to switch compaction off. +func SetToolResultCompactor(c ToolResultCompactor) { + toolResultCompactorMu.Lock() + defer toolResultCompactorMu.Unlock() + toolResultCompactor = c +} + +// maybeCompactToolResult returns the (possibly compacted) form of result for +// history. Fail-safe: an oversized result of the expand tool is never +// re-compacted (expand exists to return originals — compacting them again +// would make them unreachable), and results at or under the size threshold +// pass through untouched. +func maybeCompactToolResult(ctx context.Context, toolName, result string) string { + toolResultCompactorMu.RLock() + c := toolResultCompactor + toolResultCompactorMu.RUnlock() + if c == nil || len(result) <= MinCompactToolResultChars || toolName == tool.ExpandToolName { + return result + } + return c.CompactToolResult(ctx, toolName, result) +} + // ExecuteToolCalls runs a slice of tool calls against the session. // It uses the provided middlewares to wrap the base tool execution. // Results are added to the session history. @@ -107,6 +151,11 @@ func ExecuteToolCalls(ctx context.Context, sess *session.Session, toolCalls []cl if err != nil { result = fmt.Sprintf("Error executing tool %s: %v", tc.Function.Name, err) } + // Compaction (stage 2): oversized results may be relocated into the + // compaction store before they enter history. Shadow mode scores and + // logs without changing the result; off mode has no compactor + // installed and passes through. + result = maybeCompactToolResult(ctx, tc.Function.Name, result) if err := sess.AddToolResultMessage(tc.ID, result); err != nil { return err } diff --git a/internal/executor/stream_retry.go b/internal/executor/stream_retry.go index 76836555..b371bac4 100644 --- a/internal/executor/stream_retry.go +++ b/internal/executor/stream_retry.go @@ -167,6 +167,16 @@ func classifyStreamError(err error) streamRetryClass { } var se *client.StatusError if errors.As(err, &se) { + // 413 NEVER retries — on any tier: the provider rejected the request + // body itself (payload too large), so resending the identical body is + // guaranteed to fail again. The client surfaces it as + // *client.PayloadTooLargeError (sentinel ErrPayloadTooLarge); the + // fallthrough below would already fail fast (413 is not + // 400/429/408/5xx), but the explicit branch pins the invariant where + // a reader can see it. + if se.StatusCode == 413 { + return retryClassNone + } if se.StatusCode == 400 { return retryClassBadBody } diff --git a/internal/executor/stream_retry_integration_test.go b/internal/executor/stream_retry_integration_test.go index 4f88a6a4..f372bebf 100644 --- a/internal/executor/stream_retry_integration_test.go +++ b/internal/executor/stream_retry_integration_test.go @@ -464,6 +464,60 @@ func TestRunLoopDoesNotRetryNonRetryable(t *testing.T) { } } +func TestRunLoopDoesNotRetry413(t *testing.T) { + // A 413 (payload too large) must fail the run on the FIRST attempt with + // zero retries on every tier: the provider rejected the request BODY, so + // resending the identical body can never succeed. The client classifies + // it as *client.PayloadTooLargeError (sentinel ErrPayloadTooLarge) with + // actionable recovery guidance in the error text. + rs := newRetryServer(t, func(w http.ResponseWriter, r *http.Request) { + serveStatus(w, http.StatusRequestEntityTooLarge, "Request body too large") + }) + + sess := newRetryTestSession(t, rs.server.URL) + onRetry, retryEvents := retryCollector(t) + onRecover, recoveries := recoveryCollector(t) + + // A generous budget that must never be touched by a 413. + ctx, cancel := runLoopCtx(5, 15*time.Second) + defer cancel() + + start := time.Now() + _, err := RunLoop(ctx, sess, 1, nil, nil, nil, nil, onRetry, onRecover, nil) + elapsed := time.Since(start) + + if err == nil { + t.Fatal("RunLoop returned nil error, want the 413 to fail the run") + } + if !errors.Is(err, client.ErrPayloadTooLarge) { + t.Fatalf("RunLoop error = %v, want it to carry the ErrPayloadTooLarge sentinel", err) + } + var statusErr *client.StatusError + if !errors.As(err, &statusErr) || statusErr.StatusCode != http.StatusRequestEntityTooLarge { + t.Fatalf("RunLoop error = %v, want it to wrap *client.StatusError with 413", err) + } + if !strings.Contains(err.Error(), client.PayloadTooLargeGuidance) { + t.Errorf("RunLoop error = %q, want it to carry the recovery guidance", err.Error()) + } + if elapsed > 2*time.Second { + t.Errorf("non-retryable 413 took %v to fail, want a fast failure", elapsed) + } + + if events := retryEvents(); len(events) != 0 { + t.Errorf("got %d RetryEvents, want 0 for a non-retryable 413: %+v", len(events), events) + } + // No retries, no recovery: the flag must never fire on a clean failure. + if got := recoveries(); got != 0 { + t.Errorf("onRecover fired %d times, want 0 (no retries happened)", got) + } + if got := rs.postCount(); got != 1 { + t.Errorf("server got %d POSTs, want exactly 1 (no retry after 413)", got) + } + if len(sess.History) != 1 { + t.Errorf("history length = %d, want 1 (nothing committed)", len(sess.History)) + } +} + func TestRunLoopCancelDuringBackoffStops(t *testing.T) { rs := newRetryServer(t, func(w http.ResponseWriter, r *http.Request) { serveStatus(w, http.StatusInternalServerError, "down while user waits") diff --git a/internal/executor/stream_retry_test.go b/internal/executor/stream_retry_test.go index 35214ed1..c3bca802 100644 --- a/internal/executor/stream_retry_test.go +++ b/internal/executor/stream_retry_test.go @@ -259,6 +259,14 @@ func TestIsRetryableStreamError(t *testing.T) { err: fmt.Errorf("stream error: %w", &client.StatusError{StatusCode: 404, Status: "404 Not Found"}), want: false, }, + { + // 413 NEVER retries — any tier: the provider rejected the request + // BODY, so resending the identical body is guaranteed to fail. + // The typed client error carries the ErrPayloadTooLarge sentinel. + name: "wrapped 413 payload-too-large is not retryable", + err: fmt.Errorf("stream error: %w", &client.PayloadTooLargeError{Status: &client.StatusError{StatusCode: 413, Status: "413 Payload Too Large", Body: "Request body too large"}}), + want: false, + }, // Not retryable: anything unknown fails fast, like pre-retry behavior. { @@ -373,6 +381,14 @@ func TestClassifyStreamError(t *testing.T) { err: fmt.Errorf("stream error: %w", &client.StatusError{StatusCode: 404, Status: "404 Not Found"}), want: retryClassNone, }, + { + // 413 fails fast on every tier: the request body exceeded the + // provider's limit, so retrying cannot help (pinned here and by + // TestRunLoopDoesNotRetry413 end-to-end). + name: "wrapped 413 payload-too-large is none", + err: fmt.Errorf("stream error: %w", &client.PayloadTooLargeError{Status: &client.StatusError{StatusCode: 413, Status: "413 Payload Too Large", Body: "Request body too large"}}), + want: retryClassNone, + }, // None: cancellation fails fast even inside the wraps. { diff --git a/internal/orchestrator/base.go b/internal/orchestrator/base.go index dd2741a0..0af7c661 100644 --- a/internal/orchestrator/base.go +++ b/internal/orchestrator/base.go @@ -42,6 +42,14 @@ type BaseOrchestrator struct { // Max turns configuration maxTurns int + + // retrievalHookFn runs at every turn start — right before that turn's + // stream request — when installed (main wires it behind the + // compaction-retrieval config switch to Session.InjectRetrieved). The + // hook owns its failures: it must never panic the turn, and a retrieval + // that found nothing or failed simply stages no context. Guarded by mu; + // nil (the default) is the plain no-hook behavior. + retrievalHookFn func(context.Context) } func NewBaseOrchestrator(id string, sess *session.Session, middlewares []common.ToolMiddleware, maxTurns int) *BaseOrchestrator { @@ -88,6 +96,26 @@ func (o *BaseOrchestrator) SetMaxTurns(maxTurns int) { o.maxTurns = maxTurns } +// SetRetrievalHook installs h as this orchestrator's retrieval hook: it +// runs at every turn start, right before that turn's stream request, after +// the pending messages joined history (so a retrieval scored against the +// just-submitted task sees it). main installs it behind the +// compaction-retrieval switch, closing over the compaction pipeline, the +// shared record store, and the agent's session (Session.InjectRetrieved). +// Must be called before the first run; passing nil removes the hook. +func (o *BaseOrchestrator) SetRetrievalHook(h func(context.Context)) { + o.mu.Lock() + defer o.mu.Unlock() + o.retrievalHookFn = h +} + +// retrievalHook returns the installed hook or nil (thread-safe). +func (o *BaseOrchestrator) retrievalHook() func(context.Context) { + o.mu.RLock() + defer o.mu.RUnlock() + return o.retrievalHookFn +} + func (o *BaseOrchestrator) MaxTokens() int { o.mu.RLock() defer o.mu.RUnlock() @@ -264,6 +292,16 @@ func (o *BaseOrchestrator) Execute(text string) (string, error) { _ = o.sess.AddMessage(msg) } + // Retrieval read side (Step 17): the hook — installed by main behind + // compaction-retrieval — scores the compaction store against the + // task and stages the retrieved block; Session.StartStream appends + // it last (the work area). It runs after the pending messages joined + // history so the just-submitted task is what gets scored against. + // The hook owns its failures: it never aborts the turn. + if h := o.retrievalHook(); h != nil { + h(ctx) + } + o.eventCh <- common.StatusEvent{ID: o.id, Status: "thinking"} } @@ -388,6 +426,12 @@ func (o *BaseOrchestrator) run() { _ = o.sess.AddMessage(msg) } + // Retrieval read side (Step 17): same hook as Execute's turn + // start — this is the run()/Submit path's stream request. + if h := o.retrievalHook(); h != nil { + h(ctx) + } + o.eventCh <- common.StatusEvent{ID: o.id, Status: "thinking"} } @@ -633,6 +677,10 @@ func (o *BaseOrchestrator) Rewind(index int) error { return fmt.Errorf("invalid history index") } o.sess.History = o.sess.History[:index] + // The frozen prefix never outlives the history it froze: the compaction + // high-water mark clamps to the truncated length, and the metadata write + // below persists the clamp. + o.sess.ClampCompactionHighWater(index) if o.sess.HistoryPath != "" { if err := session.SaveHistory(o.sess.HistoryPath, o.sess.History); err != nil { return err diff --git a/internal/orchestrator/base_test.go b/internal/orchestrator/base_test.go index 9d07288d..be4c65c6 100644 --- a/internal/orchestrator/base_test.go +++ b/internal/orchestrator/base_test.go @@ -2,14 +2,14 @@ package orchestrator import ( "context" - "fmt" "encoding/json" + "fmt" "late/internal/client" "late/internal/common" "late/internal/session" + "late/internal/tool" "net/http" "net/http/httptest" - "late/internal/tool" "os" "path/filepath" "sync" @@ -82,6 +82,24 @@ func TestBaseOrchestrator_Rewind(t *testing.T) { if updatedHistory[1].Content.String() != "Reply 1" { t.Errorf("Expected second message 'Reply 1', got %q", updatedHistory[1].Content.String()) } + + // (Step 14) Rewinding clamps the compaction high-water mark to the + // truncated history — the frozen prefix never outlives the history it + // froze — and the clamp persists through the metadata write. + sess.SetCompactionHighWater(4) + if err := o.Rewind(1); err != nil { + t.Fatalf("Failed to rewind to 1: %v", err) + } + if got, want := sess.CompactionHighWater(), 1; got != want { + t.Errorf("CompactionHighWater after rewind to 1 = %d, want %d", got, want) + } + meta, err := session.LoadSessionMeta("history") + if err != nil || meta == nil { + t.Fatalf("LoadSessionMeta(history) = (%v, %v)", meta, err) + } + if meta.CompactionHighWater != 1 { + t.Errorf("persisted CompactionHighWater after rewind = %d, want 1", meta.CompactionHighWater) + } } func TestBaseOrchestrator_ResetStartsNewConversation(t *testing.T) { diff --git a/internal/orchestrator/retrieval_hook_test.go b/internal/orchestrator/retrieval_hook_test.go new file mode 100644 index 00000000..f19b6a95 --- /dev/null +++ b/internal/orchestrator/retrieval_hook_test.go @@ -0,0 +1,62 @@ +package orchestrator + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "late/internal/client" + "late/internal/session" +) + +// TestBaseOrchestrator_RetrievalHookRunsPerTurn: the hook installed by main +// behind compaction-retrieval fires at every turn start — right before that +// turn's stream request — and only when installed (the default is no-op). +func TestBaseOrchestrator_RetrievalHookRunsPerTurn(t *testing.T) { + // The base_test.go stream stub: an immediate empty completion. + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("data: [DONE]\n\n")) + })) + defer ts.Close() + + c := client.NewClient(client.Config{BaseURL: ts.URL}) + sess := session.New(c, "", []client.ChatMessage{ + {Role: "user", Content: client.TextContent("hi")}, + }, "", false) + + o := NewBaseOrchestrator("test-orch", sess, nil, 1) + var calls atomic.Int32 + o.SetRetrievalHook(func(context.Context) { calls.Add(1) }) + + if _, err := o.Execute(""); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if got := calls.Load(); got != 1 { + t.Fatalf("retrieval hook ran %d times for a 1-turn run, want 1", got) + } +} + +// TestBaseOrchestrator_NoRetrievalHookIsNoOp: without a hook the turn start +// proceeds untouched (the default, compaction-retrieval off). +func TestBaseOrchestrator_NoRetrievalHookIsNoOp(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("data: [DONE]\n\n")) + })) + defer ts.Close() + + c := client.NewClient(client.Config{BaseURL: ts.URL}) + sess := session.New(c, "", []client.ChatMessage{ + {Role: "user", Content: client.TextContent("hi")}, + }, "", false) + + o := NewBaseOrchestrator("test-orch", sess, nil, 1) + if _, err := o.Execute(""); err != nil { + t.Fatalf("Execute() error = %v", err) + } +} diff --git a/internal/pathutil/pathutil.go b/internal/pathutil/pathutil.go index bea80c33..feae1838 100644 --- a/internal/pathutil/pathutil.go +++ b/internal/pathutil/pathutil.go @@ -14,21 +14,27 @@ func LateConfigDir() (string, error) { return filepath.Join(configDir, "late"), nil } -func LateSessionDir() (string, error) { +// LateDataDir returns the late data directory — the parent of every mutable +// data file (session histories, the compaction shadow log and record store, +// the critical-error log): ~/.local/share/late on Unix-likes. Windows keeps +// all app state under the config dir (AppData), exactly like LateSessionDir. +func LateDataDir() (string, error) { if runtime.GOOS == "windows" { - // Use UserConfigDir on Windows to keep all app state under AppData. - lateConfigDir, err := LateConfigDir() - if err != nil { - return "", err - } - return filepath.Join(lateConfigDir, "sessions"), nil + return LateConfigDir() } - homeDir, err := os.UserHomeDir() if err != nil { return "", err } - return filepath.Join(homeDir, ".local", "share", "late", "sessions"), nil + return filepath.Join(homeDir, ".local", "share", "late"), nil +} + +func LateSessionDir() (string, error) { + lateDataDir, err := LateDataDir() + if err != nil { + return "", err + } + return filepath.Join(lateDataDir, "sessions"), nil } // LateProjectMCPConfigPath returns the relative project-local MCP config diff --git a/internal/pathutil/pathutil_test.go b/internal/pathutil/pathutil_test.go new file mode 100644 index 00000000..40f5d8c1 --- /dev/null +++ b/internal/pathutil/pathutil_test.go @@ -0,0 +1,83 @@ +package pathutil_test + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "late/internal/common" + "late/internal/compaction" + "late/internal/pathutil" +) + +// TestDataPathsShareLateDataDir pins the LateDataDir unification (commit +// ed7465a): the session dir, the compaction shadow log, the compaction +// record store, and the critical-error log all resolve inside ONE data dir +// — pathutil.LateDataDir — so late's mutable data never scatters across two +// roots. On Windows that root is the config dir (all app state under +// AppData, exactly like LateSessionDir's historical windows branch); on +// Unix-likes it is ~/.local/share/late. +func TestDataPathsShareLateDataDir(t *testing.T) { + if runtime.GOOS != "windows" { + // Deterministic home on Unix-likes; windows keeps its documented + // branches (config-dir root) without env surgery. + t.Setenv("HOME", t.TempDir()) + } + + dataDir, err := pathutil.LateDataDir() + if err != nil { + t.Fatalf("LateDataDir() error = %v", err) + } + sessionDir, err := pathutil.LateSessionDir() + if err != nil { + t.Fatalf("LateSessionDir() error = %v", err) + } + shadowPath, err := compaction.DefaultShadowPath() + if err != nil { + t.Fatalf("DefaultShadowPath() error = %v", err) + } + storePath, err := compaction.DefaultStorePath() + if err != nil { + t.Fatalf("DefaultStorePath() error = %v", err) + } + errorLogPath, err := common.DefaultErrorLogPath() + if err != nil { + t.Fatalf("DefaultErrorLogPath() error = %v", err) + } + + // Every data file lives directly in the shared data dir (the session + // dir is the dir's one subdirectory). + if got := filepath.Dir(sessionDir); got != dataDir || filepath.Base(sessionDir) != "sessions" { + t.Errorf("LateSessionDir() = %q, want /sessions with LateDataDir %q", sessionDir, dataDir) + } + for name, p := range map[string]string{ + "DefaultShadowPath": shadowPath, + "DefaultStorePath": storePath, + "DefaultErrorLogPath": errorLogPath, + } { + if got := filepath.Dir(p); got != dataDir { + t.Errorf("%s() = %q, want it directly inside LateDataDir %q", name, p, dataDir) + } + } + + // Platform semantics: one root for everything, per GOOS. + switch runtime.GOOS { + case "windows": + cfgDir, err := pathutil.LateConfigDir() + if err != nil { + t.Fatalf("LateConfigDir() error = %v", err) + } + if dataDir != cfgDir { + t.Errorf("LateDataDir() = %q, want the config dir %q on windows (all app state under AppData)", dataDir, cfgDir) + } + default: + home, err := os.UserHomeDir() + if err != nil { + t.Skipf("no home dir available: %v", err) + } + if want := filepath.Join(home, ".local", "share", "late"); dataDir != want { + t.Errorf("LateDataDir() = %q, want %q", dataDir, want) + } + } +} diff --git a/internal/session/compact.go b/internal/session/compact.go new file mode 100644 index 00000000..6a24f464 --- /dev/null +++ b/internal/session/compact.go @@ -0,0 +1,731 @@ +package session + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "late/internal/client" + "late/internal/common" + "late/internal/compaction" + "late/internal/tool" +) + +// Full-history context compaction. +// +// CompactContext is the core both the /jev-compact-context command and the +// auto-trigger call: it walks the session history, segments every eligible +// message after the frozen prefix, scores the segments against the ongoing +// task, and relocates low-scoring segments into the elided-original store, +// replacing them in place with [[elided …]] pointer lines that the expand +// tool resolves. It reuses the compaction port's segmentation and scoring +// contract (internal/compaction) but owns the history walk, because the +// pipeline's tool-output path (CompactToolOutput) works one tool result at a +// time and knows nothing about history structure. +// +// Invariants (the upstream jev-compaction design): +// +// - Frozen prefix: the work walk starts at index max(high-water mark, +// max(1, len(history)/4)) and never below it, so the prompt-cache +// anchor — the system prompt at index 0 plus the earliest exchanges — +// stays byte-identical across compactions. The high-water mark is the +// per-session, monotonic message index every completed mutating walk +// has covered; it persists in the session meta sidecar +// (CompactionHighWater), so the prefix is append-only across runs AND +// restarts: it never shrinks, and messages below it are never scored or +// rewritten. A walk that would mutate a message below the mark (a stale +// mark over a shrunken history) fails loudly with ErrFrozenPrefix and +// changes nothing — the reference's FrozenPrefixError analog. +// - Pointer-bearing messages are final: any message whose content carries +// an [[elided …]] pointer (compaction.FindPointers) is skipped +// entirely — never re-segmented, never re-scored, never rewritten — so +// an earlier run's pointers cannot be nested or invalidated. +// - Only a completing mutating walk advances the high-water mark (to the +// history length it covered): shadow runs mutate nothing and report +// only, mid-walk scorer aborts leave the mark where it was, and a +// failed mark persistence rolls the in-memory advance back. +// - User messages are never compacted. Pure-prose assistant messages are +// never compacted either: an assistant message with NO tool calls +// (decisions, explanations, plans) is the conversation's narrative, not +// recoverable work output, and stays byte-identical. Compaction +// candidates are tool results and assistant messages WITH ToolCalls +// (whose Content annotates the calls). Assistant ToolCalls are +// structurally required and are never touched: only Content shrinks. +// - Tool results produced by protected tools (activate_skill — see +// compaction.ProtectedTool — and expand, tool.ExpandToolName) are never +// compacted: the former IS the instructions the agent was told to +// follow, and eliding it would silently strip the guidance out of the +// conversation; the latter holds bytes the agent explicitly asked back, +// so eliding them would undo a request the agent just made. +// - Segments are scored against the ongoing task (the last user message); +// a segment scoring strictly below its elide floor is elided into the +// store, and each run of consecutive elided segments is replaced by one +// [[elided …]] pointer line standing exactly where the run stood — +// content-addressed (compaction.ContentID with salt "" and the "r" +// prefix), carrying the run's [first, last] line range and a 120-char +// escaped summary. compaction.Reconstruct over the rewritten message +// and the store is the byte-for-byte inverse of the rewrite. +// - With a gate (CompactionOptions.Gate, the pipeline's GateConfig the +// walk shares with the tool-output path) the elide floor is per kind — +// protected kinds (stacktrace, diff) only elide below their own, much +// lower, floor (compaction.GateFloorFor) — and the max-elide-fraction +// tripwire runs per message: a scorer that wants to elide more than the +// fraction of a message's tokens is distrusted and that message keeps +// everything (report.Tripwires; the shadow log when one is threaded). +// A nil gate keeps the legacy flat-threshold walk. +// - Fail-open: a scorer error that still answers every requested id (the +// pipeline's contract — unscoreable items come back as keep-scores) +// does not stop the walk: those scores are used, the scorer's errors +// accumulate and surface at the end via errors.Join. The walk stops +// mid-flight only when the scorer returns no usable scores for a +// message, or when the scorer reports an auth-class failure +// (compaction.KindAuth — a bad or missing API key, which no later +// message can score either): that stops the walk immediately with the +// typed error instead of firing one doomed request per message. +// Messages already rewritten stay rewritten (their pointers and stored +// originals are valid) and the returned error reports how far the walk +// got. Compaction never breaks a session. +// - Shadow mode (compaction-mode "shadow") runs the full scoring walk and +// computes the honest would-save report without mutating history. +// +// The scorer and the store are passed in (dependency injection): the session +// never constructs the compaction pipeline — the TUI/main holds it and hands +// CompactContext its scoring client and original-text store. +// +// ErrFrozenPrefix is the fail-loud sentinel for a frozen-prefix violation: +// a walk asked to score or rewrite a message below the persisted high-water +// mark. Like the reference's FrozenPrefixError it fires before anything is +// changed — a violating run leaves the history, the store, and the mark +// exactly as they were. +var ErrFrozenPrefix = errors.New("compaction: would mutate the frozen prefix") + +const ( + // minCompactChars is the content size above which a post-frozen-prefix + // assistant or tool-result message becomes a compaction candidate. It is + // the segment default: a message shorter than one maximum segment offers + // no elidable granularity. + minCompactChars = compaction.DefaultMaxSegChars + + // defaultFrozenPercent is the share of history messages (by count) the + // frozen prefix keeps byte-identical. + defaultFrozenPercent = 25 + + // compactionTaskFallback is the derived task when the history contains + // no user message to score against. + compactionTaskFallback = "general context compaction" + + // maxTaskChars caps the derived task so one giant prompt does not become + // a giant scoring request. + maxTaskChars = 500 + + // keepScoreFallback is the defensive fail-open score for a segment whose + // id is missing from the scorer's answer map: fully essential, keep. + // (compaction.DecisionClient.ScoreBatch fills every id; this only guards + // against a non-conforming HistoryScorer.) + keepScoreFallback = 1.0 +) + +// HistoryScorer scores one batch of segments against an ongoing task. It is +// satisfied by compaction.DecisionClient (the pipeline's scoring client); +// tests inject map-based stubs. Like the pipeline's contract, an error +// return that still carries a score for every requested id is the fail-open +// shape (unscoreable items answered as keep-scores): CompactContext keeps +// walking with those scores and surfaces the errors at the end. An error +// with scores missing means the batch could not be scored — CompactContext +// stops the walk rather than eliding on unusable answers. +type HistoryScorer interface { + ScoreBatch(ctx context.Context, task string, items map[string]compaction.Item) (map[string]float64, error) +} + +// ElideStore is the write side of the elided-original store CompactContext +// relocates runs into: PutRecord stores each elided run's full record under +// the pointer's id so the expand tool can retrieve it (tool.ExpandStore +// reads the text back) and Step 13's outcomes can attribute it (origin, +// token count, summary, contributing segment ids). New pointers are +// content-addressed (compaction.ContentID, "r:<8hex>" — same run text, same +// id, same record), so puts are idempotent: an existing id keeps its first +// record. NextID remains only for legacy "elide-" ids minted by older +// builds; new code never calls it. One store is one id space; production +// wiring backs it with the compaction pipeline's store so tool-output and +// history pointers share it. +type ElideStore interface { + NextID() string + Put(id, text string) + PutRecord(rec compaction.Record) +} + +// CompactStore is the in-memory original-text store backing history +// compaction: CompactContext Puts every elided run's original under its +// content id, and the expand tool Gets it back — it satisfies +// tool.ExpandStore directly. Put is idempotent (an existing id keeps its +// first record); NextID mints legacy "elide-" ids only. Safe for +// concurrent use. +type CompactStore struct { + mu sync.Mutex + originals map[string]string + next int +} + +// NewCompactStore returns an empty original-text store. +func NewCompactStore() *CompactStore { + return &CompactStore{originals: make(map[string]string)} +} + +// NextID mints the next legacy elide-pointer id ("elide-", 1-based). +func (s *CompactStore) NextID() string { + s.mu.Lock() + defer s.mu.Unlock() + s.next++ + return fmt.Sprintf("elide-%d", s.next) +} + +// Put stores text under id. Idempotent: an existing id keeps its first +// record (content ids hash the text, so two runs with the same id carry the +// same text anyway). +func (s *CompactStore) Put(id, text string) { + s.mu.Lock() + defer s.mu.Unlock() + if s.originals == nil { + s.originals = make(map[string]string) + } + if _, exists := s.originals[id]; exists { + return + } + s.originals[id] = text +} + +// PutRecord stores a record's text under its id. The in-memory session +// store keeps originals only — origin, kind, and counter metadata are the +// file-backed compaction store's business (main.go wires the same +// *compaction.Store into the history walk in enabled mode) — and nothing +// here relies on them: shadow runs never store, and tests only read text +// back. Idempotent like Put. +func (s *CompactStore) PutRecord(rec compaction.Record) { + s.Put(rec.ID, rec.Text) +} + +// Get returns the original stored for id (the tool.ExpandStore read side). +// An unknown id reports ("", false). +func (s *CompactStore) Get(id string) (string, bool) { + s.mu.Lock() + defer s.mu.Unlock() + text, ok := s.originals[id] + return text, ok +} + +// Len reports how many records the store holds (tests and diagnostics). +func (s *CompactStore) Len() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.originals) +} + +// The expand tool reads history-elided originals straight from this store. +var _ tool.ExpandStore = (*CompactStore)(nil) + +// The compaction pipeline's scoring client is the production HistoryScorer. +var _ HistoryScorer = (*compaction.DecisionClient)(nil) + +// CompactionOptions tunes CompactContext; zero values are production +// defaults. +type CompactionOptions struct { + // Threshold is the score strictly below which a segment is elided. + // Values outside (0, 1] fall back to compaction.DefaultRelocationThreshold + // (0.35), matching Pipeline.EnableRelocation. + Threshold float64 + // FrozenPercent is the share of history messages (by count) kept + // byte-identical in the frozen prefix. Values outside (0, 100] fall back + // to defaultFrozenPercent (25); 100 freezes everything (a no-op run). + FrozenPercent int + // ShadowOnly computes the would-save report without mutating history or + // storing originals (compaction-mode "shadow"). + ShadowOnly bool + // Gate, when non-nil, applies the pipeline's GateConfig safety semantics + // to the walk — the same protections the tool-output path gets from + // ApplyGateConfig: a segment is elided only below its kind's protected + // floor (compaction.GateFloorFor) instead of the flat Threshold, and the + // max-elide-fraction tripwire runs per MESSAGE: a message whose elided + // tokens would exceed MaxElideFraction elides nothing and records the + // tripwire (report.Tripwires, plus the shadow log via ShadowLog). A nil + // Gate keeps the legacy flat-threshold behavior. A gate whose + // KeepThreshold is outside (0, 1] falls back to the normalized Threshold + // for unprotected kinds — the effective floor is never unset. + Gate *compaction.GateConfig + // ShadowLog receives the walk's tripwire shadow entries when Gate's + // max-elide-fraction tripwire fires (best-effort: an append failure + // never fails the run; nil skips shadow logging for the walk's + // tripwires — the run summary itself remains the caller's append, as + // main.go's runner does). + ShadowLog *compaction.ShadowLog +} + +// CompactionReport summarizes one CompactContext run. In shadow mode the +// counts are what a mutating run would have done (honest staging: the same +// numbers a real run reports, nothing applied). +type CompactionReport struct { + // MessagesScanned is the number of history messages the walk examined — + // everything after the frozen prefix. + MessagesScanned int + // MessagesScored is the number of messages whose segments were actually + // scored, including fail-open-scored ones (a scorer error answered with + // a complete keep-score map still counts the message as scored). + MessagesScored int + // MessagesCompacted is the number of messages actually rewritten (or + // would-be rewritten in shadow mode). + MessagesCompacted int + // SegmentsElided is the number of segments relocated into the store. + SegmentsElided int + // TokensBefore is the summed EstimateMessageTokens of the whole history + // before the run. + TokensBefore int + // TokensAfter is the same sum after the run (the would-be sum in shadow + // mode). + TokensAfter int + // TokensSaved is TokensBefore - TokensAfter. + TokensSaved int + // StoreSize is the total byte size of the original texts this run + // relocated into the store (what they would be in shadow mode). + StoreSize int + // ShadowOnly reports whether the run mutated anything. + ShadowOnly bool + // TaskHash is the compaction.HashTask digest of the scoring task this + // run scored against (the derived ongoing task, never its text). The + // shadow log's per-run summary lines group under it, the same way the + // per-segment decision lines do. + TaskHash string + // Tripwires is the number of messages where the gate's + // max-elide-fraction tripwire fired: the scorer wanted to elide more + // than Gate.MaxElideFraction of the message's tokens, so NOTHING was + // elided in that message (the tool-output path's CompactResult.Tripwire + // analog, per message). Zero without a gate or when no message tripped. + Tripwires int +} + +// CompactContext compacts the session history in place (unless +// opts.ShadowOnly) per the package doc. scorer scores segments; store +// receives the elided originals. A nil store leaves relocation disarmed — +// like the pipeline with relocation off, nothing is elided, because a +// pointer whose original cannot be stored must never enter history. The +// returned error is non-nil when the scorer reported failures: a walk that +// completed on fail-open scores joins those scorer errors at the end, and a +// walk that had to stop (no usable scores for a message) reports how far it +// got. A walk that would mutate a message below the persisted high-water +// mark fails with ErrFrozenPrefix before changing anything. With +// opts.Gate the walk runs the pipeline's gate protections (per-kind floors +// and the per-message max-elide-fraction tripwire; see CompactionOptions); +// a nil Gate keeps the legacy flat-threshold walk. The report covers +// everything completed either way, and already-rewritten messages stay +// rewritten; history persistence is the caller's job (SaveHistory), while a +// completing mutating run persists the advanced high-water mark itself +// (SessionMeta.CompactionHighWater). +func (s *Session) CompactContext(ctx context.Context, scorer HistoryScorer, store ElideStore, opts CompactionOptions) (CompactionReport, error) { + var report CompactionReport + if scorer == nil { + return report, fmt.Errorf("context compaction: no scorer provided") + } + // Zero values are production defaults (mirrors Pipeline.EnableRelocation + // and the frozen-prefix default). + if opts.Threshold <= 0 || opts.Threshold > 1 { + opts.Threshold = compaction.DefaultRelocationThreshold + } + if opts.FrozenPercent <= 0 || opts.FrozenPercent > 100 { + opts.FrozenPercent = defaultFrozenPercent + } + report.ShadowOnly = opts.ShadowOnly + + // Gate normalization: a gate whose KeepThreshold is unset or out of + // range falls back to the walk's normalized Threshold for unprotected + // kinds — the effective floor is never unset (mirrors resolvedGate, + // whose keep is always the pipeline's armed threshold). The copy keeps + // the caller's config untouched. + if opts.Gate != nil && (opts.Gate.KeepThreshold <= 0 || opts.Gate.KeepThreshold > 1) { + g := *opts.Gate + g.KeepThreshold = opts.Threshold + opts.Gate = &g + } + + // The frozen prefix is append-only: it starts at the persisted high-water + // mark — every completed mutating walk covered the history below it, so + // those bytes are the prompt-cache anchor — and only grows to the + // count-based floor, never shrinks. + mark := s.CompactionHighWater() + if mark > len(s.History) { + // The mark outlives the history it froze (a stale sidecar over a + // truncated history). Any progress would rewrite below-mark + // messages; fail loudly and change nothing — the reference's + // FrozenPrefixError analog. + return report, fmt.Errorf("compaction: high-water mark %d is beyond the %d-message history: %w", mark, len(s.History), ErrFrozenPrefix) + } + frozen := max(mark, frozenPrefix(len(s.History), opts.FrozenPercent)) + // startLen is the history length this walk covers: a completing walk + // advances the mark to it (never mid-walk, never in shadow mode). + startLen := len(s.History) + report.MessagesScanned = len(s.History) - frozen + + report.TokensBefore = historyMessageTokens(s.History) + report.TokensAfter = report.TokensBefore + + // The scoring task is hashed up front so every report carries its group + // key even when the walk stops early: the run summary the caller appends + // to the shadow log uses this digest, and the per-segment decisions the + // scorer logs share it. + task := compactionTask(s.History) + report.TaskHash = compaction.HashTask(task) + + if store == nil { + // Relocation disarmed: nothing can be stored for the expand tool, so + // nothing is elided. The scan/token counts above still stand. + return report, nil + } + + scored := 0 + var walkErrs []error + + // Tool origins: tool_call_id → tool name from the assistant messages + // that issued the calls, so the history surface knows which tool + // produced each tool result — the same "tool:" origin the + // tool-output path stamps on its records. Tool results from protected + // tools (protectedHistoryTool — activate_skill, expand) are skipped + // before segmentation: the gate's protected score floor makes + // activate_skill results unelidable on the tool-output path, and here + // the honest equivalent is to never even score them (a kept decision is + // guaranteed, not scored into). + originTool := make(map[string]string) + for i := range s.History { + for _, tc := range s.History[i].ToolCalls { + if tc.ID != "" && tc.Function.Name != "" { + originTool[tc.ID] = tc.Function.Name + } + } + } + + for i := frozen; i < len(s.History); i++ { + // Unreachable while frozen >= mark holds (it is how frozen is + // computed); fail loudly rather than silently mutate the anchor if + // that computation ever regresses. + if i < mark { + return report, fmt.Errorf("compaction: message %d sits below the high-water mark %d: %w", i, mark, ErrFrozenPrefix) + } + msg := &s.History[i] + if msg.Role == "tool" { + if name, ok := originTool[msg.ToolCallID]; ok && protectedHistoryTool(name) { + // A protected tool's result (the skill's instructions, or + // bytes the agent explicitly asked back through expand): + // never re-segmented, never scored, never rewritten. + continue + } + } + // Already-elided messages are final: their pointers stand in history + // and their originals live in the store. Re-scoring one would let a + // pointer line become segment text and nest new pointers. + if len(compaction.FindPointers(msg.Content.Text)) > 0 { + continue + } + if !compactableContent(msg) { + continue + } + segs := compaction.SegmentSegments(msg.Content.Text, minCompactChars) + if len(segs) == 0 { + continue + } + items := make(map[string]compaction.Item, len(segs)) + for _, seg := range segs { + items[seg.ID] = compaction.Item{Text: seg.Text, Tokens: seg.Tokens} + } + scores, err := scorer.ScoreBatch(ctx, task, items) + if err != nil { + var ae *compaction.Error + if errors.As(err, &ae) && ae.Kind == compaction.KindAuth { + // Auth (the reference's JevAuthError: 401/403 — bad or + // missing API key) is a session-level failure: no later + // message can score either, so continuing the walk would + // only fire one doomed request per message. Stop right + // here and surface the typed error; everything already + // rewritten stays rewritten. + return report, fmt.Errorf("context compaction stopped after %d messages: %w", scored, err) + } + if !scoresComplete(items, scores) { + // Wholesale failure: the scorer returned no usable scores + // for this message. Fail-open mid-walk: stop the walk. + // Messages already rewritten stay rewritten — their pointers + // and stored originals are valid — and the error says how + // far the walk got. + return report, fmt.Errorf("context compaction stopped after %d messages: %w", scored, err) + } + // Fail-open complete: every requested id came back (the + // pipeline's contract — unscoreable items are answered with + // keep-scores), so the scores are usable. Keep the walk going; + // the scorer's errors surface at the end. + walkErrs = append(walkErrs, err) + } + scored++ + report.MessagesScored++ + + // Reference flush_run pattern (shared with the tool-output path via + // compaction.BuildElidedRun): consecutive below-threshold segments + // group into ONE run sharing a single record and pointer; kept + // segments flush the pending run and keep their exact text in place. + // Pointer lines stand exactly where the runs stood, so + // compaction.Reconstruct of the compacted text (with the store) + // restores the original byte for byte. Pointers are content + // addressed: id = ContentID(runText, salt="", "r"). The salt is + // empty deliberately — content addressing is per-text, and the + // history walk has no stable origin ref to namespace it with; the + // same run text always maps to the same id. + var ( + pointers []string + stored int + elidedSegs int + b strings.Builder + run []compaction.Segment + ) + flushRun := func() { + if len(run) == 0 { + return + } + er := compaction.BuildElidedRun(run, "") + pointers = append(pointers, er.Pointer) + stored += len(er.Text) + elidedSegs += er.Segments + if !opts.ShadowOnly { + // The record mirrors the reference store.py Record for a + // history-elided run: kind elided_segment, origin + // "history" (the walk has no finer ref to attribute the + // run to), the run's token count and pointer summary, and + // the contributing segment ids the outcomes ledger + // attributes back to. CreatedTurn stays 0 — turn plumbing + // does not exist yet. + store.PutRecord(compaction.Record{ + ID: er.ID, + Text: er.Text, + Kind: compaction.RecordKindElidedSegment, + Origin: compaction.Origin{Source: compaction.OriginSourceHistory}, + Tokens: er.Tokens, + CreatedTurn: 0, + Summary: er.Summary, + SegmentIDs: er.SegmentIDs, + }) + } + b.WriteString(er.Pointer) + b.WriteString("\n") + run = run[:0] + } + // Per-segment scores and the elide floor — the gate's protected-kind + // floor when a gate is set (compaction.GateFloorFor: the protected + // floor for stacktrace/diff segments, else the keep threshold), the + // flat Threshold for the legacy nil-gate walk — then the shared + // paragraph-atomicity decision (same rule as the tool-output path): + // pieces cut from the same oversized paragraph share ONE decision, + // made on the minimum sibling score — one low piece elides the whole + // paragraph (stored whole, restorable whole through the pointer), so + // a cut JSON blob is never partially elided into an unparseable + // remnant, and pieces above the floor keep the paragraph fully. + segScores := make([]float64, len(segs)) + segFloors := make([]float64, len(segs)) + for i, seg := range segs { + score, ok := scores[seg.ID] + if !ok { + score = keepScoreFallback + } + segScores[i] = score + if opts.Gate != nil { + segFloors[i] = compaction.GateFloorFor(*opts.Gate, seg.Kind) + } else { + segFloors[i] = opts.Threshold + } + } + elide := compaction.AtomicElideDecisions(segs, segScores, segFloors) + + // The max-elide-fraction tripwire (the tool-output path's gate + // guard, mirrored per MESSAGE): a scorer that wants to drop more + // than MaxElideFraction of a message's tokens is wrong more often + // than not — and one broken score map could gut a message + // wholesale. Distrust it: elide nothing in the message, count the + // tripwire in the report, and shadow-log the override when a log is + // threaded. The gate owns the decision: the legacy nil-gate walk + // never trips. + if opts.Gate != nil && opts.Gate.MaxElideFraction > 0 { + var totalTokens, elidedTokens int + for i, seg := range segs { + totalTokens += seg.Tokens + if elide[i] { + elidedTokens += seg.Tokens + } + } + if totalTokens > 0 && float64(elidedTokens) > opts.Gate.MaxElideFraction*float64(totalTokens) { + for i := range elide { + elide[i] = false + } + report.Tripwires++ + if opts.ShadowLog != nil { + // Best-effort, exactly like the pipeline's logTripwire: + // logging must not be able to break compaction, and the + // tripwire has already done its job by the time this + // runs. + _ = opts.ShadowLog.Append(compaction.ShadowEntry{ + TS: time.Now(), + TaskHash: report.TaskHash, + Tokens: totalTokens, + Type: compaction.EntryTypeTripwire, + Action: compaction.TripwireAction, + }) + } + } + } + + for i, seg := range segs { + if !elide[i] { + flushRun() + b.WriteString(seg.Text) + continue + } + run = append(run, seg) + } + flushRun() + if len(pointers) == 0 { + continue + } + compacted := b.String() + + report.MessagesCompacted++ + report.SegmentsElided += elidedSegs + report.StoreSize += stored + // Only Content.Text changes, so the per-message token delta is the + // text delta — exact for both the mutating and the shadow run. + report.TokensAfter -= common.EstimateTokenCount(msg.Content.Text) - common.EstimateTokenCount(compacted) + if !opts.ShadowOnly { + // In-place content swap: Role, ToolCalls, ToolCallID + // and ReasoningContent are preserved; only Content shrinks. + msg.Content = client.TextContent(compacted) + } + } + + report.TokensSaved = report.TokensBefore - report.TokensAfter + // The walk reached the end of history: a mutating run freezes everything + // it covered by advancing the mark to the run-start length and persisting + // it (shadow runs mutate nothing and report only; a mid-walk abort + // returned above without advancing). A failed persistence rolls the + // in-memory advance back and surfaces here — the next run re-walks the + // uncovered tail, where pointer-bearing messages are skipped, so nothing + // is ever rewritten twice. + if !opts.ShadowOnly && startLen > mark { + if err := s.UpdateCompactionHighWater(startLen); err != nil { + walkErrs = append(walkErrs, fmt.Errorf("persisting compaction high-water mark: %w", err)) + } + } + // Fail-open scorer errors accumulated along the walk surface here; a + // clean walk returns a nil error. + return report, errors.Join(walkErrs...) +} + +// protectedHistoryTool reports whether a tool result produced by toolName +// must never be compacted by the history walk: activate_skill +// (compaction.ProtectedTool — the result IS the skill's instructions, what +// the agent was told to follow) and expand (tool.ExpandToolName — the agent +// explicitly asked those bytes back, so eliding them would undo a request +// the agent just made). Such results are skipped before segmentation: a +// kept decision is guaranteed, not scored into. +func protectedHistoryTool(toolName string) bool { + return compaction.ProtectedTool(toolName) || toolName == tool.ExpandToolName +} + +// scoresComplete reports whether scores answers every id in items — the +// pipeline's fail-open contract shape (compaction.DecisionClient.ScoreBatch +// fills every id, unscoreable items with keep-scores, even when it also +// reports errors). A missing id means the scorer had nothing usable for that +// segment. +func scoresComplete(items map[string]compaction.Item, scores map[string]float64) bool { + for id := range items { + if _, ok := scores[id]; !ok { + return false + } + } + return true +} + +// frozenPrefix computes how many leading history messages are never +// compacted: max(1, len(history)*percent/100), rounded down, capped at the +// history length. The floor of 1 always keeps message[0] — the system +// prompt — inside the prefix (prompt-cache preservation); for histories of +// twelve or more messages at the default 25% the prefix is a quarter of the +// messages, rounded down. +func frozenPrefix(n, percent int) int { + if n <= 0 { + return 0 + } + frozen := n * percent / 100 + if frozen < 1 { + frozen = 1 + } + if frozen > n { + frozen = n + } + return frozen +} + +// compactableContent reports whether msg is a compaction candidate: a +// tool-result message, or an assistant message WITH tool calls, whose +// text-only content exceeds minCompactChars. +// +// User messages are never compacted. Pure-prose assistant messages — an +// assistant message with NO tool calls: decisions, explanations, plans — are +// never compacted either: they are the conversation's narrative, not +// recoverable work output, and the agent's later reasoning builds on them +// verbatim. Only tool results and the content annotating assistant tool +// calls are work output that the store can hold and the expand tool can +// restore. Multimodal messages pass through untouched (parts cannot be +// rebuilt losslessly here); shorter messages offer no elidable granularity. +func compactableContent(msg *client.ChatMessage) bool { + switch msg.Role { + case "tool": + // Tool results are the compaction surface: recoverable from the + // store through the expand tool. + case "assistant": + // Assistant content is compactable only when it annotates tool + // calls; pure prose stays byte-identical. + if len(msg.ToolCalls) == 0 { + return false + } + default: + return false + } + if len(msg.Content.Parts) != 0 { + return false + } + return len(msg.Content.Text) > minCompactChars +} + +// compactionTask derives the ongoing-task description the scorer scores +// against: the last user message's content truncated to maxTaskChars runes, +// or compactionTaskFallback when the history has no user message. +func compactionTask(history []client.ChatMessage) string { + for i := len(history) - 1; i >= 0; i-- { + if history[i].Role == "user" { + return truncateRunes(history[i].Content.String(), maxTaskChars) + } + } + return compactionTaskFallback +} + +// truncateRunes cuts s to at most max runes, rune-safe and without a suffix. +func truncateRunes(s string, max int) string { + runes := []rune(s) + if len(runes) <= max { + return s + } + return string(runes[:max]) +} + +// historyMessageTokens sums EstimateMessageTokens over the history. The +// system prompt lives outside the history (Session.systemPrompt) and is +// never touched, so it is not part of the before/after math. +func historyMessageTokens(history []client.ChatMessage) int { + total := 0 + for _, msg := range history { + total += common.EstimateMessageTokens(msg) + } + return total +} diff --git a/internal/session/compact_test.go b/internal/session/compact_test.go new file mode 100644 index 00000000..8c10574c --- /dev/null +++ b/internal/session/compact_test.go @@ -0,0 +1,2051 @@ +package session + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "sync/atomic" + "testing" + + "late/internal/client" + "late/internal/common" + "late/internal/compaction" + "late/internal/tool" +) + +// --- fixture helpers --------------------------------------------------------- +// +// All helpers are cmp-prefixed to stay clear of the history_sanitize_test.go +// fixture helpers in this package. + +// cmpStamped marks fixture message n. On the source branch it stamped the +// message with a fixed RFC3339 timestamp (timestamps must survive compaction +// untouched); the receive-time Timestamp field itself belongs to the excluded +// timestamps feature, so here it is an identity marker kept so the fixture +// call sites keep their per-message indices. +func cmpStamped(msg client.ChatMessage, _ int) client.ChatMessage { + return msg +} + +func cmpSystem(text string) client.ChatMessage { + return client.ChatMessage{Role: "system", Content: client.TextContent(text)} +} + +func cmpUser(text string) client.ChatMessage { + return client.ChatMessage{Role: "user", Content: client.TextContent(text)} +} + +func cmpAssistant(text string) client.ChatMessage { + return client.ChatMessage{Role: "assistant", Content: client.TextContent(text)} +} + +func cmpTool(text string) client.ChatMessage { + return client.ChatMessage{Role: "tool", Content: client.TextContent(text)} +} + +func cmpToolResult(callID, text string) client.ChatMessage { + return client.ChatMessage{Role: "tool", ToolCallID: callID, Content: client.TextContent(text)} +} + +func cmpAssistantWithCalls(text string, calls []client.ToolCall) client.ChatMessage { + return client.ChatMessage{Role: "assistant", Content: client.TextContent(text), ToolCalls: calls} +} + +func cmpToolCall(id string) client.ToolCall { + return client.ToolCall{Index: 0, ID: id, Type: "function", Function: client.FunctionCall{Name: "dump", Arguments: `{"path":"."}`}} +} + +// cmpLongText builds n distinct ~550-byte paragraphs separated by blank +// lines, so compaction.SegmentSegments yields exactly n single-paragraph +// segments (none tiny, none over the 1200-byte cap) and n >= 3 exceeds +// minCompactChars. +func cmpLongText(tag string, n int) string { + var b strings.Builder + for i := 0; i < n; i++ { + if i > 0 { + b.WriteString("\n\n") + } + fmt.Fprintf(&b, "%s segment %02d: ", tag, i) + b.WriteString(strings.Repeat(fmt.Sprintf("%s%02dtoken ", tag, i), 42)) + } + return b.String() +} + +// defaultFixture builds a 12-message history whose frozen prefix is +// max(1, 12/4) = 3 messages (the system prompt plus the two earliest +// exchanges) and whose eligible region mixes four compactable candidates +// (indices 4, 5, 7, 8 — assistant-with-tool-calls and tool results; a +// pure-prose assistant would NOT be a candidate), a long user message +// (index 9 — never compacted), and short filler. +func defaultFixture() []client.ChatMessage { + return []client.ChatMessage{ + cmpStamped(cmpSystem("You are a helpful coding assistant."), 0), // 0 frozen + cmpStamped(cmpUser("Explore the repository and summarize it."), 1), // 1 frozen + cmpStamped(cmpAssistant("It is a Go CLI for LLM chat sessions."), 2), // 2 frozen + cmpStamped(cmpUser("Now inspect internal/session."), 3), // 3 + cmpStamped(cmpAssistantWithCalls(cmpLongText("alpha", 3), []client.ToolCall{cmpToolCall("call_4")}), 4), // 4 candidate + cmpStamped(cmpTool(cmpLongText("beta", 3)), 5), // 5 candidate (tool result) + cmpStamped(cmpUser("What about tool calls?"), 6), // 6 + cmpStamped(cmpAssistantWithCalls(cmpLongText("gamma", 3), []client.ToolCall{cmpToolCall("call_7")}), 7), // 7 candidate + cmpStamped(cmpToolResult("call_7", cmpLongText("delta", 3)), 8), // 8 candidate (tool result, call id) + cmpStamped(cmpUser(cmpLongText("user", 3)), 9), // 9 long USER — never compacted + cmpStamped(cmpAssistant("A short closing answer."), 10), // 10 + cmpStamped(cmpTool(`{"ok":true}`), 11), // 11 + } +} + +// fixtureSegments precomputes, for every compactable candidate in fixture, +// the segments CompactContext will see (segmentation is deterministic), +// keyed by history index. +func fixtureSegments(t *testing.T, fixture []client.ChatMessage) map[int][]compaction.Segment { + t.Helper() + out := make(map[int][]compaction.Segment) + for i := range fixture { + msg := &fixture[i] + if !compactableContent(msg) { + continue + } + segs := compaction.SegmentSegments(msg.Content.Text, minCompactChars) + if len(segs) > 0 { + out[i] = segs + } + } + return out +} + +// stubErrShape selects what a failing stubScorer call returns alongside its +// error — the three ScoreBatch shapes the walk must tell apart. +type stubErrShape int + +const ( + // stubErrWholesale returns no usable scores with the error: a backend + // that produced nothing scoreable (the walk-abort shape). + stubErrWholesale stubErrShape = iota + // stubErrFailOpen returns a complete score map (this stub's normal + // answers for every requested id) alongside the error — the + // compaction.DecisionClient.ScoreBatch fail-open contract. + stubErrFailOpen + // stubErrPartial returns a map missing some requested ids alongside the + // error: only the first errPartialIds item ids (sorted) are answered. + stubErrPartial +) + +// stubScorer is the map-based HistoryScorer stub: it scores each item by +// exact segment-text lookup (fallback otherwise), can fail the Nth call in +// any of the three error shapes, and records every task it saw. No network. +type stubScorer struct { + scores map[string]float64 + fallback float64 + err error + // errOnCall is the 1-based ScoreBatch call that fails; 0 = never (or + // every call fails when err is set). + errOnCall int + // errShape selects the failing call's return shape (default wholesale). + errShape stubErrShape + // errPartialIds caps the partial shape's answer map size; unused + // otherwise. + errPartialIds int + calls int + tasks []string +} + +func (s *stubScorer) ScoreBatch(_ context.Context, task string, items map[string]compaction.Item) (map[string]float64, error) { + s.calls++ + s.tasks = append(s.tasks, task) + if s.err != nil && (s.errOnCall == 0 || s.calls == s.errOnCall) { + switch s.errShape { + case stubErrFailOpen: + // Fail-open shape: a complete score map returned alongside the + // error — every id usable. + return s.answer(items), s.err + case stubErrPartial: + // Partial shape: keep only the first errPartialIds ids (sorted, + // so the split is deterministic); the rest stay unanswered. + out := s.answer(items) + ids := make([]string, 0, len(out)) + for id := range out { + ids = append(ids, id) + } + sort.Strings(ids) + for _, id := range ids[s.errPartialIds:] { + delete(out, id) + } + return out, s.err + default: + // Wholesale shape: no usable scores. + return nil, s.err + } + } + return s.answer(items), nil +} + +// answer builds the stub's normal score map for items: the exact-text score +// when present, the fallback otherwise. +func (s *stubScorer) answer(items map[string]compaction.Item) map[string]float64 { + out := make(map[string]float64, len(items)) + for id, it := range items { + if score, ok := s.scores[it.Text]; ok { + out[id] = score + } else { + out[id] = s.fallback + } + } + return out +} + +// elideFirstScorer scores the FIRST segment of every precomputed candidate +// 0.1 (elide) and everything else 0.9 (keep), so each rewritten message +// loses exactly its opening segment. +func elideFirstScorer(segs map[int][]compaction.Segment) *stubScorer { + scores := make(map[string]float64) + for _, msgSegs := range segs { + scores[msgSegs[0].Text] = 0.1 + for _, seg := range msgSegs[1:] { + scores[seg.Text] = 0.9 + } + } + return &stubScorer{scores: scores, fallback: 0.9} +} + +func mustJSON(t *testing.T, v any) string { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return string(b) +} + +// newCompactSession wraps the fixture in an in-memory session (no history +// path: nothing is persisted) as a deep copy, so the caller's fixture stays +// pristine for comparisons. +func newCompactSession(history []client.ChatMessage) *Session { + return New(nil, "", cloneHistory(history), "", false) +} + +// assertZeroReport asserts a no-op report with the expected scan count. +func assertZeroReport(t *testing.T, report CompactionReport, wantScanned int) { + t.Helper() + if report.MessagesScanned != wantScanned { + t.Errorf("MessagesScanned = %d, want %d", report.MessagesScanned, wantScanned) + } + if report.MessagesScored != 0 || report.MessagesCompacted != 0 || report.SegmentsElided != 0 || report.TokensSaved != 0 || report.StoreSize != 0 { + t.Errorf("expected a no-op report, got %+v", report) + } + if report.TokensAfter != report.TokensBefore { + t.Errorf("TokensAfter = %d, want TokensBefore %d", report.TokensAfter, report.TokensBefore) + } +} + +// --- tests ------------------------------------------------------------------- + +// (a) The frozen prefix is byte-identical after a mutating run — including +// the system prompt at index 0 — and the walk starts right after it. +func TestCompactContextFrozenPrefixUntouched(t *testing.T) { + fixture := defaultFixture() + s := newCompactSession(fixture) + segs := fixtureSegments(t, fixture) + + report, err := s.CompactContext(context.Background(), elideFirstScorer(segs), NewCompactStore(), CompactionOptions{}) + if err != nil { + t.Fatalf("CompactContext() error = %v", err) + } + + // 12 messages at the default 25% → frozen = max(1, 3) = 3. + if frozen := frozenPrefix(len(fixture), defaultFrozenPercent); frozen != 3 { + t.Fatalf("frozenPrefix(12, 25) = %d, want 3", frozen) + } + if got, want := mustJSON(t, s.History[:3]), mustJSON(t, fixture[:3]); got != want { + t.Fatalf("frozen prefix mutated:\n got %s\nwant %s", got, want) + } + if s.History[0].Content.Text != fixture[0].Content.Text { + t.Error("the system prompt (message 0) must never be compacted") + } + // The walk starts at index 3: the first candidate (index 4) was rewritten. + if !strings.Contains(s.History[4].Content.Text, "[[elided id=") { + t.Error("expected the first eligible message (index 4) to be compacted") + } + if report.MessagesScanned != len(fixture)-3 { + t.Errorf("MessagesScanned = %d, want %d", report.MessagesScanned, len(fixture)-3) + } +} + +// (b) User messages are never compacted — not even a long one whose segments +// would all score below the threshold. +func TestCompactContextNeverCompactsUserMessages(t *testing.T) { + fixture := defaultFixture() + s := newCompactSession(fixture) + segs := fixtureSegments(t, fixture) + scorer := elideFirstScorer(segs) + + report, err := s.CompactContext(context.Background(), scorer, NewCompactStore(), CompactionOptions{}) + if err != nil { + t.Fatalf("CompactContext() error = %v", err) + } + + if got, want := s.History[9].Content.Text, fixture[9].Content.Text; got != want { + t.Fatalf("the long user message was compacted:\n got %q\nwant %q", truncateRunes(got, 120), truncateRunes(want, 120)) + } + // It was never even scored: exactly the four assistant/tool candidates + // reached the scorer. + if scorer.calls != len(segs) { + t.Errorf("scorer calls = %d, want %d (one per candidate)", scorer.calls, len(segs)) + } + if report.MessagesCompacted != len(segs) { + t.Errorf("MessagesCompacted = %d, want %d", report.MessagesCompacted, len(segs)) + } +} + +// (c) A long tool-result message is rewritten to kept segments plus an +// [[elided …]] pointer, and the original round-trips through the store — +// including through the expand tool. +func TestCompactContextElidesToolResultWithStoreRoundTrip(t *testing.T) { + fixture := defaultFixture() + s := newCompactSession(fixture) + segs := fixtureSegments(t, fixture) + // The production store type (compaction.Store, as main() wires it): it + // satisfies ElideStore, backs the expand tool, and works with + // compaction.Reconstruct. + store := compaction.NewStore() + scorer := elideFirstScorer(segs) + + report, err := s.CompactContext(context.Background(), scorer, store, CompactionOptions{}) + if err != nil { + t.Fatalf("CompactContext() error = %v", err) + } + + // The elided opening segment of the tool result at index 5 is stored + // under its content id (salt "" — history compaction is per-text), and + // the pointer stands where the run stood. + segs5 := segs[5] + runText := segs5[0].Text + id := compaction.ContentID(runText, "", "r") + pointer := compaction.FormatPointer(compaction.Pointer{ + ID: id, + Lines: &[2]int{segs5[0].LineStart, segs5[0].LineEnd}, + Tokens: segs5[0].Tokens, + Summary: compaction.Summarise(runText, compaction.SummaryMaxChars), + }) + want := pointer + "\n" + segs5[1].Text + segs5[2].Text + if got := s.History[5].Content.Text; got != want { + t.Fatalf("compacted tool result mismatch:\n got %q\nwant %q", truncateRunes(got, 200), truncateRunes(want, 200)) + } + if report.SegmentsElided != len(segs) { + t.Errorf("SegmentsElided = %d, want %d", report.SegmentsElided, len(segs)) + } + + original, ok := store.Get(id) + if !ok { + t.Fatalf("store.Get(%s) miss: the elided original was not stored", id) + } + if original != segs5[0].Text { + t.Fatalf("store round trip mismatch:\n got %q\nwant %q", truncateRunes(original, 120), truncateRunes(segs5[0].Text, 120)) + } + + // The expand tool reads the same store — content id or a whole pointer + // line, both resolve. + expanded, xerr := tool.ExpandTool{Store: store}.Execute(context.Background(), json.RawMessage(`{"id":"`+id+`"}`)) + if xerr != nil { + t.Fatalf("expand(%s) error = %v", id, xerr) + } + if expanded != segs5[0].Text { + t.Error("expand tool did not return the stored original") + } + expanded, xerr = tool.ExpandTool{Store: store}.Execute(context.Background(), json.RawMessage(`{"id":`+mustJSON(t, pointer)+`}`)) + if xerr != nil { + t.Fatalf("expand(pointer line) error = %v", xerr) + } + if expanded != segs5[0].Text { + t.Error("expand tool did not parse the pointer line down to its id") + } + + // The byte-for-byte inverse: reconstructing the compacted message with + // the store restores the original content. + if restored := compaction.Reconstruct(s.History[5].Content.Text, store); restored != fixture[5].Content.Text { + t.Errorf("Reconstruct(compacted message) is not byte-for-byte:\n got %q\nwant %q", + truncateRunes(restored, 200), truncateRunes(fixture[5].Content.Text, 200)) + } + + // The derived task is the last user message's content, truncated to + // maxTaskChars runes. + wantTask := truncateRunes(fixture[9].Content.String(), maxTaskChars) + for i, task := range scorer.tasks { + if task != wantTask { + t.Errorf("scorer task[%d] = %q, want the truncated last user message %q", i, truncateRunes(task, 80), truncateRunes(wantTask, 80)) + } + } +} + +// (c2) History compaction stores full records, not bare strings: kind +// elided_segment, origin "history" (Step 12 origin threading), the run's +// token count, the pointer summary, the contributing segment ids, and +// zeroed expand/hit counters — everything Step 13's outcomes attribute +// back through. +func TestCompactContextStoresRecordMetadata(t *testing.T) { + fixture := defaultFixture() + s := newCompactSession(fixture) + segs := fixtureSegments(t, fixture) + store := compaction.NewStore() + + if _, err := s.CompactContext(context.Background(), elideFirstScorer(segs), store, CompactionOptions{}); err != nil { + t.Fatalf("CompactContext() error = %v", err) + } + + // Message 5's elided opening segment is a single-segment run. + segs5 := segs[5] + runText := segs5[0].Text + id := compaction.ContentID(runText, "", "r") + rec, ok := store.GetRecord(id) + if !ok { + t.Fatalf("store must hold a record for %s", id) + } + if rec.Text != runText { + t.Errorf("Text = %q, want the stored run %q", truncateRunes(rec.Text, 120), truncateRunes(runText, 120)) + } + if rec.Kind != compaction.RecordKindElidedSegment { + t.Errorf("Kind = %q, want %q", rec.Kind, compaction.RecordKindElidedSegment) + } + if rec.Origin != (compaction.Origin{Source: compaction.OriginSourceHistory, Ref: "", Turn: 0}) { + t.Errorf("Origin = %+v, want {history 0}", rec.Origin) + } + if rec.CreatedTurn != 0 { + t.Errorf("CreatedTurn = %d, want 0 (turn plumbing does not exist yet)", rec.CreatedTurn) + } + if rec.Tokens != segs5[0].Tokens { + t.Errorf("Tokens = %d, want %d", rec.Tokens, segs5[0].Tokens) + } + if want := compaction.Summarise(runText, compaction.SummaryMaxChars); rec.Summary != want { + t.Errorf("Summary = %q, want %q", rec.Summary, want) + } + if !reflect.DeepEqual(rec.SegmentIDs, []string{segs5[0].ID}) { + t.Errorf("SegmentIDs = %v, want [%s]", rec.SegmentIDs, segs5[0].ID) + } + if rec.ExpandCount != 0 || rec.HitCount != 0 { + t.Errorf("counters = (expand %d, hit %d), want zeros", rec.ExpandCount, rec.HitCount) + } + + // The file-backed production store records the same shape: a put + // through the ElideStore interface and a reload must preserve it. A + // fresh session runs the walk (the first run rewrote s's history). + path := filepath.Join(t.TempDir(), "compaction-store.jsonl") + persisted, err := compaction.OpenStore(path) + if err != nil { + t.Fatal(err) + } + fixture2 := defaultFixture() + s2 := newCompactSession(fixture2) + segs2 := fixtureSegments(t, fixture2) + if _, err := s2.CompactContext(context.Background(), elideFirstScorer(segs2), persisted, CompactionOptions{}); err != nil { + t.Fatalf("CompactContext(file-backed store) error = %v", err) + } + reopened, err := compaction.OpenStore(path) + if err != nil { + t.Fatal(err) + } + rec2, ok := reopened.GetRecord(id) + if !ok { + t.Fatalf("reopened store must hold the history record for %s", id) + } + if !reflect.DeepEqual(rec2, rec) { + t.Errorf("reopened record = %+v, want %+v", rec2, rec) + } +} + +// (d) An assistant message with tool calls: Content is compacted, ToolCalls +// (and role) stay structurally intact. +func TestCompactContextCompactsAssistantContentKeepsToolCalls(t *testing.T) { + fixture := defaultFixture() + s := newCompactSession(fixture) + segs := fixtureSegments(t, fixture) + + if _, err := s.CompactContext(context.Background(), elideFirstScorer(segs), NewCompactStore(), CompactionOptions{}); err != nil { + t.Fatalf("CompactContext() error = %v", err) + } + + before, after := fixture[7], s.History[7] + if !reflect.DeepEqual(before.ToolCalls, after.ToolCalls) { + t.Fatalf("ToolCalls mutated:\n got %+v\nwant %+v", after.ToolCalls, before.ToolCalls) + } + if after.Role != before.Role { + t.Errorf("Role changed: got %q, want %q", after.Role, before.Role) + } + if !strings.Contains(after.Content.Text, "[[elided id=") { + t.Error("assistant Content was not compacted") + } + if len(after.Content.Text) >= len(before.Content.Text) { + t.Error("assistant Content did not shrink") + } + // The tool result answering the call keeps its ToolCallID too. + if s.History[8].ToolCallID != fixture[8].ToolCallID { + t.Errorf("ToolCallID changed: got %q, want %q", s.History[8].ToolCallID, fixture[8].ToolCallID) + } +} + +// (e) The token math is exact: before/after are the summed +// EstimateMessageTokens of the history and saved is their difference. +func TestCompactContextTokenMath(t *testing.T) { + fixture := defaultFixture() + s := newCompactSession(fixture) + segs := fixtureSegments(t, fixture) + + report, err := s.CompactContext(context.Background(), elideFirstScorer(segs), NewCompactStore(), CompactionOptions{}) + if err != nil { + t.Fatalf("CompactContext() error = %v", err) + } + + var wantBefore, wantAfter int + for i := range fixture { + wantBefore += common.EstimateMessageTokens(fixture[i]) + wantAfter += common.EstimateMessageTokens(s.History[i]) + } + if report.TokensBefore != wantBefore { + t.Errorf("TokensBefore = %d, want %d", report.TokensBefore, wantBefore) + } + if report.TokensAfter != wantAfter { + t.Errorf("TokensAfter = %d, want %d", report.TokensAfter, wantAfter) + } + if report.TokensSaved != wantBefore-wantAfter { + t.Errorf("TokensSaved = %d, want %d", report.TokensSaved, wantBefore-wantAfter) + } + if report.TokensSaved <= 0 { + t.Errorf("TokensSaved = %d, want > 0", report.TokensSaved) + } + if report.StoreSize <= 0 { + t.Errorf("StoreSize = %d, want > 0 (the relocated originals)", report.StoreSize) + } +} + +// (f) Shadow mode: the report carries the honest would-save numbers +// (identical to a mutating run's) and the history stays byte-identical. +func TestCompactContextShadowModeDoesNotMutate(t *testing.T) { + fixtureJSON := mustJSON(t, defaultFixture()) + segs := fixtureSegments(t, defaultFixture()) + + mutating := newCompactSession(defaultFixture()) + shadow := newCompactSession(defaultFixture()) + storeM, storeS := NewCompactStore(), NewCompactStore() + + gotMutating, err := mutating.CompactContext(context.Background(), elideFirstScorer(segs), storeM, CompactionOptions{}) + if err != nil { + t.Fatalf("mutating run error = %v", err) + } + gotShadow, err := shadow.CompactContext(context.Background(), elideFirstScorer(segs), storeS, CompactionOptions{ShadowOnly: true}) + if err != nil { + t.Fatalf("shadow run error = %v", err) + } + + if got, want := mustJSON(t, shadow.History), fixtureJSON; got != want { + t.Fatalf("the shadow run mutated history:\n got %s\nwant %s", truncateRunes(got, 300), truncateRunes(want, 300)) + } + if !gotShadow.ShadowOnly { + t.Error("the shadow report must set ShadowOnly") + } + if gotMutating.ShadowOnly { + t.Error("the mutating report must not set ShadowOnly") + } + // Honest staging: every number matches the real run. + if gotShadow.MessagesScanned != gotMutating.MessagesScanned || + gotShadow.MessagesScored != gotMutating.MessagesScored || + gotShadow.MessagesCompacted != gotMutating.MessagesCompacted || + gotShadow.SegmentsElided != gotMutating.SegmentsElided || + gotShadow.TokensBefore != gotMutating.TokensBefore || + gotShadow.TokensAfter != gotMutating.TokensAfter || + gotShadow.TokensSaved != gotMutating.TokensSaved || + gotShadow.StoreSize != gotMutating.StoreSize { + t.Fatalf("shadow report %+v differs from mutating report %+v", gotShadow, gotMutating) + } + if gotShadow.MessagesCompacted == 0 || gotShadow.TokensSaved <= 0 { + t.Fatalf("shadow report not populated: %+v", gotShadow) + } + // Shadow stores nothing; the mutating run stores under content ids. + if storeS.Len() != 0 { + t.Error("the shadow run must not store originals") + } + if storeM.Len() == 0 { + t.Error("the mutating run must store originals") + } + // The mutating run really did rewrite history. + if got := mustJSON(t, mutating.History); got == fixtureJSON { + t.Error("the mutating run left history unchanged") + } +} + +// (g) Wholesale mid-walk failure: the scorer fails on the third candidate +// with no usable scores (the default stubErrWholesale shape); the first two +// rewritten messages stay rewritten, the rest are untouched, and the error +// reports how far the walk got. +func TestCompactContextFailOpenMidWalk(t *testing.T) { + sentinel := errors.New("scorer unavailable") + fixture := []client.ChatMessage{ + cmpStamped(cmpSystem("system prompt"), 0), // frozen + cmpStamped(cmpUser("first question"), 1), // frozen + cmpStamped(cmpAssistant("first answer"), 2), // frozen + cmpStamped(cmpAssistantWithCalls(cmpLongText("c1", 3), []client.ToolCall{cmpToolCall("call_c1")}), 3), // candidate 1 + cmpStamped(cmpTool(cmpLongText("c2", 3)), 4), // candidate 2 + cmpStamped(cmpAssistantWithCalls(cmpLongText("c3", 3), []client.ToolCall{cmpToolCall("call_c3")}), 5), // candidate 3 — fails here + cmpStamped(cmpTool(cmpLongText("c4", 3)), 6), // candidate 4 + cmpStamped(cmpAssistantWithCalls(cmpLongText("c5", 3), []client.ToolCall{cmpToolCall("call_c5")}), 7), // candidate 5 + } + s := newCompactSession(fixture) + segs := fixtureSegments(t, fixture) + if len(segs) != 5 { + t.Fatalf("fixture candidates = %d, want 5", len(segs)) + } + scorer := elideFirstScorer(segs) + scorer.err = sentinel + scorer.errOnCall = 3 + + report, err := s.CompactContext(context.Background(), scorer, NewCompactStore(), CompactionOptions{}) + if err == nil { + t.Fatal("CompactContext() error = nil, want the scorer failure") + } + if !errors.Is(err, sentinel) { + t.Fatalf("error does not wrap the scorer failure: %v", err) + } + if !strings.Contains(err.Error(), "after 2 messages") { + t.Errorf("error %q does not report the completed-message count", err) + } + + // Messages 1-2 of the walk (fixture 3-4) stay rewritten. + for _, idx := range []int{3, 4} { + if !strings.Contains(s.History[idx].Content.Text, "[[elided id=") { + t.Errorf("fixture message %d should have been rewritten before the failure", idx) + } + } + // Messages 3+ of the walk are untouched. + for _, idx := range []int{5, 6, 7} { + if got, want := s.History[idx].Content.Text, fixture[idx].Content.Text; got != want { + t.Errorf("fixture message %d was mutated after the failure:\n got %q", idx, truncateRunes(got, 120)) + } + } + if report.MessagesCompacted != 2 { + t.Errorf("MessagesCompacted = %d, want 2", report.MessagesCompacted) + } + if report.SegmentsElided != 2 { + t.Errorf("SegmentsElided = %d, want 2", report.SegmentsElided) + } +} + +// (Step 7a) A scorer error WITH a complete score map is the pipeline's +// fail-open contract (compaction.DecisionClient.ScoreBatch answers every id, +// failed items as keep-scores): the walk completes with a normal-run report, +// every scored message is counted in MessagesScored, and the scorer's errors +// surface at the end wrapped in the returned error — instead of the old, +// wrong "context compaction stopped after 0 messages" abort. +func TestCompactContextFailOpenCompleteScoresFinishWalk(t *testing.T) { + sentinel := errors.New("scorer degraded") + fixture := defaultFixture() + s := newCompactSession(fixture) + segs := fixtureSegments(t, fixture) + scorer := elideFirstScorer(segs) + // Every call fails but answers every id with the stub's normal scores: + // the run must be indistinguishable from a clean one except for the + // surfaced error. + scorer.err = sentinel + scorer.errShape = stubErrFailOpen + + report, err := s.CompactContext(context.Background(), scorer, NewCompactStore(), CompactionOptions{}) + if err == nil { + t.Fatal("CompactContext() error = nil, want the scorer's fail-open errors surfaced") + } + if !errors.Is(err, sentinel) { + t.Fatalf("error does not wrap the scorer failure: %v", err) + } + if strings.Contains(err.Error(), "stopped after") { + t.Errorf("a completed walk must not report a mid-walk stop: %v", err) + } + + // The report reflects a normal run: every candidate rewritten, one + // segment each elided. + if report.MessagesCompacted != len(segs) { + t.Errorf("MessagesCompacted = %d, want %d", report.MessagesCompacted, len(segs)) + } + if report.SegmentsElided != len(segs) { + t.Errorf("SegmentsElided = %d, want %d", report.SegmentsElided, len(segs)) + } + if report.MessagesScored != len(segs) { + t.Errorf("MessagesScored = %d, want %d (every candidate scored, fail-open ones included)", report.MessagesScored, len(segs)) + } + if report.TokensSaved <= 0 { + t.Errorf("TokensSaved = %d, want > 0", report.TokensSaved) + } + // The walk really completed: the last candidate was rewritten too. + if !strings.Contains(s.History[8].Content.Text, "[[elided id=") { + t.Error("expected the last candidate (fixture index 8) to be compacted — the walk must not stop") + } +} + +// (Step 7b) A scorer error with a PARTIAL score map (some requested ids +// missing) is a wholesale failure: the walk aborts with "context compaction +// stopped after N messages", N the messages successfully scored before the +// failure — a partial answer is never trusted for elisions. +func TestCompactContextPartialScoresAbortMidWalk(t *testing.T) { + sentinel := errors.New("scorer degraded") + fixture := []client.ChatMessage{ + cmpStamped(cmpSystem("system prompt"), 0), // frozen + cmpStamped(cmpUser("first question"), 1), // frozen + cmpStamped(cmpAssistant("first answer"), 2), // frozen + cmpStamped(cmpAssistantWithCalls(cmpLongText("c1", 3), []client.ToolCall{cmpToolCall("call_c1")}), 3), // candidate 1 — scored cleanly + cmpStamped(cmpTool(cmpLongText("c2", 3)), 4), // candidate 2 — partial scores here + cmpStamped(cmpAssistantWithCalls(cmpLongText("c3", 3), []client.ToolCall{cmpToolCall("call_c3")}), 5), // candidate 3 + cmpStamped(cmpTool(cmpLongText("c4", 3)), 6), // candidate 4 + } + s := newCompactSession(fixture) + segs := fixtureSegments(t, fixture) + if len(segs) != 4 { + t.Fatalf("fixture candidates = %d, want 4", len(segs)) + } + scorer := elideFirstScorer(segs) + scorer.err = sentinel + scorer.errOnCall = 2 + scorer.errShape = stubErrPartial + scorer.errPartialIds = 1 // one of candidate 2's three segments answered + + report, err := s.CompactContext(context.Background(), scorer, NewCompactStore(), CompactionOptions{}) + if err == nil { + t.Fatal("CompactContext() error = nil, want the wholesale failure") + } + if !errors.Is(err, sentinel) { + t.Fatalf("error does not wrap the scorer failure: %v", err) + } + if !strings.Contains(err.Error(), "stopped after 1 messages") { + t.Errorf("error %q does not report the one message scored before the failure", err) + } + if report.MessagesScored != 1 { + t.Errorf("MessagesScored = %d, want 1", report.MessagesScored) + } + // Candidate 1 was scored and rewritten before the failure. + if !strings.Contains(s.History[3].Content.Text, "[[elided id=") { + t.Error("fixture message 3 should have been rewritten before the failure") + } + // Candidates 2+ are untouched — the walk stopped at the partial answer. + for _, idx := range []int{4, 5, 6} { + if got, want := s.History[idx].Content.Text, fixture[idx].Content.Text; got != want { + t.Errorf("fixture message %d was mutated after the partial failure:\n got %q", idx, truncateRunes(got, 120)) + } + } + if report.MessagesCompacted != 1 || report.SegmentsElided != 1 { + t.Errorf("expected exactly the pre-failure rewrite, got %+v", report) + } +} + +// (Step 7c) A clean scorer: nil error, and MessagesScored counts every +// message whose segments were scored. +func TestCompactContextCleanScorerCountsScoredMessages(t *testing.T) { + fixture := defaultFixture() + s := newCompactSession(fixture) + segs := fixtureSegments(t, fixture) + scorer := elideFirstScorer(segs) + + report, err := s.CompactContext(context.Background(), scorer, NewCompactStore(), CompactionOptions{}) + if err != nil { + t.Fatalf("CompactContext() error = %v", err) + } + if report.MessagesScored != len(segs) { + t.Errorf("MessagesScored = %d, want %d (one per scored candidate)", report.MessagesScored, len(segs)) + } + if report.MessagesScored != scorer.calls { + t.Errorf("MessagesScored = %d, scorer calls = %d; want them equal", report.MessagesScored, scorer.calls) + } +} + +// (Step 7d) The user's regression pin: when the very first candidate's +// ScoreBatch fails wholesale (a backend rejecting the scoring request — no +// usable scores at all), the error says "stopped after 0 messages" and +// nothing is rewritten. The fail-open-complete variant of the same symptom no +// longer aborts — see TestCompactContextFailOpenCompleteScoresFinishWalk. +func TestCompactContextWholesaleFailureOnFirstCandidateStopsAtZero(t *testing.T) { + sentinel := errors.New("decisions API error (422): model too small") + fixture := defaultFixture() + s := newCompactSession(fixture) + segs := fixtureSegments(t, fixture) + scorer := elideFirstScorer(segs) + scorer.err = sentinel + scorer.errOnCall = 1 // wholesale: no usable scores (default stubErrWholesale) + + report, err := s.CompactContext(context.Background(), scorer, NewCompactStore(), CompactionOptions{}) + if err == nil { + t.Fatal("CompactContext() error = nil, want the scorer failure") + } + if !errors.Is(err, sentinel) { + t.Fatalf("error does not wrap the scorer failure: %v", err) + } + if !strings.Contains(err.Error(), "context compaction stopped after 0 messages") { + t.Errorf("error %q does not pin the zero-progress abort", err) + } + if report.MessagesScored != 0 { + t.Errorf("MessagesScored = %d, want 0", report.MessagesScored) + } + if report.MessagesCompacted != 0 || report.SegmentsElided != 0 { + t.Errorf("expected a no-rewrite report, got %+v", report) + } + // Nothing was rewritten: the first candidate (fixture index 4) is intact. + if got, want := s.History[4].Content.Text, fixture[4].Content.Text; got != want { + t.Errorf("fixture message 4 was rewritten before the abort:\n got %q", truncateRunes(got, 120)) + } +} + +// (h) Timestamps survive compaction untouched — on compacted and frozen +// messages alike. (The receive-time Timestamp field belongs to the excluded +// timestamps feature, so that assertion lives with it; compaction's +// rewrite path preserves everything except the compacted content.) + +// (i) Short histories are no-ops: nothing eligible, nothing rewritten, a +// zero report. +func TestCompactContextShortHistoryNoOp(t *testing.T) { + t.Run("single system message", func(t *testing.T) { + fixture := []client.ChatMessage{cmpStamped(cmpSystem("only the prompt"), 0)} + s := newCompactSession(fixture) + report, err := s.CompactContext(context.Background(), elideFirstScorer(nil), NewCompactStore(), CompactionOptions{}) + if err != nil { + t.Fatalf("CompactContext() error = %v", err) + } + assertZeroReport(t, report, 0) // the only message is frozen + if got, want := mustJSON(t, s.History), mustJSON(t, fixture); got != want { + t.Fatalf("history mutated:\n got %s\nwant %s", got, want) + } + }) + + t.Run("system plus long user message", func(t *testing.T) { + fixture := []client.ChatMessage{ + cmpStamped(cmpSystem("system prompt"), 0), + cmpStamped(cmpUser(cmpLongText("u", 3)), 1), // long, but a user message + } + s := newCompactSession(fixture) + report, err := s.CompactContext(context.Background(), elideFirstScorer(nil), NewCompactStore(), CompactionOptions{}) + if err != nil { + t.Fatalf("CompactContext() error = %v", err) + } + assertZeroReport(t, report, 1) // the user message is scanned but never compacted + if got, want := mustJSON(t, s.History), mustJSON(t, fixture); got != want { + t.Fatalf("history mutated:\n got %s\nwant %s", got, want) + } + }) + + t.Run("system, user and short assistant", func(t *testing.T) { + fixture := []client.ChatMessage{ + cmpStamped(cmpSystem("system prompt"), 0), + cmpStamped(cmpUser("question"), 1), + cmpStamped(cmpAssistant("a short answer"), 2), // under minCompactChars + } + s := newCompactSession(fixture) + report, err := s.CompactContext(context.Background(), elideFirstScorer(nil), NewCompactStore(), CompactionOptions{}) + if err != nil { + t.Fatalf("CompactContext() error = %v", err) + } + assertZeroReport(t, report, 2) + if got, want := mustJSON(t, s.History), mustJSON(t, fixture); got != want { + t.Fatalf("history mutated:\n got %s\nwant %s", got, want) + } + }) + + t.Run("everything frozen via FrozenPercent 100", func(t *testing.T) { + fixture := defaultFixture() + s := newCompactSession(fixture) + report, err := s.CompactContext(context.Background(), elideFirstScorer(fixtureSegments(t, fixture)), NewCompactStore(), CompactionOptions{FrozenPercent: 100}) + if err != nil { + t.Fatalf("CompactContext() error = %v", err) + } + assertZeroReport(t, report, 0) // every message sits inside the frozen prefix + if got, want := mustJSON(t, s.History), mustJSON(t, fixture); got != want { + t.Fatalf("history mutated:\n got %s\nwant %s", got, want) + } + }) +} + +// (j) All-high scores: the walk runs (every candidate scored) but nothing is +// elided — a populated scan report with zero rewrites and unchanged history. +func TestCompactContextAllHighScoresNoOp(t *testing.T) { + fixture := defaultFixture() + s := newCompactSession(fixture) + segs := fixtureSegments(t, fixture) + scorer := &stubScorer{fallback: 0.9} // nothing elidable + + report, err := s.CompactContext(context.Background(), scorer, NewCompactStore(), CompactionOptions{}) + if err != nil { + t.Fatalf("CompactContext() error = %v", err) + } + if got, want := mustJSON(t, s.History), mustJSON(t, fixture); got != want { + t.Fatalf("history mutated under all-high scores:\n got %s", truncateRunes(got, 300)) + } + if report.MessagesScanned != len(fixture)-3 { + t.Errorf("MessagesScanned = %d, want %d", report.MessagesScanned, len(fixture)-3) + } + if report.MessagesCompacted != 0 || report.SegmentsElided != 0 || report.TokensSaved != 0 || report.StoreSize != 0 { + t.Errorf("expected a no-op report, got %+v", report) + } + if report.MessagesScored != len(segs) { + t.Errorf("MessagesScored = %d, want %d (every candidate scored)", report.MessagesScored, len(segs)) + } + if scorer.calls != len(segs) { + t.Errorf("scorer calls = %d, want %d", scorer.calls, len(segs)) + } +} + +// The threshold is exclusive and defaults to the relocation default: a +// segment scoring exactly compaction.DefaultRelocationThreshold is kept, one +// strictly below is elided. +func TestCompactContextThresholdIsExclusive(t *testing.T) { + build := func() (*Session, []client.ChatMessage, map[int][]compaction.Segment) { + fixture := []client.ChatMessage{ + cmpStamped(cmpSystem("system prompt"), 0), + cmpStamped(cmpUser("question"), 1), + cmpStamped(cmpAssistantWithCalls(cmpLongText("x", 4), []client.ToolCall{cmpToolCall("call_x")}), 2), // the only candidate (n=4 clears minCompactChars) + } + return newCompactSession(fixture), fixture, fixtureSegments(t, fixture) + } + + t.Run("exactly the default threshold is kept", func(t *testing.T) { + s, fixture, segs := build() + scorer := elideFirstScorer(segs) + scorer.scores[segs[2][0].Text] = compaction.DefaultRelocationThreshold + report, err := s.CompactContext(context.Background(), scorer, NewCompactStore(), CompactionOptions{}) + if err != nil { + t.Fatalf("CompactContext() error = %v", err) + } + if report.MessagesCompacted != 0 || report.SegmentsElided != 0 { + t.Errorf("expected a keep at the exact threshold, got %+v", report) + } + if s.History[2].Content.Text != fixture[2].Content.Text { + t.Error("history mutated at the exact threshold") + } + }) + + t.Run("just below the default threshold is elided", func(t *testing.T) { + s, _, segs := build() + scorer := elideFirstScorer(segs) + scorer.scores[segs[2][0].Text] = compaction.DefaultRelocationThreshold - 0.01 + report, err := s.CompactContext(context.Background(), scorer, NewCompactStore(), CompactionOptions{}) + if err != nil { + t.Fatalf("CompactContext() error = %v", err) + } + if report.MessagesCompacted != 1 || report.SegmentsElided != 1 { + t.Fatalf("expected one elision just below the threshold, got %+v", report) + } + if !strings.Contains(s.History[2].Content.Text, "[[elided id=r:") { + t.Error("expected the segment just below the threshold to be elided under a content id") + } + }) +} + +// (P10) Out-of-range CompactionOptions clamp to the safe production defaults: +// a threshold outside (0, 1] falls back to compaction.DefaultRelocationThreshold +// (0.35 — pinned via the keep-at-exactly-0.35 behavior, which an unclamped 0 +// would break by eliding everything and a 5 would break by keeping everything), +// and a frozen percent outside (0, 100] falls back to defaultFrozenPercent (25). +func TestCompactContextOptionsClampToDefaults(t *testing.T) { + fixture := []client.ChatMessage{ + cmpStamped(cmpSystem("system prompt"), 0), + cmpStamped(cmpUser("question"), 1), + cmpStamped(cmpAssistantWithCalls(cmpLongText("x", 4), []client.ToolCall{cmpToolCall("call_x")}), 2), + } + + t.Run("threshold 0 clamps to the default 0.35", func(t *testing.T) { + s := newCompactSession(fixture) + segs := fixtureSegments(t, fixture) + scorer := elideFirstScorer(segs) + scorer.scores[segs[2][0].Text] = compaction.DefaultRelocationThreshold // exactly the default: kept + if _, err := s.CompactContext(context.Background(), scorer, NewCompactStore(), CompactionOptions{Threshold: 0}); err != nil { + t.Fatalf("CompactContext() error = %v", err) + } + if s.History[2].Content.Text != fixture[2].Content.Text { + t.Error("threshold 0 must clamp to 0.35, which keeps a segment at exactly 0.35") + } + }) + + t.Run("threshold 5 clamps to the default 0.35", func(t *testing.T) { + s := newCompactSession(fixture) + segs := fixtureSegments(t, fixture) + scorer := elideFirstScorer(segs) + scorer.scores[segs[2][0].Text] = compaction.DefaultRelocationThreshold - 0.01 // just below: elided + if _, err := s.CompactContext(context.Background(), scorer, NewCompactStore(), CompactionOptions{Threshold: 5}); err != nil { + t.Fatalf("CompactContext() error = %v", err) + } + if !strings.Contains(s.History[2].Content.Text, "[[elided id=r:") { + t.Error("threshold 5 must clamp to 0.35, which elides a segment just below 0.35") + } + }) + + t.Run("frozen percent 0 and 150 clamp to the default 25", func(t *testing.T) { + for _, bad := range []int{0, 150} { + s := newCompactSession(fixture) + if _, err := s.CompactContext(context.Background(), &stubScorer{fallback: 0.9}, NewCompactStore(), CompactionOptions{FrozenPercent: bad}); err != nil { + t.Fatalf("FrozenPercent %d: CompactContext() error = %v", bad, err) + } + // 3 messages at the clamped default 25% → frozen = max(1, 0) = 1, + // so the assistant candidate at index 2 was scanned. + if s.History[2].Content.Text != fixture[2].Content.Text { + t.Errorf("FrozenPercent %d: history mutated (clamp broken)", bad) + } + } + }) +} + +// A nil scorer is a wiring bug: CompactContext refuses to run and leaves the +// history untouched. A nil store leaves relocation disarmed: nothing is +// elided (a pointer whose original cannot be stored must never enter +// history), mirroring the pipeline with relocation off. +func TestCompactContextFailSafes(t *testing.T) { + fixture := defaultFixture() + + t.Run("nil scorer", func(t *testing.T) { + s := newCompactSession(fixture) + report, err := s.CompactContext(context.Background(), nil, NewCompactStore(), CompactionOptions{}) + if err == nil { + t.Fatal("nil scorer: want an error") + } + if report != (CompactionReport{}) { + t.Errorf("nil scorer: want a zero report, got %+v", report) + } + if got, want := mustJSON(t, s.History), mustJSON(t, fixture); got != want { + t.Error("nil scorer: history must stay untouched") + } + }) + + t.Run("nil store disarms relocation", func(t *testing.T) { + s := newCompactSession(fixture) + report, err := s.CompactContext(context.Background(), elideFirstScorer(fixtureSegments(t, fixture)), nil, CompactionOptions{}) + if err != nil { + t.Fatalf("nil store: error = %v, want nil", err) + } + if got, want := mustJSON(t, s.History), mustJSON(t, fixture); got != want { + t.Error("nil store: history must stay untouched") + } + if report.MessagesScanned != len(fixture)-3 { + t.Errorf("nil store: MessagesScanned = %d, want %d", report.MessagesScanned, len(fixture)-3) + } + if report.MessagesCompacted != 0 || report.SegmentsElided != 0 { + t.Errorf("nil store: expected no elisions, got %+v", report) + } + }) +} + +// (Step 11) History round trip: a tool-result message carrying quotes, +// backslashes, and newlines compacts into in-place pointers under content +// ids, and compaction.Reconstruct restores the original content byte for +// byte. Legacy ids keep expanding: the counter still mints elide-N ids and +// the store resolves them. +func TestCompactContextReconstructRoundTrip(t *testing.T) { + para := func(marker string, n int) string { return marker + strings.Repeat(" "+marker+"-filler", n) } + content := strings.Join([]string{ + para(`keeper one "quoted" \ with backslashes`, 20), + "secret one starts \"with quotes\" and \\ a backslash\nsecret one line two\n" + para("s-one-tail", 12), + para("keeper two", 20), + "secret two \\ odd first line\n" + para("s-two-tail", 12), + para("keeper three", 20), + }, "\n\n") + fixture := []client.ChatMessage{ + cmpStamped(cmpSystem("system prompt"), 0), + cmpStamped(cmpUser("question"), 1), + cmpStamped(cmpAssistant("short"), 2), + cmpStamped(cmpTool(content), 3), // the only compactable candidate + } + segs := compaction.SegmentSegments(content, minCompactChars) + if len(segs) != 5 { + t.Fatalf("SegmentSegments() = %d segments, want 5", len(segs)) + } + + // Score the exact segment texts (the stub keys by text): the two secret + // paragraphs elide, everything else stays. + scorer := &stubScorer{ + scores: map[string]float64{ + segs[1].Text: 0.05, + segs[3].Text: 0.05, + }, + fallback: 0.9, + } + store := compaction.NewStore() + s := newCompactSession(fixture) + + report, err := s.CompactContext(context.Background(), scorer, store, CompactionOptions{}) + if err != nil { + t.Fatalf("CompactContext() error = %v", err) + } + if report.MessagesCompacted != 1 || report.SegmentsElided != 2 { + t.Fatalf("report = %+v, want one compacted message with 2 elided segments", report) + } + if report.StoreSize != len(segs[1].Text)+len(segs[3].Text) { + t.Errorf("StoreSize = %d, want the two run originals' size", report.StoreSize) + } + + compacted := s.History[3].Content.Text + if strings.Contains(compacted, "secret one line two") || strings.Contains(compacted, "s-two-tail") { + t.Errorf("compacted message leaked an elided run's body:\n%s", compacted) + } + for _, keeper := range []string{segs[0].Text, segs[2].Text, segs[4].Text} { + if !strings.Contains(compacted, keeper) { + t.Errorf("compacted message lost a kept segment:\n%s", compacted) + } + } + + // Two pointers, each naming its run's content id (salt ""). + pointers := compaction.FindPointers(compacted) + if len(pointers) != 2 { + t.Fatalf("FindPointers(compacted) = %d pointers, want 2", len(pointers)) + } + for i, p := range pointers { + if want := compaction.ContentID(segs[2*i+1].Text, "", "r"); p.ID != want { + t.Errorf("pointer[%d].ID = %q, want the content id %q", i, p.ID, want) + } + if p.Lines == nil || *p.Lines != [2]int{segs[2*i+1].LineStart, segs[2*i+1].LineEnd} { + t.Errorf("pointer[%d] lines = %v, want the segment's line span", i, p.Lines) + } + } + + // THE guarantee: reconstruct is the byte-for-byte inverse of the walk. + if restored := compaction.Reconstruct(compacted, store); restored != content { + t.Errorf("Reconstruct(compacted) is not byte-for-byte:\n got %q\nwant %q", restored, content) + } + + // Legacy ids keep working: the counter still mints elide-N and Put/Get + // round-trips them (backward compatibility for old pointers). + if got := store.NextID(); got != "elide-1" { + t.Errorf("NextID() = %q, want the legacy elide-1", got) + } + store.Put("elide-1", "legacy original") + if text, ok := store.Get("elide-1"); !ok || text != "legacy original" { + t.Errorf("Get(elide-1) = (%q, %v), want the legacy original", text, ok) + } +} + +// CompactStore mints sequential elide ids and round-trips originals; ids +// without a stored original miss (the expand tool's unknown-id path). +func TestCompactStoreRoundTrip(t *testing.T) { + store := NewCompactStore() + if got := store.NextID(); got != "elide-1" { + t.Errorf("NextID() = %q, want elide-1", got) + } + if got := store.NextID(); got != "elide-2" { + t.Errorf("NextID() = %q, want elide-2", got) + } + store.Put("elide-1", "original one") + got, ok := store.Get("elide-1") + if !ok || got != "original one" { + t.Fatalf("Get(elide-1) = %q, %v; want the stored original", got, ok) + } + if _, ok := store.Get("elide-2"); ok { + t.Error("Get(elide-2) = hit; want a miss for an id with no stored original") + } + if _, ok := store.Get("elide-999"); ok { + t.Error("Get(elide-999) = hit; want a miss") + } +} + +// --- Step 14: frozen-prefix high-water mark ---------------------------------- + +// (Step 14a) Two consecutive runs on a growing history: the second run +// rewrites NOTHING below the first run's high-water mark — the frozen prefix +// is append-only, the prompt-cache anchor never moves — and only the new +// messages are candidates. +func TestCompactContextHighWaterFreezesAcrossRuns(t *testing.T) { + fixture := defaultFixture() + s := newCompactSession(fixture) + segs := fixtureSegments(t, fixture) + + first, err := s.CompactContext(context.Background(), elideFirstScorer(segs), NewCompactStore(), CompactionOptions{}) + if err != nil { + t.Fatalf("first CompactContext() error = %v", err) + } + if got, want := s.CompactionHighWater(), len(fixture); got != want { + t.Fatalf("high-water after the first run = %d, want %d", got, want) + } + afterFirst := mustJSON(t, s.History) + if first.MessagesCompacted != len(segs) { + t.Fatalf("first run MessagesCompacted = %d, want %d", first.MessagesCompacted, len(segs)) + } + + // Grow the history: a fresh question answered with two compactable + // messages. Everything below the mark must stay exactly as run 1 left it. + growth := []client.ChatMessage{ + cmpStamped(cmpUser("Second question"), 12), + cmpStamped(cmpAssistantWithCalls(cmpLongText("eps", 3), []client.ToolCall{cmpToolCall("call_13")}), 13), // new candidate + cmpStamped(cmpToolResult("call_13", cmpLongText("zeta", 3)), 14), // new candidate + cmpStamped(cmpAssistant("A short new answer."), 15), + } + s.History = append(s.History, growth...) + full := append(append([]client.ChatMessage{}, fixture...), growth...) + scorer2 := elideFirstScorer(fixtureSegments(t, full)) + + second, err := s.CompactContext(context.Background(), scorer2, NewCompactStore(), CompactionOptions{}) + if err != nil { + t.Fatalf("second CompactContext() error = %v", err) + } + + // The walk starts at the mark (12), not at the count-based prefix + // (16/4 = 4): only the four new messages were examined. + if second.MessagesScanned != len(growth) { + t.Errorf("second run MessagesScanned = %d, want %d (the walk starts at the mark)", second.MessagesScanned, len(growth)) + } + // Nothing below the mark was rewritten — the first run's bytes stand. + if got, want := mustJSON(t, s.History[:len(fixture)]), afterFirst; got != want { + t.Errorf("the second run rewrote the frozen prefix:\n got %s\nwant %s", truncateRunes(got, 300), truncateRunes(want, 300)) + } + // Only the two new candidates reached the scorer. + if scorer2.calls != 2 { + t.Errorf("second run scorer calls = %d, want 2 (the new candidates only)", scorer2.calls) + } + if second.MessagesScored != 2 || second.MessagesCompacted != 2 || second.SegmentsElided != 2 { + t.Errorf("second run report = %+v, want the two new candidates scored and rewritten", second) + } + for _, idx := range []int{13, 14} { + if !strings.Contains(s.History[idx].Content.Text, "[[elided id=") { + t.Errorf("the new candidate at index %d was not compacted", idx) + } + } + // The mark advanced to the length the second walk covered. + if got, want := s.CompactionHighWater(), len(full); got != want { + t.Errorf("high-water after the second run = %d, want %d", got, want) + } +} + +// (Step 14a) The mark survives save/reload: a completed run persists it +// through the session meta sidecar, a session rebuilt the way the resume +// path does (history from disk, mark from the sidecar) starts its next walk +// at the persisted mark, and its own advance persists again. +func TestCompactContextHighWaterPersistsAcrossSaveReload(t *testing.T) { + tmpDir := t.TempDir() + oldSessionDir := SessionDir + SessionDir = func() (string, error) { return tmpDir, nil } + defer func() { SessionDir = oldSessionDir }() + + historyPath := filepath.Join(tmpDir, "session-hw.json") + fixture := defaultFixture() + s := New(nil, historyPath, cloneHistory(fixture), "", false) + + if _, err := s.CompactContext(context.Background(), elideFirstScorer(fixtureSegments(t, fixture)), NewCompactStore(), CompactionOptions{}); err != nil { + t.Fatalf("first CompactContext() error = %v", err) + } + if got, want := s.CompactionHighWater(), len(fixture); got != want { + t.Fatalf("in-memory high-water = %d, want %d", got, want) + } + // History persistence stays the caller's job (the production runner + // saves right after the run) — save so the reload below sees the + // compacted history the mark describes. + if err := SaveHistory(historyPath, s.History); err != nil { + t.Fatalf("SaveHistory() error = %v", err) + } + meta, err := LoadSessionMeta("session-hw") + if err != nil || meta == nil { + t.Fatalf("LoadSessionMeta(session-hw) = (%v, %v)", meta, err) + } + if meta.CompactionHighWater != len(fixture) { + t.Fatalf("persisted CompactionHighWater = %d, want %d", meta.CompactionHighWater, len(fixture)) + } + + // Reload the way cmd/late resumes: history from disk, mark from the + // sidecar — then grow and compact again. + reloaded, err := LoadHistory(historyPath) + if err != nil { + t.Fatalf("LoadHistory() error = %v", err) + } + resumed := New(nil, historyPath, reloaded, "", false) + resumed.SetCompactionHighWater(meta.CompactionHighWater) + + growth := []client.ChatMessage{ + cmpStamped(cmpUser("Second question"), 12), + cmpStamped(cmpAssistantWithCalls(cmpLongText("eps", 3), []client.ToolCall{cmpToolCall("call_13b")}), 13), + } + for _, msg := range growth { + if err := resumed.AddMessage(msg); err != nil { + t.Fatalf("AddMessage() error = %v", err) + } + } + full := append(append([]client.ChatMessage{}, fixture...), growth...) + scorer2 := elideFirstScorer(fixtureSegments(t, full)) + + second, err := resumed.CompactContext(context.Background(), scorer2, NewCompactStore(), CompactionOptions{}) + if err != nil { + t.Fatalf("second CompactContext() error = %v", err) + } + if second.MessagesScanned != len(growth) { + t.Errorf("second run MessagesScanned = %d, want %d (the resumed mark froze the old history)", second.MessagesScanned, len(growth)) + } + if scorer2.calls != 1 { + t.Errorf("second run scorer calls = %d, want 1 (the new candidate only)", scorer2.calls) + } + if got, want := resumed.CompactionHighWater(), len(full); got != want { + t.Errorf("high-water after the resumed run = %d, want %d", got, want) + } + meta, err = LoadSessionMeta("session-hw") + if err != nil || meta == nil { + t.Fatalf("LoadSessionMeta(session-hw) after the resumed run = (%v, %v)", meta, err) + } + if meta.CompactionHighWater != len(full) { + t.Errorf("persisted CompactionHighWater after the resumed run = %d, want %d", meta.CompactionHighWater, len(full)) + } +} + +// (Step 14b) A stale mark over a shrunken history: the persisted mark +// outlives the history it froze (the sidecar was written when the history +// held 12 messages; the history file now holds 6). The only way to make +// progress would be to rewrite below-mark messages, so the run fails loudly +// with ErrFrozenPrefix and changes nothing — the reference's +// FrozenPrefixError analog. +func TestCompactContextFrozenPrefixViolationFailsLoud(t *testing.T) { + fixture := defaultFixture() + s := newCompactSession(fixture) + s.SetCompactionHighWater(len(fixture)) + s.History = s.History[:6] + snapshot := mustJSON(t, s.History) + + report, err := s.CompactContext(context.Background(), elideFirstScorer(fixtureSegments(t, fixture)), NewCompactStore(), CompactionOptions{}) + if !errors.Is(err, ErrFrozenPrefix) { + t.Fatalf("CompactContext() error = %v, want ErrFrozenPrefix", err) + } + if !strings.Contains(err.Error(), "high-water mark 12") || !strings.Contains(err.Error(), "6-message") { + t.Errorf("error %q does not name the stale mark and the shrunken history", err) + } + if got := mustJSON(t, s.History); got != snapshot { + t.Errorf("the violating run mutated history:\n got %s", truncateRunes(got, 300)) + } + if got := s.CompactionHighWater(); got != len(fixture) { + t.Errorf("the violating run moved the mark to %d, want %d", got, len(fixture)) + } + if report.MessagesScanned != 0 || report.MessagesScored != 0 || report.MessagesCompacted != 0 || report.SegmentsElided != 0 { + t.Errorf("the violating run reported work: %+v", report) + } +} + +// (Step 14c) A pointer-bearing message is never re-scored: loaded with a +// zero mark (e.g. a sidecar from before the high-water mark existed), a tool +// result that already carries a final [[elided …]] pointer sits in the work +// area — and must be skipped entirely (never re-segmented, never re-scored, +// never rewritten), while the fresh candidate below it compacts as usual. +func TestCompactContextNeverRescoresPointerBearingMessages(t *testing.T) { + pointer := compaction.FormatPointer(compaction.Pointer{ + ID: compaction.ContentID("the earlier run's elided original", "", "r"), + Lines: &[2]int{1, 4}, + Tokens: 12, + Summary: "earlier run's elided output", + }) + pointerBearing := cmpLongText("kept", 3) + "\n" + pointer + "\n" + cmpLongText("tail", 3) + if n := len(compaction.FindPointers(pointerBearing)); n != 1 { + t.Fatalf("fixture sanity: FindPointers found %d pointers, want 1", n) + } + fixture := []client.ChatMessage{ + cmpStamped(cmpSystem("system prompt"), 0), + cmpStamped(cmpUser("question"), 1), + cmpStamped(cmpTool(pointerBearing), 2), // pointer-bearing candidate + cmpStamped(cmpAssistantWithCalls(cmpLongText("fresh", 3), []client.ToolCall{cmpToolCall("call_f")}), 3), // control candidate + cmpStamped(cmpUser("follow-up"), 4), + cmpStamped(cmpAssistant("short"), 5), + } + s := newCompactSession(fixture) + store := NewCompactStore() + scorer := &stubScorer{fallback: 0.1} // everything scored would be elided + + report, err := s.CompactContext(context.Background(), scorer, store, CompactionOptions{}) + if err != nil { + t.Fatalf("CompactContext() error = %v", err) + } + + // Only the control candidate was scored — the pointer-bearing message, + // large and post-prefix as it is, never reached the scorer. + if scorer.calls != 1 { + t.Errorf("scorer calls = %d, want 1 (the pointer-bearing message must never be re-scored)", scorer.calls) + } + if got, want := s.History[2].Content.Text, pointerBearing; got != want { + t.Errorf("the pointer-bearing message was rewritten:\n got %q\nwant %q", truncateRunes(got, 200), truncateRunes(want, 200)) + } + if !strings.Contains(s.History[3].Content.Text, "[[elided id=") { + t.Error("the control candidate was not compacted") + } + if report.MessagesScored != 1 || report.MessagesCompacted != 1 { + t.Errorf("report = %+v, want only the control candidate scored and rewritten", report) + } + // The store holds only the control's elided run: the fixture pointer's + // original was never re-stored. + if store.Len() != 1 { + t.Errorf("store holds %d records, want 1 (the control's run)", store.Len()) + } + // A completing mutating run still advances the mark. + if got, want := s.CompactionHighWater(), len(fixture); got != want { + t.Errorf("high-water = %d, want %d", got, want) + } +} + +// (Step 14d) Shadow runs report only: nothing was rewritten, so the mark +// stays where it was and the next mutating run still walks the whole work +// area. +func TestCompactContextShadowDoesNotAdvanceHighWater(t *testing.T) { + fixture := defaultFixture() + s := newCompactSession(fixture) + segs := fixtureSegments(t, fixture) + + report, err := s.CompactContext(context.Background(), elideFirstScorer(segs), NewCompactStore(), CompactionOptions{ShadowOnly: true}) + if err != nil { + t.Fatalf("shadow CompactContext() error = %v", err) + } + if report.TokensSaved <= 0 || report.MessagesCompacted == 0 { + t.Fatalf("shadow report not populated: %+v", report) + } + if got := s.CompactionHighWater(); got != 0 { + t.Fatalf("shadow run advanced the high-water mark to %d, want 0", got) + } + if got, want := mustJSON(t, s.History), mustJSON(t, fixture); got != want { + t.Fatal("shadow run mutated history") + } + + second, err := s.CompactContext(context.Background(), elideFirstScorer(segs), NewCompactStore(), CompactionOptions{}) + if err != nil { + t.Fatalf("mutating CompactContext() error = %v", err) + } + if second.MessagesScanned != len(fixture)-3 { + t.Errorf("mutating run MessagesScanned = %d, want %d (the shadow run froze nothing)", second.MessagesScanned, len(fixture)-3) + } + if got, want := s.CompactionHighWater(), len(fixture); got != want { + t.Errorf("mutating run high-water = %d, want %d", got, want) + } +} + +// (Step 14d) A mid-walk abort (wholesale scorer failure) does not advance +// the mark: the walk never reached the end of history. +func TestCompactContextMidWalkAbortKeepsHighWater(t *testing.T) { + fixture := []client.ChatMessage{ + cmpStamped(cmpSystem("system prompt"), 0), + cmpStamped(cmpUser("first question"), 1), + cmpStamped(cmpAssistant("first answer"), 2), + cmpStamped(cmpAssistantWithCalls(cmpLongText("c1", 3), []client.ToolCall{cmpToolCall("call_m1")}), 3), + cmpStamped(cmpTool(cmpLongText("c2", 3)), 4), + cmpStamped(cmpAssistantWithCalls(cmpLongText("c3", 3), []client.ToolCall{cmpToolCall("call_m3")}), 5), + } + s := newCompactSession(fixture) + scorer := elideFirstScorer(fixtureSegments(t, fixture)) + scorer.err = errors.New("scorer down") + scorer.errOnCall = 3 // wholesale failure on the third candidate + + if _, err := s.CompactContext(context.Background(), scorer, NewCompactStore(), CompactionOptions{}); err == nil { + t.Fatal("CompactContext() error = nil, want the mid-walk failure") + } + if got := s.CompactionHighWater(); got != 0 { + t.Errorf("high-water after the abort = %d, want 0", got) + } +} + +// (Step 14e) The reset paths adjust the mark: /new zeroes it (a fresh +// conversation has no frozen prefix) and PopLastUserMessage clamps it to the +// truncated length (the frozen prefix never outlives the history it froze) — +// persisted by the pop's own metadata write. +func TestCompactionHighWaterResetPaths(t *testing.T) { + t.Run("/new resets the mark to zero", func(t *testing.T) { + tmpDir := t.TempDir() + oldSessionDir := SessionDir + SessionDir = func() (string, error) { return tmpDir, nil } + defer func() { SessionDir = oldSessionDir }() + + s := New(nil, filepath.Join(tmpDir, "session-hw-new.json"), defaultFixture(), "", false) + s.SetCompactionHighWater(12) + if err := s.StartNewConversation(); err != nil { + t.Fatalf("StartNewConversation() error = %v", err) + } + if got := s.CompactionHighWater(); got != 0 { + t.Errorf("high-water after /new = %d, want 0", got) + } + // The fresh conversation's sidecar records the reset mark. + if got := s.GenerateSessionMeta().CompactionHighWater; got != 0 { + t.Errorf("fresh session meta CompactionHighWater = %d, want 0", got) + } + }) + + t.Run("PopLastUserMessage clamps and persists the mark", func(t *testing.T) { + tmpDir := t.TempDir() + oldSessionDir := SessionDir + SessionDir = func() (string, error) { return tmpDir, nil } + defer func() { SessionDir = oldSessionDir }() + + historyPath := filepath.Join(tmpDir, "session-hw-pop.json") + fixture := []client.ChatMessage{ + cmpStamped(cmpSystem("system prompt"), 0), + cmpStamped(cmpAssistant("answer"), 1), + cmpStamped(cmpUser("last question"), 2), + } + if err := SaveHistory(historyPath, fixture); err != nil { + t.Fatalf("SaveHistory() error = %v", err) + } + s := New(nil, historyPath, cloneHistory(fixture), "", false) + s.SetCompactionHighWater(len(fixture)) + + popped, err := s.PopLastUserMessage() + if err != nil || !popped { + t.Fatalf("PopLastUserMessage() = (%v, %v), want (true, nil)", popped, err) + } + if got, want := s.CompactionHighWater(), len(fixture)-1; got != want { + t.Errorf("high-water after pop = %d, want %d", got, want) + } + meta, err := LoadSessionMeta("session-hw-pop") + if err != nil || meta == nil { + t.Fatalf("LoadSessionMeta(session-hw-pop) = (%v, %v)", meta, err) + } + if meta.CompactionHighWater != len(fixture)-1 { + t.Errorf("persisted CompactionHighWater after pop = %d, want %d", meta.CompactionHighWater, len(fixture)-1) + } + }) +} + +// authScorer stands in for a decision backend that rejected the API key: it +// returns the typed compaction auth error wrapped exactly the way +// compaction.DecisionClient.ScoreBatch wraps one (an errors.Join of +// *ItemScoreError values around a *compaction.Error) alongside a complete +// fail-open score map — the shape the poisoned client synthesizes. +type authScorer struct { + calls int +} + +func (a *authScorer) ScoreBatch(_ context.Context, _ string, items map[string]compaction.Item) (map[string]float64, error) { + a.calls++ + scores := make(map[string]float64, len(items)) + errs := make([]error, 0, len(items)) + for id := range items { + scores[id] = keepScoreFallback + errs = append(errs, &compaction.ItemScoreError{ + ItemID: id, + Err: &compaction.Error{ + Kind: compaction.KindAuth, + Status: 401, + Op: "score-batch", + Err: errors.New("bad or missing API key"), + }, + }) + } + return scores, errors.Join(errs...) +} + +// TestCompactContextAuthErrorStopsWalk: a typed auth failure (the reference's +// JevAuthError — 401/403, a bad or missing API key) ends the walk on the +// first message: no later message can score either, so the walk must not +// fire one doomed scorer call per message. The error keeps the typed class +// for the caller, and the report says how far the walk got. +func TestCompactContextAuthErrorStopsWalk(t *testing.T) { + fixture := defaultFixture() + s := newCompactSession(fixture) + scorer := &authScorer{} + + report, err := s.CompactContext(context.Background(), scorer, NewCompactStore(), CompactionOptions{}) + if err == nil { + t.Fatal("CompactContext() error = nil, want the typed auth error") + } + + // The walk stopped on the first candidate (index 4, after the 3-message + // frozen prefix): exactly one scorer call, nothing scored, nothing + // rewritten, nothing advanced. + if scorer.calls != 1 { + t.Errorf("scorer calls = %d, want 1 (auth stops the walk immediately)", scorer.calls) + } + if report.MessagesScored != 0 { + t.Errorf("MessagesScored = %d, want 0", report.MessagesScored) + } + if report.MessagesCompacted != 0 || report.SegmentsElided != 0 { + t.Errorf("an aborted walk must rewrite nothing, got %+v", report) + } + if !strings.Contains(err.Error(), "stopped after 0 messages") { + t.Errorf("error %v should say the walk stopped after 0 messages", err) + } + var ae *compaction.Error + if !errors.As(err, &ae) || ae.Kind != compaction.KindAuth { + t.Errorf("error = %v, want a *compaction.Error of KindAuth through the join", err) + } + if s.CompactionHighWater() != 0 { + t.Errorf("CompactionHighWater = %d, want 0 (a mid-walk abort must not advance the mark)", s.CompactionHighWater()) + } +} + +// --- Priority fixes: prose preservation, skill preservation, atomicity ------- + +// (P3) A pure-prose assistant message — no tool calls — is the +// conversation's narrative and must stay byte-identical through a mutating +// run, even when the scorer scores everything 0.0. Only the tool result +// (the recoverable work output) is compacted. +func TestCompactContextPreservesProseAssistant(t *testing.T) { + prose := "## Plan\n\n" + cmpLongText("prose", 6) + fixture := []client.ChatMessage{ + cmpStamped(cmpSystem("system prompt"), 0), + cmpStamped(cmpUser("make a plan"), 1), + cmpStamped(cmpAssistant(prose), 2), // pure prose — never compacted + cmpStamped(cmpTool(cmpLongText("beta", 3)), 3), // control candidate + cmpStamped(cmpUser("go"), 4), + } + s := newCompactSession(fixture) + scorer := &stubScorer{fallback: 0.1} // everything scored would be elided + + report, err := s.CompactContext(context.Background(), scorer, NewCompactStore(), CompactionOptions{}) + if err != nil { + t.Fatalf("CompactContext() error = %v", err) + } + + if got := s.History[2].Content.Text; got != prose { + t.Fatalf("the pure-prose assistant message was compacted:\n got %q\nwant %q", + truncateRunes(got, 200), truncateRunes(prose, 200)) + } + // It was never even scored: only the tool result reached the scorer. + if scorer.calls != 1 { + t.Errorf("scorer calls = %d, want 1 (prose assistants are not candidates)", scorer.calls) + } + if report.MessagesScored != 1 { + t.Errorf("MessagesScored = %d, want 1", report.MessagesScored) + } + // The control tool result was compacted as usual. + if !strings.Contains(s.History[3].Content.Text, "[[elided id=") { + t.Error("the tool-result control was not compacted") + } +} + +// (P4) A tool result originating from the activate_skill tool is the skill's +// instructions — what the agent was told to follow — and must never be +// elided: the walk skips it before segmentation, so it is byte-identical and +// never scored, while a control result from another tool compacts normally. +func TestCompactContextPreservesActivateSkillResults(t *testing.T) { + skillResult := "Skill instructions:\n" + cmpLongText("skill", 6) + control := cmpLongText("bash", 6) + fixture := []client.ChatMessage{ + cmpStamped(cmpSystem("system prompt"), 0), + cmpStamped(cmpUser("use the skill"), 1), + cmpStamped(cmpAssistantWithCalls("", []client.ToolCall{ + {Index: 0, ID: "call_s", Type: "function", Function: client.FunctionCall{Name: "activate_skill", Arguments: `{"name":"demo"}`}}, + {Index: 1, ID: "call_b", Type: "function", Function: client.FunctionCall{Name: "Bash", Arguments: `{"cmd":"ls"}`}}, + }), 2), + cmpStamped(cmpToolResult("call_s", skillResult), 3), // skill result — never compacted + cmpStamped(cmpToolResult("call_b", control), 4), // control — compacted + } + s := newCompactSession(fixture) + scorer := &stubScorer{fallback: 0.0} // everything scored would be elided + + report, err := s.CompactContext(context.Background(), scorer, NewCompactStore(), CompactionOptions{}) + if err != nil { + t.Fatalf("CompactContext() error = %v", err) + } + + if got := s.History[3].Content.Text; got != skillResult { + t.Fatalf("the activate_skill result was compacted:\n got %q\nwant %q", + truncateRunes(got, 200), truncateRunes(skillResult, 200)) + } + // Only the control reached the scorer. + if scorer.calls != 1 { + t.Errorf("scorer calls = %d, want 1 (protected tool results are never scored)", scorer.calls) + } + if report.MessagesScored != 1 { + t.Errorf("MessagesScored = %d, want 1", report.MessagesScored) + } + if !strings.Contains(s.History[4].Content.Text, "[[elided id=") { + t.Error("the control tool result was not compacted") + } +} + +// (P5) A tool result whose originating call was the expand tool is bytes the +// agent explicitly asked back: the walk skips it before segmentation, so it +// survives a mutating run byte-identical and never scored, while a control +// result from another tool in the same run compacts normally. +func TestCompactContextPreservesExpandResults(t *testing.T) { + expandResult := cmpLongText("expand", 6) + control := cmpLongText("bash", 6) + fixture := []client.ChatMessage{ + cmpStamped(cmpSystem("system prompt"), 0), + cmpStamped(cmpUser("expand that pointer"), 1), + cmpStamped(cmpAssistantWithCalls("", []client.ToolCall{ + {Index: 0, ID: "call_x", Type: "function", Function: client.FunctionCall{Name: tool.ExpandToolName, Arguments: `{"id":"r:deadbeef"}`}}, + {Index: 1, ID: "call_b", Type: "function", Function: client.FunctionCall{Name: "Bash", Arguments: `{"cmd":"ls"}`}}, + }), 2), + cmpStamped(cmpToolResult("call_x", expandResult), 3), // expand result — never compacted + cmpStamped(cmpToolResult("call_b", control), 4), // control — compacted + } + s := newCompactSession(fixture) + scorer := &stubScorer{fallback: 0.0} // everything scored would be elided + + report, err := s.CompactContext(context.Background(), scorer, NewCompactStore(), CompactionOptions{}) + if err != nil { + t.Fatalf("CompactContext() error = %v", err) + } + + if got := s.History[3].Content.Text; got != expandResult { + t.Fatalf("the expand result was compacted:\n got %q\nwant %q", + truncateRunes(got, 200), truncateRunes(expandResult, 200)) + } + // Only the control reached the scorer. + if scorer.calls != 1 { + t.Errorf("scorer calls = %d, want 1 (protected tool results are never scored)", scorer.calls) + } + if report.MessagesScored != 1 { + t.Errorf("MessagesScored = %d, want 1", report.MessagesScored) + } + if !strings.Contains(s.History[4].Content.Text, "[[elided id=") { + t.Error("the control tool result was not compacted") + } +} + +// --- GateConfig on the walk: protected-kind floors and the tripwire --------- +// +// historyGate returns the reference-parity gate the walk tests share: keep +// threshold 0.35, protected stacktrace/diff floors at 0.05, and +// maxElideFraction controlling the tripwire (1 disables it — nothing can +// exceed 100%). +func historyGate(maxElideFraction float64) *compaction.GateConfig { + return &compaction.GateConfig{ + KeepThreshold: 0.35, + MaxElideFraction: maxElideFraction, + ProtectedKinds: map[compaction.SegmentKind]float64{ + compaction.KindStacktrace: 0.05, + compaction.KindDiff: 0.05, + }, + } +} + +// traceParagraph builds a single ~940-byte stacktrace-classified paragraph +// (a Python Traceback header on the first line — the classifyKind stacktrace +// signal), and plainParagraph a ~540-byte plain-prose one. Both stay under +// the 1200-byte segment cap (no cutting) and over the 80-byte tiny floor (no +// merging), so a tool result holding both segments exactly two segments. +func traceParagraph() string { + return "Traceback (most recent call last):\n" + + strings.Repeat(" File \"lib/x.py\", line 10, in run\n", 25) + + "SomeError: boom\n" +} + +func plainParagraph() string { + return strings.Repeat("plain prose token ", 30) +} + +// (G1) With a gate, a protected-kind segment (stacktrace) is kept at a score +// a flat path would elide: 0.2 sits below the flat 0.35 threshold but above +// the 0.05 protected floor, so the gate run keeps the trace verbatim while +// the flat control run elides it — and a nil gate reproduces the legacy +// flat-threshold behavior unchanged. +func TestCompactContextGateProtectedKindFloors(t *testing.T) { + result := traceParagraph() + "\n\n" + plainParagraph() + build := func() *Session { + fixture := []client.ChatMessage{ + cmpStamped(cmpSystem("system prompt"), 0), + cmpStamped(cmpUser("what broke"), 1), + cmpStamped(cmpAssistantWithCalls("", []client.ToolCall{cmpToolCall("call_1")}), 2), + cmpStamped(cmpToolResult("call_1", result), 3), + } + return newCompactSession(fixture) + } + // Fail loudly if classification drifts: the fixture's first segment must + // be the stacktrace, the second plain text. Key the stub's exact-text + // scores on the segments' real texts (each includes its absorbed + // trailing blank-line separator, so it is not exactly traceParagraph()). + segs := compaction.SegmentSegments(result, minCompactChars) + if len(segs) != 2 { + t.Fatalf("fixture segments = %d, want 2 (trace + plain)", len(segs)) + } + if segs[0].Kind != compaction.KindStacktrace || segs[1].Kind != compaction.KindText { + t.Fatalf("fixture kinds = %q, %q; want stacktrace, text", segs[0].Kind, segs[1].Kind) + } + + scorer := &stubScorer{ + scores: map[string]float64{segs[0].Text: 0.2, segs[1].Text: 0.0}, + fallback: 1.0, + } + + // The gate run: the stacktrace survives at 0.2 (above its 0.05 floor, + // below the flat threshold a nil-gate walk would apply); the plain + // segment elides at 0.0. + gated := build() + report, err := gated.CompactContext(context.Background(), scorer, NewCompactStore(), CompactionOptions{ + Threshold: 0.35, + Gate: historyGate(1), + }) + if err != nil { + t.Fatalf("CompactContext(gate) error = %v", err) + } + if got := gated.History[3].Content.Text; !strings.Contains(got, traceParagraph()) { + t.Fatalf("the gate run elided the protected stacktrace at score 0.2:\n got %q", truncateRunes(got, 200)) + } + if !strings.Contains(gated.History[3].Content.Text, "[[elided id=") { + t.Fatal("the gate run must still elide the plain segment below the threshold") + } + if report.Tripwires != 0 { + t.Errorf("Tripwires = %d, want 0 (the elided share is far under the fraction)", report.Tripwires) + } + + // The flat control twin (nil gate — the legacy behavior): 0.2 < 0.35 + // elides the stacktrace too. + flat := build() + if _, err := flat.CompactContext(context.Background(), scorer, NewCompactStore(), CompactionOptions{Threshold: 0.35}); err != nil { + t.Fatalf("CompactContext(nil gate) error = %v", err) + } + // "SomeError: boom" sits ~950 bytes into the trace — far past the 120-char + // pointer summary, so its absence means the trace body itself was elided. + if strings.Contains(flat.History[3].Content.Text, "SomeError: boom") { + t.Fatal("the nil-gate flat path must elide the trace at 0.2 (the behavior the gate overrides)") + } + if !strings.Contains(flat.History[3].Content.Text, "[[elided id=") { + t.Fatal("the flat control run must elide both segments into a pointer") + } +} + +// readShadowEntries reads a JSONL shadow log and decodes every line. +func readShadowEntries(t *testing.T, path string) []compaction.ShadowEntry { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read shadow log %s: %v", path, err) + } + var entries []compaction.ShadowEntry + for i, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + var e compaction.ShadowEntry + if err := json.Unmarshal([]byte(line), &e); err != nil { + t.Fatalf("shadow log line %d is not valid JSON: %v (%q)", i, err, line) + } + entries = append(entries, e) + } + return entries +} + +// (G2) The gate's max-elide-fraction tripwire, per MESSAGE: a scorer that +// wants to elide every segment of a message wants to drop 100% of its +// tokens — past the 0.5 fraction it is distrusted, the message keeps +// everything byte-identical, the report records the tripwire, and the +// threaded shadow log receives one tripwire entry. A partial elision (a +// quarter of the tokens) does not trip. +func TestCompactContextGateTripwireElidesNothing(t *testing.T) { + shadowLog, err := compaction.NewShadowLogAt(filepath.Join(t.TempDir(), "shadow.jsonl")) + if err != nil { + t.Fatal(err) + } + + // Tripwire run: three plain segments, all scored 0.0. + fixture := []client.ChatMessage{ + cmpStamped(cmpSystem("system prompt"), 0), + cmpStamped(cmpUser("dump"), 1), + cmpStamped(cmpAssistantWithCalls("", []client.ToolCall{cmpToolCall("call_1")}), 2), + cmpStamped(cmpToolResult("call_1", cmpLongText("trip", 3)), 3), + } + s := newCompactSession(fixture) + original := fixture[3].Content.Text + + report, err := s.CompactContext(context.Background(), &stubScorer{fallback: 0.0}, NewCompactStore(), CompactionOptions{ + Threshold: 0.35, + Gate: historyGate(0.5), + ShadowLog: shadowLog, + }) + if err != nil { + t.Fatalf("CompactContext() error = %v", err) + } + if got := s.History[3].Content.Text; got != original { + t.Fatalf("the tripwire must keep the message byte-identical:\n got %q\nwant %q", + truncateRunes(got, 200), truncateRunes(original, 200)) + } + if report.Tripwires != 1 { + t.Errorf("Tripwires = %d, want 1", report.Tripwires) + } + if report.MessagesCompacted != 0 || report.SegmentsElided != 0 || report.TokensSaved != 0 { + t.Errorf("nothing may be elided after the tripwire, report = %+v", report) + } + entries := readShadowEntries(t, shadowLog.Path()) + if len(entries) != 1 { + t.Fatalf("shadow log lines = %d, want 1 tripwire entry", len(entries)) + } + e := entries[0] + if e.Type != compaction.EntryTypeTripwire || e.Action != compaction.TripwireAction { + t.Errorf("entry Type/Action = %q/%q, want %q/%q", e.Type, e.Action, compaction.EntryTypeTripwire, compaction.TripwireAction) + } + if e.SegmentID != "" { + t.Errorf("tripwire entry must carry no segment id, got %q", e.SegmentID) + } + if e.TaskHash != report.TaskHash || e.Tokens <= 0 { + t.Errorf("tripwire entry TaskHash/Tokens = %q/%d, want the report's hash and the message's tokens", e.TaskHash, e.Tokens) + } + + // Partial-elision control: one of four segments elided (~a quarter of + // the tokens, under the 0.5 fraction) compacts normally and does not + // trip. + fixture2 := []client.ChatMessage{ + cmpStamped(cmpSystem("system prompt"), 0), + cmpStamped(cmpUser("dump"), 1), + cmpStamped(cmpAssistantWithCalls("", []client.ToolCall{cmpToolCall("call_1")}), 2), + cmpStamped(cmpToolResult("call_1", cmpLongText("part", 4)), 3), + } + s2 := newCompactSession(fixture2) + segs2 := fixtureSegments(t, fixture2) + report2, err := s2.CompactContext(context.Background(), elideFirstScorer(segs2), NewCompactStore(), CompactionOptions{ + Threshold: 0.35, + Gate: historyGate(0.5), + ShadowLog: shadowLog, + }) + if err != nil { + t.Fatalf("CompactContext(partial) error = %v", err) + } + if !strings.Contains(s2.History[3].Content.Text, "[[elided id=") { + t.Fatal("the partial elision must compact normally") + } + if report2.Tripwires != 0 { + t.Errorf("Tripwires = %d, want 0 (a quarter of the tokens is under the fraction)", report2.Tripwires) + } + if got, want := len(readShadowEntries(t, shadowLog.Path())), 1; got != want { + t.Errorf("shadow log lines = %d, want still %d (no tripwire for the partial run)", got, want) + } +} + +// (P2) History-surface paragraph atomicity: a >2x maxSegChars JSON paragraph +// cut into pieces by splitOversized elides as ONE unit when a single piece +// scores below the floor — the pointer references the full original and +// Reconstruct restores it byte for byte — and stays byte-identical when all +// pieces score above the floor. +func TestCompactContextParagraphAtomicity(t *testing.T) { + build := func() (*Session, []client.ChatMessage) { + // One pretty-printed JSON object, no blank lines: one oversized + // paragraph splitOversized cuts mid-structure. + var b strings.Builder + b.WriteString("{\n") + for i := 0; i < 60; i++ { + fmt.Fprintf(&b, " \"key_%03d\": \"value %03d with some padding text to bulk the line past trivial lengths\",\n", i, i) + } + b.WriteString(" \"final\": true\n}") + blob := b.String() + if len(blob) <= 2*minCompactChars { + t.Fatalf("fixture too small: %d bytes, want > %d", len(blob), 2*minCompactChars) + } + fixture := []client.ChatMessage{ + cmpStamped(cmpSystem("system prompt"), 0), + cmpStamped(cmpUser("parse this"), 1), + cmpStamped(cmpToolResult("call_x", blob), 2), + } + return newCompactSession(fixture), fixture + } + segment := func(t *testing.T, blob string) []compaction.Segment { + t.Helper() + segs := compaction.SegmentSegments(blob, minCompactChars) + if len(segs) < 3 { + t.Fatalf("SegmentSegments() = %d pieces, want ≥3", len(segs)) + } + for _, seg := range segs { + if seg.Group != segs[0].Group { + t.Fatal("fixture sanity: cut pieces do not share a group") + } + } + return segs + } + + t.Run("one low piece elides the whole paragraph", func(t *testing.T) { + s, fixture := build() + blob := fixture[2].Content.Text + segs := segment(t, blob) + + // Only the middle piece scores below the floor; without atomicity + // eliding it would leave an unparseable JSON remnant. + scores := map[string]float64{} + for _, seg := range segs { + scores[seg.Text] = 0.9 + } + scores[segs[1].Text] = 0.1 + scorer := &stubScorer{scores: scores, fallback: 0.9} + store := compaction.NewStore() + + report, err := s.CompactContext(context.Background(), scorer, store, CompactionOptions{}) + if err != nil { + t.Fatalf("CompactContext() error = %v", err) + } + + compacted := s.History[2].Content.Text + pointers := compaction.FindPointers(compacted) + if len(pointers) != 1 { + t.Fatalf("FindPointers(compacted) = %d pointers, want 1 (the whole paragraph)", len(pointers)) + } + if report.SegmentsElided != len(segs) { + t.Errorf("SegmentsElided = %d, want all %d pieces", report.SegmentsElided, len(segs)) + } + rec, ok := store.GetRecord(pointers[0].ID) + if !ok { + t.Fatalf("store holds no record for %s", pointers[0].ID) + } + if rec.Text != blob { + t.Error("the pointer's record is not the FULL original paragraph") + } + if restored := compaction.Reconstruct(compacted, store); restored != blob { + t.Error("Reconstruct(compacted) is not byte-for-byte") + } + }) + + t.Run("all pieces above the floor keep the message", func(t *testing.T) { + s, fixture := build() + blob := fixture[2].Content.Text + segment(t, blob) + + scorer := &stubScorer{fallback: 0.9} + report, err := s.CompactContext(context.Background(), scorer, compaction.NewStore(), CompactionOptions{}) + if err != nil { + t.Fatalf("CompactContext() error = %v", err) + } + if s.History[2].Content.Text != blob { + t.Error("a message whose pieces all stay above the floor must be kept byte-for-byte") + } + if report.MessagesCompacted != 0 || report.SegmentsElided != 0 { + t.Errorf("expected a no-op report, got %+v", report) + } + }) +} + +// (P9) The full transient-outage story, pinned end to end with the REAL +// decision client: the scorer endpoint 500s forever → the client spends its +// full 4-attempt retry budget, the walk FAILS OPEN (completes with everything +// kept), the scorer's error surfaces, and the session stays usable — the +// client is not poisoned, so a later scoring call still reaches the server. +func TestCompactContextTransientOutageCompletesWithEverythingKept(t *testing.T) { + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&hits, 1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, `{"error": {"message": "overloaded"}}`) + })) + t.Cleanup(srv.Close) + + scorer := compaction.NewDecisionClient( + compaction.ResolvedBackend{Backend: compaction.Backend{Name: "test", URL: srv.URL, Model: "jev-latest"}, APIKey: "k"}, + "k") + + fixture := []client.ChatMessage{ + cmpStamped(cmpSystem("system prompt"), 0), + cmpStamped(cmpUser("run the thing"), 1), + cmpStamped(cmpToolResult("call_out", cmpLongText("outage", 3)), 2), // one candidate + cmpStamped(cmpUser("next"), 3), + } + s := newCompactSession(fixture) + + report, err := s.CompactContext(context.Background(), scorer, NewCompactStore(), CompactionOptions{}) + if err == nil { + t.Fatal("CompactContext() error = nil, want the scorer's outage errors surfaced") + } + + // The full retry budget was spent, then fail-open kept everything. + if got := atomic.LoadInt32(&hits); got != 4 { + t.Errorf("server hits = %d, want 4 (full attempt budget)", got) + } + if report.MessagesScored != 1 { + t.Errorf("MessagesScored = %d, want 1 (the fail-open scores completed the walk)", report.MessagesScored) + } + if report.MessagesCompacted != 0 || report.SegmentsElided != 0 { + t.Errorf("an outage must keep everything, got %+v", report) + } + if got, want := s.History[2].Content.Text, fixture[2].Content.Text; got != want { + t.Error("the outage mutated the tool result") + } + // The walk completed: the mark advanced, so the session stays coherent. + if got := s.CompactionHighWater(); got != len(fixture) { + t.Errorf("high-water = %d, want %d (the walk completed)", got, len(fixture)) + } + // The session is usable: an outage is transient, not a poison — the + // client still reaches the server on the next scoring call. + if scorer.Unavailable() { + t.Error("the client was poisoned by a 500 outage; only auth may poison") + } + before := atomic.LoadInt32(&hits) + if _, err := scorer.ScoreBatch(context.Background(), "still alive", map[string]compaction.Item{"seg-1": {Text: "x", Tokens: 1}}); err == nil { + t.Error("the server is still down; want an error") + } + if got := atomic.LoadInt32(&hits) - before; got != 4 { + t.Errorf("post-outage call hit the server %d times, want 4 (the session still scores)", got) + } +} diff --git a/internal/session/models.go b/internal/session/models.go index 7bafd3c2..77664173 100644 --- a/internal/session/models.go +++ b/internal/session/models.go @@ -23,6 +23,13 @@ type SessionMeta struct { SubagentSeq int `json:"subagent_seq"` SaveSubagentHistories *bool `json:"save_subagent_histories,omitempty"` WorkingDir string `json:"working_dir,omitempty"` // Absolute path of the project directory where the session was started + // CompactionHighWater is the history compaction high-water mark: the + // monotonic message index below which the frozen prefix ends. The + // compactor never re-scores or rewrites a message with a smaller index, + // so the prompt-cache anchor survives across compaction runs and + // restarts. omitempty keeps legacy sidecars byte-identical while the + // mark is zero. See compact.go for the invariants it enforces. + CompactionHighWater int `json:"compaction_high_water,omitempty"` } // SessionDir returns the directory where session metadata and histories are stored diff --git a/internal/session/retrieve.go b/internal/session/retrieve.go new file mode 100644 index 00000000..bd74842e --- /dev/null +++ b/internal/session/retrieve.go @@ -0,0 +1,70 @@ +package session + +import ( + "context" + + "late/internal/compaction" +) + +// Retrieval read side (implementation_plan.md Step 17, config-gated by +// compaction-retrieval): before a stream request, the orchestrator calls +// InjectRetrieved, which scores the compaction store's digest against the +// current task (the session's own last-user-message task, the same one +// history compaction scores against) and stages the top-k relevant records +// as an ephemeral block. StartStream then appends that block as the LAST +// outgoing message — the tail of the message list is the work area; the +// frozen prefix (the head) is never touched — and nothing reaches History +// or disk: the block is request-scoped and every call overwrites it. + +// retrievedContextRole is the role of the ephemeral retrieved-context +// message. "system" marks the block as harness-injected context rather +// than a user turn (a trailing user message would read as a fresh prompt); +// the message exists only in the outgoing request copy, so the transcript +// is never polluted either way. +const retrievedContextRole = "system" + +// InjectRetrieved scores the store's digest against the session's current +// task through p.Retrieve and stages the results for the next StartStream +// request. It returns the number of records injected. +// +// Overwrite semantics: every call replaces the staged block — an empty +// selection (nothing relevant, empty store, or a scoring error) clears it, +// so a stale block can never outlive the turn that staged it. Scoring +// errors are returned to the caller (which decides how to warn; the hook +// main installs warns once) and stage nothing: an unreliable ranking must +// not stuff the work area, mirroring Pipeline.Retrieve's abort-on-error +// contract. A nil pipeline or store is a no-op clear. +func (s *Session) InjectRetrieved(ctx context.Context, p *compaction.Pipeline, store *compaction.Store, k, budget int, threshold float64) (int, error) { + if p == nil || store == nil { + s.setRetrievedBlock("") + return 0, nil + } + task := compactionTask(s.History) + records, err := p.Retrieve(ctx, task, store, k, budget, threshold) + if err != nil { + s.setRetrievedBlock("") + return 0, err + } + if len(records) == 0 { + s.setRetrievedBlock("") + return 0, nil + } + s.setRetrievedBlock(compaction.RetrievedBlock(records)) + return len(records), nil +} + +// setRetrievedBlock replaces the staged block (thread-safe; an empty string +// clears it). +func (s *Session) setRetrievedBlock(block string) { + s.retrievedMu.Lock() + defer s.retrievedMu.Unlock() + s.retrievedBlock = block +} + +// retrievedBlockForRequest returns the staged block for the outgoing +// request ("" when nothing was staged). +func (s *Session) retrievedBlockForRequest() string { + s.retrievedMu.Lock() + defer s.retrievedMu.Unlock() + return s.retrievedBlock +} diff --git a/internal/session/retrieve_test.go b/internal/session/retrieve_test.go new file mode 100644 index 00000000..346a118b --- /dev/null +++ b/internal/session/retrieve_test.go @@ -0,0 +1,263 @@ +package session + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "late/internal/client" + "late/internal/compaction" +) + +// newRetrieveTestSession builds a session over a fake OpenAI-compatible +// stream server (the session_startstream_test.go pattern) plus a compaction +// pipeline over a scripted decisions server, and returns everything the +// retrieval tests need: the captured chat request bodies channel and the +// pipeline. +func newRetrieveTestSession(t *testing.T, history []client.ChatMessage, historyPath string, answers map[string]float64, answerStatus int) (*Session, *compaction.Pipeline, *compaction.Store, <-chan []byte) { + t.Helper() + + requestBodies := make(chan []byte, 1) + chatServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/chat/completions") { + http.NotFound(w, r) + return + } + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "read error", http.StatusBadRequest) + return + } + requestBodies <- body + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, "data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n") + fmt.Fprint(w, "data: [DONE]\n") + })) + t.Cleanup(chatServer.Close) + + decisionsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(answerStatus) + // The decisions protocol answers with noul objects, not bare numbers. + wire := make(map[string]any, len(answers)) + for ref, score := range answers { + wire[ref] = map[string]any{"type": "noul", "noul": score} + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "answers": wire, + "usage": map[string]int{"input_tokens": 1}, + }) + })) + t.Cleanup(decisionsServer.Close) + + backend := compaction.ResolvedBackend{ + Backend: compaction.Backend{Name: "test", URL: decisionsServer.URL, Model: "jev-latest"}, + APIKey: "k", + } + pipeline := compaction.NewPipeline(backend, "k", nil, compaction.PipelineOptions{}) + + store := compaction.NewStore() + store.PutRecord(compaction.Record{ID: "rec-a", Text: "stored original A", Summary: "a", Tokens: 5}) + store.PutRecord(compaction.Record{ID: "rec-b", Text: "stored original B", Summary: "b", Tokens: 5}) + + c := client.NewClient(client.Config{BaseURL: chatServer.URL, Model: "test-model"}) + return New(c, historyPath, history, "", false), pipeline, store, requestBodies +} + +// TestInjectRetrievedStagesWorkAreaBlock: the retrieved block is appended +// LAST in the outgoing messages (work area, after everything), the session +// history is untouched by both the injection and the request build, and +// nothing reaches the history file on disk. +func TestInjectRetrievedStagesWorkAreaBlock(t *testing.T) { + tmp := t.TempDir() + historyPath := filepath.Join(tmp, "history.json") + history := []client.ChatMessage{ + {Role: "user", Content: client.TextContent("please fix the login bug")}, + } + if err := SaveHistory(historyPath, history); err != nil { + t.Fatal(err) + } + saved, err := os.ReadFile(historyPath) + if err != nil { + t.Fatal(err) + } + + // rec-a scores 0.9 (injected), rec-b scores 0.3 (skipped at the default + // threshold 0.5). + s, pipeline, store, requestBodies := newRetrieveTestSession(t, history, historyPath, + map[string]float64{"rec-a": 0.9, "rec-b": 0.3}, http.StatusOK) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + n, err := s.InjectRetrieved(ctx, pipeline, store, 0, 0, 0) + if err != nil { + t.Fatalf("InjectRetrieved() error = %v", err) + } + if n != 1 { + t.Fatalf("InjectRetrieved() = %d records, want 1 (only rec-a clears the threshold)", n) + } + + outCh, errCh := s.StartStream(ctx, nil) + for range outCh { + } + if err, ok := <-errCh; ok && err != nil { + t.Fatalf("unexpected stream error: %v", err) + } + + var body []byte + select { + case body = <-requestBodies: + case <-time.After(5 * time.Second): + t.Fatal("fake server never received the chat completion request") + } + + var payload struct { + Messages []client.ChatMessage `json:"messages"` + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("decode captured request: %v\nbody: %s", err, body) + } + + // Expect: the user message, then the retrieved block LAST. + if len(payload.Messages) != 2 { + t.Fatalf("outgoing messages = %d, want 2:\n%s", len(payload.Messages), body) + } + last := payload.Messages[len(payload.Messages)-1] + if last.Role != retrievedContextRole { + t.Errorf("last outgoing message role = %q, want %q", last.Role, retrievedContextRole) + } + content := last.Content.String() + if !strings.HasPrefix(content, compaction.RetrievedContextHeader) { + t.Errorf("last outgoing message = %q, want the retrieved-context header", content) + } + if !strings.Contains(content, "stored original A") { + t.Errorf("block does not carry the injected record text: %q", content) + } + if strings.Contains(content, "stored original B") { + t.Errorf("block carries the below-threshold record: %q", content) + } + + // The ephemeral guarantee: history in memory AND on disk are untouched. + after, err := json.Marshal(s.History) + if err != nil { + t.Fatal(err) + } + before, err := json.Marshal(history) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) { + t.Fatalf("s.History was mutated by the retrieval path:\nbefore: %s\nafter: %s", before, after) + } + onDisk, err := os.ReadFile(historyPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(saved, onDisk) { + t.Fatalf("the history file was rewritten by the retrieval path:\nbefore: %s\nafter: %s", saved, onDisk) + } +} + +// TestInjectRetrievedClearsStaleBlock: every call overwrites the staged +// block — an empty selection (empty store) clears it, so no stale block can +// ride into the next request. +func TestInjectRetrievedClearsStaleBlock(t *testing.T) { + history := []client.ChatMessage{ + {Role: "user", Content: client.TextContent("please fix the login bug")}, + } + s, pipeline, store, requestBodies := newRetrieveTestSession(t, history, "", + map[string]float64{"rec-a": 0.9, "rec-b": 0.3}, http.StatusOK) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if n, err := s.InjectRetrieved(ctx, pipeline, store, 0, 0, 0); err != nil || n != 1 { + t.Fatalf("first InjectRetrieved() = (%d, %v), want (1, nil)", n, err) + } + // A second injection against an empty store finds nothing relevant. + if n, err := s.InjectRetrieved(ctx, pipeline, compaction.NewStore(), 0, 0, 0); err != nil || n != 0 { + t.Fatalf("second InjectRetrieved() = (%d, %v), want (0, nil)", n, err) + } + + outCh, errCh := s.StartStream(ctx, nil) + for range outCh { + } + if err, ok := <-errCh; ok && err != nil { + t.Fatalf("unexpected stream error: %v", err) + } + + var body []byte + select { + case body = <-requestBodies: + case <-time.After(5 * time.Second): + t.Fatal("fake server never received the chat completion request") + } + var payload struct { + Messages []client.ChatMessage `json:"messages"` + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("decode captured request: %v\nbody: %s", err, body) + } + if len(payload.Messages) != 1 { + t.Fatalf("outgoing messages = %d, want 1 (the cleared block must not ride along):\n%s", + len(payload.Messages), body) + } + if strings.Contains(payload.Messages[0].Content.String(), "stored original") { + t.Fatalf("the cleared block leaked into the request: %s", body) + } +} + +// TestInjectRetrievedScoringErrorClearsStagedBlock: a scoring failure stages +// nothing (and clears anything staged earlier) — an unreliable ranking must +// not stuff the work area. +func TestInjectRetrievedScoringErrorClearsStagedBlock(t *testing.T) { + history := []client.ChatMessage{ + {Role: "user", Content: client.TextContent("please fix the login bug")}, + } + s, pipeline, store, requestBodies := newRetrieveTestSession(t, history, "", + nil, http.StatusUnauthorized) // 401: never retried, fails fast + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if n, err := s.InjectRetrieved(ctx, pipeline, store, 0, 0, 0); err == nil { + t.Fatalf("InjectRetrieved() error = nil, want the auth-class failure surfaced") + } else if n != 0 { + t.Fatalf("InjectRetrieved() = %d records, want 0 on a scoring failure", n) + } + + outCh, errCh := s.StartStream(ctx, nil) + for range outCh { + } + if err, ok := <-errCh; ok && err != nil { + t.Fatalf("unexpected stream error: %v", err) + } + + var body []byte + select { + case body = <-requestBodies: + case <-time.After(5 * time.Second): + t.Fatal("fake server never received the chat completion request") + } + var payload struct { + Messages []client.ChatMessage `json:"messages"` + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("decode captured request: %v\nbody: %s", err, body) + } + for _, m := range payload.Messages { + if strings.Contains(m.Content.String(), compaction.RetrievedContextHeader) { + t.Fatalf("a failed retrieval staged no block, yet the request carries one: %s", body) + } + } +} diff --git a/internal/session/session.go b/internal/session/session.go index b1812fff..63c38d09 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -27,6 +27,28 @@ type Session struct { subagentSeq int saveSubagentHistories *bool Registry *tool.Registry + + // compactionHighWater is the history compaction high-water mark: the + // monotonic message index below which the frozen prefix ends. The + // compactor (compact.go) never re-scores or rewrites a message with a + // smaller index, so previously frozen bytes — the prompt-cache anchor — + // never change between runs. It is guarded by compactionMu the same way + // client guards clientMu; it round-trips through SessionMeta + // (GenerateSessionMeta) so it survives save/reload. + compactionHighWater int + compactionMu sync.Mutex + + // retrievedBlock holds the ephemeral retrieved-context block staged by + // InjectRetrieved (retrieve.go) for the NEXT stream request: StartStream + // appends it as the LAST outgoing message — the tail of the message list + // is the work area; the frozen prefix (the head) is never touched. It is + // request-scoped by contract: it exists only in the outgoing request + // copy, never in History and never on disk, and every InjectRetrieved + // call overwrites it (an empty result clears it). Guarded by + // retrievedMu, so a staged block is also safe against the TUI's + // cross-goroutine paths. + retrievedMu sync.Mutex + retrievedBlock string } func New(c *client.Client, historyPath string, history []client.ChatMessage, systemPrompt string, useTools bool) *Session { @@ -94,6 +116,73 @@ func (s *Session) UpdateSubagentSeq(seq int) error { return nil } +// CompactionHighWater returns the history compaction high-water mark: the +// monotonic message index below which the frozen prefix ends (compact.go). +func (s *Session) CompactionHighWater() int { + s.compactionMu.Lock() + defer s.compactionMu.Unlock() + return s.compactionHighWater +} + +// SetCompactionHighWater sets the in-memory high-water mark without +// persisting. The resume path (cmd/late) restores the persisted mark into a +// freshly constructed session with it, the reset paths (StartNewConversation) +// zero it with it, and tests use it to pin frozen-prefix behavior. +// Negative values clamp to zero. +func (s *Session) SetCompactionHighWater(n int) { + if n < 0 { + n = 0 + } + s.compactionMu.Lock() + defer s.compactionMu.Unlock() + s.compactionHighWater = n +} + +// UpdateCompactionHighWater durably advances the high-water mark to n — +// monotonic: a value at or below the current mark is a no-op — and persists +// it through the session meta sidecar, mirroring UpdateSubagentSeq: +// in-memory sessions (no history path) and subagent sessions (no sidecar) +// keep the mark in memory only, and a failed metadata write rolls the +// in-memory advance back so memory and disk never disagree. +func (s *Session) UpdateCompactionHighWater(n int) error { + s.compactionMu.Lock() + if n <= s.compactionHighWater { + s.compactionMu.Unlock() + return nil + } + previous := s.compactionHighWater + s.compactionHighWater = n + s.compactionMu.Unlock() + + if s.skipMetadata || s.HistoryPath == "" { + return nil + } + if err := s.UpdateSessionMetadata(); err != nil { + s.compactionMu.Lock() + s.compactionHighWater = previous + s.compactionMu.Unlock() + return err + } + return nil +} + +// ClampCompactionHighWater lowers the in-memory high-water mark to at most +// maxIndex — the reset-path hook (rewind, pop, new conversation): the frozen +// prefix never outlives the history it froze, or a stale mark would freeze +// messages that no longer exist. The caller's own metadata write +// (UpdateSessionMetadata) persists the clamp; in-memory sessions need no +// persistence at all. +func (s *Session) ClampCompactionHighWater(maxIndex int) { + if maxIndex < 0 { + maxIndex = 0 + } + s.compactionMu.Lock() + defer s.compactionMu.Unlock() + if s.compactionHighWater > maxIndex { + s.compactionHighWater = maxIndex + } +} + // ExecuteTool executes a tool call and returns the response as a string. func (s *Session) ExecuteTool(ctx context.Context, tc client.ToolCall) (string, error) { // First check registry @@ -172,6 +261,10 @@ func (s *Session) PopLastUserMessage() (bool, error) { return false, nil } s.History = s.History[:len(s.History)-1] + // The frozen prefix never outlives the history it froze: the high-water + // mark clamps to the truncated length, and the metadata write below + // persists the clamp. + s.ClampCompactionHighWater(len(s.History)) // Popping the first-and-only message empties the history. saveAndNotify() // treats empty history as "nothing to persist" (its empty-guard exists so @@ -255,6 +348,19 @@ func (s *Session) StartStream(ctx context.Context, extraBody map[string]any, onC // intentionally left untouched. messages = append(messages, SanitizeForRequest(s.History)...) + // Retrieved context (Step 17, compaction-retrieval): the block staged by + // InjectRetrieved is appended LAST, so it lands in the work area — after + // the frozen prefix by construction — and is ephemeral: it lives only in + // this request copy, never in s.History, never on disk, and the TUI + // transcript (which renders History) never shows it. Role "system" marks + // it as harness-injected context rather than a user turn. + if block := s.retrievedBlockForRequest(); block != "" { + messages = append(messages, client.ChatMessage{ + Role: retrievedContextRole, + Content: client.TextContent(block), + }) + } + var onConn func() if len(onConnect) > 0 && onConnect[0] != nil { onConn = onConnect[0] @@ -399,6 +505,7 @@ func (s *Session) GenerateSessionMeta() SessionMeta { SubagentSeq: s.subagentSeq, SaveSubagentHistories: s.saveSubagentHistories, WorkingDir: s.workingDir, + CompactionHighWater: s.CompactionHighWater(), } } @@ -437,6 +544,10 @@ func (s *Session) StartNewConversation() error { sessionID := fmt.Sprintf("session-%s-%09d", now.Format("20060102-150405"), now.Nanosecond()) s.HistoryPath = filepath.Join(dir, sessionID+".json") s.History = []client.ChatMessage{} + // A fresh conversation has no frozen prefix: the compaction high-water + // mark resets with the history (the sidecar of the preserved old + // conversation keeps its own mark for when it is resumed). + s.SetCompactionHighWater(0) return nil } diff --git a/internal/tool/expand_tool.go b/internal/tool/expand_tool.go new file mode 100644 index 00000000..d17c62e8 --- /dev/null +++ b/internal/tool/expand_tool.go @@ -0,0 +1,183 @@ +package tool + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "late/internal/compaction" +) + +// ExpandToolName is the registry name of the compaction expand tool. The +// executor also uses it to exempt expand results from re-compaction: the +// tool exists to return full originals, compacting them again would make +// them unreachable. +const ExpandToolName = "expand" + +// ExpandStore is the read side of the compaction original-text store +// (implemented by *compaction.Store). The indirection keeps internal/tool +// decoupled from internal/compaction and lets tests inject a fake. +type ExpandStore interface { + Get(id string) (string, bool) +} + +// expandOutcomeStore is the optional outcome side of the expand store: the +// production *compaction.Store implements it (Touch counters, the attached +// shadow log, record snapshots); minimal test fakes need not. Execute +// type-asserts the store and silently records nothing when it lacks the +// side — outcomes are a ledger, never a precondition for expanding. +type expandOutcomeStore interface { + // Touch bumps a record's expand/hit counters; reports whether the + // record exists. + Touch(id string, expand, hit bool) bool + // ShadowLog returns the store's attached outcome log, or nil. + ShadowLog() *compaction.ShadowLog + // GetRecord snapshots a record — its SegmentIDs attribute the expand + // back to the score decisions that caused the elision. + GetRecord(id string) (*compaction.Record, bool) +} + +// ExpandTool retrieves the original text of a tool-output run that +// compaction relocated (compaction-mode "enabled"): compacted results +// contain pointer lines like +// +// [[elided id=r:1a2b3c4d lines=12-40 tokens=310 "first 120 chars of the run …"]] +// +// (content-addressed ids, r:<8 hex>) — or, from older builds, +// +// [[elided id=elide-3 lines=12-31 tokens=310 "first sixty chars …"]] +// +// (legacy counter ids; the pre-reference count form "lines=12" is not +// parseable and never was — the id alone is what matters here) +// +// and calling this tool with such an id returns the full original text. +// Passing a whole pointer line instead of the bare id works too: the id is +// parsed out of it. It is registered on the main session registry when +// compaction-mode is enabled, and subagents inherit it from the parent +// registry. Each successful retrieval is recorded as an expand outcome (the +// store's Touch counter plus shadow-log outcome lines attributing back to +// the record and its contributing segments) when the store carries a shadow +// log; see recordExpandOutcome. +type ExpandTool struct { + Store ExpandStore +} + +func (t ExpandTool) Name() string { return ExpandToolName } + +func (t ExpandTool) Description() string { + return "Retrieve the ORIGINAL text of an elided (compacted) tool-output run. " + + "When a large tool result was compacted, history contains pointer lines like " + + "[[elided id=r:1a2b3c4d lines=12-40 tokens=310 \"first 120 chars of the run\"]] " + + "(content-addressed id, r: plus 8 hex chars; legacy builds minted elide-N ids) " + + "instead of the full text. " + + "Call this tool with that id — or with the whole pointer line — to fetch the complete original." +} + +func (t ExpandTool) Parameters() json.RawMessage { + return json.RawMessage(`{ + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The elided-run id from an [[elided id=...]] pointer line (content id like \"r:1a2b3c4d\", or a legacy \"elide-3\"); a whole pointer line is also accepted." + } + }, + "required": ["id"] + }`) +} + +func (t ExpandTool) RequiresConfirmation(args json.RawMessage) bool { return false } + +func (t ExpandTool) CallString(args json.RawMessage) string { + id := expandID(args) + if id == "" { + return "Retrieving elided segment..." + } + return fmt.Sprintf("Retrieving elided segment %s...", truncate(id, 50)) +} + +func (t ExpandTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { + if t.Store == nil { + return "", fmt.Errorf("no elided-original store is wired — nothing to expand") + } + var params struct { + ID string `json:"id"` + } + if err := json.Unmarshal(args, ¶ms); err != nil { + return "", fmt.Errorf("invalid parameters for expand: %w", err) + } + id := expandIDFromArg(params.ID) + if id == "" { + return "", fmt.Errorf("id is required (use an id from an [[elided id=...]] pointer line)") + } + original, ok := t.Store.Get(id) + if !ok { + return "", fmt.Errorf("unknown elided id %q", id) + } + t.recordExpandOutcome(id) + return original, nil +} + +// recordExpandOutcome marks one successful retrieval — every expand is a +// recorded false negative (the reference pipeline.py expand()): the record's +// expand counter moves (Store.Touch), and one "expand" outcome naming the +// record id plus one naming each contributing segment id land in the +// store's attached shadow log, attributing the expand back to the score +// decisions that caused the elision (ShadowLog.FalseNegativeRate and the +// replay table's still-missed column are built on it). +// +// Best-effort and nil-safe by contract: a store without the outcome side +// (test fakes) or without an attached shadow log records nothing, a failed +// outcome append never fails the expand itself, and turn stays 0 (turn +// plumbing does not exist yet, mirroring every other writer). +func (t ExpandTool) recordExpandOutcome(id string) { + s, ok := t.Store.(expandOutcomeStore) + if !ok { + return + } + if !s.Touch(id, true, false) { + return // unknown record: nothing to attribute + } + shadow := s.ShadowLog() + if shadow == nil { + return // no outcome log attached at wiring: skip silently + } + _ = shadow.AppendOutcome(compaction.EntryTypeExpand, id, 0) + rec, ok := s.GetRecord(id) + if !ok { + return + } + for _, segID := range rec.SegmentIDs { + if segID == "" { + continue + } + _ = shadow.AppendOutcome(compaction.EntryTypeExpand, segID, 0) + } +} + +// expandID extracts the id argument from raw tool arguments for the +// progress string; unknown shapes yield "". +func expandID(args json.RawMessage) string { + var params struct { + ID string `json:"id"` + } + if err := json.Unmarshal(args, ¶ms); err != nil { + return "" + } + return expandIDFromArg(params.ID) +} + +// expandIDFromArg normalizes the id argument: surrounding whitespace is +// trimmed, and a whole [[elided …]] pointer line is accepted in place of the +// bare id — its id is parsed out with the shared pointer parser, so content +// ids and legacy counter ids both work. +func expandIDFromArg(arg string) string { + arg = strings.TrimSpace(arg) + if strings.Contains(arg, "[[elided") { + if p, ok := compaction.ParsePointer(arg); ok { + return p.ID + } + } + return arg +} diff --git a/internal/tool/expand_tool_nilstore_test.go b/internal/tool/expand_tool_nilstore_test.go new file mode 100644 index 00000000..b0d408a6 --- /dev/null +++ b/internal/tool/expand_tool_nilstore_test.go @@ -0,0 +1,18 @@ +package tool + +import ( + "context" + "encoding/json" + "testing" +) + +// TestExpandTool_NilStore: a tool wired without a store must fail with a +// clear error result instead of panicking on the nil interface — the agent +// sees an error, never a crash. +func TestExpandTool_NilStore(t *testing.T) { + e := ExpandTool{Store: nil} + _, err := e.Execute(context.Background(), json.RawMessage(`{"id":"r:1a2b3c4d"}`)) + if err == nil { + t.Fatal("Execute with a nil store succeeded; want an error result") + } +} diff --git a/internal/tool/expand_tool_test.go b/internal/tool/expand_tool_test.go new file mode 100644 index 00000000..1e3c9e9b --- /dev/null +++ b/internal/tool/expand_tool_test.go @@ -0,0 +1,295 @@ +package tool + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "late/internal/compaction" +) + +// fakeExpandStore is a minimal ExpandStore for unit-testing the tool layer. +type fakeExpandStore struct { + originals map[string]string + getCalled bool +} + +func (s *fakeExpandStore) Get(id string) (string, bool) { + s.getCalled = true + text, ok := s.originals[id] + return text, ok +} + +func TestExpandTool_Metadata(t *testing.T) { + e := ExpandTool{Store: &fakeExpandStore{}} + if e.Name() != "expand" { + t.Errorf("Name() = %q, want expand", e.Name()) + } + if e.RequiresConfirmation(nil) { + t.Error("RequiresConfirmation() = true, want false (read-only lookup)") + } + if !strings.Contains(e.Description(), "ORIGINAL") { + t.Errorf("Description() should advertise original retrieval: %q", e.Description()) + } + var params map[string]any + if err := json.Unmarshal(e.Parameters(), ¶ms); err != nil { + t.Fatalf("Parameters() is not valid JSON: %v", err) + } + if params["type"] != "object" { + t.Errorf("Parameters() type = %v, want object", params["type"]) + } +} + +func TestExpandTool_Execute(t *testing.T) { + store := &fakeExpandStore{originals: map[string]string{ + "elide-3": "the original segment text\n\n", + }} + e := ExpandTool{Store: store} + + // Known id → the stored original, byte-for-byte. + got, err := e.Execute(context.Background(), []byte(`{"id":"elide-3"}`)) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + if got != "the original segment text\n\n" { + t.Errorf("Execute() = %q, want the stored original", got) + } + if !store.getCalled { + t.Error("Execute() never consulted the store") + } + + // Surrounding whitespace on the id is tolerated. + if _, err := e.Execute(context.Background(), []byte(`{"id":" elide-3 "}`)); err != nil { + t.Errorf("Execute() with padded id error = %v", err) + } + + // Unknown id → the documented error result. + _, err = e.Execute(context.Background(), []byte(`{"id":"elide-999"}`)) + if err == nil || !strings.Contains(err.Error(), "unknown elided id") { + t.Errorf("Execute(unknown id) error = %v, want the unknown-elided-id error", err) + } + + // Missing id → a required-parameter error. + _, err = e.Execute(context.Background(), []byte(`{}`)) + if err == nil || !strings.Contains(err.Error(), "id is required") { + t.Errorf("Execute(missing id) error = %v, want the required-id error", err) + } + + // Malformed arguments surface as invalid parameters. + _, err = e.Execute(context.Background(), []byte(`not json`)) + if err == nil || !strings.Contains(err.Error(), "invalid parameters") { + t.Errorf("Execute(bad json) error = %v, want an invalid-parameters error", err) + } +} + +// TestExpandTool_StoreWithoutEntries: a tool pointing at an empty store must +// not panic; it reports the id as unknown. +func TestExpandTool_StoreWithoutEntries(t *testing.T) { + e := ExpandTool{Store: &fakeExpandStore{}} + _, err := e.Execute(context.Background(), []byte(`{"id":"elide-1"}`)) + if err == nil || !strings.Contains(err.Error(), "unknown elided id") { + t.Errorf("Execute() error = %v, want the unknown-elided-id error", err) + } +} + +// readShadowOutcomes parses a shadow log's JSONL lines into entries; the +// fake-free helper keeps the outcome assertions independent of the +// compaction package's own test helpers. A missing log file is zero +// outcomes: the log is only created on the first append. +func readShadowOutcomes(t *testing.T, path string) []compaction.ShadowEntry { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + t.Fatalf("read shadow log: %v", err) + } + var out []compaction.ShadowEntry + for i, line := range strings.Split(string(data), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + var e compaction.ShadowEntry + if err := json.Unmarshal([]byte(line), &e); err != nil { + t.Fatalf("shadow line %d invalid: %v (%q)", i, err, line) + } + out = append(out, e) + } + return out +} + +// TestExpandTool_WritesOutcomes: a successful expand bumps the record's +// expand counter and appends one expand outcome per record id AND one per +// contributing segment id — the Step 13 attribution ledger. +func TestExpandTool_WritesOutcomes(t *testing.T) { + shadowPath := filepath.Join(t.TempDir(), "shadow.jsonl") + shadow, err := compaction.NewShadowLogAt(shadowPath) + if err != nil { + t.Fatal(err) + } + store := compaction.NewStore().WithShadowLog(shadow) + store.PutRecord(compaction.Record{ + ID: "r:1a2b3c4d", + Text: "the original run", + Tokens: 42, + SegmentIDs: []string{"seg-1", "seg-2"}, + }) + + e := ExpandTool{Store: store} + if _, err := e.Execute(context.Background(), []byte(`{"id":"r:1a2b3c4d"}`)); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + // The record's expand counter moved (Store.Touch ran). + rec, ok := store.GetRecord("r:1a2b3c4d") + if !ok { + t.Fatal("record vanished from the store") + } + if rec.ExpandCount != 1 { + t.Errorf("ExpandCount = %d, want 1", rec.ExpandCount) + } + + outcomes := readShadowOutcomes(t, shadowPath) + if len(outcomes) != 3 { // 1 record id + 2 segment ids + t.Fatalf("got %d outcome lines, want 3", len(outcomes)) + } + ids := make(map[string]bool, len(outcomes)) + for _, oc := range outcomes { + if oc.Type != compaction.EntryTypeExpand { + t.Errorf("outcome type = %q, want %q", oc.Type, compaction.EntryTypeExpand) + } + if oc.ItemID == "" { + t.Error("outcome carries no item id") + } + ids[oc.ItemID] = true + } + for _, want := range []string{"r:1a2b3c4d", "seg-1", "seg-2"} { + if !ids[want] { + t.Errorf("no expand outcome attributed to %q (got %v)", want, ids) + } + } + + // A second retrieval appends another round of outcomes (the ledger is + // append-only; Stats counts distinct ids). + if _, err := e.Execute(context.Background(), []byte(`{"id":"r:1a2b3c4d"}`)); err != nil { + t.Fatalf("second Execute() error = %v", err) + } + if got := len(readShadowOutcomes(t, shadowPath)); got != 6 { + t.Errorf("after two expands got %d outcome lines, want 6", got) + } + if rec, _ := store.GetRecord("r:1a2b3c4d"); rec.ExpandCount != 2 { + t.Errorf("ExpandCount after two expands = %d, want 2", rec.ExpandCount) + } +} + +// TestExpandTool_OutcomesWithoutShadowLog: a store with no shadow log +// attached (and, implicitly, one without persistence) still expands — the +// outcome ledger is best-effort and nil-safe, never a precondition. +func TestExpandTool_OutcomesWithoutShadowLog(t *testing.T) { + store := compaction.NewStore() + store.PutRecord(compaction.Record{ + ID: "elide-3", + Text: "the original", + SegmentIDs: []string{"seg-1"}, + }) + e := ExpandTool{Store: store} + got, err := e.Execute(context.Background(), []byte(`{"id":"elide-3"}`)) + if err != nil || got != "the original" { + t.Fatalf("Execute() = (%q, %v), want the original with no error", got, err) + } + if rec, _ := store.GetRecord("elide-3"); rec.ExpandCount != 1 { + t.Errorf("ExpandCount = %d, want 1 (Touch still runs without a shadow log)", rec.ExpandCount) + } + if store.ShadowLog() != nil { + t.Error("ShadowLog() = non-nil, want nil when nothing was attached") + } +} + +// TestExpandTool_UnknownIDRecordsNothing: a failed lookup must not touch the +// counters or the outcome ledger. +func TestExpandTool_UnknownIDRecordsNothing(t *testing.T) { + shadowPath := filepath.Join(t.TempDir(), "shadow.jsonl") + shadow, err := compaction.NewShadowLogAt(shadowPath) + if err != nil { + t.Fatal(err) + } + store := compaction.NewStore().WithShadowLog(shadow) + store.PutRecord(compaction.Record{ID: "r:good", Text: "kept", SegmentIDs: []string{"seg-1"}}) + + e := ExpandTool{Store: store} + if _, err := e.Execute(context.Background(), []byte(`{"id":"r:missing"}`)); err == nil { + t.Fatal("Execute(unknown id) error = nil, want the unknown-elided-id error") + } + if outcomes := readShadowOutcomes(t, shadowPath); len(outcomes) != 0 { + t.Errorf("got %d outcome lines, want 0 (failed lookups record nothing)", len(outcomes)) + } + if rec, _ := store.GetRecord("r:good"); rec.ExpandCount != 0 { + t.Errorf("ExpandCount = %d, want 0 (the miss must not bump anything)", rec.ExpandCount) + } +} + +func TestExpandTool_CallString(t *testing.T) { + e := ExpandTool{Store: &fakeExpandStore{}} + if got := e.CallString([]byte(`{"id":"elide-3"}`)); !strings.Contains(got, "elide-3") { + t.Errorf("CallString() = %q, want it to name the id", got) + } + if got := e.CallString([]byte(`{"id":"r:1a2b3c4d"}`)); !strings.Contains(got, "r:1a2b3c4d") { + t.Errorf("CallString() = %q, want it to name the content id", got) + } +} + +// TestExpandTool_ContentIDsAndPointerLines: content-addressed ids +// ("r:<8hex>") resolve like legacy "elide-N" ids, and a whole pointer line +// may be passed in place of the bare id — its id is parsed out. +func TestExpandTool_ContentIDsAndPointerLines(t *testing.T) { + store := &fakeExpandStore{originals: map[string]string{ + "r:1a2b3c4d": "the content-addressed original\n\nwith its tail", + "elide-3": "the legacy original", + }} + e := ExpandTool{Store: store} + + // Content id, plain. + got, err := e.Execute(context.Background(), []byte(`{"id":"r:1a2b3c4d"}`)) + if err != nil || got != "the content-addressed original\n\nwith its tail" { + t.Errorf("Execute(content id) = (%q, %v), want the stored original", got, err) + } + + // Legacy id still works alongside it. + if got, err := e.Execute(context.Background(), []byte(`{"id":"elide-3"}`)); err != nil || got != "the legacy original" { + t.Errorf("Execute(legacy id) = (%q, %v), want the stored original", got, err) + } + + // A whole pointer line (reference format, escaped quotes included) + // resolves down to its id. + pointer := `[[elided id=r:1a2b3c4d lines=3-9 tokens=310 "first \"quoted\" chars"]]` + args, err := json.Marshal(map[string]string{"id": pointer}) + if err != nil { + t.Fatalf("marshal pointer arg: %v", err) + } + if got, err := e.Execute(context.Background(), args); err != nil || got != "the content-addressed original\n\nwith its tail" { + t.Errorf("Execute(pointer line) = (%q, %v), want the stored original", got, err) + } + + // The description and parameter schema advertise the r: id format. + if !strings.Contains(e.Description(), "r:1a2b3c4d") { + t.Errorf("Description() must mention the r:<8hex> id format: %q", e.Description()) + } + var params map[string]any + if err := json.Unmarshal(e.Parameters(), ¶ms); err != nil { + t.Fatalf("Parameters() is not valid JSON: %v", err) + } + props := params["properties"].(map[string]any) + idDesc := props["id"].(map[string]any)["description"].(string) + if !strings.Contains(idDesc, "r:1a2b3c4d") || !strings.Contains(idDesc, "pointer line") { + t.Errorf("id parameter description must document content ids and pointer lines: %q", idDesc) + } + + // An unparseable pointer-ish argument stays a clean unknown-id error. + if _, err := e.Execute(context.Background(), []byte(`{"id":"[[elided nope"}`)); err == nil || !strings.Contains(err.Error(), "unknown elided id") { + t.Errorf("Execute(bad pointer) error = %v, want the unknown-id error", err) + } +} diff --git a/internal/tui/compaction_cmd_test.go b/internal/tui/compaction_cmd_test.go new file mode 100644 index 00000000..775c55cd --- /dev/null +++ b/internal/tui/compaction_cmd_test.go @@ -0,0 +1,583 @@ +package tui + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "late/internal/client" + "late/internal/common" + "late/internal/compaction" + "late/internal/config" + "late/internal/session" +) + +// The compaction pipeline's store is the production session.ElideStore: the +// session's CompactContext shares the pipeline's elide-id space with the +// expand tool through it (cmd/late/main.go passes it as session.ElideStore). +var _ session.ElideStore = (*compaction.Store)(nil) + +// pressEnter delivers a terminal Enter keypress to m and returns the +// resulting model. Private copy: the helper lives in an excluded +// feature's test file on the source branch. +func pressEnter(t *testing.T, m Model) Model { + t.Helper() + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + next, ok := updated.(Model) + if !ok { + t.Fatalf("Update returned %T, want tui.Model", updated) + } + return next +} + +// unknownCtxOrchestrator reports an unknown context size (ContextSize -1) +// so the auto-compaction trigger path can be exercised without a real +// window size. Private copy: the helper lives in an excluded feature's +// test file on the source branch. +type unknownCtxOrchestrator struct { + mockOrchestrator +} + +func (m *unknownCtxOrchestrator) MaxTokens() int { return -1 } + +// The pipeline's decision client is the production HistoryScorer behind +// Model.Compactor (wired via compaction.Pipeline.HistoryScorer). +var _ session.HistoryScorer = (*compaction.DecisionClient)(nil) + +// fakeHistoryScorer scores every segment with one fixed score, standing in +// for the compaction pipeline's decision client in the end-to-end test. +type fakeHistoryScorer struct { + score float64 + calls int +} + +func (f *fakeHistoryScorer) ScoreBatch(_ context.Context, _ string, items map[string]compaction.Item) (map[string]float64, error) { + f.calls++ + out := make(map[string]float64, len(items)) + for id := range items { + out[id] = f.score + } + return out, nil +} + +// okRunner is the no-op CompactionRunner stub: it reports a fixed mutating +// run without touching anything. +func okRunner(report session.CompactionReport) CompactionRunner { + return func(context.Context) (session.CompactionReport, error) { + return report, nil + } +} + +func TestJevCompactStoreSharesElideIDSpace(t *testing.T) { + store := compaction.NewStore() + var elideStore session.ElideStore = store + + id1 := elideStore.NextID() + id2 := elideStore.NextID() + if id1 != "elide-1" || id2 != "elide-2" { + t.Fatalf("NextID() = %q, %q; want elide-1, elide-2", id1, id2) + } + elideStore.Put(id1, "original text") + if got, ok := store.Get(id1); !ok || got != "original text" { + t.Fatalf("store.Get(%s) = (%q, %v), want the stored original", id1, got, ok) + } +} + +func TestJevCompactContextListedInAvailableCommands(t *testing.T) { + found := false + for _, cmd := range AvailableCommands { + if cmd.Name == "/jev-compact-context" { + found = true + if cmd.Description == "" { + t.Fatal("/jev-compact-context must carry a description") + } + } + } + if !found { + t.Fatal("/jev-compact-context missing from AvailableCommands") + } +} + +func TestJevCompactContextCommandUnavailable(t *testing.T) { + m := NewModel(&mockOrchestrator{}, nil, &config.Config{}) + m.SetSize(80, 24) + m.Input.SetValue("/jev-compact-context") + m = pressEnter(t, m) + + s := m.GetAgentState(m.Focused.ID()) + if s.StatusText != compactionUnavailableStatus { + t.Fatalf("StatusText = %q, want %q", s.StatusText, compactionUnavailableStatus) + } + if m.CompactionRunning { + t.Fatal("unavailable compaction must not start a run") + } +} + +func TestJevCompactContextCommandDispatchesRun(t *testing.T) { + m := NewModel(&mockOrchestrator{}, nil, &config.Config{}) + m.SetSize(80, 24) + + runs := 0 + m.Compactor = func(context.Context) (session.CompactionReport, error) { + runs++ + return session.CompactionReport{TokensSaved: 25, SegmentsElided: 2}, nil + } + + m.Input.SetValue("/jev-compact-context") + updated, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + next, ok := updated.(Model) + if !ok { + t.Fatalf("Update returned %T, want tui.Model", updated) + } + if !next.CompactionRunning { + t.Fatal("dispatch must set the in-flight guard") + } + if cmd == nil { + t.Fatal("dispatch must return the compaction command") + } + if next.Input.Value() != "" { + t.Fatalf("dispatch must clear the input, got %q", next.Input.Value()) + } + if runs != 0 { + t.Fatal("the run must happen off the update loop, not during dispatch") + } + + // The command runs off-loop; its report lands as a status. + msg := next.startCompaction()() + result, ok := msg.(compactionResultMsg) + if !ok { + t.Fatalf("command returned %T, want compactionResultMsg", msg) + } + if result.err != nil { + t.Fatalf("runner error = %v", result.err) + } + final, _ := next.Update(result) + done := final.(Model) + if done.CompactionRunning { + t.Fatal("the result message must clear the in-flight guard") + } + if got := done.GetAgentState(done.Focused.ID()).StatusText; got != "compacted: saved ~25 tokens, 2 segments elided (scored 0/0 messages)… (estimate — the next request's usage refreshes the bar)" { + t.Fatalf("StatusText = %q, want the saved report", got) + } + if runs != 1 { + t.Fatalf("runner invoked %d times, want 1", runs) + } +} + +func TestCompactionResultStatuses(t *testing.T) { + cases := []struct { + name string + msg compactionResultMsg + want string + }{ + { + name: "shadow run", + msg: compactionResultMsg{report: session.CompactionReport{ + ShadowOnly: true, TokensSaved: 500, SegmentsElided: 7, + MessagesScanned: 8, MessagesScored: 8, + }}, + want: "shadow report: would save ~500 tokens, 7 segments elided (scored 8/8 messages; enable compaction-mode to apply)", + }, + { + name: "scorer failure", + msg: compactionResultMsg{err: fmt.Errorf("scorer down")}, + want: "compaction failed after scoring 0/0 messages: scorer down", + }, + { + // A mid-walk wholesale failure: the status says how far the + // scoring got before it stopped (the honesty requirement). + name: "scorer failure mid-walk", + msg: compactionResultMsg{ + report: session.CompactionReport{MessagesScanned: 12, MessagesScored: 5}, + err: fmt.Errorf("scorer down"), + }, + want: "compaction failed after scoring 5/12 messages: scorer down", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := NewModel(&mockOrchestrator{}, nil, &config.Config{}) + m.SetSize(80, 24) + m.CompactionRunning = true + + updated, _ := m.Update(tc.msg) + next := updated.(Model) + if next.CompactionRunning { + t.Fatal("the result message must clear the in-flight guard") + } + if got := next.GetAgentState(next.Focused.ID()).StatusText; got != tc.want { + t.Fatalf("StatusText = %q, want %q", got, tc.want) + } + }) + } +} + +func TestJevCompactContextEndToEnd(t *testing.T) { + // A completing mutating run persists the compaction high-water mark + // through the session meta sidecar — sandbox SessionDir so this test + // never writes into the real user sessions directory. + sessionDir := t.TempDir() + originalSessionDir := session.SessionDir + session.SessionDir = func() (string, error) { return sessionDir, nil } + t.Cleanup(func() { session.SessionDir = originalSessionDir }) + + sess := session.New(nil, filepath.Join(t.TempDir(), "history.json"), []client.ChatMessage{ + {Role: "user", Content: client.TextContent("Please analyze this build log.")}, + // The compaction candidate: assistant content annotating a tool + // call (a pure-prose assistant message is never compacted). + { + Role: "assistant", + Content: client.TextContent(strings.Repeat("verbose analysis ", 200)), + ToolCalls: []client.ToolCall{{Index: 0, ID: "call_1", Type: "function", Function: client.FunctionCall{Name: "Bash", Arguments: `{"cmd":"make build"}`}}}, + }, + }, "system prompt", false) + scorer := &fakeHistoryScorer{score: 0} + store := compaction.NewStore() + + m := NewModel(&mockOrchestrator{}, nil, &config.Config{}) + m.SetSize(80, 24) + m.Compactor = func(ctx context.Context) (session.CompactionReport, error) { + return sess.CompactContext(ctx, scorer, store, session.CompactionOptions{}) + } + + msg := m.startCompaction()() + result, ok := msg.(compactionResultMsg) + if !ok { + t.Fatalf("command returned %T, want compactionResultMsg", msg) + } + if result.err != nil { + t.Fatalf("CompactContext() error = %v", result.err) + } + if result.report.SegmentsElided == 0 || result.report.TokensSaved <= 0 { + t.Fatalf("expected a real elision, report = %+v", result.report) + } + if scorer.calls == 0 { + t.Fatal("the scorer was never consulted") + } + + updated, _ := m.Update(result) + next := updated.(Model) + want := fmt.Sprintf("compacted: saved ~%d tokens, %d segments elided (scored %d/%d messages)… (estimate — the next request's usage refreshes the bar)", + result.report.TokensSaved, result.report.SegmentsElided, + result.report.MessagesScored, result.report.MessagesScanned) + if got := next.GetAgentState(next.Focused.ID()).StatusText; got != want { + t.Fatalf("StatusText = %q, want %q", got, want) + } + if !strings.Contains(sess.History[1].Content.Text, "[[elided") { + t.Fatal("compaction must rewrite the assistant message in place with pointers") + } +} + +func TestJevAutoCompactFiresFromUsageUpdate(t *testing.T) { + m := NewModel(&mockOrchestrator{}, nil, &config.Config{JevAutocompact: true}) + m.SetSize(80, 24) + m.Compactor = okRunner(session.CompactionReport{TokensSaved: 10}) + + updated, cmd := m.Update(OrchestratorEventMsg{Event: common.ContentEvent{ + ID: m.Focused.ID(), + Usage: client.Usage{TotalTokens: 100}, // 100% of the mock's 100-token context + }}) + next := updated.(Model) + s := next.GetAgentState(next.Focused.ID()) + if !next.CompactionRunning { + t.Fatal("crossing the threshold with jev-autocompact on must start a compaction") + } + if !s.AutocompactDisarmed { + t.Fatal("firing must disarm the agent until usage drops back") + } + if cmd == nil { + t.Fatal("Update must return the compaction command") + } +} + +func TestJevAutoCompactDisabledNeverFires(t *testing.T) { + m := NewModel(&mockOrchestrator{}, nil, &config.Config{JevAutocompact: false}) + m.SetSize(80, 24) + m.Compactor = okRunner(session.CompactionReport{}) + + updated, _ := m.Update(OrchestratorEventMsg{Event: common.ContentEvent{ + ID: m.Focused.ID(), + Usage: client.Usage{TotalTokens: 100}, + }}) + next := updated.(Model) + s := next.GetAgentState(next.Focused.ID()) + if next.CompactionRunning || s.AutocompactDisarmed { + t.Fatal("jev-autocompact off must never fire the trigger") + } +} + +func TestJevAutoCompactNoRunnerNeverFires(t *testing.T) { + m := NewModel(&mockOrchestrator{}, nil, &config.Config{JevAutocompact: true}) + m.SetSize(80, 24) + + updated, _ := m.Update(OrchestratorEventMsg{Event: common.ContentEvent{ + ID: m.Focused.ID(), + Usage: client.Usage{TotalTokens: 100}, + }}) + next := updated.(Model) + if next.CompactionRunning { + t.Fatal("without the pipeline (nil Compactor) the trigger must never fire") + } +} + +func TestJevAutoCompactUnknownContextNeverFires(t *testing.T) { + m := NewModel(&unknownCtxOrchestrator{mockOrchestrator{}}, nil, &config.Config{JevAutocompact: true}) + m.SetSize(80, 24) + m.Compactor = okRunner(session.CompactionReport{}) + + updated, _ := m.Update(OrchestratorEventMsg{Event: common.ContentEvent{ + ID: m.Focused.ID(), + Usage: client.Usage{TotalTokens: 10000}, + }}) + next := updated.(Model) + if next.CompactionRunning { + t.Fatal("without a known ctx size the trigger must skip silently") + } +} + +func TestJevAutoCompactOncePerCrossingRearms(t *testing.T) { + m := NewModel(&mockOrchestrator{}, nil, &config.Config{JevAutocompact: true}) + m.SetSize(80, 24) + m.Compactor = okRunner(session.CompactionReport{}) + s := m.GetAgentState(m.Focused.ID()) + s.CumulativeTokenCount = 100 // 100% of the 100-token context + + if cmd := m.maybeJevAutoCompact(s); cmd == nil { + t.Fatal("first crossing must fire") + } + if !s.AutocompactDisarmed { + t.Fatal("firing must disarm the agent") + } + + // Still above the threshold: one crossing fires exactly once. + m.CompactionRunning = false + if cmd := m.maybeJevAutoCompact(s); cmd != nil { + t.Fatal("a crossing must fire only once") + } + + // Usage falls below the re-arm level (99-9 = 90% → under 90 tokens): + // the trigger re-arms without firing. + s.CumulativeTokenCount = 50 + if cmd := m.maybeJevAutoCompact(s); cmd != nil { + t.Fatal("the re-arm update must not fire (usage below the threshold)") + } + if s.AutocompactDisarmed { + t.Fatal("usage below the re-arm level must re-arm the trigger") + } + + // The next crossing fires again. + s.CumulativeTokenCount = 100 + if cmd := m.maybeJevAutoCompact(s); cmd == nil { + t.Fatal("a re-armed crossing must fire again") + } +} + +func TestNewModelPlumbsAutocompactConfig(t *testing.T) { + m := NewModel(&mockOrchestrator{}, nil, nil) + if m.JevAutocompact || m.JevAutocompactPercent != config.DefaultJevAutocompactPercent { + t.Fatalf("nil config must default to disabled/%d, got %v/%d", + config.DefaultJevAutocompactPercent, m.JevAutocompact, m.JevAutocompactPercent) + } + + m = NewModel(&mockOrchestrator{}, nil, &config.Config{JevAutocompact: true, JevAutocompactPercent: 55}) + if !m.JevAutocompact || m.JevAutocompactPercent != 55 { + t.Fatalf("valid config must plumb through, got %v/%d", m.JevAutocompact, m.JevAutocompactPercent) + } + + m = NewModel(&mockOrchestrator{}, nil, &config.Config{JevAutocompact: true, JevAutocompactPercent: 250}) + if !m.JevAutocompact || m.JevAutocompactPercent != config.DefaultJevAutocompactPercent { + t.Fatalf("invalid percent must normalize to %d, got %v/%d", + config.DefaultJevAutocompactPercent, m.JevAutocompact, m.JevAutocompactPercent) + } +} + +// TestJevAutoCompactPerModelOverrideFiresAtOverride pins the per-model +// override resolution in the trigger itself: the trigger watches the ROOT +// agent, so the root's agent_models-routed model entry carrying +// jev-autocompact-percent 55 fires at 55% of the (mock 100-token) context — +// even while a subagent is focused. (The compaction pass always rewrites the +// root session's history, so only root usage can drive the trigger.) +func TestJevAutoCompactPerModelOverrideFiresAtOverride(t *testing.T) { + cfg := &config.Config{ + JevAutocompact: true, + JevAutocompactPercent: 99, // the global threshold + Models: []config.ModelSetting{ + {ID: "small-ctx", URL: "http://a:8080", Key: "k", Model: "model-a", JevAutocompactPercent: 55}, + }, + AgentModels: map[string]string{"orchestrator": "small-ctx"}, + } + m := NewModel(&mockOrchestrator{}, nil, cfg) + m.SetSize(80, 24) + m.Compactor = okRunner(session.CompactionReport{}) + // The root's ID maps to "orchestrator" through agentTypeForID; focus a + // subagent to pin that the trigger keys off the root regardless. + m.Root = &typedOrchestrator{mockOrchestrator{}, common.MainAgentID} + m.Focused = &typedOrchestrator{mockOrchestrator{}, "researcher-subagent-0"} + + // 54 of 100 tokens: below the 55% override — no fire. + m.GetAgentState(m.Root.ID()).CumulativeTokenCount = 54 + if cmd := m.maybeJevAutoCompact(m.GetAgentState(m.Root.ID())); cmd != nil { + t.Fatal("55% root override must not fire at 54% usage") + } + + // 55 of 100 tokens: exactly the override threshold — fire. + m.GetAgentState(m.Root.ID()).CumulativeTokenCount = 55 + if cmd := m.maybeJevAutoCompact(m.GetAgentState(m.Root.ID())); cmd == nil { + t.Fatal("55% override must fire at exactly 55% usage") + } + if !m.CompactionRunning { + t.Fatal("firing must set CompactionRunning") + } +} + +// TestJevAutoCompactWatchesRootRegardlessOfFocus drives the trigger through +// the Update event path: the ROOT agent's usage crossing fires the +// compaction while a subagent is focused (the runner compacts the root +// session's history, so the disarm lands on the root's state), and a focused +// subagent's own usage crossing never triggers it. Re-arm semantics stay on +// the root's state. +func TestJevAutoCompactWatchesRootRegardlessOfFocus(t *testing.T) { + m := NewModel(&mockOrchestrator{}, nil, &config.Config{JevAutocompact: true}) + m.SetSize(80, 24) + m.Compactor = okRunner(session.CompactionReport{}) + // Focus a subagent whose usage is already maxed: its context is not the + // one the trigger watches. + m.Focused = &typedOrchestrator{mockOrchestrator{}, "researcher-subagent-0"} + subagentState := m.GetAgentState(m.Focused.ID()) + subagentState.CumulativeTokenCount = 100 // 100% of the mock's 100-token context + + // A focused subagent's usage update crossing the threshold does NOT fire. + // (cmd is ignored here: the subagent turning busy legitimately returns + // spinner/tick commands — only the compaction guard matters.) + updated, _ := m.Update(OrchestratorEventMsg{Event: common.ContentEvent{ + ID: m.Focused.ID(), + Usage: client.Usage{TotalTokens: 100}, + }}) + next := updated.(Model) + if next.CompactionRunning { + t.Fatal("a focused subagent's usage crossing must never fire the root-history compaction") + } + if next.GetAgentState(next.Root.ID()).AutocompactDisarmed { + t.Fatal("the root must stay armed while only the subagent's usage crossed") + } + + // The ROOT agent's usage crossing fires — regardless of focus. + updatedRoot, cmd := m.Update(OrchestratorEventMsg{Event: common.ContentEvent{ + ID: m.Root.ID(), + Usage: client.Usage{TotalTokens: 100}, // 100% of the mock's 100-token context + }}) + next = updatedRoot.(Model) + if !next.CompactionRunning { + t.Fatal("root usage crossing must fire the compaction while a subagent is focused") + } + if cmd == nil { + t.Fatal("Update must return the compaction command") + } + rootState := next.GetAgentState(next.Root.ID()) + if !rootState.AutocompactDisarmed { + t.Fatal("firing must disarm the ROOT agent's state, not the focused subagent's") + } + if rootState.StatusText != "compacting context..." { + t.Fatalf("the compacting status must land on the root's state, got %q", rootState.StatusText) + } + + // Re-arm semantics live on the root: still disarmed above the threshold + // (the in-flight guard aside, one crossing fires once)... + m = next + rootState = m.GetAgentState(m.Root.ID()) + m.CompactionRunning = false + if cmd := m.maybeJevAutoCompact(rootState); cmd != nil { + t.Fatal("a crossing must fire only once") + } + // ...then usage falls below the re-arm level (99-9 = 90% → under 90 + // tokens): the root re-arms without firing, and the next crossing fires + // again. + rootState.CumulativeTokenCount = 50 + if cmd := m.maybeJevAutoCompact(rootState); cmd != nil { + t.Fatal("the re-arm update must not fire (usage below the threshold)") + } + if rootState.AutocompactDisarmed { + t.Fatal("root usage below the re-arm level must re-arm the trigger") + } + rootState.CumulativeTokenCount = 100 + if cmd := m.maybeJevAutoCompact(rootState); cmd == nil { + t.Fatal("a re-armed root crossing must fire again") + } +} + +// TestJevAutoCompactPerModelOverrideRootAgent pins the same resolution for +// the root agent: agentTypeForID("main") maps to "orchestrator", whose +// agent_models entry carries the override. +func TestJevAutoCompactPerModelOverrideRootAgent(t *testing.T) { + cfg := &config.Config{ + JevAutocompact: true, + JevAutocompactPercent: 99, + Models: []config.ModelSetting{ + {ID: "big-ctx", URL: "http://a:8080", Key: "k", Model: "model-a", JevAutocompactPercent: 80}, + }, + AgentModels: map[string]string{"orchestrator": "big-ctx"}, + } + m := NewModel(&mockOrchestrator{}, nil, cfg) + m.SetSize(80, 24) + m.Compactor = okRunner(session.CompactionReport{}) + // The trigger watches the ROOT agent: point Root (and Focused, as the + // real TUI does when the user is on the root tab) at the "main" ID that + // maps to "orchestrator" through agentTypeForID. + m.Root = &typedOrchestrator{mockOrchestrator{}, common.MainAgentID} + m.Focused = m.Root + + // 70 of 100 tokens is below the 80% override but would already have + // fired under the 99% global. + m.GetAgentState(m.Root.ID()).CumulativeTokenCount = 70 + if cmd := m.maybeJevAutoCompact(m.GetAgentState(m.Root.ID())); cmd != nil { + t.Fatal("80% root override must not fire at 70% usage") + } + m.GetAgentState(m.Root.ID()).CumulativeTokenCount = 80 + if cmd := m.maybeJevAutoCompact(m.GetAgentState(m.Root.ID())); cmd == nil { + t.Fatal("80% root override must fire at exactly 80% usage") + } +} + +// TestJevAutoCompactFallsBackToGlobalWithoutOverride pins the fallback: when +// the focused agent's model entry has no jev-autocompact-percent (or no +// entry applies at all), the trigger uses the global percent — 99 here, so +// 55% usage must not fire. +func TestJevAutoCompactFallsBackToGlobalWithoutOverride(t *testing.T) { + cfg := &config.Config{ + JevAutocompact: true, + JevAutocompactPercent: 99, + Models: []config.ModelSetting{ + {ID: "plain", URL: "http://a:8080", Key: "k", Model: "model-a"}, + }, + AgentModels: map[string]string{"researcher": "plain"}, + } + m := NewModel(&mockOrchestrator{}, nil, cfg) + m.SetSize(80, 24) + m.Compactor = okRunner(session.CompactionReport{}) + m.Focused = &typedOrchestrator{mockOrchestrator{}, "researcher-subagent-0"} + m.GetAgentState(m.Focused.ID()).CumulativeTokenCount = 55 + if cmd := m.maybeJevAutoCompact(m.GetAgentState(m.Focused.ID())); cmd != nil { + t.Fatal("without an override the global 99% must apply: no fire at 55%") + } + + // A config with models but no agent_models routing falls back too. + cfgNoRouting := &config.Config{ + JevAutocompact: true, + JevAutocompactPercent: 99, + Models: []config.ModelSetting{ + {ID: "overridden", URL: "http://a:8080", Key: "k", Model: "model-a", JevAutocompactPercent: 55}, + }, + } + m = NewModel(&mockOrchestrator{}, nil, cfgNoRouting) + m.SetSize(80, 24) + m.Compactor = okRunner(session.CompactionReport{}) + m.Focused = &typedOrchestrator{mockOrchestrator{}, "researcher-subagent-0"} + m.GetAgentState(m.Focused.ID()).CumulativeTokenCount = 55 + if cmd := m.maybeJevAutoCompact(m.GetAgentState(m.Focused.ID())); cmd != nil { + t.Fatal("an unrouted model's override must never apply: no fire at 55%") + } +} diff --git a/internal/tui/diagnostics_test.go b/internal/tui/diagnostics_test.go new file mode 100644 index 00000000..e4f905f8 --- /dev/null +++ b/internal/tui/diagnostics_test.go @@ -0,0 +1,147 @@ +package tui + +import ( + "strings" + "testing" + "time" + + "github.com/charmbracelet/x/ansi" + + "late/internal/client" +) + +// DiagnosticMsg carries mid-session diagnostics (hook timeouts, hook stderr, +// dropped-progress-event notices) that used to be fmt.Fprintf(os.Stderr, ...) +// writes painting raw text over the alt-screen. They must surface as a +// WARNING toast with a 6s expiry and the standard clear tick. + +func TestDiagnosticMsgShowsWarningToast(t *testing.T) { + m := NewModel(&mockOrchestrator{}, nil, nil) + m.SetSize(120, 30) + + before := time.Now().UnixMilli() + updated, _ := m.Update(DiagnosticMsg{Text: "late: 44 events dropped (consumer stalled)"}) + m = updated.(Model) + + if !m.ToastWarning { + t.Fatal("DiagnosticMsg must render as a warning toast") + } + if m.ToastMessage != "late: 44 events dropped (consumer stalled)" { + t.Fatalf("ToastMessage = %q", m.ToastMessage) + } + // 6s expiry (± scheduling slack). + if m.ToastExpireTime < before+5500 || m.ToastExpireTime > before+7000 { + t.Fatalf("ToastExpireTime = %d, want ~6s after %d", m.ToastExpireTime, before) + } + // cmd is always non-nil after Update (present() batches a frame tick), + // so the clear tick itself is exercised by the expiry above. + if !strings.Contains(ansi.Strip(m.statusBarView()), "events dropped") { + t.Fatal("toast text not rendered in the status bar") + } +} + +func TestDiagnosticMsgTruncatesLongText(t *testing.T) { + m := NewModel(&mockOrchestrator{}, nil, nil) + m.SetSize(40, 20) + + long := strings.Repeat("x", 200) + updated, _ := m.Update(DiagnosticMsg{Text: long}) + m = updated.(Model) + + if got := ansi.StringWidth(m.ToastMessage); got > 40 { + t.Fatalf("toast width = %d, want <= terminal width 40", got) + } + if !strings.HasSuffix(m.ToastMessage, "...") { + t.Fatalf("truncated toast %q must end with an ellipsis", m.ToastMessage) + } +} + +func TestDiagnosticMsgEmptyTextIgnored(t *testing.T) { + m := NewModel(&mockOrchestrator{}, nil, nil) + m.SetSize(120, 30) + + updated, _ := m.Update(DiagnosticMsg{Text: ""}) + m = updated.(Model) + + if m.ToastMessage != "" || m.ToastWarning { + t.Fatalf("empty diagnostic produced toast %q (warning=%v), want nothing", m.ToastMessage, m.ToastWarning) + } +} + +// TestStaleTickDoesNotClearNewerDiagnosticToast pins the overlap fix: hook +// diagnostics fire in bursts, and each toast schedules its own 6s clear tick. +// When a second toast replaces the first, the FIRST toast's tick still fires +// later — it must not clear the second toast early. The clear handler now +// ignores a clear while the current toast has not yet expired; only a tick +// arriving at/after the live toast's own expiry (or a direct clear with no +// live toast) clears it. +func TestStaleTickDoesNotClearNewerDiagnosticToast(t *testing.T) { + m := NewModel(&mockOrchestrator{}, nil, nil) + m.SetSize(120, 30) + + // First toast schedules its tick (cmd1 is not run — in production it + // fires 6s later, while newer toasts have replaced this one). + updated, cmd1 := m.Update(DiagnosticMsg{Text: "first diagnostic"}) + m = updated.(Model) + if cmd1 == nil { + t.Fatal("the first toast must schedule a clear tick") + } + + // A second diagnostic lands 1ms later (two hooks timing out together). + updated, cmd2 := m.Update(DiagnosticMsg{Text: "second diagnostic"}) + m = updated.(Model) + if m.ToastMessage != "second diagnostic" { + t.Fatalf("ToastMessage = %q, want the second toast", m.ToastMessage) + } + if cmd2 == nil { + t.Fatal("the second toast must schedule its own clear tick") + } + + // The stale tick from the FIRST toast fires now: the second toast must + // survive it (previously the unconditional clear killed it ~6s early). + updated, _ = m.Update(clearToastMsg{}) + m = updated.(Model) + if m.ToastMessage != "second diagnostic" { + t.Fatalf("stale tick cleared the newer toast: got %q", m.ToastMessage) + } + if !m.ToastWarning { + t.Fatal("the surviving toast must keep its warning styling") + } + + // Once the live toast's own expiry has passed (simulated by rewinding + // ToastExpireTime — the same wall-clock comparison the handler uses), a + // clear tick does clear it. + m.ToastExpireTime = time.Now().UnixMilli() - 1 + updated, _ = m.Update(clearToastMsg{}) + m = updated.(Model) + if m.ToastMessage != "" || m.ToastWarning { + t.Fatalf("expired toast not cleared: %q (warning=%v)", m.ToastMessage, m.ToastWarning) + } +} + +// TestToastMsgReplacesDiagnosticToastWithoutStaleClear pins the same +// guarantee across toast KINDS: the 8s 413-guidance ToastMsg can land while a +// 6s diagnostic toast is alive, and the diagnostic's stale tick must not +// truncate the guidance toast. +func TestToastMsgReplacesDiagnosticToastWithoutStaleClear(t *testing.T) { + m := NewModel(&mockOrchestrator{}, nil, nil) + m.SetSize(120, 30) + + updated, _ := m.Update(DiagnosticMsg{Text: "hook timed out"}) + m = updated.(Model) + + // 413 guidance arrives while the diagnostic toast is alive. + guidance := ToastMsg{Text: client.PayloadTooLargeGuidance, Warning: true, Duration: 8 * time.Second} + updated, _ = m.Update(guidance) + m = updated.(Model) + if m.ToastMessage != client.PayloadTooLargeGuidance { + t.Fatalf("ToastMessage = %q, want the 413 guidance", m.ToastMessage) + } + + // The diagnostic toast's stale 6s tick must not clear the guidance. + updated, _ = m.Update(clearToastMsg{}) + m = updated.(Model) + if m.ToastMessage != client.PayloadTooLargeGuidance { + t.Fatalf("stale diagnostic tick truncated the 413 guidance toast: got %q", m.ToastMessage) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 6b0d0501..51c62d06 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -75,31 +75,39 @@ func NewModel(root common.Orchestrator, renderer *glamour.TermRenderer, cfg *con } } + // JEV auto-compaction plumbing: ResolveAutocompact also normalizes an + // invalid configured percent back to the default. The warning it returns + // for an invalid value is surfaced once at startup by cmd/late/main.go; + // the TUI only needs the normalized values. + autocompactEnabled, autocompactPercent, _ := config.ResolveAutocompact(cfg) + m := Model{ - Mode: ViewChat, - Root: root, - Focused: root, - Input: ti, - Viewport: vp, - Renderer: renderer, - Width: 80, - Height: 24, // Default start height - AgentStates: make(map[string]*AppState), - InspectingTool: false, + Mode: ViewChat, + Root: root, + Focused: root, + Input: ti, + Viewport: vp, + Renderer: renderer, + Width: 80, + Height: 24, // Default start height + AgentStates: make(map[string]*AppState), + InspectingTool: false, Spinner: spinner.New(spinner.WithSpinner(spinner.Spinner{ Frames: spinner.Dot.Frames, FPS: 40 * time.Millisecond, })), - InputHistory: make([]string, 0), - HistoryIndex: -1, - CWD: cwd, - ShowCWD: true, - GitBranch: git.CurrentBranch(cwd), - cachedRendererWidth: -1, // Force first creation - Pastes: make(map[string]string), - AppConfig: cfg, - SelectedTheme: "default", - activeThemeStyles: LateTheme, + InputHistory: make([]string, 0), + HistoryIndex: -1, + CWD: cwd, + ShowCWD: true, + GitBranch: git.CurrentBranch(cwd), + cachedRendererWidth: -1, // Force first creation + Pastes: make(map[string]string), + AppConfig: cfg, + SelectedTheme: "default", + activeThemeStyles: LateTheme, + JevAutocompact: autocompactEnabled, + JevAutocompactPercent: autocompactPercent, } fp := filepicker.New() diff --git a/internal/tui/paste_test.go b/internal/tui/paste_test.go index 8d6a91f0..76a38cf7 100644 --- a/internal/tui/paste_test.go +++ b/internal/tui/paste_test.go @@ -56,6 +56,17 @@ func (m *mockOrchestrator) DrainQueuedMessages() []string { return q } +// typedOrchestrator is a mockOrchestrator pinned to a specific orchestrator +// ID, so tests can focus states whose ID maps to a specific agent type +// through agentTypeForID ("main" → orchestrator, "-subagent-" → +// ""). +type typedOrchestrator struct { + mockOrchestrator + id string +} + +func (m *typedOrchestrator) ID() string { return m.id } + type mockKey struct { code rune text string diff --git a/internal/tui/payload_recovery_test.go b/internal/tui/payload_recovery_test.go new file mode 100644 index 00000000..2e91b5e6 --- /dev/null +++ b/internal/tui/payload_recovery_test.go @@ -0,0 +1,354 @@ +package tui + +import ( + "context" + "fmt" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "late/internal/client" + "late/internal/common" + "late/internal/config" + "late/internal/session" +) + +// payloadTooLargeErr mirrors the real production error chain for an HTTP 413: +// the client wraps the StatusError in *PayloadTooLargeError (sentinel +// ErrPayloadTooLarge) and the executor wraps that in "stream error: %w". +func payloadTooLargeErr() error { + return fmt.Errorf("stream error: %w", &client.PayloadTooLargeError{ + Status: &client.StatusError{ + StatusCode: 413, + Status: "413 Payload Too Large", + Body: "Request body too large", + }, + }) +} + +// newPayloadRecoveryModel builds a model wired for 413 recovery tests: a +// counting Compactor stub and CompactionApplies (mode "enabled"). +func newPayloadRecoveryModel(runs *int) *Model { + m := NewModel(&mockOrchestrator{}, nil, &config.Config{}) + m.SetSize(80, 24) + m.Compactor = func(context.Context) (session.CompactionReport, error) { + *runs++ + return session.CompactionReport{TokensSaved: 400, SegmentsElided: 1}, nil + } + m.CompactionApplies = true + return &m +} + +// dispatchErrorEvent feeds one orchestrator error status event through Update +// and returns the updated model plus the command Bubble Tea would run. +func dispatchErrorEvent(t *testing.T, m *Model, agentID string, eventErr error) (*Model, tea.Cmd) { + t.Helper() + updated, cmd := m.Update(OrchestratorEventMsg{Event: common.StatusEvent{ + ID: agentID, + Status: "error", + Error: eventErr, + }}) + next, ok := updated.(Model) + if !ok { + t.Fatalf("Update returned %T, want tui.Model", updated) + } + return &next, cmd +} + +// collectMsgs flattens cmd's result, recursively expanding BatchMsg (the +// outer Update wraps the handler's commands in extra batches with the +// spinner/frame ticks, so children can be batches themselves). +func collectMsgs(msg tea.Msg, into *[]tea.Msg) { + if batch, ok := msg.(tea.BatchMsg); ok { + for _, child := range batch { + collectMsgs(child(), into) + } + return + } + *into = append(*into, msg) +} + +// drainCmd runs cmd and feeds every produced message back through Update +// exactly as Bubble Tea would. It reports whether a compactionResultMsg was +// among them. +func drainCmd(t *testing.T, m *Model, cmd tea.Cmd) bool { + t.Helper() + if cmd == nil { + return false + } + var msgs []tea.Msg + collectMsgs(cmd(), &msgs) + compacted := false + for _, child := range msgs { + if _, isResult := child.(compactionResultMsg); isResult { + compacted = true + } + followUp, _ := m.Update(child) + *m = followUp.(Model) + } + return compacted +} + +// runErrorEvent is dispatch + drain in one step. +func runErrorEvent(t *testing.T, m *Model, agentID string, eventErr error) bool { + t.Helper() + next, cmd := dispatchErrorEvent(t, m, agentID, eventErr) + *m = *next + return drainCmd(t, m, cmd) +} + +// TestPayloadRecoveryTriggersOnce pins the one-shot semantics: the first 413 +// on the root agent fires exactly one recovery compaction (with the recovery +// status while in flight); a second 413 on the same conversation must not +// start another run. +func TestPayloadRecoveryTriggersOnce(t *testing.T) { + runs := 0 + m := newPayloadRecoveryModel(&runs) + + // First 413: dispatch synchronously sets the guard, the in-flight flag, + // and the recovery status, and returns the compaction command. + next, cmd := dispatchErrorEvent(t, m, m.Focused.ID(), payloadTooLargeErr()) + m = next + s := m.GetAgentState(m.Focused.ID()) + if !m.CompactionRunning { + t.Fatal("the first 413 must start the recovery compaction") + } + if !s.PayloadRecoveryUsed { + t.Fatal("the first 413 must set the one-shot guard") + } + if s.StatusText != payloadRecoveryStatus { + t.Fatalf("StatusText = %q, want the recovery status %q", s.StatusText, payloadRecoveryStatus) + } + if cmd == nil { + t.Fatal("the first 413 must return the compaction command") + } + + // Drain the command (and the toast) like Bubble Tea would: the runner + // executes exactly once and its report clears the guard. + if !drainCmd(t, m, cmd) { + t.Fatal("the drained command must produce a compactionResultMsg") + } + if runs != 1 { + t.Fatalf("compaction runner invoked %d times, want 1", runs) + } + if m.CompactionRunning { + t.Fatal("the result message must clear the in-flight guard") + } + if !strings.Contains(m.GetAgentState(m.Focused.ID()).StatusText, "compacted: saved") { + t.Fatalf("StatusText = %q, want the compaction report", m.GetAgentState(m.Focused.ID()).StatusText) + } + + // Second 413 (provider still over the limit): no second run, no loop. + if compacted := runErrorEvent(t, m, m.Focused.ID(), payloadTooLargeErr()); compacted { + t.Fatal("the second 413 must not run another recovery compaction") + } + if runs != 1 { + t.Fatalf("compaction runner invoked %d times after the second 413, want 1 (recovery fires once per conversation)", runs) + } + if m.CompactionRunning { + t.Fatal("the second 413 must not start another compaction run") + } + if !m.GetAgentState(m.Focused.ID()).PayloadRecoveryUsed { + t.Fatal("the one-shot guard must stay set until /new") + } +} + +// TestPayloadRecoveryReArmsOnNew pins the /new re-arm: a fresh conversation +// clears PayloadRecoveryUsed, so a later 413 can fire the recovery once +// again. +func TestPayloadRecoveryReArmsOnNew(t *testing.T) { + runs := 0 + m := newPayloadRecoveryModel(&runs) + + if compacted := runErrorEvent(t, m, m.Focused.ID(), payloadTooLargeErr()); !compacted { + t.Fatal("the first 413 must run the recovery compaction") + } + if runs != 1 { + t.Fatalf("compaction runner invoked %d times, want 1", runs) + } + + // /new starts a fresh conversation and re-arms the trigger. + m.Input.SetValue("/new") + *m = pressEnter(t, *m) + if m.GetAgentState(m.Focused.ID()).PayloadRecoveryUsed { + t.Fatal("/new must clear the one-shot payload-recovery guard") + } + + if compacted := runErrorEvent(t, m, m.Focused.ID(), payloadTooLargeErr()); !compacted { + t.Fatal("after /new the next 413 must run the recovery compaction again") + } + if runs != 2 { + t.Fatalf("compaction runner invoked %d times after /new, want 2", runs) + } +} + +// TestPayloadRecoveryGuards pins every inert combination: the recovery must +// never fire without an actionable compaction (mode enabled + runner), for +// non-413 errors, while a compaction is already running, or for a non-root +// agent (the recovery always compacts the ROOT session). +func TestPayloadRecoveryGuards(t *testing.T) { + t.Run("shadow mode never triggers", func(t *testing.T) { + runs := 0 + m := newPayloadRecoveryModel(&runs) + m.CompactionApplies = false + + if compacted := runErrorEvent(t, m, m.Focused.ID(), payloadTooLargeErr()); compacted { + t.Fatal("shadow mode (report-only) must not fire the recovery") + } + if runs != 0 || m.CompactionRunning || m.GetAgentState(m.Focused.ID()).PayloadRecoveryUsed { + t.Fatal("shadow mode must not start a run or set the guard") + } + }) + + t.Run("nil Compactor never triggers", func(t *testing.T) { + runs := 0 + m := newPayloadRecoveryModel(&runs) + m.Compactor = nil + + if compacted := runErrorEvent(t, m, m.Focused.ID(), payloadTooLargeErr()); compacted { + t.Fatal("a nil Compactor must not fire the recovery") + } + if m.CompactionRunning { + t.Fatal("a nil Compactor must not set the in-flight guard") + } + }) + + t.Run("non-413 error never triggers", func(t *testing.T) { + runs := 0 + m := newPayloadRecoveryModel(&runs) + + non413 := fmt.Errorf("stream error: %w", &client.StatusError{StatusCode: 500, Body: "boom"}) + if compacted := runErrorEvent(t, m, m.Focused.ID(), non413); compacted { + t.Fatal("a non-413 error must not fire the recovery") + } + if runs != 0 || m.GetAgentState(m.Focused.ID()).PayloadRecoveryUsed { + t.Fatal("a non-413 error must not start a run or set the guard") + } + }) + + t.Run("subagent 413 never triggers", func(t *testing.T) { + runs := 0 + m := newPayloadRecoveryModel(&runs) + + if compacted := runErrorEvent(t, m, "subagent-1", payloadTooLargeErr()); compacted { + t.Fatal("a subagent's 413 must not fire the ROOT-session recovery") + } + if runs != 0 || m.CompactionRunning { + t.Fatal("a subagent's 413 must not start a run") + } + }) + + t.Run("compaction already running never triggers", func(t *testing.T) { + runs := 0 + m := newPayloadRecoveryModel(&runs) + m.CompactionRunning = true // a manual command is in flight + + if compacted := runErrorEvent(t, m, m.Focused.ID(), payloadTooLargeErr()); compacted { + t.Fatal("a 413 while a compaction is in flight must not start another") + } + if runs != 0 { + t.Fatalf("compaction runner invoked %d times, want 0", runs) + } + }) +} + +// TestPayloadRecoveryToastOn413 pins the warning-toast path (FIX 1b): the +// 413 error surfaces the recovery guidance as a warning toast in addition to +// the error box, which renders the same text. +func TestPayloadRecoveryToastOn413(t *testing.T) { + runs := 0 + m := newPayloadRecoveryModel(&runs) + + next, cmd := dispatchErrorEvent(t, m, m.Focused.ID(), payloadTooLargeErr()) + if cmd == nil { + t.Fatal("the 413 error must return a command (toast and/or compaction)") + } + + var toasts []ToastMsg + var msgs []tea.Msg + collectMsgs(cmd(), &msgs) + for _, msg := range msgs { + if toast, ok := msg.(ToastMsg); ok { + toasts = append(toasts, toast) + } + } + + found := false + for _, toast := range toasts { + if strings.Contains(toast.Text, client.PayloadTooLargeGuidance) && toast.Warning { + found = true + } + } + if !found { + t.Fatalf("toasts %+v, want a warning toast carrying the recovery guidance", toasts) + } + _ = next +} + +// TestCompactionSuccessStatusCarriesEstimateSuffix pins the counter-honesty +// suffix (FIX 3): after a mutating run the token bar was recomputed by the +// LOCAL estimator while steady state tracks the provider-reported usage, so +// the success status must say the next request's usage refreshes the bar. +func TestCompactionSuccessStatusCarriesEstimateSuffix(t *testing.T) { + const wantSuffix = "… (estimate — the next request's usage refreshes the bar)" + + m := NewModel(&mockOrchestrator{}, nil, &config.Config{}) + m.SetSize(80, 24) + m.CompactionRunning = true + + result := compactionResultMsg{report: session.CompactionReport{ + TokensSaved: 25, SegmentsElided: 2, MessagesScanned: 4, MessagesScored: 3, + }} + updated, _ := m.Update(result) + next := updated.(Model) + + got := next.GetAgentState(next.Focused.ID()).StatusText + if !strings.HasSuffix(got, wantSuffix) { + t.Fatalf("StatusText = %q, want it to end with %q", got, wantSuffix) + } + if !strings.Contains(got, "compacted: saved ~25 tokens") { + t.Fatalf("StatusText = %q, want the saved report before the suffix", got) + } +} + +// TestRewindReArmsPayloadRecovery pins the rewind re-arm: a rewind rewrote +// the focused agent's history, so the one-shot 413 recovery must re-arm for +// it exactly like /new does. Without this, a conversation that already burned +// its recovery pass keeps a later 413 unrecoverable even after the user +// rolled back to a smaller history. +func TestRewindReArmsPayloadRecovery(t *testing.T) { + runs := 0 + m := newPayloadRecoveryModel(&runs) + + // Burn the one shot on a first 413. + if compacted := runErrorEvent(t, m, m.Focused.ID(), payloadTooLargeErr()); !compacted { + t.Fatal("the first 413 must run the recovery compaction") + } + if !m.GetAgentState(m.Focused.ID()).PayloadRecoveryUsed { + t.Fatal("precondition: the one-shot guard must be set after the first 413") + } + + // Rewind the focused (root) agent: the guard must clear. + m.RewindEntries = []RewindEntry{{Index: 0, Content: "earlier message"}} + m.RewindIndex = 0 + m.Mode = ViewRewind + m.Input.SetValue("") + updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) + *m = updated.(Model) + + if m.Mode != ViewChat { + t.Fatalf("Mode = %v, want ViewChat after the rewind", m.Mode) + } + if m.GetAgentState(m.Focused.ID()).PayloadRecoveryUsed { + t.Fatal("rewind must re-arm the one-shot 413 payload-recovery guard") + } + + // And the re-armed trigger really fires again on the next 413. + if compacted := runErrorEvent(t, m, m.Focused.ID(), payloadTooLargeErr()); !compacted { + t.Fatal("after a rewind the next 413 must run the recovery compaction again") + } + if runs != 2 { + t.Fatalf("compaction runner invoked %d times, want 2 (once before, once after the rewind)", runs) + } +} diff --git a/internal/tui/state.go b/internal/tui/state.go index c7757015..42612c66 100644 --- a/internal/tui/state.go +++ b/internal/tui/state.go @@ -6,6 +6,7 @@ import ( "late/internal/common" "late/internal/config" "late/internal/git" + "late/internal/session" "strings" "time" @@ -60,6 +61,7 @@ type CommandDef struct { var AvailableCommands = []CommandDef{ {Name: "/compose", Description: "Compose a message with an editor"}, {Name: "/help", Description: "Show help and shortcuts"}, + {Name: "/jev-compact-context", Description: "Compact the conversation context with Jev"}, {Name: "/log", Description: "View git commit log"}, {Name: "/model", Description: "Select AI model for agents"}, {Name: "/new", Description: "Start fresh conversation"}, @@ -150,8 +152,33 @@ type AppState struct { // status as a silent safety net; recovery is announced separately by // the dedicated RecoveryEvent. Empty means the agent is not retrying. RetryVerb string + + // AutocompactDisarmed records that the JEV auto-compaction trigger + // already fired for the root agent's current crossing of the threshold + // (the trigger watches the ROOT agent's state only — the compaction it + // fires always rewrites the root session's history). It re-arms (clears) + // once root usage falls below (percent-9)% of the context window — + // typically right after a compaction shrank the history — or when /new + // starts a fresh conversation. + AutocompactDisarmed bool + + // PayloadRecoveryUsed records that the one-shot 413 payload-recovery + // compaction already ran for this agent's conversation: when the root + // agent's request fails with the payload-too-large sentinel, the TUI + // triggers exactly one compaction pass, then waits for the user (or the + // next turn) rather than spinning a compact→retry→413 loop. /new clears + // it together with AutocompactDisarmed. + PayloadRecoveryUsed bool } +// CompactionRunner runs one full-history context-compaction pass on the +// session behind the TUI: session.CompactContext with the compaction +// pipeline's scoring client and the shared elide store, persisting the +// mutated history when the run mutates (shadow runs only report). +// cmd/late/main.go wires the live session; tests inject stubs. A nil +// Model.Compactor means compaction is unavailable. +type CompactionRunner func(ctx context.Context) (session.CompactionReport, error) + type Model struct { cachedScreen tea.View screenReady bool @@ -189,6 +216,33 @@ type Model struct { TodoPaneFocused bool TodoScrollOffset int + // JEV history compaction (the /jev-compact-context command and the + // auto-trigger). Compactor is nil when compaction is unavailable — + // compaction-mode off, or no System One backend resolved — and the + // command then reports the unavailable status instead of running. + Compactor CompactionRunner + + // JevAutocompact enables the auto-trigger; JevAutocompactPercent is the + // context-usage percentage that fires it (config jev-autocompact + + // jev-autocompact-percent, resolved via config.ResolveAutocompact in + // NewModel). + JevAutocompact bool + JevAutocompactPercent int + + // CompactionRunning is the shared in-flight guard: exactly one + // CompactContext run (manual command or auto-trigger) may execute at a + // time. Set when the run is dispatched, cleared by compactionResultMsg. + CompactionRunning bool + + // CompactionApplies reports that the session's compaction mode is + // "enabled" — the mode whose runs actually rewrite (shrink) history. + // The 413 payload-recovery trigger only fires behind it: a shadow-mode + // run would only report what a compaction would save, which cannot + // recover a request the provider already rejected for size. Wired from + // cmd/late/main.go (the resolved mode after its shadow fallback); + // tests set it directly. + CompactionApplies bool + // Double-click copy & Toast tracking LastClickX int LastClickY int @@ -442,6 +496,18 @@ type BootstrapStatusMsg struct { NextToast *ToastMsg } +// DiagnosticMsg carries a mid-session diagnostic line into the TUI update +// loop as a warning toast. It replaces the raw fmt.Fprintf(os.Stderr, ...) +// writes that would paint text over the alt-screen: without this sink those +// lines garble the footer status line and visually replace the agent-name +// row. The compaction pipeline's warning sink (Pipeline.SetWarningSink) and +// the retrieval-skip warning route here through main's diag closure when the +// program is live; without a sink installed they fall back to os.Stderr +// (CLI flows, tests). +type DiagnosticMsg struct { + Text string +} + // FindOrchestrator recursively searches for an orchestrator by ID. func (m *Model) FindOrchestrator(id string) common.Orchestrator { var search func(curr common.Orchestrator) common.Orchestrator diff --git a/internal/tui/update.go b/internal/tui/update.go index 892d1957..ab24d7b6 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -9,6 +9,7 @@ import ( "late/internal/common" "late/internal/config" "late/internal/git" + "late/internal/session" "math/rand/v2" "net/http" "os" @@ -52,6 +53,20 @@ type pluginCommandResultMsg struct { err error } +// compactionUnavailableStatus is the status shown when /jev-compact-context +// fires with no compaction pipeline wired (compaction-mode off, or the +// System One backend never resolved, so there is no scorer to run). +const compactionUnavailableStatus = "compaction unavailable — enable compaction-mode first" + +// compactionResultMsg carries the outcome of one full-history context +// compaction run (/jev-compact-context or the auto-trigger). The run +// executes off the TUI update loop — network scoring can take seconds — so +// the report is delivered back as a message. +type compactionResultMsg struct { + report session.CompactionReport + err error +} + // messageHookResultMsg carries the outcome of asynchronously running a // plugin's onMessageSend hooks (PluginManager.HookedMessage). Each hook // script runs off the TUI update loop — a slow or misbehaving script must @@ -152,6 +167,17 @@ func (m Model) updateInternal(msg tea.Msg) (Model, tea.Cmd) { } if _, ok := msg.(clearToastMsg); ok { + // Stale expiry tick: toasts overlap often (hook diagnostics fire in + // bursts, the 413 guidance toast lasts 8s), and a tea.Tick scheduled + // by the PREVIOUS toast can land while a NEWER toast is still alive. + // Clearing unconditionally let that old tick kill the new toast + // early. Every toast-set rewrites ToastExpireTime, so while "now" is + // still before that expiry, the arriving tick cannot be the one the + // current toast scheduled — ignore it; the current toast owns its + // own clear tick. + if m.ToastMessage != "" && time.Now().UnixMilli() < m.ToastExpireTime { + return m, nil + } m.ToastMessage = "" m.ToastWarning = false m.updateViewport() @@ -380,6 +406,54 @@ func (m Model) updateInternal(msg tea.Msg) (Model, tea.Cmd) { m = m.finishSubmit(msg.target, msg.input) return m, nil } + if msg, ok := msg.(compactionResultMsg); ok { + // Exactly one compaction run may be in flight (Model.CompactionRunning + // guards both the manual command and the auto-trigger); it just ended. + m.CompactionRunning = false + if !msg.report.ShadowOnly { + // The walk rewrote (possibly partially, on a mid-walk scorer + // failure) the root session's history: invalidate the root + // state's render and token caches so the transcript re-renders + // from the compacted messages and the context bar reflects the + // new size — the same bookkeeping the rewind path does. + if rootState, ok := m.AgentStates[m.Root.ID()]; ok { + rootState.Transcript.generation++ + rootState.Transcript.dirty = true + rootState.RenderedHistory = nil + rootState.LastTotalContent = "" + rootState.CachedHistoryLen = 0 + rootState.CachedHistoryTokens = 0 + rootState.CumulativeTokenCount = common.CalculateHistoryTokens( + m.Root.History(), + m.Root.SystemPrompt(), + m.Root.ToolDefinitions(), + ) + } + } + // The status lands on whichever agent the user is looking at; the + // compaction itself always targets the root session. + s := m.GetAgentState(m.Focused.ID()) + switch { + case msg.err != nil: + s.StatusText = fmt.Sprintf("compaction failed after scoring %d/%d messages: %v", + msg.report.MessagesScored, msg.report.MessagesScanned, msg.err) + case msg.report.ShadowOnly: + s.StatusText = fmt.Sprintf("shadow report: would save ~%d tokens, %d segments elided (scored %d/%d messages; enable compaction-mode to apply)", + msg.report.TokensSaved, msg.report.SegmentsElided, + msg.report.MessagesScored, msg.report.MessagesScanned) + default: + // The suffix is honesty, not decoration: the count above was just + // recomputed locally (CalculateHistoryTokens), while steady state + // tracks the provider-reported usage — the two estimators + // disagree, and the bar may jump once the next request's real + // usage arrives. + s.StatusText = fmt.Sprintf("compacted: saved ~%d tokens, %d segments elided (scored %d/%d messages)… (estimate — the next request's usage refreshes the bar)", + msg.report.TokensSaved, msg.report.SegmentsElided, + msg.report.MessagesScored, msg.report.MessagesScanned) + } + m.updateViewport() + return m, nil + } // Snapshot state before updateChat processes the key and potentially changes it var stateBefore ValidationState @@ -836,6 +910,12 @@ func (m Model) updateChat(msg tea.Msg) (Model, tea.Cmd) { m.Focused.SystemPrompt(), m.Focused.ToolDefinitions(), ) + // A rewind rewrote this agent's history: re-arm the + // one-shot 413 payload-recovery compaction, exactly like + // /new does. Without this, a conversation that already + // burned its recovery pass keeps a 413 permanent even + // after the user rolled back to a smaller history. + focusedState.PayloadRecoveryUsed = false m.ToastMessage = "conversation rewound" m.ToastWarning = false @@ -1092,6 +1172,30 @@ func (m Model) updateChat(msg tea.Msg) (Model, tea.Cmd) { m.updateLayout() return m, nil } + if cmd == "/jev-compact-context" { + m.Input.Reset() + m.Input.SetValue("") + m.ShowAutocomplete = false + m.AutocompleteItems = nil + m.AutocompleteIndex = 0 + // Compaction needs the pipeline's scorer and store; without + // them (compaction-mode off / no backend) there is nothing + // to run. + if m.Compactor == nil { + focusedState.StatusText = compactionUnavailableStatus + m.updateViewport() + return m, nil + } + if m.CompactionRunning { + focusedState.StatusText = "compaction already running" + m.updateViewport() + return m, nil + } + m.CompactionRunning = true + focusedState.StatusText = "compacting context..." + m.updateViewport() + return m, m.startCompaction() + } if cmd == "/model" { m.Input.Reset() m.Input.SetValue("") @@ -1165,6 +1269,11 @@ func (m Model) updateChat(msg tea.Msg) (Model, tea.Cmd) { state.CachedHistoryLen = 0 state.CachedHistoryTokens = 0 state.LastTotalContent = "" + // A fresh conversation re-arms the JEV auto-compaction + // trigger for every agent. + state.AutocompactDisarmed = false + // ...and the one-shot 413 payload-recovery compaction. + state.PayloadRecoveryUsed = false } m.LastFocusedID = "" m.Viewport.GotoTop() @@ -1459,7 +1568,14 @@ func (m Model) updateChat(msg tea.Msg) (Model, tea.Cmd) { m.ToastMessage = msg.Text m.ToastWarning = msg.Warning m.ToastExpireTime = time.Now().UnixMilli() + 3000 - return m, func() tea.Msg { return clearToastMsg{} } + // The toast carries its own expiry above: schedule the clear tick + // instead of clearing on the next loop iteration. The old + // immediate-clear command erased the toast the moment Bubble Tea ran + // the command — one rendered frame — making the toast invisible even + // though the expiry was set for 3s. + return m, tea.Tick(3*time.Second, func(t time.Time) tea.Msg { + return clearToastMsg{} + }) case ToastMsg: m.ToastMessage = msg.Text @@ -1492,6 +1608,30 @@ func (m Model) updateChat(msg tea.Msg) (Model, tea.Cmd) { } return m, nil + case DiagnosticMsg: + // Mid-session diagnostics (hook timeouts/errors, dropped-progress- + // event notices) surface as a WARNING toast instead of raw stderr + // writes, which would paint text over the alt-screen. A toast — + // unlike a stderr line — cannot duplicate or displace the footer + // status row. Empty text is ignored (nothing to report). + if msg.Text == "" { + return m, nil + } + text := msg.Text + // The toast lives in the status bar; a long hook error must not + // wrap or overflow it. Truncate to the terminal width with the + // same ellipsis helper the bar itself uses at render time. + if m.Width > 0 { + text = m.truncateWithEllipsis(text, m.Width) + } + m.ToastMessage = text + m.ToastWarning = true + m.ToastExpireTime = time.Now().UnixMilli() + 6000 + m.updateViewport() + return m, tea.Tick(6*time.Second, func(t time.Time) tea.Msg { + return clearToastMsg{} + }) + case OrchestratorEventMsg: s := m.GetAgentState(msg.Event.OrchestratorID()) // restoredToast delivers the recovery toast through the existing @@ -1499,6 +1639,16 @@ func (m Model) updateChat(msg tea.Msg) (Model, tea.Cmd) { // the retried attempt actually produced a response; it is returned // after the event switch below. var restoredToast tea.Cmd + // autoCompactCmd fires when the focused agent's usage update crosses + // the JEV auto-compaction threshold (see maybeJevAutoCompact); it is + // returned after the event switch below. + var autoCompactCmd tea.Cmd + // payloadToastCmd surfaces the 413 payload-too-large guidance as a + // warning toast (the error box already carries the full text). + var payloadToastCmd tea.Cmd + // payloadRecoveryCmd runs the one-shot 413 recovery compaction (see + // maybePayloadRecoveryCompaction). + var payloadRecoveryCmd tea.Cmd switch event := msg.Event.(type) { case common.ContentEvent: @@ -1551,6 +1701,16 @@ func (m Model) updateChat(msg tea.Msg) (Model, tea.Cmd) { if event.ID == m.Focused.ID() { m.updateViewport() } + // JEV auto-compaction trigger — the same flow as + // /jev-compact-context, fired when the ROOT agent's just-updated + // usage crosses the configured share of the context window, + // regardless of which agent is focused: the compaction pass + // always rewrites the root session's history, so only root + // usage can drive the trigger (a focused subagent's usage + // crossing is its own context, never compacted here). + if event.ID == m.Root.ID() { + autoCompactCmd = m.maybeJevAutoCompact(m.GetAgentState(m.Root.ID())) + } case common.StatusEvent: switch event.Status { case "thinking": @@ -1598,6 +1758,23 @@ func (m Model) updateChat(msg tea.Msg) (Model, tea.Cmd) { } else { s.StatusText = fmt.Sprintf("Error: %v", event.Error) s.Error = event.Error + // A 413 (payload too large) is actionable, not just + // fatal: the provider rejected the request body + // outright, so the error text itself carries the + // recovery guidance. The error box renders it above; + // the warning toast repeats it where it stays readable + // for a few seconds. + if errors.Is(event.Error, client.ErrPayloadTooLarge) { + payloadToastCmd = payloadTooLargeToastCmd() + // One-shot recovery: compact once so the next + // request can fit. Only the ROOT agent triggers it — + // the recovery always compacts the root session, and + // a subagent's 413 is not fixed by rewriting root + // history. + if event.ID == m.Root.ID() { + payloadRecoveryCmd = m.maybePayloadRecoveryCompaction(s) + } + } } // A turn that ended in error must not produce a recovery // toast on the next turn. @@ -1689,8 +1866,24 @@ func (m Model) updateChat(msg tea.Msg) (Model, tea.Cmd) { } } + // Compose every command the event produced. tea.Batch drops nils and + // returns nil when the list is empty, so the fall-through to the + // generic tail below is preserved when nothing fired. + var eventCmds []tea.Cmd if restoredToast != nil { - return m, restoredToast + eventCmds = append(eventCmds, restoredToast) + } + if autoCompactCmd != nil { + eventCmds = append(eventCmds, autoCompactCmd) + } + if payloadRecoveryCmd != nil { + eventCmds = append(eventCmds, payloadRecoveryCmd) + } + if payloadToastCmd != nil { + eventCmds = append(eventCmds, payloadToastCmd) + } + if len(eventCmds) > 0 { + return m, tea.Batch(eventCmds...) } case ConfirmRequestMsg: @@ -1705,6 +1898,126 @@ func (m Model) updateChat(msg tea.Msg) (Model, tea.Cmd) { return m, nil } +// startCompaction returns the tea.Cmd that runs one full-history compaction +// pass off the TUI update loop (network scoring can take seconds) and +// delivers the outcome as a compactionResultMsg. The caller must have set +// Model.CompactionRunning — the shared in-flight guard — beforehand; a nil +// Compactor (compaction unavailable) yields a nil command. +func (m *Model) startCompaction() tea.Cmd { + runner := m.Compactor + if runner == nil { + return nil + } + return func() tea.Msg { + report, err := runner(context.Background()) + return compactionResultMsg{report: report, err: err} + } +} + +// autocompactRearmPoints is how far below the trigger percentage usage must +// fall before the JEV auto-compaction trigger re-arms: after one crossing +// fires, the agent stays disarmed until its usage drops under +// (percent-9)% — typically because a compaction just shrank the history — +// or /new starts a fresh conversation. +const autocompactRearmPoints = 9 + +// maybeJevAutoCompact returns the tea.Cmd that runs one full-history +// compaction pass when the ROOT agent's usage has crossed the configured +// percentage (config jev-autocompact + jev-autocompact-percent, default 99) +// of the context window — the same m.Root.MaxTokens() source the info bar's +// context bar uses. The trigger always watches the root session, whichever +// agent is focused: the compaction pass rewrites the root session's history +// (the Compactor runs session.CompactContext on it), so a focused +// subagent's usage crossing must not fire it. The percent keys off the +// watched root agent's model: that is whose context window fills, so a +// per-model jev-autocompact-percent override inside the root's models[] +// entry (resolved by Config.AutocompactPercentForAgent through +// agent_models) wins over the global threshold. The run is guarded by +// Model.CompactionRunning and fires once per crossing: the root agent is +// disarmed until usage falls below the re-arm level or a new session +// starts. Without a known context size (MaxTokens() <= 0) there is no +// threshold to cross, so the trigger skips silently. s must be the root +// agent's state (the trigger's usage source, disarm flag, and status text +// all live there). +func (m *Model) maybeJevAutoCompact(s *AppState) tea.Cmd { + if !m.JevAutocompact || m.Compactor == nil || m.CompactionRunning { + return nil + } + maxTokens := m.Root.MaxTokens() + if maxTokens <= 0 { + // Unknown context size (client.ContextSize -1, or unlimited): the + // threshold is undefined — skip silently. + return nil + } + // The global percent (validated at startup by ResolveAutocompact) is the + // fallback; the root agent's model entry can override it. A nil + // AppConfig has no model registry, so only the global applies there. + percent := m.JevAutocompactPercent + if m.AppConfig != nil { + percent = m.AppConfig.AutocompactPercentForAgent(agentTypeForID(m.Root.ID()), m.JevAutocompactPercent) + } + if percent <= 0 || percent > 100 { + percent = config.DefaultJevAutocompactPercent + } + if s.AutocompactDisarmed { + // Re-arm once usage falls below (percent-9)% again. A percent + // below the re-arm margin resolves to a negative level, which + // usage (always >= 0) can never cross: the trigger stays disarmed. + if s.CumulativeTokenCount < maxTokens*(percent-autocompactRearmPoints)/100 { + s.AutocompactDisarmed = false + } + return nil + } + if s.CumulativeTokenCount < maxTokens*percent/100 { + return nil + } + m.CompactionRunning = true + s.AutocompactDisarmed = true + s.StatusText = "compacting context..." + return m.startCompaction() +} + +// payloadRecoveryStatus is the status shown while the one-shot 413 recovery +// compaction runs. +const payloadRecoveryStatus = "request too large — compacting context (recovery)…" + +// payloadTooLargeToastCmd builds the warning-toast command for a 413 +// failure: the recovery guidance travels on the error text (rendered by the +// error box), but the status bar truncates, so the toast repeats it where it +// stays readable. The ToastMsg handler owns the expiry tick. +func payloadTooLargeToastCmd() tea.Cmd { + return func() tea.Msg { + return ToastMsg{ + Text: client.PayloadTooLargeGuidance, + Warning: true, + Duration: 8 * time.Second, + } + } +} + +// maybePayloadRecoveryCompaction returns the tea.Cmd that runs ONE +// full-history compaction pass after the root agent's request failed with +// the payload-too-large (413) sentinel: the provider rejected the request +// body outright, so the context must shrink before the same request can +// succeed. It reuses the shared CompactionRunning in-flight guard and fires +// at most once per conversation — PayloadRecoveryUsed (reset by /new) keeps +// a provider that still rejects after a compaction from spinning a +// compact→retry→413 loop. The trigger is inert unless compaction can +// actually shrink history (CompactionApplies: mode "enabled") or is already +// running. s must be the failing (root) agent's state. +func (m *Model) maybePayloadRecoveryCompaction(s *AppState) tea.Cmd { + if m.Compactor == nil || !m.CompactionApplies || m.CompactionRunning { + return nil + } + if s.PayloadRecoveryUsed { + return nil + } + s.PayloadRecoveryUsed = true + m.CompactionRunning = true + s.StatusText = payloadRecoveryStatus + return m.startCompaction() +} + // submitMessage runs the full "user pressed Enter" pipeline for a message: // preflight context warning, attachment re-validation, paste expansion, // plugin message hooks (onMessageSend), input history, and orchestrator diff --git a/internal/tui/view.go b/internal/tui/view.go index 8f7bf367..e50207a2 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -525,6 +525,20 @@ func formatAgentBreadcrumb(id string) string { return id } +// agentTypeForID maps an orchestrator ID to the agent type used by +// config.AgentModels lookups: the root agent ("main") maps to "orchestrator" +// and "-subagent-" (the NextChildID scheme) maps to "". +// Unrecognized IDs return "" (no config lookup possible). +func agentTypeForID(id string) string { + if id == "" || id == common.MainAgentID { + return "orchestrator" + } + if idx := strings.Index(id, "-subagent-"); idx > 0 { + return id[:idx] + } + return "" +} + func (m *Model) statusBarView() string { w := max(m.Width, 1)