diff --git a/benchmarks/compaction/main.go b/benchmarks/compaction/main.go index 4fe2ce3fda..a159c22135 100644 --- a/benchmarks/compaction/main.go +++ b/benchmarks/compaction/main.go @@ -42,7 +42,7 @@ func main() { window := flag.Int("window", 128_000, "context window in tokens") control := flag.Bool("control", true, "fidelity: also score probes against full history") arm := flag.String("arm", "full", "full | incremental: re-derive each digest from canonical, or fold the previous projection") - snip := flag.Bool("snip", false, "run the stale-tool-result snip pass before each fold, as the 0.6 threshold does in a real session") + snip := flag.Bool("snip", false, "legacy no-op: automatic snip projections are gone; kept so old scripts do not fail") out := flag.String("out", "", "write the JSON report here") flag.Parse() @@ -143,6 +143,8 @@ func (h *harness) runGeneration(ctx context.Context, gen int, probes []probe) ge h.calls.reset() start := time.Now() if h.snip { + // SnipStaleToolResults is intentionally a no-op; record zeros for + // report schema compatibility with pre-content-driven baselines. st, serr := h.agentA.SnipStaleToolResults() if serr != nil { r.Error = serr.Error() @@ -161,7 +163,11 @@ func (h *harness) runGeneration(ctx context.Context, gen int, probes []probe) ge } if st, ok, sterr := agent.LoadCompactionState(h.path); sterr == nil && ok { r.ProjectionTokens = st.Projection.ProjectionTokens - r.Mode = st.LastMode + if st.LastReceipt != nil && st.LastReceipt.Action == "summary" { + r.Mode = agent.CompactionModeSummarized + } else if st.LastMode != "" { + r.Mode = st.LastMode + } } return r } diff --git a/benchmarks/compaction/snip_test.go b/benchmarks/compaction/snip_test.go index 5cae4c5708..15544874a4 100644 --- a/benchmarks/compaction/snip_test.go +++ b/benchmarks/compaction/snip_test.go @@ -5,34 +5,9 @@ import ( "testing" ) -// The buried-evidence probe only measures something if its planted fact sits -// outside the head/tail window the snip geometry keeps. Guard the probe design -// against a later edit that shortens the log back into reach. -func TestBuriedFactSitsOutsideSnipGeometry(t *testing.T) { - const ( - head = 80 // defaultReadOnlySnip.head - tail = 12 // defaultReadOnlySnip.tail - ) - lines := strings.Split(buriedTestLog(), "\n") - idx := -1 - for i, line := range lines { - if strings.Contains(line, "beta-7d21") { - idx = i - break - } - } - if idx < 0 { - t.Fatal("buried fact absent from the planted log") - } - if idx < head || idx >= len(lines)-tail { - t.Errorf("fact at line %d of %d is inside the kept head(%d)/tail(%d); snipping would preserve it and the probe would measure nothing", idx, len(lines), head, tail) - } -} - -// A tool result the host recorded as failed outranks the snip geometry: the -// assertion detail sits where head/tail cannot reach it, so it survives only -// because the keep policy reads the execution record rather than the text. -func TestSnipKeepsRecordedFailure(t *testing.T) { +// Automatic snip projections are gone. SnipStaleToolResults remains as a no-op +// so older harness flags and call sites do not panic or rewrite history. +func TestSnipIsNoOp(t *testing.T) { dir, cleanup, err := tempDir() if err != nil { t.Fatal(err) @@ -47,20 +22,20 @@ func TestSnipKeepsRecordedFailure(t *testing.T) { } before := renderAll(h.sess.Snapshot()) if !strings.Contains(before, "beta-7d21") { - t.Fatal("planted fact missing before the snip pass") + t.Fatal("planted fact missing before the snip call") } st, err := h.agentA.SnipStaleToolResults() if err != nil { t.Fatal(err) } - if st.Results == 0 { - t.Fatal("snip pass rewrote nothing") + if st.Results != 0 || st.SavedChars != 0 { + t.Fatalf("snip no-op rewrote history: results=%d chars=%d", st.Results, st.SavedChars) } - visible, err := visibleContext(h.path, h.sess) - if err != nil { - t.Fatal(err) + after := renderAll(h.sess.Snapshot()) + if after != before { + t.Fatal("canonical transcript changed after SnipStaleToolResults") } - if !strings.Contains(renderAll(visible), "beta-7d21") { - t.Errorf("recorded failure was snipped away (%d results, %d chars); keep policy did not protect it", st.Results, st.SavedChars) + if !strings.Contains(after, "beta-7d21") { + t.Error("planted fact missing after snip no-op") } } diff --git a/benchmarks/context-maintenance-e2e/main.go b/benchmarks/context-maintenance-e2e/main.go index ac553392bc..40af557abd 100644 --- a/benchmarks/context-maintenance-e2e/main.go +++ b/benchmarks/context-maintenance-e2e/main.go @@ -1,5 +1,6 @@ -// Drives the context-maintenance E2E scenarios against the real DeepSeek API: -// seed → (idle past cache TTL) → resume A/B-compares cold-restart miss tokens with and without pruning. +// Cost-capped seed → resume → continue smoke for content-driven maintenance. +// Offline covers seed/resume; live continue needs DEEPSEEK_API_KEY and allows +// at most one summary after growing past compact_ratio=0.85. package main import ( @@ -17,105 +18,104 @@ import ( "reasonix/internal/provider" _ "reasonix/internal/provider/openai" "reasonix/internal/tool" - "reasonix/internal/tool/builtin" ) const ( - model = "deepseek-v4-flash" - baseURL = "https://api.deepseek.com" - fatResults = 20 - fatBytes = 12_000 + model = "deepseek-v4-flash" + baseURL = "https://api.deepseek.com" + windowTokens = 1_000_000 + compactRatio = 0.85 + defaultMaxUSD = 0.50 + fatBytes = 32 * 1024 ) -func prov() provider.Provider { - key := os.Getenv("DEEPSEEK_API_KEY") - if key == "" { - fmt.Fprintln(os.Stderr, "DEEPSEEK_API_KEY not set") - os.Exit(1) - } - p, err := provider.New("openai", provider.Config{Name: "e2e", BaseURL: baseURL, Model: model, APIKey: key}) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - return p +type costCap struct { + maxUSD float64 + spent float64 } -func fakeGoFile(nonce string, i, size int) string { - var b strings.Builder - fmt.Fprintf(&b, "// module %s file%02d\npackage stress\n\n", nonce, i) - line := 0 - for b.Len() < size { - fmt.Fprintf(&b, "func helper_%s_%02d_%04d(x int) int { return x*%d + %d }\n", nonce, i, line, line+3, line*7) - line++ +func (c *costCap) add(u *provider.Usage, pricing *provider.Pricing) error { + if c == nil || u == nil || pricing == nil { + return nil } - return b.String() -} - -func seedSession(nonce string) *agent.Session { - s := agent.NewSession("You are a terse coding agent reviewing a Go codebase.") - s.Add(provider.Message{Role: provider.RoleUser, Content: "Review every file in module " + nonce + " one by one. Keep notes short."}) - for i := range fatResults { - id := fmt.Sprintf("c%02d", i) - name := fmt.Sprintf("src/file%02d.go", i) - s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "read_file", Arguments: fmt.Sprintf(`{"path":%q}`, name)}}}) - s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "read_file", Content: fakeGoFile(nonce, i, fatBytes)}) - s.Add(provider.Message{Role: provider.RoleAssistant, Content: fmt.Sprintf("Reviewed %s.", name)}) + c.spent += pricing.Cost(u) + if c.maxUSD > 0 && c.spent > c.maxUSD { + return fmt.Errorf("cost cap exceeded: spent $%.4f > max $%.4f", c.spent, c.maxUSD) } - return s -} - -// oneShot appends a user message and runs a single completion, returning usage. -func oneShot(p provider.Provider, msgs []provider.Message, question string) (provider.Usage, error) { - req := provider.Request{ - Messages: append(append([]provider.Message(nil), msgs...), provider.Message{Role: provider.RoleUser, Content: question}), - MaxTokens: 32, - } - ch, err := p.Stream(context.Background(), req) - if err != nil { - return provider.Usage{}, err - } - var u provider.Usage - for c := range ch { - switch c.Type { - case provider.ChunkUsage: - u = *c.Usage - case provider.ChunkError: - return u, c.Err - } - } - return u, nil + return nil } type meta struct { - SeededAt time.Time `json:"seeded_at"` - Nonces map[string]string `json:"nonces"` - SeedUse map[string]provider.Usage `json:"seed_usage"` + SeededAt time.Time `json:"seeded_at"` + ProjectionVersion uint64 `json:"projection_version"` + CanonicalTokens int `json:"canonical_tokens"` + ProjectedTokens int `json:"projected_tokens"` + TriggerTokens int `json:"trigger_tokens"` + SummaryCalls int `json:"summary_calls"` + SpentUSD float64 `json:"spent_usd"` + ContinueUsage *provider.Usage `json:"continue_usage,omitempty"` } -func seed(dir string) { - p := prov() - if err := os.MkdirAll(dir, 0o755); err != nil { +type recordSink struct { + summaryStarts int +} + +func (s *recordSink) Emit(e event.Event) { + if e.Kind == event.CompactionStarted { + s.summaryStarts++ + } +} + +func prov() (provider.Provider, *provider.Pricing) { + key := os.Getenv("DEEPSEEK_API_KEY") + if key == "" { + fmt.Fprintln(os.Stderr, "DEEPSEEK_API_KEY not set (use -offline for seed/resume)") + os.Exit(1) + } + p, err := provider.New("openai", provider.Config{ + Name: "e2e", BaseURL: baseURL, Model: model, APIKey: key, + Extra: map[string]any{"max_output_tokens": 1024}, + }) + if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - m := meta{SeededAt: time.Now(), Nonces: map[string]string{}, SeedUse: map[string]provider.Usage{}} - for _, arm := range []string{"pruned", "control"} { - nonce := fmt.Sprintf("%s%d", arm, time.Now().UnixNano()%1_000_000) - s := seedSession(nonce) - u, err := oneShot(p, s.Snapshot(), "How many files have you reviewed so far? Reply with just the number.") - if err != nil { - fmt.Fprintf(os.Stderr, "seed %s: %v\n", arm, err) - os.Exit(1) - } - if err := s.Save(filepath.Join(dir, arm+".jsonl")); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - m.Nonces[arm] = nonce - m.SeedUse[arm] = u - fmt.Printf("seeded %-7s prompt=%d hit=%d miss=%d\n", arm, u.PromptTokens, u.CacheHitTokens, u.CacheMissTokens) + pricing := &provider.Pricing{Input: 0.14, Output: 0.28, CacheHit: 0.014, Currency: "$"} + return p, pricing +} + +func fatHistory(n int) []provider.Message { + result := strings.Repeat("x", fatBytes) + msgs := []provider.Message{ + {Role: provider.RoleSystem, Content: "You are a terse coding agent."}, + {Role: provider.RoleUser, Content: "Review the large tool outputs carefully."}, } + for i := range n { + id := fmt.Sprintf("tool-%d", i) + msgs = append(msgs, + provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "read_file", Arguments: "{}"}}}, + provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "read_file", Content: result}, + ) + } + return append(msgs, + provider.Message{Role: provider.RoleUser, Content: "summarize status when asked"}, + provider.Message{Role: provider.RoleAssistant, Content: "standing by"}, + ) +} + +func newAgent(p provider.Provider, sess *agent.Session, path string, sink event.Sink) *agent.Agent { + return agent.New(p, tool.NewRegistry(), sess, agent.Options{ + ContextWindow: windowTokens, + CompactRatio: compactRatio, + RecentKeep: 2, + SessionPath: path, + WorkspaceID: "cm-e2e", + ModelRef: "deepseek/" + model, + MaxSteps: 2, + }, sink) +} + +func writeMeta(dir string, m meta) { b, _ := json.MarshalIndent(m, "", " ") if err := os.WriteFile(filepath.Join(dir, "meta.json"), b, 0o644); err != nil { fmt.Fprintln(os.Stderr, err) @@ -123,8 +123,7 @@ func seed(dir string) { } } -func resume(dir string) { - p := prov() +func readMeta(dir string) meta { raw, err := os.ReadFile(filepath.Join(dir, "meta.json")) if err != nil { fmt.Fprintln(os.Stderr, err) @@ -135,121 +134,189 @@ func resume(dir string) { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - idle := time.Since(m.SeededAt).Round(time.Minute) + return m +} - out := map[string]provider.Usage{} - for _, arm := range []string{"pruned", "control"} { - s, err := agent.LoadSession(filepath.Join(dir, arm+".jsonl")) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - prunes := 0 - if arm == "pruned" { - a := agent.New(nil, tool.NewRegistry(), s, agent.Options{ContextWindow: 1_000_000, ArchiveDir: filepath.Join(dir, "archive")}, event.Discard) - st, err := a.PruneStaleToolResults() - if err != nil { - fmt.Fprintln(os.Stderr, "prune:", err) - os.Exit(1) - } - prunes = st.Results - } - u, err := oneShot(p, s.Snapshot(), "Which file did you review first? Reply with just the path.") - if err != nil { - fmt.Fprintf(os.Stderr, "resume %s: %v\n", arm, err) - os.Exit(1) - } - out[arm] = u - fmt.Printf("resume %-7s idle=%s pruned=%d prompt=%d hit=%d miss=%d\n", arm, idle, prunes, u.PromptTokens, u.CacheHitTokens, u.CacheMissTokens) - } - b, _ := json.MarshalIndent(map[string]any{"idle": idle.String(), "resume_usage": out}, "", " ") - if err := os.WriteFile(filepath.Join(dir, fmt.Sprintf("resume-%d.json", time.Now().Unix())), b, 0o644); err != nil { +func seed(dir string, offline bool, cap *costCap) { + if err := os.MkdirAll(dir, 0o755); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - - c, pr := out["control"], out["pruned"] - if c.CacheMissTokens > 0 { - fmt.Printf("\ncold-restart miss tokens: control=%d pruned=%d (%.0f%% reduction)\n", - c.CacheMissTokens, pr.CacheMissTokens, 100*(1-float64(pr.CacheMissTokens)/float64(c.CacheMissTokens))) + path := filepath.Join(dir, "session.jsonl") + // ~80×32KB tool results: well under 85% of 1M for a cold estimate. + sess := &agent.Session{Messages: fatHistory(80)} + sink := &recordSink{} + var p provider.Provider + if !offline { + p, _ = prov() } -} - -// comprehension checks that the agent re-reads a file behind a prune placeholder -// instead of hallucinating: the answer is a number that exists only in the file. -func comprehension(trials int) { - p := prov() - pass := 0 - for t := range trials { - dir, err := os.MkdirTemp("", "cm-e2e-") - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - secret := fmt.Sprintf("%d", 1000+time.Now().UnixNano()%9000) - content := "package cfg\n\n// retention floor, milliseconds\nconst cacheRetentionFloor = " + secret + "\n" + strings.Repeat("// padding line filler for prune eligibility\n", 400) - if err := os.WriteFile(filepath.Join(dir, "config.go"), []byte(content), 0o644); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - - s := agent.NewSession("You are a terse coding agent. Use tools when you need file contents.") - s.Add(provider.Message{Role: provider.RoleUser, Content: "Read config.go and note its constants."}) - s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "r1", Name: "read_file", Arguments: `{"path":"config.go"}`}}}) - s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "r1", Name: "read_file", Content: content}) - s.Add(provider.Message{Role: provider.RoleAssistant, Content: "Noted the constants in config.go."}) - for i := range 4 { - s.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("ack %d", i)}) - s.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"}) - } - - reg := tool.NewRegistry() - for _, tl := range (builtin.Workspace{Dir: dir}).Tools("read_file") { - reg.Add(tl) - } - a := agent.New(p, reg, s, agent.Options{ContextWindow: 2000, RecentKeep: 2, MaxSteps: 5}, event.Discard) - st, err := a.PruneStaleToolResults() - if err != nil || st.Results == 0 { - fmt.Fprintf(os.Stderr, "trial %d: prune did not fire (st=%+v err=%v)\n", t, st, err) - os.Exit(1) - } - - err = a.Run(context.Background(), "What is the exact numeric value of cacheRetentionFloor in config.go? Reply with just the number.") - reRead, answered := false, false - for _, msg := range s.Snapshot() { - if msg.Role == provider.RoleTool && strings.Contains(msg.Content, "cacheRetentionFloor = "+secret) { - reRead = true - } - if msg.Role == provider.RoleAssistant && strings.Contains(msg.Content, secret) { - answered = true - } - } - ok := err == nil && reRead && answered - if ok { - pass++ - } - fmt.Printf("trial %d: re-read=%v answered=%v err=%v\n", t, reRead, answered, err) - os.RemoveAll(dir) - } - fmt.Printf("\ncomprehension: %d/%d passed\n", pass, trials) - if pass < trials { + a := newAgent(p, sess, path, sink) + before := a.ContextMaintenanceSnapshot() + if before.TriggerTokens <= 0 { + fmt.Fprintln(os.Stderr, "seed: missing trigger tokens") os.Exit(1) } + if before.ProjectedTokens >= before.TriggerTokens { + fmt.Fprintf(os.Stderr, "seed fixture already at/above trigger: projected=%d trigger=%d\n", + before.ProjectedTokens, before.TriggerTokens) + os.Exit(1) + } + if err := a.PrepareContext(context.Background()); err != nil { + fmt.Fprintln(os.Stderr, "seed prepare:", err) + os.Exit(1) + } + after := a.ContextMaintenanceSnapshot() + if after.ProjectionVersion != 0 { + fmt.Fprintf(os.Stderr, "seed installed projection version %d below compact_ratio\n", after.ProjectionVersion) + os.Exit(1) + } + if sink.summaryStarts != 0 { + fmt.Fprintf(os.Stderr, "seed started %d summary calls below trigger\n", sink.summaryStarts) + os.Exit(1) + } + if err := sess.Save(path); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + m := meta{ + SeededAt: time.Now(), + ProjectionVersion: after.ProjectionVersion, + CanonicalTokens: after.CanonicalTokens, + ProjectedTokens: after.ProjectedTokens, + TriggerTokens: after.TriggerTokens, + SummaryCalls: sink.summaryStarts, + SpentUSD: cap.spent, + } + writeMeta(dir, m) + fmt.Printf("seed ok projected=%d trigger=%d version=%d summary_calls=%d spent=$%.4f\n", + m.ProjectedTokens, m.TriggerTokens, m.ProjectionVersion, m.SummaryCalls, m.SpentUSD) +} + +func resume(dir string, offline bool) { + path := filepath.Join(dir, "session.jsonl") + m := readMeta(dir) + sess, err := agent.LoadSession(path) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + sink := &recordSink{} + var p provider.Provider + if !offline { + p, _ = prov() + } + a := newAgent(p, sess, path, sink) + snap := a.ContextMaintenanceSnapshot() + if snap.ProjectionVersion != m.ProjectionVersion { + fmt.Fprintf(os.Stderr, "resume version = %d, want %d\n", snap.ProjectionVersion, m.ProjectionVersion) + os.Exit(1) + } + if err := a.PrepareContext(context.Background()); err != nil { + fmt.Fprintln(os.Stderr, "resume prepare:", err) + os.Exit(1) + } + after := a.ContextMaintenanceSnapshot() + if after.ProjectionVersion != m.ProjectionVersion { + fmt.Fprintf(os.Stderr, "resume advanced version to %d\n", after.ProjectionVersion) + os.Exit(1) + } + if sink.summaryStarts != 0 { + fmt.Fprintf(os.Stderr, "resume re-ran summary %d times\n", sink.summaryStarts) + os.Exit(1) + } + fmt.Printf("resume ok idle=%s version=%d summary_calls=0\n", + time.Since(m.SeededAt).Round(time.Second), m.ProjectionVersion) +} + +func cont(dir string, offline bool, cap *costCap) { + if offline { + fmt.Fprintln(os.Stderr, "continue requires live provider (omit -offline)") + os.Exit(1) + } + path := filepath.Join(dir, "session.jsonl") + m := readMeta(dir) + sess, err := agent.LoadSession(path) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + // Grow past the 85% trigger with additional fat tool turns (skip system/user prelude). + for _, msg := range fatHistory(40)[2:] { + sess.Add(msg) + } + sink := &recordSink{} + p, pricing := prov() + a := newAgent(p, sess, path, sink) + before := a.ContextMaintenanceSnapshot() + if before.ProjectedTokens < before.TriggerTokens { + fmt.Fprintf(os.Stderr, "continue fixture still below trigger: projected=%d trigger=%d\n", + before.ProjectedTokens, before.TriggerTokens) + os.Exit(1) + } + if err := a.PrepareContext(context.Background()); err != nil { + fmt.Fprintln(os.Stderr, "continue prepare:", err) + os.Exit(1) + } + if sink.summaryStarts > 1 { + fmt.Fprintf(os.Stderr, "continue started %d summaries, want ≤1\n", sink.summaryStarts) + os.Exit(1) + } + after := a.ContextMaintenanceSnapshot() + if sink.summaryStarts == 1 && after.ProjectionVersion != m.ProjectionVersion+1 { + fmt.Fprintf(os.Stderr, "continue version = %d, want %d after one summary\n", + after.ProjectionVersion, m.ProjectionVersion+1) + os.Exit(1) + } + if u := a.LastUsage(); u != nil { + if err := cap.add(u, pricing); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + m.ContinueUsage = u + } + startsBefore := sink.summaryStarts + _ = a.Run(context.Background(), "Reply with exactly: ok") + if u := a.LastUsage(); u != nil { + if err := cap.add(u, pricing); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + } + if sink.summaryStarts != startsBefore { + fmt.Fprintf(os.Stderr, "post-checkpoint run started extra summaries (%d→%d)\n", + startsBefore, sink.summaryStarts) + os.Exit(1) + } + if err := sess.Save(path); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + final := a.ContextMaintenanceSnapshot() + m.ProjectionVersion = final.ProjectionVersion + m.ProjectedTokens = final.ProjectedTokens + m.CanonicalTokens = final.CanonicalTokens + m.SummaryCalls = sink.summaryStarts + m.SpentUSD = cap.spent + writeMeta(dir, m) + fmt.Printf("continue ok version=%d projected=%d summary_calls=%d spent=$%.4f\n", + m.ProjectionVersion, m.ProjectedTokens, m.SummaryCalls, m.SpentUSD) } func main() { - dir := flag.String("dir", "benchmarks/context-maintenance-e2e/run", "state directory for seed/resume") - trials := flag.Int("trials", 5, "comprehension trials") + dir := flag.String("dir", "benchmarks/context-maintenance-e2e/run", "state directory") + maxUSD := flag.Float64("max-usd", defaultMaxUSD, "hard cost cap for live API legs") + offline := flag.Bool("offline", false, "skip live provider (seed/resume only)") flag.Parse() + cap := &costCap{maxUSD: *maxUSD} switch flag.Arg(0) { case "seed": - seed(*dir) + seed(*dir, *offline, cap) case "resume": - resume(*dir) - case "comprehension": - comprehension(*trials) + resume(*dir, *offline) + case "continue": + cont(*dir, *offline, cap) default: - fmt.Fprintln(os.Stderr, "usage: context-maintenance-e2e [seed|resume|comprehension]") + fmt.Fprintln(os.Stderr, "usage: context-maintenance-e2e [-dir DIR] [-max-usd N] [-offline] seed|resume|continue") os.Exit(1) } } diff --git a/desktop/context_maintenance.go b/desktop/context_maintenance.go index f5dfaeaed6..9f2a26e43c 100644 --- a/desktop/context_maintenance.go +++ b/desktop/context_maintenance.go @@ -29,9 +29,11 @@ type ContextMaintenanceInfo struct { ProjectedTokens int `json:"projectedTokens,omitempty"` SummaryTokens int `json:"summaryTokens,omitempty"` LastSavedTokens int `json:"lastSavedTokens,omitempty"` - SnipTrigger int `json:"snipTrigger,omitempty"` - FoldTrigger int `json:"foldTrigger,omitempty"` - ForceTrigger int `json:"forceTrigger,omitempty"` + SnipTrigger int `json:"snipTrigger,omitempty"` // always 0; legacy compatibility + FoldTrigger int `json:"foldTrigger,omitempty"` // alias of TriggerTokens + ForceTrigger int `json:"forceTrigger,omitempty"` // always 0; legacy compatibility + TriggerTokens int `json:"triggerTokens,omitempty"` + CheckpointState string `json:"checkpointState,omitempty"` // none|restored|applied HardInputCeiling int `json:"hardInputCeiling,omitempty"` Headroom int `json:"headroom,omitempty"` ProjectionVersion uint64 `json:"projectionVersion,omitempty"` @@ -69,8 +71,9 @@ func contextMaintenanceInfo(snapshot agent.ContextMaintenanceSnapshot) *ContextM info := &ContextMaintenanceInfo{ CanonicalTokens: snapshot.CanonicalTokens, ProjectedTokens: snapshot.ProjectedTokens, SummaryTokens: snapshot.SummaryTokens, LastSavedTokens: snapshot.LastSavedTokens, - SnipTrigger: snapshot.SnipTrigger, FoldTrigger: snapshot.FoldTrigger, - ForceTrigger: snapshot.ForceTrigger, HardInputCeiling: snapshot.HardInputCeiling, + // Snip/Force remain zero for one-version compatibility with older frontends. + FoldTrigger: snapshot.TriggerTokens, TriggerTokens: snapshot.TriggerTokens, + CheckpointState: snapshot.CheckpointState, HardInputCeiling: snapshot.HardInputCeiling, Headroom: snapshot.Headroom, ProjectionVersion: snapshot.ProjectionVersion, Blocked: snapshot.Blocked, } diff --git a/desktop/context_maintenance_contract_test.go b/desktop/context_maintenance_contract_test.go index ce85052606..28e9b5bcde 100644 --- a/desktop/context_maintenance_contract_test.go +++ b/desktop/context_maintenance_contract_test.go @@ -8,16 +8,21 @@ import ( func TestContextMaintenanceInfoUsesOptionalCamelCaseWailsFields(t *testing.T) { b, err := json.Marshal(ContextInfo{Maintenance: &ContextMaintenanceInfo{ - ProjectedTokens: 1200, + CanonicalTokens: 800_000, ProjectedTokens: 160_000, + TriggerTokens: 850_000, CheckpointState: "applied", LastReceipt: &ContextMaintenanceReceiptInfo{ - OperationID: "op", Action: "prune", SavedTokens: 4096, + OperationID: "op", Action: "summary", SavedTokens: 4096, }, }}) if err != nil { t.Fatal(err) } got := string(b) - for _, want := range []string{`"maintenance"`, `"projectedTokens":1200`, `"lastReceipt"`, `"operationId":"op"`, `"savedTokens":4096`} { + for _, want := range []string{ + `"maintenance"`, `"projectedTokens":160000`, `"canonicalTokens":800000`, + `"triggerTokens":850000`, `"checkpointState":"applied"`, + `"lastReceipt"`, `"operationId":"op"`, `"savedTokens":4096`, `"action":"summary"`, + } { if !strings.Contains(got, want) { t.Fatalf("ContextInfo JSON missing %s: %s", want, got) } diff --git a/desktop/frontend/src/App.tsx b/desktop/frontend/src/App.tsx index 9d27a321a3..e3d962661f 100644 --- a/desktop/frontend/src/App.tsx +++ b/desktop/frontend/src/App.tsx @@ -44,7 +44,6 @@ import { generativeMusic, isGenerativeMusicEnabled } from "./lib/generative-musi import { clearAttentionChimeKeys, playAttentionChime, playSuccessChime, shouldPlayAttentionChimeForEvent } from "./lib/sound"; import { NoticeCard, Transcript } from "./components/Transcript"; import { Composer } from "./components/Composer"; -import { TranscriptSelectionMenu } from "./components/TranscriptSelectionMenu"; import { TodoPanel } from "./components/TodoPanel"; import { ApprovalModal } from "./components/ApprovalModal"; import { AskCard } from "./components/AskCard"; @@ -66,7 +65,6 @@ import { RemoteWorkspaceLaunchGate, resolveRemoteWorkspace } from "./lib/remoteW import { CommandPalette, type PaletteItem } from "./components/CommandPalette"; import { UpdateBanner } from "./components/UpdateBanner"; import { UpdaterProvider } from "./lib/useUpdater"; -import { ContextPanel } from "./components/ContextPanel"; import { Tooltip } from "./components/Tooltip"; import { StartupSplash } from "./components/StartupSplash"; import { OnboardingOverlay } from "./components/OnboardingOverlay"; @@ -293,6 +291,8 @@ function NoticePreviewPanel() { ); } +const TranscriptSelectionMenu = lazy(() => import("./components/TranscriptSelectionMenu").then((module) => ({ default: module.TranscriptSelectionMenu }))); +const ContextPanel = lazy(() => import("./components/ContextPanel").then((module) => ({ default: module.ContextPanel }))); const HistoryPanel = lazy(() => import("./components/HistoryPanel").then((module) => ({ default: module.HistoryPanel }))); const SettingsPanel = lazy(() => import("./components/SettingsPanelEntry").then((module) => ({ default: module.SettingsPanel }))); const RemotePanel = lazy(() => import("./components/RemotePanel").then((module) => ({ default: module.RemotePanel }))); @@ -5297,21 +5297,23 @@ export default function App() { setWorkspacePanel(false)} /> ) : rightDockMode === "context" && desktopLayoutStyle !== "creation" ? ( - + + + ) : ( setHeartbeatOpen(false)} onOpenTopic={(scope, workspaceRoot, topicId) => { void handleOpenTopic(scope, workspaceRoot, topicId); }} /> - + {windowsFramelessChrome && ( > = { - "context.maintenanceTitle": "上下文维护", - "context.maintenanceActionPrune": "工具结果裁剪", + "context.maintenanceTitle": "上下文短视图", + "context.maintenanceAppliedSummary": "已生成短视图", + "context.maintenanceBlockedSummary": "摘要未形成短视图 · 已停重试", + "context.maintenanceFailedSummary": "摘要失败 · 已停重试", "context.tokensValue": "{value} tokens", - "projectTree.status.paused": "已暂停", - "settings.typography.applied": "已应用", - "task.state.failed": "失败", + "summary.detail": "摘要", }; const translate: Translator = (key, vars) => { @@ -22,25 +26,52 @@ const translate: Translator = (key, vars) => { const applied = formatContextMaintenanceNotice({ status: "applied", - action: "prune", + action: "summary", inputTokens: 120, resultTokens: 80, savedTokens: 40, }, translate); -ok(applied === "上下文维护 · 工具结果裁剪 · 已应用 · 120 → 80 tokens · −40 tokens", `unexpected applied notice: ${applied}`); -ok(!applied.includes("Context") && !applied.includes("applied"), "maintenance notice leaked hardcoded English"); +ok(applied === "已生成短视图", `unexpected applied notice: ${applied}`); const blocked = formatContextMaintenanceNotice({ status: "blocked" }, translate); -ok(blocked === "上下文维护 · 已暂停", `unexpected blocked notice: ${blocked}`); +ok(blocked === "摘要未形成短视图 · 已停重试", `unexpected blocked notice: ${blocked}`); + +const failed = formatContextMaintenanceNotice({ status: "failed" }, translate); +ok(failed === "摘要失败 · 已停重试", `unexpected failed notice: ${failed}`); const contextPanelSource = readFileSync(new URL("../components/ContextPanel.tsx", import.meta.url), "utf8"); ok( - !contextPanelSource.includes('t("context.maintenanceProjected")'), - "ContextPanel must not render the internal context-maintenance composition block", + contextPanelSource.includes("context.maintenanceCanonical"), + "ContextPanel must show canonical vs model-visible tokens", ); ok( - !contextPanelSource.includes("const maintenance = context?.maintenance"), - "ContextPanel must not derive hidden maintenance UI state", + contextPanelSource.includes("triggerTokens") || contextPanelSource.includes("maintenance?.triggerTokens"), + "ContextPanel must surface triggerTokens", +); +ok( + contextPanelSource.includes("checkpointState"), + "ContextPanel must surface checkpointState", +); +ok( + !contextPanelSource.includes("snipTrigger") && !contextPanelSource.includes("forceTrigger"), + "ContextPanel must not present retired multi-threshold triggers as user settings", +); + +ok(isNewMaintenanceOperation([], "op-1"), "empty seen list accepts first operationId"); +ok(isNewMaintenanceOperation(["op-1"], "op-1") === false, "duplicate operationId is rejected"); +ok(isNewMaintenanceOperation(["op-1"], "op-2"), "distinct operationId is accepted"); +ok(isNewMaintenanceOperation(["op-1"], ""), "missing operationId is still shown"); +const remembered = rememberMaintenanceOperation(["op-1"], "op-2"); +ok(remembered.includes("op-1") && remembered.includes("op-2"), "remember keeps prior and new ids"); +ok(rememberMaintenanceOperation(["op-1"], "op-1").length === 1, "remember is idempotent for same id"); +const many = Array.from({ length: 70 }, (_, i) => `op-${i}`); +const bounded = rememberMaintenanceOperation(many.slice(0, 64), "op-new"); +ok(bounded.length === 64 && bounded[bounded.length - 1] === "op-new", "remember bounds to 64 ids"); + +const settingsSource = readFileSync(new URL("../components/SettingsPanel.tsx", import.meta.url), "utf8"); +ok( + !settingsSource.includes("settings.coldResumePrune") && !settingsSource.includes("SetColdResumePrune"), + "SettingsPanel must not expose retired coldResumePrune control", ); console.log("context-maintenance-notice: ok"); diff --git a/desktop/frontend/src/__tests__/settings-refresh-snapshot.test.tsx b/desktop/frontend/src/__tests__/settings-refresh-snapshot.test.tsx index 95a9eb6ec1..57da98c74a 100644 --- a/desktop/frontend/src/__tests__/settings-refresh-snapshot.test.tsx +++ b/desktop/frontend/src/__tests__/settings-refresh-snapshot.test.tsx @@ -270,13 +270,13 @@ await act(async () => { }); ok(compactRootEl.textContent?.includes("Advanced context management") === false, "compaction preference has no redundant advanced disclosure"); ok(compactRootEl.textContent?.includes("Automatic compaction threshold") === true, "compaction preference is visible without expanding a disclosure"); -ok(compactRootEl.textContent?.includes("80,000 tokens") === true, "compact ratio shows the default model token threshold"); -ok(compactRootEl.textContent?.includes("Current threshold: 80% · Balanced") === true, "compact ratio summarizes the saved preset separately"); +ok(compactRootEl.textContent?.includes("85,000 tokens") === true, "compact ratio shows the default model token threshold"); +ok(compactRootEl.textContent?.includes("Current threshold: 85% · Recommended") === true, "compact ratio summarizes the saved preset separately"); ok(compactRootEl.textContent?.includes("effective threshold is 75%") === true, "project override shows the active effective threshold"); ok(compactRootEl.querySelector('input[aria-label="Custom compaction threshold percentage"]') === null, "custom compact ratio editor stays hidden on the default path"); -const balancedCompactButton = compactRootEl.querySelector('button[aria-label="80% · Balanced"]') as HTMLButtonElement | null; -if (!balancedCompactButton) throw new Error("balanced compaction preset did not render"); -ok(balancedCompactButton.getAttribute("aria-pressed") === "true", "saved compact ratio starts selected"); +const recommendedCompactButton = compactRootEl.querySelector('button[aria-label="85% · Recommended"]') as HTMLButtonElement | null; +if (!recommendedCompactButton) throw new Error("recommended compaction preset did not render"); +ok(recommendedCompactButton.getAttribute("aria-pressed") === "true", "saved compact ratio starts selected"); const customCompactButton = Array.from(compactRootEl.querySelectorAll("button")).find((button) => button.textContent?.includes("Custom threshold…")) as HTMLButtonElement | undefined; if (!customCompactButton) throw new Error("custom compaction threshold option did not render"); ok(customCompactButton.closest(".compact-ratio-presets") === null, "custom compaction is a separate disclosure rather than a preset value"); @@ -287,12 +287,12 @@ await act(async () => { }); let customCompactInput = compactRootEl.querySelector('input[aria-label="Custom compaction threshold percentage"]') as HTMLInputElement | null; if (!customCompactInput) throw new Error("custom compaction threshold input did not open"); -eq(customCompactInput.value, "80", "custom compaction threshold defaults older backends to 80 percent"); -ok(compactRootEl.textContent?.includes("Tool output is trimmed at 60%") === true, "custom compact ratio explains the lower guard rail"); -ok(compactRootEl.textContent?.includes("90% forces compaction") === true, "custom compact ratio explains the upper guard rail"); +eq(customCompactInput.value, "85", "custom compaction threshold defaults older backends to 85 percent"); +ok(compactRootEl.textContent?.includes("65%") === true, "custom compact ratio explains the lower guard rail"); +ok(compactRootEl.textContent?.includes("85%") === true, "custom compact ratio explains the upper guard rail"); ok(document.activeElement === customCompactInput, "opening the custom compact ratio moves focus to its input"); ok(customCompactButton.getAttribute("aria-expanded") === "true", "custom compact ratio exposes its expanded state"); -ok(balancedCompactButton.getAttribute("aria-pressed") === "true", "opening custom editing preserves the saved preset selection"); +ok(recommendedCompactButton.getAttribute("aria-pressed") === "true", "opening custom editing preserves the saved preset selection"); const customCompactApply = Array.from(customCompactInput.closest(".compact-ratio-custom")?.querySelectorAll("button") ?? []).find((button) => button.textContent === "Apply") as HTMLButtonElement | undefined; if (!customCompactApply) throw new Error("custom compaction threshold apply action did not render"); const inputValueSetter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set; @@ -350,15 +350,15 @@ await act(async () => { }); eq(compactRatioCalls.length, 1, "Cancel closes a custom compact ratio without saving"); ok(compactRootEl.querySelector('input[aria-label="Custom compaction threshold percentage"]') === null, "Cancel collapses the custom compact ratio editor"); -const earlierCompactButton = compactRootEl.querySelector('button[aria-label="70% · Earlier"]') as HTMLButtonElement | null; -if (!earlierCompactButton) throw new Error("earlier compaction preset did not render"); +const activeCompactButton = compactRootEl.querySelector('button[aria-label="70% · Active"]') as HTMLButtonElement | null; +if (!activeCompactButton) throw new Error("active compaction preset did not render"); await act(async () => { - earlierCompactButton.click(); + activeCompactButton.click(); await flushPromises(); }); eq(compactRatioCalls.length, 2, "compact ratio preset adds one mutation"); eq(compactRatioCalls[1], 0.7, "compact ratio preset sends the expected fraction"); -ok(earlierCompactButton.getAttribute("aria-pressed") === "true", "saved compact ratio is selected after Settings reload"); +ok(activeCompactButton.getAttribute("aria-pressed") === "true", "saved compact ratio is selected after Settings reload"); await act(async () => { compactRoot.unmount(); diff --git a/desktop/frontend/src/components/ContextPanel.tsx b/desktop/frontend/src/components/ContextPanel.tsx index 94421d31bc..0bb54a4423 100644 --- a/desktop/frontend/src/components/ContextPanel.tsx +++ b/desktop/frontend/src/components/ContextPanel.tsx @@ -28,7 +28,6 @@ interface ContextPanelProps { usageSeq?: number; } - function fmtDuration(ms: number, t: Translator): string { if (ms <= 0) return "-"; const totalSeconds = Math.max(1, Math.round(ms / 1000)); @@ -38,7 +37,6 @@ function fmtDuration(ms: number, t: Translator): string { return t("context.durationMinutesSeconds", { minutes, seconds }); } - interface MetricTokenDisplay { display: string; exact: string; @@ -422,10 +420,20 @@ export function ContextPanel({ const changedFiles = asArray(info?.changedFiles); const usagePct = windowTokens > 0 ? Math.min(100, Math.round((usedTokens / windowTokens) * 100)) : 0; - const compactRatio = context?.compactRatio && context.compactRatio > 0 ? context.compactRatio : 0.8; + const compactRatio = context?.compactRatio && context.compactRatio > 0 ? context.compactRatio : 0.85; const compactPct = Math.round(compactRatio * 100); - const compactTokens = windowTokens > 0 ? Math.round(windowTokens * compactRatio) : 0; + const maintenance = context?.maintenance; + const triggerTokens = maintenance?.triggerTokens && maintenance.triggerTokens > 0 + ? maintenance.triggerTokens + : windowTokens > 0 + ? Math.round(windowTokens * compactRatio) + : 0; + const compactTokens = triggerTokens > 0 ? triggerTokens : (windowTokens > 0 ? Math.round(windowTokens * compactRatio) : 0); const tokensUntilCompact = compactTokens > usedTokens ? compactTokens - usedTokens : 0; + const canonicalTokens = maintenance?.canonicalTokens ?? 0; + const projectedTokens = maintenance?.projectedTokens ?? usedTokens; + const checkpointState = maintenance?.checkpointState || (maintenance?.projectionVersion ? "restored" : "none"); + const lastReceipt = maintenance?.lastReceipt; const breakdown = contextBreakdown(usedTokens, windowTokens, promptTokens, completionTokens, reasoningTokens); const eventTimes = [ ...readFiles.map((file) => file.time), @@ -548,6 +556,53 @@ export function ContextPanel({ + {(canonicalTokens > 0 || projectedTokens > 0 || (maintenance?.projectionVersion ?? 0) > 0) && ( +
+ +
+ + + 0 ? formatTokens(triggerTokens) : "-"} + title={t("context.maintenanceTriggerHint", { percent: compactPct })} + /> + + + {(maintenance?.projectionVersion ?? 0) > 0 && ( + + )} + {typeof lastReceipt?.savedTokens === "number" && lastReceipt.savedTokens > 0 && ( + + )} +
+
+ )}
diff --git a/desktop/frontend/src/components/ContextWindowRing.tsx b/desktop/frontend/src/components/ContextWindowRing.tsx index 7694068be5..01c9bb37ec 100644 --- a/desktop/frontend/src/components/ContextWindowRing.tsx +++ b/desktop/frontend/src/components/ContextWindowRing.tsx @@ -54,7 +54,7 @@ export function ContextWindowRing({ enabled = true, context, tabId, turnCost, cu const used = context?.used ?? 0; const windowTokens = context?.window ?? 0; const usagePct = windowTokens > 0 ? Math.min(100, Math.round((used / windowTokens) * 100)) : 0; - const compactRatio = context?.compactRatio && context.compactRatio > 0 ? context.compactRatio : 0.8; + const compactRatio = context?.compactRatio && context.compactRatio > 0 ? context.compactRatio : 0.85; const compactPct = Math.round(compactRatio * 100); const status = contextWindowStatus(usagePct, compactPct); diff --git a/desktop/frontend/src/components/SettingsPanel.tsx b/desktop/frontend/src/components/SettingsPanel.tsx index c016096a46..64ee01a721 100644 --- a/desktop/frontend/src/components/SettingsPanel.tsx +++ b/desktop/frontend/src/components/SettingsPanel.tsx @@ -1420,12 +1420,12 @@ function normalizeSettingsView(view: SettingsView | null | undefined): SettingsV noProxy: "", proxy: { type: "socks5", server: "", port: 0, username: "", password: "" }, }; - const agent = view.agent ?? { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, maxSubagentConcurrency: 6, maxParallelWriters: 3, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto", compactRatio: 0.8 }; + const agent = view.agent ?? { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, maxSubagentConcurrency: 6, maxParallelWriters: 3, systemPrompt: "", reasoningLanguage: "auto", compactRatio: 0.85 }; agent.plannerMaxSteps = Number.isFinite(agent.plannerMaxSteps) ? Math.max(0, Math.trunc(agent.plannerMaxSteps)) : 0; agent.maxSteps = Number.isFinite(agent.maxSteps) ? Math.max(0, Math.trunc(agent.maxSteps)) : 0; agent.maxSubagentDepth = Number.isFinite(agent.maxSubagentDepth) && agent.maxSubagentDepth <= 1 ? 1 : 2; agent.reasoningLanguage = normalizeReasoningLanguage(agent.reasoningLanguage); - agent.compactRatio = Number.isFinite(agent.compactRatio) && Number(agent.compactRatio) > 0 ? Number(agent.compactRatio) : 0.8; + agent.compactRatio = Number.isFinite(agent.compactRatio) && Number(agent.compactRatio) > 0 ? Number(agent.compactRatio) : 0.85; agent.effectiveCompactRatio = Number.isFinite(agent.effectiveCompactRatio) && Number(agent.effectiveCompactRatio) > 0 ? Number(agent.effectiveCompactRatio) : agent.compactRatio; @@ -3961,8 +3961,8 @@ function ModelsSection({ s, busy, apply, backgroundApply, initialFocus }: Models : !providerIsConfigured(defaultProviderView) ? t("settings.modelNeedsKey", { provider: modelProviderLabel(defaultProvider, defaultProviderView, t) }) : ""; - const agent = s.agent ?? { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, maxSubagentConcurrency: 6, maxParallelWriters: 3, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto", compactRatio: 0.8 }; - const compactRatio = agent.compactRatio ?? 0.8; + const agent = s.agent ?? { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, maxSubagentConcurrency: 6, maxParallelWriters: 3, systemPrompt: "", reasoningLanguage: "auto", compactRatio: 0.85 }; + const compactRatio = agent.compactRatio ?? 0.85; const compactRatioPercent = Math.round(compactRatio * 1000) / 10; const [compactRatioDraft, setCompactRatioDraft] = useState(() => String(compactRatioPercent)); const [compactRatioCustomOpen, setCompactRatioCustomOpen] = useState(false); @@ -4246,20 +4246,6 @@ function ModelsSection({ s, busy, apply, backgroundApply, initialFocus }: Models {modelIssue &&
{modelIssue}
} - -
- {([true, false] as const).map((on) => ( - - ))} -
-
{(["auto", "zh", "en"] as const).map((lang) => ( diff --git a/desktop/frontend/src/lib/bridge.ts b/desktop/frontend/src/lib/bridge.ts index ac4c143e99..f2b4ebd357 100644 --- a/desktop/frontend/src/lib/bridge.ts +++ b/desktop/frontend/src/lib/bridge.ts @@ -556,7 +556,6 @@ export interface AppBindings { SetDesktopConversationWidth(width: string): Promise; MigrateDesktopPreferences(language: string, theme: string, style: string): Promise; SetAgentParams(temperature: number, maxSteps: number, plannerMaxSteps: number, systemPrompt: string): Promise; - SetColdResumePrune(enabled: boolean): Promise; SetCompactRatio(ratio: number): Promise; SetReasoningLanguage(lang: string): Promise; SetTrayLocale(locale: "en" | "zh" | "zh-TW"): Promise; @@ -1615,7 +1614,7 @@ function makeMockApp(): AppBindings { noProxy: "", proxy: { type: "socks5", server: "127.0.0.1", port: 7890, username: "", password: "" }, }, - agent: { temperature: 0.2, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, maxSubagentConcurrency: 6, maxParallelWriters: 3, systemPrompt: "You are Reasonix, a coding agent.", coldResumePrune: true, reasoningLanguage: "auto", compactRatio: 0.8 }, + agent: { temperature: 0.2, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, maxSubagentConcurrency: 6, maxParallelWriters: 3, systemPrompt: "You are Reasonix, a coding agent.", reasoningLanguage: "auto", compactRatio: 0.8 }, bot: { enabled: !freshMock, model: "", @@ -4878,9 +4877,6 @@ function makeMockApp(): AppBindings { async SetAgentParams(temperature: number, maxSteps: number, plannerMaxSteps: number, systemPrompt: string) { settings.agent = { ...settings.agent, temperature, maxSteps, plannerMaxSteps, systemPrompt }; }, - async SetColdResumePrune(enabled: boolean) { - settings.agent = { ...settings.agent, coldResumePrune: enabled }; - }, async SetCompactRatio(ratio: number) { if (!Number.isFinite(ratio) || ratio < 0.65 || ratio > 0.85) throw new Error("compact ratio must be between 0.65 and 0.85"); settings.agent = { ...settings.agent, compactRatio: ratio }; diff --git a/desktop/frontend/src/lib/contextMaintenanceTypes.ts b/desktop/frontend/src/lib/contextMaintenanceTypes.ts index d2bcddb61d..61848d4b9e 100644 --- a/desktop/frontend/src/lib/contextMaintenanceTypes.ts +++ b/desktop/frontend/src/lib/contextMaintenanceTypes.ts @@ -1,7 +1,8 @@ import type { Translator } from "./i18n"; export type ContextMaintenanceStatus = "planned" | "applied" | "noop" | "blocked" | "failed"; -export type ContextMaintenanceAction = "snip" | "prune" | "summary" | "native_tool_clear" | "noop"; +/** New writers only emit summary | noop. snip/prune/native are legacy restore-only. */ +export type ContextMaintenanceAction = "summary" | "noop" | "snip" | "prune" | "native_tool_clear"; export interface WireContextMaintenance { status?: ContextMaintenanceStatus; @@ -34,9 +35,15 @@ export interface ContextMaintenanceInfo { projectedTokens?: number; summaryTokens?: number; lastSavedTokens?: number; + /** @deprecated always 0; use triggerTokens */ snipTrigger?: number; + /** @deprecated alias of triggerTokens */ foldTrigger?: number; + /** @deprecated always 0; use triggerTokens */ forceTrigger?: number; + triggerTokens?: number; + /** none | restored | applied — runtime only */ + checkpointState?: "none" | "restored" | "applied"; hardInputCeiling?: number; headroom?: number; projectionVersion?: number; @@ -44,25 +51,19 @@ export interface ContextMaintenanceInfo { lastReceipt?: ContextMaintenanceReceipt; } -function maintenanceActionLabel(action: ContextMaintenanceAction | undefined, t: Translator): string | undefined { - switch (action) { - case "snip": return t("context.maintenanceActionSnip"); - case "prune": return t("context.maintenanceActionPrune"); - case "summary": return t("summary.detail"); - case "native_tool_clear": return t("context.maintenanceActionNative"); - default: return undefined; - } -} - export function formatContextMaintenanceNotice(m: WireContextMaintenance, t: Translator): string { - const parts = [t("context.maintenanceTitle")]; - const action = maintenanceActionLabel(m.action, t); - if (action) parts.push(action); switch (m.status) { - case "blocked": parts.push(t("projectTree.status.paused")); break; - case "failed": parts.push(t("task.state.failed")); break; - case "applied": parts.push(t("settings.typography.applied")); break; + case "applied": + return t("context.maintenanceAppliedSummary"); + case "blocked": + return t("context.maintenanceBlockedSummary"); + case "failed": + return t("context.maintenanceFailedSummary"); + default: + break; } + const parts = [t("context.maintenanceTitle")]; + if (m.action === "summary") parts.push(t("summary.detail")); if (typeof m.inputTokens === "number" && typeof m.resultTokens === "number") { parts.push(t("context.tokensValue", { value: `${m.inputTokens.toLocaleString()} → ${m.resultTokens.toLocaleString()}`, @@ -73,3 +74,20 @@ export function formatContextMaintenanceNotice(m: WireContextMaintenance, t: Tra } return parts.join(" · "); } + +const MAX_SEEN_MAINTENANCE_OPS = 64; + +/** True when this operationId has not yet been rendered as a notice. */ +export function isNewMaintenanceOperation(seen: readonly string[] | undefined, operationId?: string): boolean { + const id = (operationId ?? "").trim(); + if (!id) return true; + return !(seen ?? []).includes(id); +} + +/** Remember an operationId for reconnect/replay dedupe (bounded FIFO). */ +export function rememberMaintenanceOperation(seen: readonly string[] | undefined, operationId?: string): string[] { + const id = (operationId ?? "").trim(); + if (!id) return [...(seen ?? [])]; + if ((seen ?? []).includes(id)) return [...(seen ?? [])]; + return [...(seen ?? []), id].slice(-MAX_SEEN_MAINTENANCE_OPS); +} diff --git a/desktop/frontend/src/lib/types.ts b/desktop/frontend/src/lib/types.ts index 6aff50189f..2d978de3e6 100644 --- a/desktop/frontend/src/lib/types.ts +++ b/desktop/frontend/src/lib/types.ts @@ -1882,7 +1882,6 @@ export interface AgentView { maxSubagentConcurrency: number; maxParallelWriters: number; systemPrompt: string; - coldResumePrune: boolean; reasoningLanguage: string; // "auto" | "zh" | "en" compactRatio?: number; // Advanced global default; older backends omit it. effectiveCompactRatio?: number; // Active local session after project overrides. diff --git a/desktop/frontend/src/lib/useController.ts b/desktop/frontend/src/lib/useController.ts index 7e5b8f2a5d..c61ce9e08e 100644 --- a/desktop/frontend/src/lib/useController.ts +++ b/desktop/frontend/src/lib/useController.ts @@ -8,7 +8,7 @@ import { asArray } from "./array"; import { addBreadcrumb } from "./breadcrumbs"; import { app, onEvent, onReady, onRuntimeRebuilt, onTabMeta, onTopicActivation } from "./bridge"; import { invalidateCache } from "./composerHistory"; -import { formatContextMaintenanceNotice } from "./contextMaintenanceTypes"; +import { formatContextMaintenanceNotice, isNewMaintenanceOperation, rememberMaintenanceOperation } from "./contextMaintenanceTypes"; import { formatGuardianAssessmentNotice } from "./guardianEvents"; import { invalidateSharedQuery } from "./queryCoalesce"; import { createRafBatch } from "./rafBatch"; @@ -467,6 +467,9 @@ interface State { // title…). Drives right-panel snapshot refreshes so sub-agent activity keeps // the session metrics live; state.usage stays executor-gated for the gauge. usageSeq: number; + // Bounded set of context_maintenance operationIds already shown as notices + // so reconnect/replay does not insert duplicate timeline cards. + seenMaintenanceOps: string[]; // Extension UI surfaces (stage 8b2). See the ExtensionStatusEntry block // above for the keying/lifecycle rules. extensionStatuses: Record; @@ -524,6 +527,7 @@ export const initialState: State = { sessionGen: 0, contextPanelSeq: 0, usageSeq: 0, + seenMaintenanceOps: [], extensionStatuses: {}, extensionNotifications: [], extensionGenerations: {}, @@ -1701,7 +1705,9 @@ function applyEvent(s: State, e: WireEvent): State { case "context_maintenance": { const m = e.maintenance; if (!m || m.status === "noop") return s; - return appendNoticeToState(s, m.status === "failed" ? "warn" : "info", formatContextMaintenanceNotice(m, t), m.reason); + if (!isNewMaintenanceOperation(s.seenMaintenanceOps, m.operationId)) return s; + const next = appendNoticeToState(s, m.status === "failed" ? "warn" : "info", formatContextMaintenanceNotice(m, t), m.reason); + return { ...next, seenMaintenanceOps: rememberMaintenanceOperation(s.seenMaintenanceOps, m.operationId) }; } case "phase": return { ...s, seq: s.seq + 1, items: [...s.items, { kind: "phase", id: `p${s.seq}`, text: e.text ?? "" }] }; diff --git a/desktop/frontend/src/locales/en.ts b/desktop/frontend/src/locales/en.ts index c98ed96784..323b24e975 100644 --- a/desktop/frontend/src/locales/en.ts +++ b/desktop/frontend/src/locales/en.ts @@ -1766,10 +1766,6 @@ export const en = { "settings.defaultToolApprovalMode.yolo": "Yolo", "settings.agentRuntime": "Agent runtime", "settings.agentRuntimeHint": "Reasonix manages execution progress automatically; these preferences control resume behavior and visible reasoning.", - "settings.coldResumePrune": "Trim on cold reopen", - "settings.coldResumePruneHint": "When reopening an expired session, trim old tool results to reduce continuation cost. Turn off to keep full history.", - "settings.coldResumePrune.on": "On", - "settings.coldResumePrune.off": "Off", "settings.reasoningLanguage": "Thinking language", "settings.reasoningLanguageHint": "Affects visible reasoning only. Auto anchors clear Chinese prompts; referenced context is ignored.", "settings.reasoningLanguage.auto": "Auto", @@ -1786,10 +1782,10 @@ export const en = { "settings.compactRatioCustomValue": "{percent}% · Custom", "settings.compactRatioApply": "Apply", "settings.compactRatioCustomAria": "Custom compaction threshold percentage", - "settings.compactRatioCustomHint": "Set 65%–85%. Tool output is trimmed at 60%; 90% forces compaction. Press Enter to apply or Esc to cancel.", - "settings.compactRatioPreset.70": "70% · Earlier", - "settings.compactRatioPreset.80": "80% · Balanced", - "settings.compactRatioPreset.85": "85% · Cache first", + "settings.compactRatioCustomHint": "Set 65%–85%. When the threshold is reached, Reasonix builds one structured history summary and keeps a recent verbatim tail. Press Enter to apply or Esc to cancel.", + "settings.compactRatioPreset.70": "70% · Active", + "settings.compactRatioPreset.80": "80% · Steady", + "settings.compactRatioPreset.85": "85% · Recommended", "settings.sound": "Sound", "settings.soundHint": "Background music and notification sounds", "settings.soundStatus.allOff": "All off", @@ -3091,7 +3087,21 @@ export const en = { "context.windowUsed": "Used context", "context.windowCompactThreshold": "Compression threshold", "context.windowCompactHeadroom": "Before compression", - "context.maintenanceTitle": "Current context composition", + "context.maintenanceTitle": "Context checkpoint", + "context.maintenanceAppliedSummary": "Built a short context view · history summarized", + "context.maintenanceBlockedSummary": "Context summary could not form a safe short view · auto-retry stopped", + "context.maintenanceFailedSummary": "Context summary failed · auto-retry stopped", + "context.maintenanceCanonical": "Full history", + "context.maintenanceVisible": "Model-visible", + "context.maintenanceTrigger": "Trigger", + "context.maintenanceTriggerHint": "Effective compact_ratio {percent}% of the window", + "context.maintenanceRatio": "Threshold", + "context.maintenanceCheckpoint": "Checkpoint", + "context.maintenanceCheckpointNone": "None", + "context.maintenanceCheckpointApplied": "Created this session", + "context.maintenanceCheckpointRestored": "Restored short view", + "context.maintenanceVersion": "Version", + "context.maintenanceSaved": "Last savings", "context.maintenanceActionSnip": "Tool result snip", "context.maintenanceActionPrune": "Tool result prune", "context.maintenanceActionNative": "Native tool clearing", diff --git a/desktop/frontend/src/locales/zh-TW.ts b/desktop/frontend/src/locales/zh-TW.ts index 057a1e3a3d..35bbd9e4e0 100644 --- a/desktop/frontend/src/locales/zh-TW.ts +++ b/desktop/frontend/src/locales/zh-TW.ts @@ -1349,10 +1349,6 @@ export const zhTW: Record = { "settings.defaultToolApprovalMode.yolo": "Yolo", "settings.agentRuntime": "Agent 執行", "settings.agentRuntimeHint": "Reasonix 會自動管理執行進度;這裡僅保留續聊行為和可見推理偏好。", - "settings.coldResumePrune": "冷啟動精簡", - "settings.coldResumePruneHint": "重開過期工作階段時精簡舊工具結果,降低續聊成本;關閉則保留完整歷史。", - "settings.coldResumePrune.on": "開", - "settings.coldResumePrune.off": "關", "settings.reasoningLanguage": "思考語言", "settings.reasoningLanguageHint": "只影響可見思考過程。自動在原始提問明顯為中文時錨定中文,引用內容不參與判斷。", "settings.reasoningLanguage.auto": "自動", @@ -1361,18 +1357,18 @@ export const zhTW: Record = { "settings.compactRatio": "自動壓縮閾值", "settings.compactRatioHint": "全域預設 · 通常無需修改", "settings.compactRatioCurrent": "目前閾值:{value}", - "settings.compactRatioImpact": "調低會更早壓縮,並可能降低提示詞快取複用率。", - "settings.compactRatioImpactWithTokens": "預設模型約在 {tokens} tokens 時壓縮。調低會更早壓縮,並可能降低提示詞快取複用率。", - "settings.compactRatioProjectOverride": "目前專案覆寫了這個全域預設值;實際生效閾值為 {percent}%。", + "settings.compactRatioImpact": "調低會更早壓縮,可能降低快取複用。", + "settings.compactRatioImpactWithTokens": "約在 {tokens} tokens 時壓縮。調低可能降低快取複用。", + "settings.compactRatioProjectOverride": "專案覆寫預設;生效 {percent}%。", "settings.compactRatioCustom": "自訂閾值", "settings.compactRatioCustomOption": "自訂閾值…", "settings.compactRatioCustomValue": "{percent}% · 自訂", "settings.compactRatioApply": "套用", "settings.compactRatioCustomAria": "自訂壓縮閾值百分比", - "settings.compactRatioCustomHint": "可設定 65%–85%。系統會在 60% 先清理舊工具輸出,並在 90% 強制壓縮;此範圍用於避免頻繁壓縮或上下文溢位。按 Enter 套用,按 Esc 取消。", - "settings.compactRatioPreset.70": "70% · 提前清理", - "settings.compactRatioPreset.80": "80% · 平衡", - "settings.compactRatioPreset.85": "85% · 快取優先", + "settings.compactRatioCustomHint": "65至85%。達閾值後摘要並保留近期原文。Enter 套用。", + "settings.compactRatioPreset.70": "70% · 主動", + "settings.compactRatioPreset.80": "80% · 穩妥", + "settings.compactRatioPreset.85": "85% · 推薦", "settings.keySet": "已設金鑰", "settings.noKey": "無金鑰", "settings.noKeyRequired": "無需金鑰", @@ -3177,7 +3173,21 @@ export const zhTW: Record = { "context.windowUsed": "已用上下文", "context.windowCompactThreshold": "壓縮閾值", "context.windowCompactHeadroom": "壓縮前餘量", - "context.maintenanceTitle": "上下文構成", + "context.maintenanceTitle": "上下文短視圖", + "context.maintenanceAppliedSummary": "已生成短視圖", + "context.maintenanceBlockedSummary": "摘要未形成短視圖 · 已停重試", + "context.maintenanceFailedSummary": "摘要失敗 · 已停重試", + "context.maintenanceCanonical": "完整歷史", + "context.maintenanceVisible": "模型可見", + "context.maintenanceTrigger": "觸發點", + "context.maintenanceTriggerHint": "compact_ratio {percent}% × 窗口", + "context.maintenanceRatio": "閾值", + "context.maintenanceCheckpoint": "短視圖", + "context.maintenanceCheckpointNone": "無", + "context.maintenanceCheckpointApplied": "本會話剛生成", + "context.maintenanceCheckpointRestored": "已恢復短視圖", + "context.maintenanceVersion": "版本", + "context.maintenanceSaved": "上次節省", "context.maintenanceActionSnip": "裁短", "context.maintenanceActionPrune": "裁剪", "context.maintenanceActionNative": "原生清理", diff --git a/desktop/frontend/src/locales/zh.ts b/desktop/frontend/src/locales/zh.ts index 696102b85c..e78a17ec94 100644 --- a/desktop/frontend/src/locales/zh.ts +++ b/desktop/frontend/src/locales/zh.ts @@ -1768,10 +1768,6 @@ export const zh: Record = { "settings.defaultToolApprovalMode.yolo": "Yolo", "settings.agentRuntime": "Agent 运行", "settings.agentRuntimeHint": "Reasonix 会自动管理执行进度;这里仅保留续聊行为和可见推理偏好。", - "settings.coldResumePrune": "冷启动精简", - "settings.coldResumePruneHint": "重开过期会话时精简旧工具结果,降低续聊成本;关闭则保留完整历史。", - "settings.coldResumePrune.on": "开", - "settings.coldResumePrune.off": "关", "settings.reasoningLanguage": "思考语言", "settings.reasoningLanguageHint": "只影响可见思考过程。自动在原始提问明显为中文时锚定中文,引用内容不参与判断。", "settings.reasoningLanguage.auto": "自动", @@ -1780,18 +1776,18 @@ export const zh: Record = { "settings.compactRatio": "自动压缩阈值", "settings.compactRatioHint": "全局默认 · 通常无需修改", "settings.compactRatioCurrent": "当前阈值:{value}", - "settings.compactRatioImpact": "调低会更早压缩,并可能降低提示词缓存复用率。", - "settings.compactRatioImpactWithTokens": "默认模型约在 {tokens} tokens 时压缩。调低会更早压缩,并可能降低提示词缓存复用率。", - "settings.compactRatioProjectOverride": "当前项目覆盖了这个全局默认值;实际生效阈值为 {percent}%。", + "settings.compactRatioImpact": "调低会更早压缩,可能降低缓存复用。", + "settings.compactRatioImpactWithTokens": "约在 {tokens} tokens 时压缩。调低可能降低缓存复用。", + "settings.compactRatioProjectOverride": "项目覆盖默认;生效 {percent}%。", "settings.compactRatioCustom": "自定义阈值", "settings.compactRatioCustomOption": "自定义阈值…", "settings.compactRatioCustomValue": "{percent}% · 自定义", "settings.compactRatioApply": "应用", "settings.compactRatioCustomAria": "自定义压缩阈值百分比", - "settings.compactRatioCustomHint": "可设置 65%–85%。系统会在 60% 先清理旧工具输出,并在 90% 强制压缩;该范围用于避免频繁压缩或上下文溢出。按 Enter 应用,按 Esc 取消。", - "settings.compactRatioPreset.70": "70% · 提前清理", - "settings.compactRatioPreset.80": "80% · 平衡", - "settings.compactRatioPreset.85": "85% · 缓存优先", + "settings.compactRatioCustomHint": "65至85%。达阈值后摘要并保留近期原文。Enter 应用。", + "settings.compactRatioPreset.70": "70% · 主动", + "settings.compactRatioPreset.80": "80% · 稳妥", + "settings.compactRatioPreset.85": "85% · 推荐", "settings.sound": "声音", "settings.soundHint": "背景音乐与通知音效", "settings.soundStatus.allOff": "全部关闭", @@ -3094,7 +3090,21 @@ export const zh: Record = { "context.windowUsed": "已用上下文", "context.windowCompactThreshold": "压缩阈值", "context.windowCompactHeadroom": "压缩前余量", - "context.maintenanceTitle": "上下文构成", + "context.maintenanceTitle": "上下文短视图", + "context.maintenanceAppliedSummary": "已生成短视图", + "context.maintenanceBlockedSummary": "摘要未形成短视图 · 已停重试", + "context.maintenanceFailedSummary": "摘要失败 · 已停重试", + "context.maintenanceCanonical": "完整历史", + "context.maintenanceVisible": "模型可见", + "context.maintenanceTrigger": "触发点", + "context.maintenanceTriggerHint": "compact_ratio {percent}% × 窗口", + "context.maintenanceRatio": "阈值", + "context.maintenanceCheckpoint": "短视图", + "context.maintenanceCheckpointNone": "无", + "context.maintenanceCheckpointApplied": "本会话刚生成", + "context.maintenanceCheckpointRestored": "已恢复短视图", + "context.maintenanceVersion": "版本", + "context.maintenanceSaved": "上次节省", "context.maintenanceActionSnip": "裁短", "context.maintenanceActionPrune": "裁剪", "context.maintenanceActionNative": "原生清理", diff --git a/desktop/frontend/src/styles.css b/desktop/frontend/src/styles.css index 197060e31e..ceb94aa128 100644 --- a/desktop/frontend/src/styles.css +++ b/desktop/frontend/src/styles.css @@ -25038,6 +25038,19 @@ body > .mermaid-diagram--fullscreen { linear-gradient(180deg, color-mix(in srgb, var(--accent) 5%, var(--bg)) 0%, var(--bg) 100%); } +.context-panel__maintenance { + min-width: 0; + margin-top: 10px; + display: grid; + gap: 8px; +} + +.context-panel__maintenance-rows { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px 8px; +} + .context-panel__capacity-card--good { border-color: color-mix(in srgb, var(--ok) 20%, var(--border-soft)); } diff --git a/desktop/frontend/src/test-support/settingsTestFixtures.ts b/desktop/frontend/src/test-support/settingsTestFixtures.ts index 1e57e440dd..8e78736979 100644 --- a/desktop/frontend/src/test-support/settingsTestFixtures.ts +++ b/desktop/frontend/src/test-support/settingsTestFixtures.ts @@ -43,7 +43,7 @@ export function baseSettings(displayMode: "standard" | "compact" = "standard"): permissions: { mode: "ask", allow: [], ask: [], deny: [] }, sandbox: { bash: "enforce", network: false, workspaceRoot: "", allowWrite: [], effectiveWorkspaceRoot: "/work", effectiveWriteRoots: ["/work"], shell: "auto" }, network: { proxyMode: "auto", proxyUrl: "", noProxy: "", proxy: { type: "socks5", server: "", port: 0, username: "", password: "" } }, - agent: { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, maxSubagentConcurrency: 6, maxParallelWriters: 3, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto", compactRatio: 0.8 }, + agent: { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, maxSubagentConcurrency: 6, maxParallelWriters: 3, systemPrompt: "", reasoningLanguage: "auto", compactRatio: 0.85 }, bot: { enabled: false, model: "", diff --git a/desktop/reasoning_display_app.go b/desktop/reasoning_display_app.go index b47fba4a14..004415e31c 100644 --- a/desktop/reasoning_display_app.go +++ b/desktop/reasoning_display_app.go @@ -40,8 +40,8 @@ func (a *App) defaultSettingsView() SettingsView { Agent: AgentView{ PlannerMaxSteps: 0, MaxSubagentDepth: agent.DefaultMaxSubagentDepth, MaxSubagentConcurrency: agent.DefaultMaxSubagentConcurrency, MaxParallelWriters: agent.DefaultMaxParallelWriters, - ColdResumePrune: true, ReasoningLanguage: "auto", - CompactRatio: defaults.Agent.CompactRatio, EffectiveCompactRatio: defaults.Agent.CompactRatio, + ReasoningLanguage: "auto", + CompactRatio: defaults.Agent.CompactRatio, EffectiveCompactRatio: defaults.Agent.CompactRatio, }, Bot: botSettingsView(config.BotConfig{}), AutoPlan: "off", DesktopLayoutStyle: "workbench", DesktopTheme: "auto", DesktopThemeStyle: "graphite", DesktopTerminalTheme: "auto", diff --git a/desktop/settings_app.go b/desktop/settings_app.go index 19d69143ed..3cb68cc77f 100644 --- a/desktop/settings_app.go +++ b/desktop/settings_app.go @@ -162,7 +162,6 @@ type AgentView struct { MaxSubagentConcurrency int `json:"maxSubagentConcurrency"` MaxParallelWriters int `json:"maxParallelWriters"` SystemPrompt string `json:"systemPrompt"` - ColdResumePrune bool `json:"coldResumePrune"` ReasoningLanguage string `json:"reasoningLanguage"` CompactRatio float64 `json:"compactRatio,omitempty"` EffectiveCompactRatio float64 `json:"effectiveCompactRatio,omitempty"` @@ -1003,7 +1002,6 @@ func (a *App) Settings() SettingsView { MaxSubagentConcurrency: desktopSubagentConcurrency(cfg.Agent.MaxSubagentConcurrency), MaxParallelWriters: desktopParallelWriters(cfg.Agent.MaxParallelWriters, cfg.Agent.MaxSubagentConcurrency), SystemPrompt: cfg.Agent.SystemPrompt, - ColdResumePrune: cfg.ColdResumePruneEnabled(), ReasoningLanguage: cfg.ReasoningLanguage(), CompactRatio: cfg.Agent.CompactRatio, EffectiveCompactRatio: cfg.Agent.CompactRatio, @@ -3505,10 +3503,6 @@ func (a *App) SetAgentParams(temperature float64, maxSteps int, plannerMaxSteps }) } -func (a *App) SetColdResumePrune(enabled bool) error { - return a.applyConfigChange(func(c *config.Config) error { return c.SetColdResumePrune(enabled) }) -} - func (a *App) SetCompactRatio(ratio float64) error { _, err := a.applyConfigChangeWithWarning("context compaction threshold", func(c *config.Config) error { return c.SetCompactRatio(ratio) diff --git a/desktop/settings_app_test.go b/desktop/settings_app_test.go index b835333132..ad38a22f77 100644 --- a/desktop/settings_app_test.go +++ b/desktop/settings_app_test.go @@ -1956,8 +1956,8 @@ func TestSetCompactRatioPersistsToUserConfig(t *testing.T) { app := NewApp() defaultView := app.Settings() - if defaultView.Agent.CompactRatio != 0.8 || defaultView.Agent.EffectiveCompactRatio != 0.8 { - t.Fatalf("default compact ratios = %v/%v, want 0.8/0.8", defaultView.Agent.CompactRatio, defaultView.Agent.EffectiveCompactRatio) + if defaultView.Agent.CompactRatio != 0.85 || defaultView.Agent.EffectiveCompactRatio != 0.85 { + t.Fatalf("default compact ratios = %v/%v, want 0.85/0.85", defaultView.Agent.CompactRatio, defaultView.Agent.EffectiveCompactRatio) } if err := app.SetCompactRatio(0.7); err != nil { t.Fatalf("SetCompactRatio: %v", err) @@ -1972,8 +1972,9 @@ func TestSetCompactRatioPersistsToUserConfig(t *testing.T) { if cfg.Agent.CompactRatio != 0.7 { t.Fatalf("saved compact ratio = %v, want 0.7", cfg.Agent.CompactRatio) } - if cfg.Agent.ToolResultSnipRatio != 0.6 || cfg.Agent.CompactForceRatio != 0.9 { - t.Fatalf("setting compact ratio changed adjacent thresholds: %+v", cfg.Agent) + // Deprecated multi-threshold fields stay cleared / unused. + if cfg.Agent.ToolResultSnipRatio != 0 || cfg.Agent.CompactForceRatio != 0 { + t.Fatalf("setting compact ratio revived deprecated thresholds: %+v", cfg.Agent) } if err := app.SetCompactRatio(0.9); err == nil { @@ -1994,7 +1995,7 @@ func TestSetCompactRatioRejectsActiveWorkBeforeSaving(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "stop background jobs") { t.Fatalf("SetCompactRatio with background job error = %v, want active-work guard", err) } - if got := config.LoadForEdit(config.UserConfigPath()).Agent.CompactRatio; got != 0.8 { + if got := config.LoadForEdit(config.UserConfigPath()).Agent.CompactRatio; got != 0.85 { t.Fatalf("compact ratio changed after rejected update: %v", got) } } diff --git a/docs/CLI.md b/docs/CLI.md index eaa54833d7..1fdbb23d92 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -118,7 +118,7 @@ reasonix config compact-ratio 75 # set the user-global default reasonix config compact-ratio --local 75 # override in ./reasonix.toml ``` -The editable range is 65–85%, with 80% as the built-in default. Lower values +The editable range is 65–85%, with 85% as the built-in default. Lower values compact earlier and may reduce prompt-prefix cache reuse; higher values retain more context before compaction. Project `reasonix.toml` takes precedence over the user config. Changes apply to new CLI sessions; an already-running session diff --git a/docs/CLI.zh-CN.md b/docs/CLI.zh-CN.md index db199abf62..ee9125754d 100644 --- a/docs/CLI.zh-CN.md +++ b/docs/CLI.zh-CN.md @@ -107,7 +107,7 @@ reasonix config compact-ratio 75 # 设置用户全局默认值 reasonix config compact-ratio --local 75 # 写入 ./reasonix.toml 项目覆盖 ``` -可设置范围为 65–85%,内置默认值为 80%。数值越低越早压缩,可能降低 prompt prefix +可设置范围为 65–85%,内置默认值为 85%。数值越低越早压缩,可能降低 prompt prefix 缓存复用率;数值越高则会在压缩前保留更多上下文。项目 `reasonix.toml` 的优先级高于 用户全局配置。修改会应用于新启动的 CLI 会话;已经运行的会话继续使用启动时加载的阈值。 diff --git a/docs/GOAL_ENFORCEMENT.zh-CN.md b/docs/GOAL_ENFORCEMENT.zh-CN.md index 4a480afb5e..4d881e8e23 100644 --- a/docs/GOAL_ENFORCEMENT.zh-CN.md +++ b/docs/GOAL_ENFORCEMENT.zh-CN.md @@ -69,7 +69,7 @@ runtime: turns 12/20, tokens 214000, requests 37, no-progress 6 (observational), `/goal resume` 恢复目标:只有 10/20/40 外层轮次型暂停才追加一档同类别轮数;`goal_run_budget` 与 `goal_stuck` 只开启一个新的 Run,不增加外层额度。累计 token、请求数与 `budget_extensions` 保留。旧版本因 `budget_tokens` 或 `no_progress` 暂停的 sidecar 在新版本加载时会自动改为 `running` 并立即持久化;旧 `noProgressLimit` 字段继续兼容读写但不再参与决策。 -上下文压缩继续使用全局既有策略(约 50% 提示、60% 工具结果清理、80% compact、90% 强制 compact)。Goal 开启本身不额外触发 summarizer,也不改变工具 Schema 或稳定 prompt 前缀。 +上下文压缩继续使用全局既有策略:仅由 `compact_ratio`(默认 85%)触发一次内容驱动摘要 checkpoint,不另设 soft/snip/force 多阈值。Goal 开启本身不额外触发 summarizer,也不改变工具 Schema 或稳定 prompt 前缀。 ### 任务合约 diff --git a/docs/GUIDE.md b/docs/GUIDE.md index dbb7ea0a9a..cf15935a68 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -68,8 +68,12 @@ reasoning_language = "auto" # visible reasoning text: auto|zh|en # max_subagent_depth = 2 # nested delegation depth; set 1 for the old single-layer boundary # max_subagent_concurrency = 6 # session-wide sub-agent concurrency (task/fleet/skills) # max_parallel_writers = 3 # concurrent writers with non-overlapping write_paths -tool_result_snip_ratio = 0.6 # at compact_ratio, pruning stale tool output replaces the summary if it gets under this -# context_editing = "native" # opt in only for the official Anthropic endpoint; default local +# compact_ratio = 0.85 # sole auto trigger; presets 0.70 / 0.80 / 0.85 +# max_output_tokens = 0 # recommended: automatic (DeepSeek default high → ~64K; not unlimited) +# max_output_tokens = 32768 # ordinary coding / cost control +# max_output_tokens = 65536 # heavy reasoning / long tool loops +# max_output_tokens = 131072 # only after repeated finish_reason=length +# max_output_tokens never changes compact_ratio; only the final send-time clip does [[providers]] name = "deepseek-flash" diff --git a/docs/GUIDE.zh-CN.md b/docs/GUIDE.zh-CN.md index 000e720bd8..5499681e9d 100644 --- a/docs/GUIDE.zh-CN.md +++ b/docs/GUIDE.zh-CN.md @@ -62,8 +62,12 @@ reasoning_language = "auto" # 可见思考过程语言:auto|zh|en # max_subagent_depth = 2 # 子代理嵌套委派深度;设为 1 可恢复旧的单层边界 # max_subagent_concurrency = 6 # 会话级子代理总并发(task/fleet/skills) # max_parallel_writers = 3 # 互不重叠 write_paths 时的并行写入上限 -tool_result_snip_ratio = 0.6 # 到达 compact_ratio 时,修剪旧工具输出若能降到此值以下则免去摘要 -# context_editing = "native" # 仅官方 Anthropic 端点显式启用;默认 local +# compact_ratio 是唯一自动维护阈值(默认 0.85;预设 0.70/0.80/0.85) +# max_output_tokens = 0 # 推荐:自动(DeepSeek 默认 high → 约 64K;不是无限) +# max_output_tokens = 32768 # 普通编码 / 控制费用 +# max_output_tokens = 65536 # 重推理、长工具链 +# max_output_tokens = 131072 # 仅在反复 finish_reason=length 时再考虑 +# max_output_tokens 不参与 compact_ratio;只在发送阶段裁剪本轮最长输出 [[providers]] name = "deepseek-flash" diff --git a/docs/SPEC.md b/docs/SPEC.md index b594e9f89a..f22ce7c2c3 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -248,72 +248,59 @@ prefix cache-stable: "cache-first" with "two-model collaboration": switching models *inside one shared conversation* would break the prefix and tank cache hits, so we don't. -### 3.6 Context management (compaction) +### 3.6 Context management (content-driven summary) -Long tasks eventually fill the model's context window. Reasonix manages this with -**low-frequency compaction** that respects the cache-first design: +Long tasks fill the model window. Reasonix keeps a **cache-first, append-only** +canonical transcript and installs a short **provider-visible checkpoint** only +when the sole automatic threshold is crossed. -- Each provider declares its `context_window` (tokens). There is one trigger: - `agent.compact_ratio` (default `0.8`). Below it the history is never - rewritten, because a rewrite invalidates the provider's prompt cache from that - point on; `agent.soft_compact_ratio` (default `0.5`) only emits a notice. At - the trigger, stale tool results before the recent tail are archived and pruned - to short placeholders first; if that alone brings the prompt below - `agent.tool_result_snip_ratio` (default `0.6`) it stands in for the summary and - no summarizer call is made, otherwise summary compaction runs. At - `agent.compact_force_ratio` (default `0.9`), the forced fold may proceed even - when the fold economics would normally skip it. -- Users can inspect or change the 65–85% automatic threshold with - `reasonix config compact-ratio [--local] [VALUE]`. The default is 80%; the - project-local value overrides the shared user config used by desktop and new - CLI sessions. -- A positive `model_overrides..context_window` replaces the provider-wide - value after model resolution. Missing or zero model overrides inherit the - provider value; provider-level `context_window = 0` disables compaction. -- `max_output_tokens` is a separate total-output budget, not a conversion from - the client reasoning byte guard. Zero selects the provider's safe default, - positive values set an explicit cap, and negative values omit optional wire - limits. `model_overrides..max_output_tokens` can specialize mixed - gateways; Anthropic still supplies a mandatory `max_tokens` fallback. -- Tool-result snip/prune never removes messages, so assistant `tool_calls` and - tool results stay paired. `KeepErrors` preserves failed tool outputs, - recognised from the recorded execution (state, exit code, verification) or, - when there is none, an `error:`/`blocked:` text prefix. A failure with a - recorded execution keeps its failure-carrying lines rather than the whole - result, since the record still identifies it as a failure after a rewrite; a - text-only failure has no such anchor and is kept whole. The recent tail is not - rewritten. Snipped results can later be upgraded to pruned placeholders; - already-pruned results are left alone. -- Automatic maintenance is planned once before a sampling request from the - current visible projection plus its append-only canonical tail. It never - rewrites the canonical transcript. A failed or non-convergent view fingerprint - is durably blocked until the transcript, model, provider policy, or projection - lineage changes, preventing the same cleanup or summary from looping. -- `agent.context_editing` defaults to `"local"`. Setting it to `"native"` opts - the official Anthropic endpoint into native tool-use clearing; DeepSeek and - other Anthropic-compatible gateways remain on local maintenance. Native tool - clearing does not replace Reasonix summary folding or canonical history. -- When summary compaction runs, the fold region (everything between the pinned - prefix and the recent tail) is split three ways: the first few **small user - turns** are hoisted verbatim ahead of the digest, messages the keep policy - protects are kept, and **everything else** — assistant/tool work, later - user turns, and any prior digest — is summarized into a single digest, using - the executor's own provider, no tools. The split is a partition: a message in - the region is either kept verbatim or reaches the summarizer, never neither. - The tail boundary is aligned backward off any tool result so the recent tail - never begins with an orphan tool message whose `tool_calls` were summarized - away. -- One fold never outgrows one summarizer call. A fold region larger than such a - call can hold — the window minus room for the digest it must return, the - summary prompt, and the caller's instructions — first gives up the bulk of its - stale tool results: head and tail lines are kept, and only in the summarizer's - copy, never in the transcript or the projection. A region still too large is summarized in - consecutive parts whose digests are merged in a final pass, capped so a single - compaction cannot cost an unbounded number of calls; whatever a part had to - drop is stated in the text the summarizer reads. -- The dropped originals are archived under the user config dir - (`reasonix/archive/.jsonl`; see §5 for its per-OS location), one - message per line, so the full history stays traceable. +- Each provider declares `context_window` (tokens). The only automatic trigger is + `agent.compact_ratio` (default **0.85**; presets 0.70 / 0.80 / 0.85; range + 0.65–0.85). + `triggerTokens = floor(context_window × compact_ratio)`. +- **Below the trigger** history is never rewritten: no summary, no prune/snip + projection, no sidecar write, no projection-version bump, no maintenance event. + Any rewrite would invalidate the prompt cache from that point on. +- **At the trigger** Reasonix runs **one** summary transaction: + `stable prefix + one structured digest + recent verbatim tail`. + Acceptance (normal path): candidate ≤ 50% of the window, strictly smaller than + the source, and below `triggerTokens`. Candidates are **not** padded toward 50%. + Typical landings are about 10%–30% of the window. + Internal construction budgets (not user settings): + `recentTailBudget = clamp(window×10%, 32K, 96K)`, summary output max **16K**. +- Users inspect or change the threshold with + `reasonix config compact-ratio [--local] [VALUE]`. Project config overrides the + user-global value used by desktop and new CLI sessions. UI always shows the + **effective** ratio. +- `max_output_tokens` is an independent **per-turn** completion ceiling. + Recommended: `0` (**automatic**, not unlimited; DeepSeek default high → ~64K). + User presets: `32768` ordinary coding / cost control, `65536` heavy reasoning / + long tool loops, `131072` only after repeated `finish_reason=length`. + Negative omits optional wire limits when the protocol allows. Clipped only at + send time against remaining window and **never** changes `triggerTokens` or + maintenance timing. Billing follows actual completion tokens, not the ceiling. +- Giant tool results are bounded **once**, on first entry to the model: + `Content` is the stable ≤32KB visible form; `RawContent` holds the full original + only when they differ. Maintenance never rewrites old tool bodies. + `ModelMessages` strips `RawContent` so provider serialization and cache hashes + never include it. +- Automatic maintenance is planned once in `ContextManager.Prepare` from the + current projection plus the append-only canonical tail. The canonical + transcript is never rewritten. Subsequent thresholds merge + **prior digest + new history** into a single digest (no multi-span merge, no + application-layer retry). Failure records a generation-scoped + `blocked`/`failed` receipt; the same generation does not pay for another + automatic summary. Manual `compress` can retry. +- Old multi-threshold keys (`soft_compact_ratio`, `tool_result_snip_ratio`, + `compact_force_ratio`, `cold_resume_prune`, `context_editing`) are removed on + ordinary start and ignored at runtime. Native provider tool clearing is not + used; every provider uses the local summary checkpoint path. +- Keep policy (`keep` / `recent_keep`) and the active tool turn remain protected + content. Restart restores an existing checkpoint without re-summarizing or + replaying timeline cards. +- Full history remains in the session transcript. The read-only `history` tool + provides BM25 retrieval over sessions; new summary checkpoints do not create + prune archives. - The read-only `history` tool gives the agent on-demand BM25 retrieval over saved session JSONL files. `scope="project"` searches the current controller's session directory; `scope="global"` also searches the user-global session @@ -1018,7 +1005,11 @@ default = "deepseek-v4-flash" # optional; defaults to models[0] api_key_env = "DEEPSEEK_API_KEY" web_search = true context_window = 1000000 # tokens; harness compacts older history near this limit (0 disables) -max_output_tokens = 32768 # total visible + reasoning + tool-call output; 0 = provider default +# max_output_tokens = 0 # recommended: automatic (DeepSeek default high → ~64K) +# max_output_tokens = 32768 # ordinary coding / cost control +# max_output_tokens = 65536 # heavy reasoning / long tool loops +# max_output_tokens = 131072 # only after repeated finish_reason=length +# max_output_tokens never changes compact_ratio # model_overrides = { "deepseek-v4-flash" = { context_window = 1000000, max_output_tokens = 32768 } } # A single-model entry still works for custom OpenAI-compatible endpoints. diff --git a/docs/SPEC.zh-CN.md b/docs/SPEC.zh-CN.md index e5770a61df..3a7301662a 100644 --- a/docs/SPEC.zh-CN.md +++ b/docs/SPEC.zh-CN.md @@ -73,7 +73,7 @@ func New(kind string, cfg Config) (Provider, error) - OpenAI-compatible vendor 只是 `kind = "openai"` 的不同配置实例,通过 `base_url`、`model`、`api_key_env` 区分;新增兼容模型通常只需改配置。 - 一个 provider 表示一个 vendor endpoint,可通过 `models` 暴露多个模型,并以 `default` 指定默认项。`default_model`、`--model` 和桌面端模型选择器都经 `Config.ResolveModel` 解析,可接受 provider 名、裸模型名或 `provider/model`。 - `context_window` 是 provider 级默认值;`model_overrides..context_window` 可覆盖单个模型。 -- `max_output_tokens` 是独立的总输出预算,不由客户端 reasoning 字节上限换算。0 表示使用 provider 安全默认值,正数表示显式上限,负数表示在协议允许时省略;混合网关可用 `model_overrides..max_output_tokens` 覆盖单个模型。Anthropic 因协议要求仍会提供 `max_tokens` 默认值。 +- `max_output_tokens` 是独立的本轮输出上限,不由客户端 reasoning 字节上限换算,也不参与 `compact_ratio`。推荐 `0`(自动:DeepSeek 默认 high 约 64K);显式 `32768` 控费/普通编码,`65536` 重推理/长工具链,`131072` 仅在反复 `finish_reason=length` 时再考虑。正数为显式上限,负数为在协议允许时省略;混合网关可用 `model_overrides..max_output_tokens` 覆盖单个模型。Anthropic 因协议要求仍会提供 `max_tokens` 默认值。 - streaming tool-call delta 在 provider 内按 index 聚合,只向上层发出完整 `ToolCall`。 ### 3.2 Tool 与 registry(`internal/tool`) @@ -140,51 +140,45 @@ type Tool interface { - executor 在另一 session 中验证候选假设,并使用完整工具执行计划; - 两条会话互不混合,prompt prefix 都只追加增长,避免切换模型破坏 prefix cache。 -### 3.6 上下文管理 +### 3.6 上下文管理(内容驱动摘要) -Reasonix 通过低频 compaction 保持 cache-first: +长任务会填满模型窗口。Reasonix 保持 **cache-first、append-only** 的 canonical +transcript,仅在唯一自动阈值被跨越时安装 provider 可见的短 **checkpoint**。 -- 低于 `agent.compact_ratio` 时绝不改写历史:任何改写都会让该处之后的 prompt - 缓存整体失效;`agent.soft_compact_ratio` 只发提示,不参与决策; -- 达到 `agent.compact_ratio` 后,先归档并把旧 tool result 修剪为占位符;若仅此 - 就降到 `agent.tool_result_snip_ratio` 以下,则以它替代摘要,不调用摘要模型, - 否则再执行摘要 compaction; -- 达到 `agent.compact_force_ratio` 后,可执行强制折叠; -- `context_window = 0` 会关闭该实例的 compaction。 - -用户可用 `reasonix config compact-ratio [--local] [VALUE]` 查看或修改 65–85% 的自动 -压缩阈值,内置默认值为 80%。项目级设置优先于桌面端与新 CLI 会话共用的用户全局配置。 - -tool result 的 snip/prune 不删除消息,确保 assistant `tool_calls` 与 tool result 配对。 - -每次自动维护只在模型请求前规划一次,输入是当前可见 projection 加上 canonical 的追加 tail; -维护流程绝不改写 canonical transcript。失败或无法收敛的视图指纹会持久进入 blocked 状态, -直到 transcript、模型、provider 策略或 projection lineage 变化,避免相同裁剪或摘要循环重试。 - -`agent.context_editing` 默认为 `"local"`。显式设为 `"native"` 时,只有官方 Anthropic -端点启用原生 tool-use clearing;DeepSeek 与其他 Anthropic 兼容网关仍使用本地维护。 -原生工具清理不会替代 Reasonix 的摘要折叠,也不会修改 canonical history。 - -摘要折叠时,固定前缀与近期 tail 之间的区间被划分为三部分:开头若干条小体量用户回合原样提升到 -digest 之前,keep policy 保护的消息予以保留,其余全部——assistant/tool 工作、后续用户回合、以及 -已有 digest——折叠进同一条 digest。三者构成一个划分:区间内的消息要么原样保留,要么进入摘要输入, -不存在两者皆非的情况。 - -一次折叠不会超出一次摘要调用。折叠区间超过单次调用能容纳的量(窗口减去 digest 出参、摘要提示词与 -调用方 instructions 所占空间)时,先让旧 tool result 交出主体 -(只保留首尾若干行,且仅作用于送给摘要器的副本,transcript 与 projection 都不改);仍然过大的区间 -被拆成连续几部分分别摘要,再由最后一次调用合并,并对份数设上限,使单次 compaction 的调用数有界; -某一部分被迫丢弃的内容,会写在摘要器读到的文本里。 - -因此逐字保留的是:system prompt、体量足够小的首个用户回合、折叠区间开头若干条小体量用户回合, -以及近期 tail。keep policy 保护的消息同样留存,但带有执行记录的失败只保留承载失败的若干行。 -其余均为尽力而为——只在 digest 抓住它的前提下留存, -其中包括超出提升窗口的小体量用户回合,所以长期有效的约束更适合在近期回合中重述。 - -两个性质限制了这种损失:每次折叠都从 canonical transcript 重新生成 digest,而不是在上一条 digest -之上再摘要,因此 digest 不会形成链条,反复压缩也不会累积摘要漂移;同时 compaction 只写 projection, -canonical transcript 保留全部原文,被折叠的细节仍可通过 `history` tool 与归档 -(`reasonix/archive/.jsonl`)取回。 +- 每个 provider 声明 `context_window`(tokens)。唯一自动触发值是 + `agent.compact_ratio`(默认 **0.85**;预设 0.70 / 0.80 / 0.85;范围 0.65–0.85)。 + `triggerTokens = floor(context_window × compact_ratio)`。 +- **阈值以下**绝不改写历史:不摘要、不安装 prune/snip projection、不写 sidecar、 + 不增加 projection version、不发维护事件。任何改写都会使该点之后的 prompt 缓存失效。 +- **达到阈值**时运行 **一次** 摘要事务: + `稳定前缀 + 一个结构化摘要 + 最近原文尾部`。 + 正常验收:候选 ≤ 窗口 50%、严格小于源、且低于 `triggerTokens`;**不会**向 50% 回填。 + 典型落地约占窗口 10%–30%。 + 内部构造预算(非用户设置): + `recentTailBudget = clamp(window×10%, 32K, 96K)`,摘要输出上限 **16K**。 +- 用户可用 `reasonix config compact-ratio [--local] [VALUE]` 查看或修改阈值。 + 项目配置优先于桌面与新 CLI 会话共用的用户全局配置。UI 始终展示**实际生效**值。 +- `max_output_tokens` 是独立的**本轮**输出上限。 + 推荐 `0`(**自动**,不是无限;DeepSeek 默认 high → 约 64K)。 + 用户侧常用值:`32768` 普通编码/控费,`65536` 重推理/长工具链,`131072` 仅在反复 + `finish_reason=length` 时再考虑。负数为在协议允许时省略。仅在发送阶段按剩余 + 窗口裁剪,**绝不**改变 `triggerTokens` 或维护时机。计费按实际 completion,不按配置上限。 +- 巨型工具结果只在**第一次**进入模型前限长:`Content` 为稳定 ≤32KB 可见版; + 超限时 `RawContent` 保存完整原文。后续维护不得回头改写。`ModelMessages` 会去掉 + `RawContent`,provider 序列化与缓存 hash 永不包含它。 +- 自动维护只在 `ContextManager.Prepare` 中规划一次,输入为当前 projection 加上 + append-only canonical tail;canonical 永不改写。后续阈值合并 + **上一摘要 + 新增历史** 为单条 digest(无 multi-span、无应用层重试)。 + 失败以 generation 为边界记录 `blocked`/`failed`,同 generation 不自动再付费; + 手动 `compress` 可重试。 +- 旧多阈值键(`soft_compact_ratio`、`tool_result_snip_ratio`、 + `compact_force_ratio`、`cold_resume_prune`、`context_editing`)在普通启动时删除, + 运行时忽略。不再使用 provider 原生 tool clearing;所有 provider 走本地 summary + checkpoint。 +- `keep` / `recent_keep` 与活跃工具轮次仍受保护。重启只恢复既有 checkpoint, + 不重新摘要、不重放时间线卡片。 +- 完整历史保留在会话 transcript 中;`history` tool 提供 BM25 检索。新 checkpoint + 不再创建 prune archive。 `history` tool 支持对 session 与归档进行 BM25 搜索;`memory` tool 用于检索自动记忆, `remember` 与 `forget` 负责写入和归档。每个真实用户回合前,Reasonix 会用原始用户消息执行 @@ -401,7 +395,10 @@ default = "deepseek-v4-flash" api_key_env = "DEEPSEEK_API_KEY" web_search = true context_window = 1000000 -max_output_tokens = 32768 # 正文、reasoning 与工具调用共用的总输出预算;0 使用 provider 默认值 +# max_output_tokens = 0 # 推荐:自动(DeepSeek 默认 high → 约 64K) +# max_output_tokens = 32768 # 普通编码 / 控制费用 +# max_output_tokens = 65536 # 重推理、长工具链 +# max_output_tokens = 131072 # 仅在反复 finish_reason=length 时再考虑 [tools] enabled = [] diff --git a/docs/research/cache-aware-compaction-design.md b/docs/research/cache-aware-compaction-design.md index c662550842..75d3578420 100644 --- a/docs/research/cache-aware-compaction-design.md +++ b/docs/research/cache-aware-compaction-design.md @@ -1,114 +1,123 @@ -# Cache-Aware Context Projection 与惰性压缩 +# Content-Driven Context Maintenance(Cache-Aware Checkpoint) -> 日期:2026-08-07 -> 状态:当前实现说明 -> 核心约束:canonical transcript 是永久事实源;缓存状态只影响成本策略,不直接触发历史改写。 +> 日期:2026-08-10 +> 状态:当前实现说明(取代多阈值 prune/snip/native 自动维护叙述) +> 核心约束:canonical transcript 是永久事实源;唯一自动触发是 `compact_ratio`;缓存状态只影响成本与观测,不触发历史改写。 ## 一、问题与目标 -长会话需要同时满足两个目标: +长会话需要同时满足: 1. 保留完整历史,以支持恢复、回退、分支和审计; -2. 在模型上下文接近上限时,构造更短且稳定的 provider-visible 请求。 +2. 在上下文接近上限时,构造更短且稳定的 provider-visible 请求; +3. 不因 cache TTL / cold resume 主动改写仍可命中的前缀。 -旧路径把 cache TTL 过期与 session 压缩绑定:cold resume 会同步压缩并重写历史。这既把成本信号变成了数据变更信号,也会在用户只是恢复会话时破坏原有前缀。 +旧路径使用 soft / snip / force 多阈值,并在压力下自动安装 prune 投影或调用 provider native compaction。该路径把维护成本与可恢复性缠在一起,也会在 resume 时破坏缓存前缀。 -当前设计将两者拆开: +当前产品路径: ```text -canonical transcript (Session.Messages,普通压缩永不改写) +canonical transcript (Session.Messages,普通维护永不改写) | - +-- model-visible context projection + +-- model-visible context projection / checkpoint + | stable prefix + one structured summary + recent tail | - +-- cache state (warm/cold/unknown,仅参与成本与观测) + +-- first-visible tool bound (创建时 32KB Content/RawContent) + | + +-- cache state (warm/cold/unknown,仅成本与观测) ``` -## 二、持久化边界 +## 二、唯一自动触发 + +- 配置键:`agent.compact_ratio`(默认 `0.85`) +- 入口:`Prepare` / preflight 是唯一自动维护入口;`ObserveUsage` 只更新统计 +- 不再存在自动 soft compact、tool_result snip/prune 投影、native multi-threshold 路径 +- 兼容:旧配置键与 sidecar 字段可读;加载时清零/忽略/迁移删除,不新建 prune 投影 + +## 三、Checkpoint 形态 + +当 projected tokens ≥ `compact_ratio × context_window` 时,生成内容驱动 checkpoint: + +```text +stable system / early prefix +-> 一条结构化 summary(单次摘要请求,上限约 16K) +-> recent tail(约 10% 窗口,夹在 32K–96K) +``` + +验收要点: + +- 默认接受天花板约 50% 窗口;强制路径可放宽但不用不同 estimator 绕过 +- 摘要失败不写 mechanical marker,不安装半成品,不改 canonical +- provider-visible 始终最多一条 summary;旧 summary 可进入下一次 fold 被滚动吸收 +- 首次安装会预期 cache miss;安装后前缀应保持稳定以利后续 hit + +## 四、持久化边界 ### Canonical transcript -- `Session.Messages` 始终保存完整 transcript。 -- 普通 compaction、cold resume、tool prune/snip 不删除或替换 canonical 消息。 -- rewind、fork、branch 仍以 canonical 为事实源。 +- `Session.Messages` 始终保存完整 transcript +- 普通 compaction、cold resume、旧 prune/snip API no-op 均不删除或替换 canonical 消息 +- rewind / fork / branch 仍以 canonical 为事实源 ### Context projection sidecar -- projection 存储在 `.context.json`,不改变原 session 文件格式。 -- sidecar 保存 projection、covered prefix fingerprint、transcript/projection version、prompt cache key、cache 状态和压缩 telemetry。 -- 删除 session 时 sidecar 纳入同一删除清单。 -- 老版本不知道 sidecar 时仍可读取完整 session;新版本遇到缺失、旧 schema 或校验失败的 sidecar 会安全重建。 +- 路径:`.context.json`(schema v3) +- 保存 projection、covered prefix fingerprint、version、prompt cache key、cache 状态与 telemetry +- 旧 prune / native 字段可加载后忽略;校验失败则安全重建 +- 删除 session 时 sidecar 一并删除 -## 三、运行时行为 +## 五、运行时行为 -### Resume 只记录缓存状态 +### Resume -恢复会话时,根据 provider TTL 和最后活动时间记录 `warm`、`cold` 或 `unknown`。Resume 路径不会调用 `Compact`、`SnapshotRewrite` 或 `PruneStaleToolResults`,也不会修改 canonical transcript。 +只根据 provider TTL 与最后活动时间记录 `warm` / `cold` / `unknown`。Resume 不调用 Compact、不安装 projection、不改写 tool results。 -### Preflight 惰性生成 projection +### Prepare -每次模型请求前,`contextPreflight` 根据当前 token 压力判断是否需要 projection: +每次模型请求前: -- 未达到压力阈值:继续发送 append-only canonical view; -- 达到压缩阈值:尝试生成并安装 projection; -- 达到 force 阈值但没有可折叠内容:在非 tool loop 中返回可重试的 `ErrCompactionRequired`; -- 摘要失败:不写 mechanical marker,不安装半成品,也不改写 canonical; -- tool loop 进行中:只发 notice,由后续 preflight/stuck guard 处理,避免中断工具调用配对。 +1. 估计 projected tokens +2. 低于 `compact_ratio`:发送 append-only / 现有有效 projection +3. 达到阈值:至多一次 summary,CAS 安装 checkpoint +4. tool loop 中优先 notice,避免打断 tool 配对 -### Provider-visible 顺序 +### First-visible tool bound -projection 使用稳定顺序: +工具结果创建时把模型可见 `Content` 限制在约 32KB,完整原文进 `RawContent` / archive。这是写入时策略,不是阈值触发的历史 snip 投影。 -```text -system --> 确定性的早期 user turns --> 一条 rolling summary --> 必须保留的消息 --> recent tail -``` +## 六、Provider 与输出预算 -早期 user turn 的资格使用固定 token/char 估算和 context-window 上限,不依赖最近一次 provider usage。动态 usage 校准只用于 tail sizing 等不决定消息身份的估算,因此 projection 激活后,早期前缀不会因 canonical/projection 统计口径变化而漂移。 +- 应用层 summary 是默认路径;Responses 等 native compaction 标记 unsupported 时回退 summary +- `max_output_tokens=0` 表示 auto ladder(普通 16K / reasoning 32K / high·max 64K);128K 仅显式配置 +- auto ladder 与 `compact_ratio` 解耦 -旧 summary 会进入下一次 fold,由新 summary 滚动吸收;provider-visible projection 始终只保留一条 summary,不会形成无限摘要链。原 summary 仍保留在 canonical transcript 中。 +## 七、缓存影响 -## 四、有效性与失效 +| 场景 | 预期 | +| --- | --- | +| warm resume 低于阈值 | 复用 append-only 前缀,无摘要 | +| 首次跨过 compact_ratio | 前缀变为 prefix+summary+tail,一次预期 miss | +| checkpoint 安装后继续对话 | 稳定 prefix 利于 hit;generation 作用域避免重复摘要 | +| cold resume | 只记 cache 状态,不因 TTL 重写历史 | -projection 采用 fail-closed 校验: +## 八、验证与烟雾 -- `CoveredPrefixHash` 对 `ModelMessages(canonical[:CoveredCount])` 的完整 provider-visible 内容生成稳定 fingerprint,覆盖图片、reasoning 元数据、Responses items、tool call ID/name/arguments/thought signature; -- `PromptCacheKey` 必须存在,并严格匹配 `workspace|session lineage|model`; -- 缺少 fingerprint、前缀被 edit/rewrite、切换模型或 lineage 时,内存 projection 立即失效; -- rewind、fork、branch、snip/prune 和显式范围摘要会使相关 projection 失效或隔离。 +- 确定性:`internal/agent` compact / projection / prune no-op / restart 测试 +- 离线 e2e:`benchmarks/context-maintenance-e2e` 的 `seed` + `resume`(`-offline`) +- 在线 e2e:同目录 `continue`(`DEEPSEEK_API_KEY`,`-max-usd` 费用上限,至多一次摘要) -加载时发现某模型的 sidecar key 不匹配,只丢弃当前内存状态,不删除磁盘文件,避免破坏其它模型仍可使用的状态。 +## 九、有意保留的兼容层 -## 五、Provider compaction 与失败策略 +不算功能缺口,也不声称“代码里已无旧概念”: -Provider 接口已定义: +1. 配置结构体仍可读旧 soft/snip/force 键,加载时清零并迁移删除 +2. sidecar 仍可解码旧 prune/native 字段后忽略 +3. `PruneStaleToolResults` / `SnipStaleToolResults` 保留为 no-op API,避免旧调用点 panic +4. snip 几何 helpers 仍服务 first-visible 与 summary fold 输入,不再用于自动投影安装 -- `NativeCompactor`; -- `CompactionRequest` / `CompactionResult`; -- `CompactionCapabilities`; -- `ErrCompactionUnsupported`。 +## 十、明确未做 -当前 Responses vendor 明确返回 unsupported,并回退到 Reasonix 的摘要路径。摘要首次失败后的重试会聚合两次 attempt 的 usage 与 request count,供成本和 telemetry 使用。 - -Anthropic、DeepSeek 等原生 compaction endpoint 尚未接入;能力接口不代表这些 endpoint 已经可用。 - -## 六、缓存影响 - -- 正向影响:cold resume 不再为了 TTL 状态改写历史,缓存仍 warm 时可以继续复用原 append-only 前缀。 -- 预期 miss:首次在高压力下激活 projection 时,请求前缀会从 canonical 切换为 `summary + tail`,因此会发生一次预期的 cache miss。 -- 稳定性:激活后,确定性的早期轮次、单条 rolling summary、稳定 cache key 和 fail-closed fingerprint 降低后续无意义的前缀漂移。 -- 不确定点:token 估算可能使 preflight 比旧路径更早或更晚触发摘要,需要通过 telemetry 持续观察 break-even 成本。 - -## 七、明确未实现的后续 - -以下能力不属于当前阶段,不能按已落地行为依赖: - -1. Anthropic/DeepSeek 原生 compaction endpoint; -2. compaction 后调用 `SaveKnowledge` 或写入 EventChain; -3. 依靠 EventChain 完成跨 session 的 L2 自动恢复; -4. feature flag 观测期、旧兼容路径的最终清理; -5. 完整 break-even 成本 dashboard 聚合。 - -这些后续必须分别设计失败原子性、持久化兼容、缓存影响和 provider 能力探测,不能重新把 cache TTL 与 canonical transcript 改写绑定。 +1. 重新启用多阈值自动 prune/snip 投影 +2. 把 cache TTL 重新绑定到 transcript 改写 +3. 跨 session 的 EventChain L2 自动恢复作为维护主路径 +4. 完整 break-even 成本 dashboard diff --git a/internal/agent/ablation_test.go b/internal/agent/ablation_test.go index 7ed2c6a180..7930772572 100644 --- a/internal/agent/ablation_test.go +++ b/internal/agent/ablation_test.go @@ -28,16 +28,15 @@ func TestEvidenceAblationStandsDownTheReadinessGate(t *testing.T) { } func TestCompactionAblationCollapsesTheCachePreservingDeferral(t *testing.T) { - full := &Agent{contextWindow: 100_000, softCompactRatio: 0.5, toolResultSnipRatio: 0.7, compactRatio: 0.8} - soft, snip, high := full.compactThresholds() - if soft != 50_000 || snip != 70_000 || high != 80_000 { - t.Fatalf("control thresholds = %d/%d/%d, want 50000/70000/80000", soft, snip, high) + full := &Agent{contextWindow: 100_000, compactRatio: 0.8} + if got := full.compactTrigger(); got != 80_000 { + t.Fatalf("control trigger = %d, want 80000", got) } - off := &Agent{contextWindow: 100_000, softCompactRatio: 0.5, toolResultSnipRatio: 0.7, compactRatio: 0.8, + off := &Agent{contextWindow: 100_000, compactRatio: 0.8, ablation: ablation.New(ablation.Compaction)} - soft, snip, high = off.compactThresholds() - if soft != 50_000 || snip != soft || high != soft { - t.Fatalf("ablated thresholds = %d/%d/%d, want all three at 50000", soft, snip, high) + // Compaction ablation forces the sole trigger down to 50% so folds fire earlier. + if got := off.compactTrigger(); got != 50_000 { + t.Fatalf("ablated trigger = %d, want 50000", got) } } diff --git a/internal/agent/agent.go b/internal/agent/agent.go index d1e01725ad..2b94fdad75 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -538,19 +538,10 @@ type Agent struct { subagentDepth int maxSubagentDepth int - // Context management: when a turn's prompt nears contextWindow, the older - // middle of the session is summarized away, keeping a token-bounded recent - // tail verbatim (recentKeep is the message floor) and archiving the originals - // under archiveDir. compactStuck latches when compaction can't get the prompt - // under the window (consecutiveCompacts crosses the limit), so auto-compaction - // pauses instead of looping. softCompactNoticed gates the one-shot soft-ratio - // notice so it fires once per approach, not every turn. + // Context management keeps the canonical transcript immutable and installs + // at most one provider-visible checkpoint each time compactRatio is crossed. contextWindow int - softCompactRatio float64 - toolResultSnipRatio float64 compactRatio float64 - compactForceRatio float64 - softCompactNoticed bool recentKeep int archiveDir string keepPolicy KeepPolicy @@ -558,7 +549,8 @@ type Agent struct { consecutiveCompacts int sessionPath string // bound transcript path for projection sidecars workspaceID string // stable prompt-cache lineage component - cacheState string // warm/cold/unknown; never provider-visible + cacheState string // legacy resume telemetry; never provider-visible + checkpointState string // none|restored|applied; runtime-only compactionState CompactionState // compactionMu guards projection snapshots/install and the in-memory sidecar // generation. Network summarization never runs while this lock is held. @@ -570,7 +562,6 @@ type Agent struct { // from paying for two summaries during one active tool loop. lastCompactionTurn atomic.Int64 strictAlternatingRoles bool // coalesce adjacent user turns on provider request copies - contextEditingState // activeTurnCreatedAt identifies the real/synthetic user message that began // the currently running turn. Compaction may rewrite older history while a // tool loop is active, but it must keep this message and everything after it @@ -1133,10 +1124,12 @@ type Options struct { // Context management. ContextWindow <= 0 disables compaction. Ratios and // RecentKeep fall back to defaults when unset. - ContextWindow int + ContextWindow int + CompactRatio float64 + // Deprecated compatibility inputs. New agents ignore these fields; automatic + // maintenance is controlled only by CompactRatio. SoftCompactRatio float64 ToolResultSnipRatio float64 - CompactRatio float64 CompactForceRatio float64 RecentKeep int ArchiveDir string @@ -1144,7 +1137,7 @@ type Options struct { SessionPath string // projection sidecar path; empty = memory only WorkspaceID string // prompt-cache lineage component StrictAlternatingRoles bool // merge adjacent user turns for strict providers at request time - ContextEditing string // local (default) or native (explicit opt-in) + ContextEditing string // deprecated; native provider editing was removed // Hooks fires PreToolUse / PostToolUse shell hooks around tool calls. nil // disables hook firing. @@ -1250,21 +1243,9 @@ type Options struct { // provider errors (compaction keeps the context bounded). A nil sink is replaced // with event.Discard so the agent can always emit unconditionally. func New(prov provider.Provider, tools *tool.Registry, session *Session, opts Options, sink event.Sink) *Agent { - if opts.SoftCompactRatio <= 0 { - opts.SoftCompactRatio = defaultSoftCompactRatio - } - if opts.ToolResultSnipRatio <= 0 { - opts.ToolResultSnipRatio = defaultToolResultSnipRatio - } if opts.CompactRatio <= 0 { opts.CompactRatio = defaultCompactRatio } - if opts.ToolResultSnipRatio >= opts.CompactRatio { - opts.ToolResultSnipRatio = opts.CompactRatio - } - if opts.CompactForceRatio <= 0 { - opts.CompactForceRatio = defaultCompactForceRatio - } if opts.RecentKeep <= 0 { opts.RecentKeep = minRecentKeep } @@ -1345,10 +1326,7 @@ func New(prov provider.Provider, tools *tool.Registry, session *Session, opts Op capabilityLedger: opts.CapabilityLedger, capabilityAudit: opts.CapabilityAudit, contextWindow: opts.ContextWindow, - softCompactRatio: opts.SoftCompactRatio, - toolResultSnipRatio: opts.ToolResultSnipRatio, compactRatio: opts.CompactRatio, - compactForceRatio: opts.CompactForceRatio, recentKeep: opts.RecentKeep, archiveDir: opts.ArchiveDir, keepPolicy: opts.KeepPolicy, @@ -1356,7 +1334,6 @@ func New(prov provider.Provider, tools *tool.Registry, session *Session, opts Op workspaceID: strings.TrimSpace(opts.WorkspaceID), cacheState: CacheStateUnknown, strictAlternatingRoles: opts.StrictAlternatingRoles, - contextEditingState: newContextEditingState(opts.ContextEditing, prov), subagentDepth: subagentDepth, maxSubagentDepth: maxSubagentDepth, mutationObserver: opts.MutationObserver, @@ -2664,16 +2641,12 @@ func batchStormSignature(calls []provider.ToolCall, outcomes []toolOutcome) (str return sb.String(), true } -// toolOutcome is one tool call's result, split into the model-facing output and -// the display-facing notice bits. errMsg is the short failure reason (empty on -// success) — a refused call, an unknown tool, or an execution error — so a sink -// renders the result as failed ("⊘ name " / a red card) instead of OK; -// blocked narrows that to a refusal (plan mode / permission). truncMsg is set -// (without the "· " prefix) when the output was head+tailed. images carries -// data URLs from a tool.ImageTool result; they ride outside output so text -// truncation can never corrupt an image payload. +// toolOutcome is one tool call's result. output is the first-visible bounded +// form the model sees; rawOutput is the full original when truncation applied +// (empty when identical so we avoid double storage). images ride outside text. type toolOutcome struct { output string + rawOutput string // full original when different from output images []string blocked bool errMsg string @@ -3227,20 +3200,77 @@ func firstLine(s string) string { return s } -// truncateToolOutput head+tails s when it exceeds maxToolOutputBytes, slicing -// on rune boundaries so we never split a multibyte glyph. Returns the possibly -// trimmed body plus a one-line user-facing notice when truncation happened -// (empty when it didn't, without the "· " display prefix). +// truncateToolOutput is the first-visible hard cap for a tool result. Under-cap +// bodies are returned byte-identical. Over-cap bodies keep a tool-aware head and +// tail under maxToolOutputBytes; the full original is stored separately as +// RawContent by the session writer. The bounded form is stable for the message +// lifetime and is never re-truncated by later maintenance. func truncateToolOutput(s string) (string, string) { + return truncateToolOutputFor(s, "", "") +} + +// truncateToolOutputFor is the tool-aware first-visible limiter. toolName and +// toolCallID populate the truncation marker so the model can re-fetch. +func truncateToolOutputFor(s, toolName, toolCallID string) (string, string) { if len(s) <= maxToolOutputBytes { return s, "" } - keep := maxToolOutputBytes / 2 - head := snapToRuneBoundary(s, 0, keep) - tail := snapToRuneBoundary(s, len(s)-keep, len(s)) + strategy := snipStrategy{head: 40, tail: 40, headChars: 8000, tailChars: 8000} + switch { + case toolName == "bash" || toolName == "shell" || strings.Contains(toolName, "bash"): + strategy = snipStrategy{head: 40, tail: 40, headChars: 8000, tailChars: 8000} + case toolName == "read_file" || toolName == "web_fetch" || strings.Contains(toolName, "read"): + strategy = snipStrategy{head: 120, tail: 12, headChars: 12000, tailChars: 2000} + case toolName == "grep" || toolName == "glob" || toolName == "ls" || toolName == "list_dir": + strategy = snipStrategy{head: 80, tail: 8, headChars: 10000, tailChars: 1000} + } + headKeep := strategy.headChars + tailKeep := strategy.tailChars + if headKeep+tailKeep > maxToolOutputBytes-512 { + headKeep = maxToolOutputBytes * 2 / 3 + tailKeep = maxToolOutputBytes - headKeep - 512 + } + if headKeep < 1024 { + headKeep = maxToolOutputBytes / 2 + tailKeep = maxToolOutputBytes / 2 + } + // Prefer more tail when the body looks like a failure. + lower := strings.ToLower(s) + if strings.Contains(lower, "error:") || strings.Contains(lower, "panic:") || strings.Contains(lower, "fatal:") { + tailKeep = max(tailKeep, maxToolOutputBytes/3) + if headKeep+tailKeep > maxToolOutputBytes-512 { + headKeep = maxToolOutputBytes - 512 - tailKeep + } + } + head := snapToRuneBoundary(s, 0, headKeep) + tail := snapToRuneBoundary(s, len(s)-tailKeep, len(s)) omitted := len(s) - len(head) - len(tail) + namePart := toolName + if namePart == "" { + namePart = "tool" + } + idPart := toolCallID + if idPart == "" { + idPart = "-" + } notice := fmt.Sprintf("tool output truncated: %d of %d bytes elided", omitted, len(s)) - body := head + fmt.Sprintf("\n\n…[truncated %d of %d bytes — rerun with narrower args to see the middle]…\n\n", omitted, len(s)) + tail + marker := fmt.Sprintf( + "\n\n…[truncated tool=%s call_id=%s original_bytes=%d kept_bytes=%d — full original retained in canonical transcript; re-read or retry with narrower args]…\n\n", + namePart, idPart, len(s), len(head)+len(tail), + ) + body := head + marker + tail + if len(body) > maxToolOutputBytes { + overflow := len(body) - maxToolOutputBytes + trimHead := overflow / 2 + trimTail := overflow - trimHead + if trimHead < len(head) { + head = snapToRuneBoundary(head, 0, len(head)-trimHead) + } + if trimTail < len(tail) { + tail = snapToRuneBoundary(tail, trimTail, len(tail)) + } + body = head + marker + tail + } return body, notice } diff --git a/internal/agent/compact.go b/internal/agent/compact.go index 7c92db5fd1..fd8d67fffd 100644 --- a/internal/agent/compact.go +++ b/internal/agent/compact.go @@ -1,15 +1,10 @@ package agent import ( - "bytes" "context" - "crypto/sha256" - "encoding/hex" "encoding/json" "errors" "fmt" - "os" - "path/filepath" "sort" "strings" "time" @@ -22,29 +17,29 @@ import ( ) // Compaction is a low-frequency cache-reset point: the prompt grows append-only -// (high cache hits) until a turn nears compactRatio of the window, then it is -// compacted down to a tail budget. The budget is a fixed token count, not a -// fraction of the window, so a huge window still compacts rarely while a small -// one still lands below the trigger (which is what stops the re-compaction loop). +// until compactRatio of the window is crossed, then one content-driven summary +// checkpoint is installed (stable prefix + one structured digest + recent tail). +// 50% is only the normal acceptance ceiling — candidates are never padded up to it. const ( - defaultSoftCompactRatio = 0.5 // report growing context here, but keep the cache-stable prefix intact - defaultToolResultSnipRatio = 0.6 // rewrite stale tool results cheaply before summary compaction - defaultCompactRatio = 0.8 // trigger: prompt at this fraction of the window compacts - defaultCompactForceRatio = 0.9 // force compaction at this high-water mark even for low-value folds - defaultCompactTarget = 0.5 // safety cap: the kept tail never exceeds this fraction of the window - defaultTailTokens = 16384 // verbatim recent-tail budget, in tokens - minRecentKeep = 2 // never keep fewer recent messages than this - minCompactMessages = 2 // skip compaction below this many compactable messages - fallbackTokPerChar = 0.25 // ~4 chars/token, used before any usage is available to calibrate - maxPinnedFirstUserTokens = 1500 // ceiling on pinning the first user turn verbatim; larger first turns (pasted content) stay foldable - pinnedFirstUserWindowFrac = 0.15 // and never pin a first turn worth more than this fraction of the window - maxCarriedDigestTokens = 12000 // ceiling on digests carried verbatim across folds before one consolidating fold merges them - maxEarlyUserTurns = 3 // small user turns hoisted verbatim ahead of the digest; position-fixed (the first N of the fold region, never "the latest N") so the projection prefix stays byte-stable - protocolReserveTokens = 256 // provider framing and control fields not represented by message estimates - maxOutputReserveRatio = 0.25 // ceiling on the window share an output budget may reserve before the thresholds collapse + defaultCompactRatio = 0.85 // sole automatic maintenance trigger (new configs) + checkpointCeilingRatio = 0.50 // normal auto-checkpoint acceptance ceiling + recentTailBudgetRatio = 0.10 // recent verbatim tail as a fraction of the window + minRecentTailTokens = 32 * 1024 + maxRecentTailTokens = 96 * 1024 + summaryOutputMaxTokens = 16 * 1024 // max digest output; further clipped by remaining candidate space + exceptionalMinSavingsRatio = 0.25 // when fixed prefix alone exceeds 50%, require at least this savings + minRecentKeep = 2 // never keep fewer recent messages than this + minCompactMessages = 2 // skip compaction below this many compactable messages + fallbackTokPerChar = 0.25 // ~4 chars/token, used before any usage is available to calibrate + maxPinnedFirstUserTokens = 1500 // ceiling on pinning the first user turn verbatim + pinnedFirstUserWindowFrac = 0.15 // and never pin a first turn worth more than this fraction of the window + protocolReserveTokens = 256 // provider framing and control fields not represented by message estimates ) -var errSummaryOutputTruncated = errors.New("summarizer output truncated") +var ( + errSummaryOutputTruncated = errors.New("summarizer output truncated") + errCheckpointRejected = errors.New("checkpoint candidate rejected") +) // summaryTag wraps the compaction summary so the model can distinguish it from // live user input and later strip or skip it when reasoning about the current turn. @@ -57,12 +52,8 @@ const ( // failure (then a mechanical fold) instead of hanging compaction indefinitely. const summaryTimeout = 90 * time.Second -// summarySystemPrompt steers the executor to distill older history into a -// structured briefing it can keep relying on after the originals are dropped. -// The section layout mirrors what a coding agent actually needs to resume work -// mid-task: the goal verbatim, the concrete state of the code, and an explicit -// next step — so the post-compaction turn doesn't lose the thread or re-derive -// decisions already made. +// summarySystemPrompt asks for a structured resume briefing (facts, goal, +// decisions, files, commands, errors, next step) under fixed headings. const summarySystemPrompt = `You are compacting the earlier part of a coding agent's conversation to save context. The agent keeps your summary alongside the user's own turns (kept verbatim) and the recent tail; your job is to fold the assistant/tool work into a briefing it can resume from. Write under these exact headings, omitting a heading only if it has no content: @@ -90,74 +81,72 @@ What is still in progress or unstarted, and the single most concrete next action Rules: be terse — bullet points and fragments, not prose. Preserve identifiers, paths, and numbers exactly. Do NOT invent anything not present in the messages; if something is unknown, leave it out rather than guessing.` -// compactThresholds returns the prompt-token boundaries ContextManager switches -// on. Only high triggers maintenance: soft is a notice, and snip is how far a -// tool-result pass must bring the prompt down to stand in for the summary. The -// compaction ablation arm folds at soft instead, which is what a harness with -// no prompt-cache strategy does. -func (a *Agent) compactThresholds() (soft, snip, high int) { - hard := a.hardInputCeiling() - high = int(float64(a.contextWindow) * a.compactRatio) - // Ratios >= 1 are the documented test/config escape hatch for disabling - // automatic compaction; do not turn that into an immediate hard-trigger by - // clamping it down to the output-budget ceiling. - if a.compactRatio < 1 { - high = minInt(high, hard) +// compactTrigger is the sole automatic context-maintenance boundary. Output +// budgets are intentionally absent: they are clipped against the final request +// at send time and must never make compaction happen earlier than the user's +// configured compact_ratio. +func (a *Agent) compactTrigger() int { + if a == nil || a.contextWindow <= 0 { + return 0 + } + ratio := a.compactRatio + if ratio <= 0 { + ratio = defaultCompactRatio } - snip = minInt(int(float64(a.contextWindow)*a.toolResultSnipRatio), high) - soft = minInt(int(float64(a.contextWindow)*a.softCompactRatio), high) if a.ablation.Off(ablation.Compaction) { - high, snip = soft, soft + ratio = 0.5 } - return soft, snip, high + return max(1, int(float64(a.contextWindow)*ratio)) } -// hardInputCeiling leaves room for the configured output and protocol framing. -// A zero output budget means the provider's input window is the only declared -// limit, preserving the historical behavior for providers that manage output -// tokens themselves. +// hardInputCeiling is a physical input-safety boundary, not another user +// compaction threshold. Reply budgets are resolved independently at send time. func (a *Agent) hardInputCeiling() int { if a == nil || a.contextWindow <= 0 { return 0 } - hard := a.contextWindow - if outputBudget := a.reservedOutputTokens(); outputBudget > 0 { - hard -= outputBudget + protocolReserveTokens - } - return max(1, hard) + return max(1, a.contextWindow-protocolReserveTokens) } -// reservedOutputTokens is the window share the thresholds hold back for the -// reply. An output budget can exceed the configured window entirely (DeepSeek -// defaults to 128K), which would drive every threshold to the one-token floor. -// effectiveOutputBudget clips the actual reply at send time, so reserving more -// than a quarter of the window buys nothing here. -func (a *Agent) reservedOutputTokens() int { - budget := a.maxOutputTokens - if budget <= 0 && sharesContextWindow(a.prov) { - budget = a.outputBudget +// recentTailBudget is the content-construction budget for the recent verbatim +// tail. Production windows use clamp(window×10%, 32K, 96K). Smaller synthetic +// windows (tests / constrained providers) drop the 32K floor so the tail cannot +// alone exceed the window. +func (a *Agent) recentTailBudget() int { + if a == nil || a.contextWindow <= 0 { + return minRecentTailTokens } - if budget <= 0 { - return 0 + n := int(float64(a.contextWindow) * recentTailBudgetRatio) + if a.contextWindow >= minRecentTailTokens*2 { + if n < minRecentTailTokens { + n = minRecentTailTokens + } } - return minInt(budget, int(float64(a.contextWindow)*maxOutputReserveRatio)) + if n > maxRecentTailTokens { + n = maxRecentTailTokens + } + if max := a.contextWindow / 2; max > 0 && n > max { + n = max + } + return max(1, n) } -func (a *Agent) forceCompactThreshold(high int) int { - hard := a.hardInputCeiling() - force := minInt(int(float64(a.contextWindow)*a.compactForceRatio), hard) - return max(high, force) -} - -// minimumMaintenanceSavingsTokens avoids cache-breaking projections that save -// too little to matter. Small synthetic windows can never reach the production -// floor; those are allowed through when they are already at the hard ceiling. -func (a *Agent) minimumMaintenanceSavingsTokens() int { +// checkpointCeiling is the normal auto-checkpoint acceptance upper bound +// (50% of the window). Candidates below this are accepted without padding. +func (a *Agent) checkpointCeiling() int { if a == nil || a.contextWindow <= 0 { return 0 } - n := int(float64(a.contextWindow) * 0.02) - return minInt(max(n, 4096), 20000) + return max(1, int(float64(a.contextWindow)*checkpointCeilingRatio)) +} + +// exceptionalMinimumSavings is required only when the fixed prefix alone already +// exceeds the 50% ceiling; otherwise ordinary candidates simply stay under 50%. +func (a *Agent) exceptionalMinimumSavings() int { + if a == nil || a.contextWindow <= 0 { + return 0 + } + return max(1, int(float64(a.contextWindow)*exceptionalMinSavingsRatio)) } // foldEconomics estimates whether compacting the given region saves enough @@ -303,12 +292,8 @@ func (a *Agent) pinnedPrefixLen(msgs []provider.Message) int { return i } -// fixedPinnableUserTurn reports whether a user turn is small enough to keep -// verbatim in a position-stable prefix. Identity decisions must not use the -// latest provider usage: after projection activates, that usage describes the -// projection while the canonical transcript remains larger, which would make -// the same turn drift in or out across compactions. Dynamic token calibration is -// reserved for non-identity estimates such as tail sizing. +// fixedPinnableUserTurn uses a fixed estimate only: provider usage after +// projection would make the same turn drift across checkpoints. func (a *Agent) fixedPinnableUserTurn(m provider.Message) bool { budget := maxPinnedFirstUserTokens if a.contextWindow > 0 { @@ -437,44 +422,36 @@ func toolCallIDs(m provider.Message) map[string]bool { return ids } -// planCompaction locates the region to summarize. head is the count of leading -// messages preserved verbatim (see pinnedPrefixLen); start is where the preserved -// recent tail begins, so msgs[head:start] is compacted. The tail is bounded by a -// token budget (not a message count), so a few large tool outputs can't keep it -// above the trigger and re-fire compaction every turn. ok is false when there is -// too little to compact. -func (a *Agent) planCompaction(msgs []provider.Message, min int) (head, start int, ok bool) { +// planCompaction returns [head:start] to fold; the tail is recentTailBudget +// unless force halves it so CompactNow still reduces mid-size sessions. +func (a *Agent) planCompaction(msgs []provider.Message, min int, force bool) (head, start int, ok bool) { head = a.pinnedPrefixLen(msgs) if a.contextWindow > 0 { - budget := defaultTailTokens - if maxByWin := int(float64(a.contextWindow) * defaultCompactTarget); maxByWin < budget { - budget = maxByWin + budget := a.recentTailBudget() + if force { + if half := estimateMessagesTokens(provider.ModelMessages(msgs)) / 2; half > 0 && half < budget { + budget = half + } } start = tailStart(msgs, head, budget, a.tokPerChar(), a.tailFloor()) - // The calibrated character ratio is useful for cheap planning but can - // under-estimate CJK/code-heavy turns and leave the projection above its - // target. Re-measure the actual message estimate and move the boundary - // forward until the retained tail itself fits the budget. - for !a.strictAlternatingRoles && start < len(msgs)-a.tailFloor() && estimateMessagesTokens(provider.ModelMessages(msgs[start:])) > budget { + // Remeasure when force or non-strict roles; strict-alternating otherwise + // keeps a cheap tokPerChar overestimate of the tail under force. + floor := max(head, len(msgs)-a.tailFloor()) + remeasure := force || !a.strictAlternatingRoles + for remeasure && start < floor && estimateMessagesTokens(provider.ModelMessages(msgs[start:])) > budget { start++ - // Never leave a tool result at the beginning of the retained tail; - // skip the remainder of that tool-result group so its assistant call - // is folded together with the result. - for start < len(msgs)-a.tailFloor() && msgs[start].Role == provider.RoleTool { + for start < floor && start < len(msgs) && msgs[start].Role == provider.RoleTool { start++ } } } else { - // No window to budget against (manual /compact on an unconfigured - // provider): keep a fixed count of recent messages, aligned off any tool. + // No window: keep a fixed recent count, aligned off tool results. start = len(msgs) - a.tailFloor() - for start > head && msgs[start].Role == provider.RoleTool { + for start > head && start < len(msgs) && msgs[start].Role == provider.RoleTool { start-- } } - if start < head { - start = head - } + start = max(start, head) if start-head < min { return head, start, false } @@ -567,12 +544,16 @@ func (a *Agent) summarize(ctx context.Context, region []provider.Message, instru } }() defer trackPublishedHostStream(ctx, cancel)() + maxOut := summaryOutputMaxTokens + if a.maxOutputTokens > 0 && a.maxOutputTokens < maxOut { + maxOut = a.maxOutputTokens + } req := provider.Request{ Messages: []provider.Message{ {Role: provider.RoleSystem, Content: sys}, {Role: provider.RoleUser, Content: renderTranscript(region)}, }, - MaxTokens: a.maxOutputTokens, + MaxTokens: maxOut, Temperature: provider.OptionalTemperature(a.temperature), } if budget, clipped, budgetErr := a.effectiveOutputBudget(req); budgetErr != nil { @@ -580,6 +561,15 @@ func (a *Agent) summarize(ctx context.Context, region []provider.Message, instru } else if clipped { req.MaxTokens = budget } + if req.MaxTokens > summaryOutputMaxTokens { + req.MaxTokens = summaryOutputMaxTokens + } + if req.MaxTokens < 256 { + return "", usage, fmt.Errorf("summary output budget too small (%d tokens)", req.MaxTokens) + } + if a.prov == nil { + return "", usage, fmt.Errorf("summary unavailable") + } ch, err := a.prov.Stream(ctx, req) if err != nil { return "", usage, err @@ -614,15 +604,11 @@ func (a *Agent) summarize(ctx context.Context, region []provider.Message, instru } } -// summarizeWithRetry retries one non-timeout failure; timeout/cancel do not retry. -// Token and request counts from both attempts are merged into the returned Usage. -func (a *Agent) summarizeWithRetry(ctx context.Context, fold []provider.Message, instructions string) (string, *provider.Usage, error) { - summary, usage, err := a.summarize(ctx, fold, instructions) - if err == nil || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || errors.Is(err, errSummaryOutputTruncated) { - return summary, usage, err - } - summary2, usage2, err2 := a.summarize(ctx, fold, instructions) - return summary2, mergeStreamUsage(usage, usage2), err2 +// summarizeOnce performs exactly one application-layer summary request. +// Timeouts, empty results, stream errors, and output truncation all fail once +// with no second attempt. +func (a *Agent) summarizeOnce(ctx context.Context, fold []provider.Message, instructions string) (string, *provider.Usage, error) { + return a.summarize(ctx, fold, instructions) } // renderTranscript flattens messages into a readable transcript for summarization. @@ -644,7 +630,11 @@ func renderTranscript(msgs []provider.Message) string { } b.WriteString("\n") case provider.RoleTool: - fmt.Fprintf(&b, "[tool %s result]\n%s\n\n", m.Name, m.Content) + body := m.Content + if m.RawContent != "" { + body = m.RawContent + } + fmt.Fprintf(&b, "[tool %s result]\n%s\n\n", m.Name, body) case provider.RoleSystem: fmt.Fprintf(&b, "[system]\n%s\n\n", m.Content) } @@ -672,61 +662,3 @@ func summarizeToolArgs(args string) string { sort.Strings(keys) return fmt.Sprintf("{%s} (%d keys)", strings.Join(keys, ", "), len(parsed)) } - -// archiveMessages writes the dropped originals to a content-addressed .jsonl -// (one message per line) under dir. Retrying the same failed/stale compaction -// therefore reuses one archive instead of creating timestamp duplicates. -func archiveMessages(dir string, msgs []provider.Message) (string, error) { - if err := os.MkdirAll(dir, 0o755); err != nil { - return "", err - } - var b bytes.Buffer - enc := json.NewEncoder(&b) - for _, m := range msgs { - if err := enc.Encode(m); err != nil { - return "", err - } - } - sum := sha256.Sum256(b.Bytes()) - path := filepath.Join(dir, "context-"+hex.EncodeToString(sum[:16])+".jsonl") - if _, err := os.Stat(path); err == nil { - return path, nil - } else if !os.IsNotExist(err) { - return "", err - } - f, err := os.CreateTemp(dir, ".context-archive-*.tmp") - if err != nil { - return "", err - } - tmpName := f.Name() - cleanup := true - defer func() { - if cleanup { - _ = os.Remove(tmpName) - } - }() - if err := f.Chmod(0o600); err != nil { - _ = f.Close() - return "", err - } - if _, err := f.Write(b.Bytes()); err != nil { - _ = f.Close() - return "", err - } - if err := f.Sync(); err != nil { - _ = f.Close() - return "", err - } - if err := f.Close(); err != nil { - return "", err - } - if err := os.Link(tmpName, path); err != nil { - if os.IsExist(err) { - return path, nil - } - return "", err - } - _ = os.Remove(tmpName) - cleanup = false - return path, nil -} diff --git a/internal/agent/compact_commit.go b/internal/agent/compact_commit.go index 5e6f1eabcf..6d9b4d7b08 100644 --- a/internal/agent/compact_commit.go +++ b/internal/agent/compact_commit.go @@ -17,44 +17,43 @@ type summaryProjectionCommit struct { sourceTokens, projectionTokens int } -// commitSummaryProjection performs the final CAS, content-addressed archive, -// and sidecar switch after all network and interceptor work has completed. +// commitSummaryProjection CAS-installs a checkpoint under compactionMu: +// transcript version/hash, projection version, and generation must still match. +// The maintenance event is emitted only after the lock is released so a sink +// that re-enters ContextMaintenanceSnapshot cannot deadlock. func (a *Agent) commitSummaryProjection(commit summaryProjectionCommit) (CompactionState, error) { - current, currentVersion := a.session.snapshotMessagesVersion() + state := a.summaryProjectionState(commit) a.compactionMu.Lock() - currentProjectionVersion := a.compactionState.Projection.ProjectionVersion - currentGeneration := a.compactionState.Generation - a.compactionMu.Unlock() - if currentVersion != commit.transcriptVersion || len(current) != len(commit.canonical) || + current, currentVersion := a.session.snapshotMessagesVersion() + if currentVersion != commit.transcriptVersion || + len(current) != len(commit.canonical) || coveredPrefixHash(current, len(current)) != coveredPrefixHash(commit.canonical, len(commit.canonical)) || - currentProjectionVersion != commit.projectionVersion || currentGeneration != commit.generation { + a.compactionState.Projection.ProjectionVersion != commit.projectionVersion || + a.compactionState.Generation != commit.generation { + a.compactionMu.Unlock() return CompactionState{}, errCompressStaleContext } - - archive := "" - if a.archiveDir != "" { - path, err := archiveMessages(a.archiveDir, commit.fold) - if err != nil { - return CompactionState{}, fmt.Errorf("archive: %w", err) - } - archive = path - } - state := a.summaryProjectionState(commit, archive) - if err := a.installProjectionIfCurrent(state, commit.projectionVersion, commit.generation); err != nil { + prev := a.compactionState + a.compactionState = state + if err := a.persistCompactionStateLocked(); err != nil { + a.compactionState = prev + a.compactionMu.Unlock() if errors.Is(err, errCompressStaleContext) { return CompactionState{}, err } return CompactionState{}, fmt.Errorf("persist projection: %w", err) } + a.checkpointState = "applied" if commit.activeTurn != 0 && commit.trigger != CompactionTriggerManual { a.lastCompactionTurn.Store(commit.activeTurn) } - a.session.NoteContentRewrite("compact_" + commit.trigger) - a.emitContextMaintenance(state.LastReceipt) + receipt := state.LastReceipt + a.compactionMu.Unlock() + a.emitContextMaintenance(receipt) return state, nil } -func (a *Agent) summaryProjectionState(commit summaryProjectionCommit, archive string) CompactionState { +func (a *Agent) summaryProjectionState(commit summaryProjectionCommit) CompactionState { projectionVersion := commit.projectionVersion + 1 now := time.Now().UTC() summaryHash := summaryContentHash(commit.summary) @@ -65,25 +64,19 @@ func (a *Agent) summaryProjectionState(commit summaryProjectionCommit, archive s ProjectionVersion: projectionVersion, CoveredCount: len(commit.canonical), CoveredPrefixHash: coveredHash, InputHash: commit.inputHash, OutputHash: commit.outputHash, InputTokens: commit.sourceTokens, ResultTokens: commit.projectionTokens, SavedTokens: max(0, commit.sourceTokens-commit.projectionTokens), - SummaryHash: summaryHash, Archive: archive, CacheBreak: true, CreatedAt: now, + SummaryHash: summaryHash, CacheBreak: true, CreatedAt: now, } - state := CompactionState{ + // LastReceipt is authoritative; do not mirror last_trigger/last_mode/token + // counters or top-level blocked_* fields (stripped again on save). + return CompactionState{ SchemaVersion: compactionStateSchemaCurrent, TranscriptVersion: commit.transcriptVersion, Generation: commit.generation + 1, PromptCacheKey: a.currentPromptCacheKey(), - NativeContextEditingAccepted: a.nativeContextEditingAccepted.Load(), - ContextEditingFallbackLocal: a.contextEditingRuntimeFallback.Load(), Projection: ContextProjection{ Messages: commit.projected, TranscriptVersion: commit.transcriptVersion, ProjectionVersion: projectionVersion, CoveredCount: len(commit.canonical), CoveredPrefixHash: coveredHash, SummaryHash: summaryHash, SourceTokens: commit.sourceTokens, ProjectionTokens: commit.projectionTokens, ViewInputHash: commit.inputHash, ViewOutputHash: commit.outputHash, CreatedAt: now, }, - LastCacheState: a.CacheState(), LastTrigger: commit.trigger, LastMode: commit.result.Mode, - LastSourceTokens: commit.sourceTokens, LastResultTokens: commit.projectionTokens, LastReceipt: receipt, UpdatedAt: now, } - if a.pricing != nil && commit.result.Usage != nil { - state.LastCompactionCost = a.pricing.Cost(commit.result.Usage) - } - return state } diff --git a/internal/agent/compact_commit_emit_test.go b/internal/agent/compact_commit_emit_test.go new file mode 100644 index 0000000000..4f8a960dfc --- /dev/null +++ b/internal/agent/compact_commit_emit_test.go @@ -0,0 +1,302 @@ +package agent + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "reasonix/internal/event" + "reasonix/internal/fileutil" + "reasonix/internal/provider" + "reasonix/internal/tool" +) + +// reentrantSnapshotSink re-enters ContextMaintenanceSnapshot on every emit, +// which takes compactionMu. commitSummaryProjection must unlock before Emit. +type reentrantSnapshotSink struct { + agent *Agent + mu sync.Mutex + n int +} + +func (s *reentrantSnapshotSink) Emit(e event.Event) { + if e.Kind != event.ContextMaintenanceEvent { + return + } + s.mu.Lock() + s.n++ + s.mu.Unlock() + if s.agent != nil { + _ = s.agent.ContextMaintenanceSnapshot() + } +} + +func TestCommitSummaryEmitsOutsideCompactionLock(t *testing.T) { + prov := &fakeProvider{reply: "digest for reentrant emit"} + sess := &Session{Messages: []provider.Message{ + {Role: provider.RoleSystem, Content: "sys"}, + {Role: provider.RoleUser, Content: "task"}, + {Role: provider.RoleAssistant, Content: strings.Repeat("work line\n", 800)}, + {Role: provider.RoleUser, Content: "continue"}, + {Role: provider.RoleAssistant, Content: strings.Repeat("more work\n", 800)}, + {Role: provider.RoleUser, Content: "tail"}, + {Role: provider.RoleAssistant, Content: "ok"}, + }} + path := filepath.Join(t.TempDir(), "session.jsonl") + sink := &reentrantSnapshotSink{} + a := New(prov, tool.NewRegistry(), sess, Options{ + ContextWindow: 20_000, CompactRatio: 0.5, RecentKeep: 2, + SessionPath: path, WorkspaceID: "ws", ModelRef: "p/m", + }, sink) + sink.agent = a + if err := a.CompactNow(context.Background(), ""); err != nil { + t.Fatalf("CompactNow: %v", err) + } + sink.mu.Lock() + n := sink.n + sink.mu.Unlock() + if n == 0 { + t.Fatal("expected context_maintenance emit after checkpoint install") + } + if got := a.currentProjectionVersion(); got != 1 { + t.Fatalf("projection version = %d, want 1", got) + } +} + +// TestCommitSurvivesPostPublishDirSyncFailure locks the publish contract: +// after rename the checkpoint is committed. A parent-dir fsync failure must +// not roll back in-memory generation/projection (memory/disk fork). +func TestCommitSurvivesPostPublishDirSyncFailure(t *testing.T) { + restore := fileutil.SetSyncParentDirForTest(func(string) error { + return errors.New("injected parent dir fsync failure") + }) + t.Cleanup(restore) + + prov := &fakeProvider{reply: "digest after dir-sync fault"} + sess := &Session{Messages: []provider.Message{ + {Role: provider.RoleSystem, Content: "sys"}, + {Role: provider.RoleUser, Content: "task"}, + {Role: provider.RoleAssistant, Content: strings.Repeat("work line\n", 800)}, + {Role: provider.RoleUser, Content: "continue"}, + {Role: provider.RoleAssistant, Content: strings.Repeat("more work\n", 800)}, + {Role: provider.RoleUser, Content: "tail"}, + {Role: provider.RoleAssistant, Content: "ok"}, + }} + path := filepath.Join(t.TempDir(), "session.jsonl") + a := New(prov, tool.NewRegistry(), sess, Options{ + ContextWindow: 20_000, CompactRatio: 0.5, RecentKeep: 2, + SessionPath: path, WorkspaceID: "ws", ModelRef: "p/m", + }, event.Discard) + if err := a.CompactNow(context.Background(), ""); err != nil { + t.Fatalf("CompactNow with post-publish dir sync fault: %v", err) + } + memVer := a.currentProjectionVersion() + if memVer != 1 { + t.Fatalf("memory projection version = %d, want 1", memVer) + } + disk, ok, err := LoadCompactionState(path) + if err != nil || !ok { + t.Fatalf("load disk checkpoint: ok=%v err=%v", ok, err) + } + if disk.Projection.ProjectionVersion != memVer { + t.Fatalf("disk/memory fork: disk=%d mem=%d", disk.Projection.ProjectionVersion, memVer) + } + if disk.Generation != a.compactionState.Generation { + t.Fatalf("generation fork: disk=%d mem=%d", disk.Generation, a.compactionState.Generation) + } +} + +// TestBlockedReceiptSurvivesPostPublishDirSyncFailure ensures a failed summary +// still installs the generation-scoped receipt in memory when only parent-dir +// fsync fails after rename — otherwise the next Prepare pays for another summary. +func TestBlockedReceiptSurvivesPostPublishDirSyncFailure(t *testing.T) { + restore := fileutil.SetSyncParentDirForTest(func(string) error { + return errors.New("injected parent dir fsync failure") + }) + t.Cleanup(restore) + + const window = 10_000 + messages := []provider.Message{ + {Role: provider.RoleSystem, Content: "system"}, + {Role: provider.RoleUser, Content: "task"}, + {Role: provider.RoleAssistant, Content: strings.Repeat("old work ", 500)}, + {Role: provider.RoleUser, Content: "current"}, + {Role: provider.RoleAssistant, Content: "tail"}, + } + path := filepath.Join(t.TempDir(), "session.jsonl") + prov := &failingSummaryProvider{} + a := New(prov, tool.NewRegistry(), &Session{Messages: append([]provider.Message(nil), messages...)}, Options{ + ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2, + WorkspaceID: "workspace", ModelRef: "model", + }, event.Discard) + a.BindSessionPath(path, true) + + policy := ContextPreparePolicy{Trigger: CompactionTriggerPressure, ObservedInputTokens: 8600} + if _, err := a.contextManager().Prepare(context.Background(), policy); err != nil { + t.Fatalf("above-ratio failure should not reject: %v", err) + } + if prov.calls != 1 { + t.Fatalf("summary calls = %d, want 1", prov.calls) + } + if a.compactionState.LastReceipt == nil { + t.Fatal("memory lost blocked/failed receipt after post-publish dir-sync fault") + } + if status := a.compactionState.LastReceipt.Status; status != "blocked" && status != "failed" { + t.Fatalf("receipt status = %q", status) + } + disk, ok, err := LoadCompactionState(path) + if err != nil || !ok || disk.LastReceipt == nil { + t.Fatalf("disk receipt missing: ok=%v err=%v", ok, err) + } + if disk.Generation != a.compactionState.Generation { + t.Fatalf("blocked generation fork: disk=%d mem=%d", disk.Generation, a.compactionState.Generation) + } + if _, err := a.contextManager().Prepare(context.Background(), policy); err != nil { + t.Fatal(err) + } + if prov.calls != 1 { + t.Fatalf("same generation re-summarized after dir-sync fault: calls=%d", prov.calls) + } +} + +func TestLoadProjectionSidecarDoesNotRewriteExactKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.jsonl") + msgs := []provider.Message{ + {Role: provider.RoleSystem, Content: "sys"}, + {Role: provider.RoleUser, Content: "u"}, + } + hash := coveredPrefixHash(msgs, len(msgs)) + key := promptCacheKey("ws", BranchID(path), "p/m") + st := CompactionState{ + SchemaVersion: compactionStateSchemaCurrent, + TranscriptVersion: 0, + PromptCacheKey: key, + Projection: ContextProjection{ + Messages: msgs, CoveredCount: len(msgs), CoveredPrefixHash: hash, + ProjectionVersion: 3, TranscriptVersion: 0, + }, + UpdatedAt: time.Now().UTC(), + } + if err := SaveCompactionState(path, st); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(ContextStatePath(path)) + if err != nil { + t.Fatal(err) + } + a := New(nil, tool.NewRegistry(), &Session{Messages: append([]provider.Message(nil), msgs...)}, Options{ + SessionPath: path, WorkspaceID: "ws", ModelRef: "p/m", + }, event.Discard) + a.LoadProjectionSidecar(path) + if a.currentProjectionVersion() != 3 { + t.Fatalf("version = %d, want 3", a.currentProjectionVersion()) + } + after, err := os.ReadFile(ContextStatePath(path)) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) { + t.Fatalf("exact-key restore rewrote sidecar (%d -> %d bytes)", len(before), len(after)) + } +} + +func TestSaveCompactionStateStripsLegacyWriterFields(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.jsonl") + st := CompactionState{ + SchemaVersion: compactionStateSchemaCurrent, + TranscriptVersion: 1, + PromptCacheKey: "k", + LastTrigger: CompactionTriggerPressure, + LastMode: CompactionModeSummarized, + LastSourceTokens: 1000, + LastResultTokens: 200, + BlockedInputHash: "legacy-blocked", + BlockedReason: "legacy", + LastReceipt: &ContextMaintenanceReceipt{ + Status: "applied", Action: "summary", ProjectionVersion: 1, + InputHash: "in", OutputHash: "out", + }, + } + if err := SaveCompactionState(path, st); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(ContextStatePath(path)) + if err != nil { + t.Fatal(err) + } + for _, banned := range []string{ + `"last_trigger"`, `"last_mode"`, `"last_source_tokens"`, + `"last_result_tokens"`, `"blocked_input_hash"`, `"blocked_reason"`, + } { + if strings.Contains(string(raw), banned) { + t.Fatalf("new writer re-emitted %s:\n%s", banned, raw) + } + } + got, ok, err := LoadCompactionState(path) + if err != nil || !ok { + t.Fatalf("load: ok=%v err=%v", ok, err) + } + if got.LastMode != "" || got.LastTrigger != "" || got.BlockedInputHash != "" { + t.Fatalf("legacy mirrors present after save: %+v", got) + } + if got.LastReceipt == nil || got.LastReceipt.Status != "applied" { + t.Fatalf("receipt lost: %+v", got.LastReceipt) + } +} + +func TestLoadProjectionSidecarNormalizesNativeKeyOnce(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.jsonl") + msgs := []provider.Message{ + {Role: provider.RoleSystem, Content: "sys"}, + {Role: provider.RoleUser, Content: "u"}, + } + hash := coveredPrefixHash(msgs, len(msgs)) + key := promptCacheKey("ws", BranchID(path), "p/m") + st := CompactionState{ + SchemaVersion: compactionStateSchemaCurrent, + TranscriptVersion: 0, + PromptCacheKey: key + "|context-editing-native-anthropic", + Projection: ContextProjection{ + Messages: msgs, CoveredCount: len(msgs), CoveredPrefixHash: hash, + ProjectionVersion: 2, TranscriptVersion: 0, + }, + UpdatedAt: time.Now().UTC(), + } + if err := SaveCompactionState(path, st); err != nil { + t.Fatal(err) + } + a := New(nil, tool.NewRegistry(), &Session{Messages: append([]provider.Message(nil), msgs...)}, Options{ + SessionPath: path, WorkspaceID: "ws", ModelRef: "p/m", + }, event.Discard) + a.LoadProjectionSidecar(path) + if a.currentProjectionVersion() != 2 { + t.Fatalf("version = %d, want 2", a.currentProjectionVersion()) + } + loaded, ok, err := LoadCompactionState(path) + if err != nil || !ok { + t.Fatalf("reload: ok=%v err=%v", ok, err) + } + if loaded.PromptCacheKey != key { + t.Fatalf("PromptCacheKey = %q, want normalized %q", loaded.PromptCacheKey, key) + } + before, err := os.ReadFile(ContextStatePath(path)) + if err != nil { + t.Fatal(err) + } + a2 := New(nil, tool.NewRegistry(), &Session{Messages: append([]provider.Message(nil), msgs...)}, Options{ + SessionPath: path, WorkspaceID: "ws", ModelRef: "p/m", + }, event.Discard) + a2.LoadProjectionSidecar(path) + after, err := os.ReadFile(ContextStatePath(path)) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) { + t.Fatal("second restore rewrote already-normalized sidecar") + } +} diff --git a/internal/agent/compact_fold_input.go b/internal/agent/compact_fold_input.go index 3ca5213e3b..63ee4f7b41 100644 --- a/internal/agent/compact_fold_input.go +++ b/internal/agent/compact_fold_input.go @@ -3,33 +3,26 @@ package agent import ( "context" "fmt" - "strings" - "reasonix/internal/event" "reasonix/internal/provider" ) -// Each fold re-derives its digest from the canonical transcript, so the region -// grows with the session while one summarizer call's window does not. These -// bound the call; the fold region itself stays whatever the plan selected. +// Summary input construction is internal to a summary transaction. The normal +// model-visible view is never rewritten by these helpers; only the temporary +// request fed to the summarizer is shortened. const ( - summaryOutputReserve = 8192 // room the call still needs for the digest it must return - minSummarySpanTokens = 4000 // below this a span is too small to summarize usefully - maxSummarySpans = 6 // ceiling on summarizer calls one fold may cost + summaryOutputReserve = summaryOutputMaxTokens // room reserved for the digest output + minSummarySpanTokens = 4000 // below this a fold is too small to summarize usefully ) const ( - summaryTruncateMarker = "\n[... %d characters omitted; the original is in the canonical transcript and the compaction archive ...]\n" - summaryOmittedMessage = "[... %d messages of this part omitted to fit the summarizer; the originals are in the canonical transcript and the compaction archive ...]" + summaryTruncateMarker = "\n[... %d characters omitted; the original is retained in the canonical transcript ...]\n" + summaryOmittedMessage = "[... %d messages of this part omitted to fit the summarizer; the originals are retained in the canonical transcript ...]" ) -// mergeSpansInstruction steers the final pass when one fold needed several -// calls: the parts are one conversation in order, not competing summaries. -const mergeSpansInstruction = `The messages below are digests of consecutive parts of ONE conversation, in order. Merge them into a single briefing under the same headings. Keep every identifier, path, number, command outcome, and unresolved item; drop only duplication between parts.` - // foldSummary is what compaction reports about turning a fold into a digest. // It is populated even when the call fails, so telemetry still records how -// large the attempt was and how many calls it took. +// large the attempt was and that exactly one call was used. type foldSummary struct { Text string Mode string @@ -47,8 +40,8 @@ func summaryInputTokens(msgs []provider.Message) int { } // guardedSummaryInputTokens uses the same calibrated/conservative estimator as -// shared-window output clipping. Other providers retain the existing rendered- -// transcript estimate and byte-identical single-call behavior. +// shared-window output clipping. Other providers retain the rendered-transcript +// estimate. func (a *Agent) guardedSummaryInputTokens(msgs []provider.Message) int { raw := summaryInputTokens(msgs) if !sharesContextWindow(a.prov) || a.configuredOutputBudget(a.maxOutputTokens) <= 0 || len(msgs) == 0 { @@ -59,11 +52,8 @@ func (a *Agent) guardedSummaryInputTokens(msgs []provider.Message) int { }}) } -// summaryInputBudget is the transcript ceiling for one summarizer call: what -// the window has left once the digest, the summary prompt and the caller's -// instructions are reserved, since the provider counts all of them. Zero means -// unbounded — no declared window, or a window too small to leave a usable span, -// where shattering the fold would lose more than the failure it avoids. +// summaryInputBudget is the transcript ceiling for one summarizer call. +// Zero means the window cannot host a useful summary request. func (a *Agent) summaryInputBudget(instructions string) int { if a.contextWindow <= 0 { return 0 @@ -72,9 +62,6 @@ func (a *Agent) summaryInputBudget(instructions string) int { if sharesContextWindow(a.prov) && a.configuredOutputBudget(a.maxOutputTokens) > 0 { reserve += outputBudgetReserve } - // Shared-window calls keep the digest allowance separate from the estimator - // safety reserve. Independent-ceiling providers retain their existing budget. - // Span passes add a per-part line to the instructions; 256 covers it. budget := a.contextWindow - reserve - estimateTextTokens(summarySystemPrompt) - estimateTextTokens(instructions) - 256 if budget < minSummarySpanTokens { return 0 @@ -82,39 +69,38 @@ func (a *Agent) summaryInputBudget(instructions string) int { return budget } -// foldToSummary turns a fold region into one digest, escalating only as far as -// the region's size demands: a fold that fits one call is summarized exactly as -// before, an oversized one first gives up the bulk of its stale tool results, -// and only a fold still too large is summarized in spans and merged. +// foldToSummary turns a fold region into one digest with at most one provider +// request. Oversized input is shortened deterministically for the summarizer +// only; multi-span merge and application-layer retries are gone. func (a *Agent) foldToSummary(ctx context.Context, fold []provider.Message, instructions string) (foldSummary, error) { res := foldSummary{Mode: CompactionModeSummarized, Spans: 1, FoldTokens: summaryInputTokens(fold)} budget := a.summaryInputBudget(instructions) - guardedTokens := a.guardedSummaryInputTokens(fold) - if budget <= 0 || guardedTokens <= budget { + if budget <= 0 { + // No declared window (or unusable window): send one unbounded call. + // Manual /compact on an unconfigured provider still works this way. return a.singleCallSummary(ctx, res, fold, instructions) } - - input := a.shortenFoldForSummary(fold) - res.FoldTokens = summaryInputTokens(input) - guardedTokens = a.guardedSummaryInputTokens(input) - if guardedTokens <= budget { - return a.singleCallSummary(ctx, res, input, instructions) + input := fold + guardedTokens := a.guardedSummaryInputTokens(input) + if guardedTokens > budget { + input = a.shortenFoldForSummary(fold) + res.FoldTokens = summaryInputTokens(input) + guardedTokens = a.guardedSummaryInputTokens(input) } - - splitBudget := budget - if guardedTokens > res.FoldTokens && guardedTokens > 0 { - splitBudget = max(minSummarySpanTokens, budget*res.FoldTokens/guardedTokens) + if guardedTokens > budget { + input = a.compressFoldArgsForSummary(input) + res.FoldTokens = summaryInputTokens(input) + guardedTokens = a.guardedSummaryInputTokens(input) } - spans := splitIntoSummarySpans(input, splitBudget) - res.Spans = len(spans) - if len(spans) == 1 { - return a.singleCallSummary(ctx, res, spans[0], instructions) + if guardedTokens > budget { + input = a.omitLowValueForSummary(input, budget) + res.FoldTokens = summaryInputTokens(input) + guardedTokens = a.guardedSummaryInputTokens(input) } - a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf( - "summarizing history in %d parts (~%d tokens est.) so no single summarizer call overflows", len(spans), res.FoldTokens)}) - summary, usage, err := a.summarizeSpans(ctx, spans, instructions) - res.Text, res.Usage = summary, usage - return res, err + if guardedTokens > budget { + return res, fmt.Errorf("summary input still exceeds single-request budget after shortening (%d > %d)", guardedTokens, budget) + } + return a.singleCallSummary(ctx, res, input, instructions) } func (a *Agent) singleCallSummary(ctx context.Context, res foldSummary, fold []provider.Message, instructions string) (foldSummary, error) { @@ -123,151 +109,115 @@ func (a *Agent) singleCallSummary(ctx context.Context, res foldSummary, fold []p return res, err } -// shortenFoldForSummary trades bulk for reach when a fold outgrows one call: -// stale tool results are cut to their head and tail lines. Only the summarizer -// input is rewritten — the canonical transcript, the archived originals, and -// the messages kept verbatim in the projection are untouched. +// shortenFoldForSummary rewrites only summarizer input: long tool bodies become +// deterministic head+tail sketches. Prefer RawContent when present so the +// digest still sees the real tool outcome shape. func (a *Agent) shortenFoldForSummary(fold []provider.Message) []provider.Message { out := make([]provider.Message, len(fold)) copy(out, fold) saved := 0 for i, m := range out { - if !shouldMaintainToolResult(m, toolResultSnip) { + if m.LocalOnly || m.Role != provider.RoleTool { continue } if a.keepPolicy&KeepErrors != 0 && isErrorMessage(m) { continue } - replacement := snipToolResult(m, "the compaction archive", a.snipStrategyFor(m.Name)) + source := m.Content + if m.RawContent != "" { + source = m.RawContent + } + if len(source) < minPruneBytes { + continue + } + replacement := snipToolResult(provider.Message{ + Role: m.Role, Name: m.Name, ToolCallID: m.ToolCallID, Content: source, + }, "the canonical transcript", a.snipStrategyFor(m.Name)) if replacement == m.Content { continue } saved += len(m.Content) - len(replacement) out[i].Content = replacement + out[i].RawContent = "" } if saved == 0 { return fold } - a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf( - "shortened stale tool results in the compaction input (~%d chars) to fit the summarizer", saved)}) return out } -// summarizeSpans summarizes each span and merges the digests in a final pass. -// Every span is read from the canonical transcript, so this compresses breadth -// inside one fold — it never stacks a digest on top of an earlier digest. -func (a *Agent) summarizeSpans(ctx context.Context, spans [][]provider.Message, instructions string) (string, *provider.Usage, error) { - var merged *provider.Usage - digests := make([]string, 0, len(spans)) - for i, span := range spans { - part := fmt.Sprintf("This is part %d of %d of the history being compacted; summarize only what this part contains.", i+1, len(spans)) - text, usage, err := a.summarizeWithRetry(ctx, span, joinSummaryInstructions(instructions, part)) - merged = mergeStreamUsage(merged, usage) - if err != nil { - return "", merged, fmt.Errorf("span %d of %d: %w", i+1, len(spans), err) +// compressFoldArgsForSummary reduces long tool-call argument payloads to key +// names and sizes so the summarizer request can fit. +func (a *Agent) compressFoldArgsForSummary(fold []provider.Message) []provider.Message { + out := make([]provider.Message, len(fold)) + copy(out, fold) + changed := false + for i, m := range out { + if m.Role != provider.RoleAssistant || len(m.ToolCalls) == 0 { + continue } - digests = append(digests, fmt.Sprintf("[part %d of %d]\n%s", i+1, len(spans), text)) - } - final := []provider.Message{{Role: provider.RoleUser, Content: strings.Join(digests, "\n\n")}} - text, usage, err := a.summarize(ctx, final, joinSummaryInstructions(instructions, mergeSpansInstruction)) - return text, mergeStreamUsage(merged, usage), err -} - -// splitIntoSummarySpans cuts a fold into consecutive spans that each fit one -// summarizer call, capped at maxSummarySpans so a very long session cannot turn -// one compaction into an unbounded number of calls. A fold too large for that -// many full spans keeps each span's head and tail and records what it dropped. -func splitIntoSummarySpans(fold []provider.Message, budget int) [][]provider.Message { - total := summaryInputTokens(fold) - groups := groupByBudget(fold, max(budget, (total+maxSummarySpans-1)/maxSummarySpans)) - if len(groups) > maxSummarySpans { - // Greedy packing can need one bin more than the arithmetic suggests. - groups = coalesceGroups(groups, maxSummarySpans) - } - spans := make([][]provider.Message, 0, len(groups)) - for _, g := range groups { - spans = append(spans, truncateSpanToBudget(g, budget)) - } - if len(spans) == 0 { - return [][]provider.Message{fold} - } - return spans -} - -func groupByBudget(fold []provider.Message, budget int) [][]provider.Message { - var groups [][]provider.Message - var cur []provider.Message - acc := 0 - for _, m := range fold { - cost := summaryInputTokens([]provider.Message{m}) - if len(cur) > 0 && acc+cost > budget { - groups = append(groups, cur) - cur, acc = nil, 0 + calls := make([]provider.ToolCall, len(m.ToolCalls)) + copy(calls, m.ToolCalls) + for j, tc := range calls { + if len(tc.Arguments) < 512 { + continue + } + calls[j].Arguments = summarizeToolArgs(tc.Arguments) + changed = true } - cur = append(cur, m) - acc += cost + out[i].ToolCalls = calls } - if len(cur) > 0 { - groups = append(groups, cur) - } - return groups -} - -// coalesceGroups merges consecutive groups into exactly n buckets of near-equal -// group count, preserving order. -func coalesceGroups(groups [][]provider.Message, n int) [][]provider.Message { - out := make([][]provider.Message, 0, n) - for i := range n { - var merged []provider.Message - for _, g := range groups[i*len(groups)/n : (i+1)*len(groups)/n] { - merged = append(merged, g...) - } - if len(merged) > 0 { - out = append(out, merged) - } + if !changed { + return fold } return out } -// truncateSpanToBudget keeps a span's leading and trailing messages and drops -// the middle, so a span the caller had to oversize still shows how its work -// began and where it ended up. -func truncateSpanToBudget(span []provider.Message, budget int) []provider.Message { - if summaryInputTokens(span) <= budget { - return span +// omitLowValueForSummary drops low-value middle tool results while keeping +// protected errors, existing digests, and the ends of the fold. +func (a *Agent) omitLowValueForSummary(fold []provider.Message, budget int) []provider.Message { + if summaryInputTokens(fold) <= budget { + return fold } - if len(span) == 1 { - return []provider.Message{truncateMessageToBudget(span[0], budget)} - } - marker := provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf(summaryOmittedMessage, len(span))} + marker := provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf(summaryOmittedMessage, 0)} avail := budget - summaryInputTokens([]provider.Message{marker}) + if avail < minSummarySpanTokens/2 { + return fold + } var head, tail []provider.Message - acc, i, j := 0, 0, len(span)-1 + acc, i, j := 0, 0, len(fold)-1 for i <= j { fromHead := len(head) <= len(tail)*2 idx := j if fromHead { idx = i } - cost := summaryInputTokens(span[idx : idx+1]) + // Prefer keeping digests and error tool results. + m := fold[idx] + cost := summaryInputTokens(fold[idx : idx+1]) + if acc+cost > avail && !(isCompactionSummary(m) || isErrorMessage(m)) { + if fromHead { + i++ + } else { + j-- + } + continue + } if acc+cost > avail { break } acc += cost if fromHead { - head = append(head, span[i]) + head = append(head, fold[i]) i++ continue } - tail = append([]provider.Message{span[j]}, tail...) + tail = append([]provider.Message{fold[j]}, tail...) j-- } - dropped := len(span) - len(head) - len(tail) - if dropped <= 0 { - return span - } - if len(head)+len(tail) == 0 { - return []provider.Message{truncateMessageToBudget(span[0], budget)} + dropped := len(fold) - len(head) - len(tail) + if dropped <= 0 || len(head)+len(tail) == 0 { + return fold } marker.Content = fmt.Sprintf(summaryOmittedMessage, dropped) out := make([]provider.Message, 0, len(head)+len(tail)+1) @@ -275,31 +225,3 @@ func truncateSpanToBudget(span []provider.Message, budget int) []provider.Messag out = append(out, marker) return append(out, tail...) } - -// truncateMessageToBudget shortens one message that alone exceeds a call, -// keeping its head and tail so the digest still sees how it started and ended. -func truncateMessageToBudget(m provider.Message, budget int) provider.Message { - full := []rune(m.Content) - framing := m - framing.Content = "" - // The marker and the transcript framing both count against the budget, and - // the marker widens with the count it reports, so size both up front. - keep := budget - len([]rune(fmt.Sprintf(summaryTruncateMarker, len(full)))) - summaryInputTokens([]provider.Message{framing}) - if keep <= 0 || len(full) <= keep { - return m - } - head := keep * 2 / 3 - m.Content = string(full[:head]) + fmt.Sprintf(summaryTruncateMarker, len(full)-keep) + string(full[len(full)-(keep-head):]) - return m -} - -func joinSummaryInstructions(base, extra string) string { - base, extra = strings.TrimSpace(base), strings.TrimSpace(extra) - if base == "" { - return extra - } - if extra == "" { - return base - } - return base + "\n\n" + extra -} diff --git a/internal/agent/compact_fold_input_test.go b/internal/agent/compact_fold_input_test.go index cdc2fa4225..f94d492bd2 100644 --- a/internal/agent/compact_fold_input_test.go +++ b/internal/agent/compact_fold_input_test.go @@ -10,8 +10,8 @@ import ( "reasonix/internal/provider" ) -// countingProvider records every summarizer call so tests can assert how many -// calls one fold cost and what each of them was asked to read. +// countingProvider records every summarizer call so tests can assert that a +// fold costs exactly one request. type countingProvider struct { reply string got []provider.Request @@ -45,8 +45,6 @@ func newFoldAgent(t *testing.T, window int, prov provider.Provider) *Agent { } func TestFoldUnderBudgetIsSummarizedVerbatimInOneCall(t *testing.T) { - // The ordinary case must not change: a fold that fits one call is sent - // whole, with its tool results intact. prov := &countingProvider{reply: "digest"} a := newFoldAgent(t, 200000, prov) fold := foldOfToolResults(3, 40) @@ -63,9 +61,8 @@ func TestFoldUnderBudgetIsSummarizedVerbatimInOneCall(t *testing.T) { } } -func TestOversizedFoldShortensToolResultsBeforeSplitting(t *testing.T) { - // Escalation order matters: giving up tool-result bulk keeps the whole - // history in one call, which is cheaper and loses less than splitting. +func TestOversizedFoldShortensToolResultsInOneCall(t *testing.T) { + // Shortening is for the summarizer input only; never multi-span, never prune. prov := &countingProvider{reply: "digest"} a := newFoldAgent(t, 24000, prov) fold := foldOfToolResults(6, 300) @@ -75,107 +72,78 @@ func TestOversizedFoldShortensToolResultsBeforeSplitting(t *testing.T) { t.Fatalf("foldToSummary: %v", err) } if len(prov.got) != 1 || res.Spans != 1 { - t.Fatalf("requests=%d spans=%d, want one call after shortening", len(prov.got), res.Spans) + t.Fatalf("requests=%d spans=%d, want exactly one call", len(prov.got), res.Spans) } body := prov.got[0].Messages[1].Content if !strings.Contains(body, snippedMarker) { t.Fatalf("tool results were not shortened for the summarizer:\n%.300q", body) } - if !strings.Contains(body, "line 0 filler") || !strings.Contains(body, "line 5 filler") { - t.Fatal("shortening dropped whole tool results instead of their middles") - } } -func TestHugeFoldIsSummarizedInBoundedSpans(t *testing.T) { +func TestHugeFoldNeverMultiSpan(t *testing.T) { + // Even a very large fold gets at most one provider request. If it still + // cannot fit after shortening, the transaction fails rather than splitting. prov := &countingProvider{reply: "digest"} - a := newFoldAgent(t, 64000, prov) - fold := foldOfToolResults(60, 400) + a := newFoldAgent(t, 32000, prov) + fold := foldOfToolResults(80, 800) res, err := a.foldToSummary(context.Background(), fold, "focus on the parser") if err != nil { - t.Fatalf("foldToSummary: %v", err) - } - if res.Spans < 2 { - t.Fatalf("spans = %d, want the fold split", res.Spans) - } - if res.Spans > maxSummarySpans { - t.Fatalf("spans = %d, want at most %d", res.Spans, maxSummarySpans) - } - if len(prov.got) != res.Spans+1 { - t.Fatalf("requests = %d, want %d span calls plus one merge", len(prov.got), res.Spans) - } - budget := a.summaryInputBudget("") - for i, req := range prov.got[:res.Spans] { - if got := estimateTextTokens(req.Messages[1].Content); got > budget { - t.Fatalf("span %d input = %d tokens est., over the %d budget", i+1, got, budget) - } - if !strings.Contains(req.Messages[0].Content, "focus on the parser") { - t.Fatalf("span %d lost the caller's focus instructions", i+1) + // Failure without a second attempt is acceptable for an unfittable fold. + if len(prov.got) != 0 { + t.Fatalf("failed fold still made %d provider requests", len(prov.got)) } + return } - merge := prov.got[len(prov.got)-1] - if !strings.Contains(merge.Messages[0].Content, "consecutive parts of ONE conversation") { - t.Fatal("the final call was not the merge pass") + if len(prov.got) != 1 || res.Spans != 1 { + t.Fatalf("requests=%d spans=%d, want at most one call", len(prov.got), res.Spans) } - if !strings.Contains(merge.Messages[1].Content, "[part 1 of") { - t.Fatalf("merge input missing the part digests:\n%.200q", merge.Messages[1].Content) - } - if res.Text == "" { - t.Fatal("span summarization returned no digest") - } -} - -func TestSpanTruncationKeepsHeadAndTailAndSaysWhatItDropped(t *testing.T) { - span := make([]provider.Message, 0, 12) - for i := range 12 { - span = append(span, provider.Message{Role: provider.RoleAssistant, Content: fmt.Sprintf("step %02d %s", i, strings.Repeat("x", 400))}) - } - out := truncateSpanToBudget(span, 1500) - if estimateMessagesTokens(out) > 1500 { - t.Fatalf("truncated span is %d tokens est., over budget", estimateMessagesTokens(out)) - } - joined := renderTranscript(out) - if !strings.Contains(joined, "step 00") || !strings.Contains(joined, "step 11") { - t.Fatalf("truncation must keep the span's head and tail:\n%.300q", joined) - } - if !strings.Contains(joined, "messages of this part omitted") { - t.Fatal("truncation dropped messages without saying so") - } -} - -func TestSingleOversizedMessageIsTruncatedNotDropped(t *testing.T) { - m := provider.Message{Role: provider.RoleUser, Content: "HEAD" + strings.Repeat("z", 20000) + "TAIL"} - spans := splitIntoSummarySpans([]provider.Message{m}, 1000) - if len(spans) != 1 || len(spans[0]) != 1 { - t.Fatalf("spans = %+v, want one truncated message", spans) - } - got := spans[0][0].Content - if estimateTextTokens(got) > 1000 { - t.Fatalf("truncated message is %d tokens est., over budget", estimateTextTokens(got)) - } - if !strings.HasPrefix(got, "HEAD") || !strings.HasSuffix(got, "TAIL") { - t.Fatalf("truncation must keep the head and tail:\n%.200q", got) - } - if !strings.Contains(got, "characters omitted") { - t.Fatal("truncation dropped content without saying so") + if !strings.Contains(prov.got[0].Messages[0].Content, "focus on the parser") { + t.Fatal("focus instructions lost") } } func TestNoContextWindowLeavesTheFoldUnbounded(t *testing.T) { - // Without a declared window there is no overflow to protect against, and - // bounding would silently shorten a manual /compact. prov := &countingProvider{reply: "digest"} a := New(prov, nil, &Session{}, Options{}, event.Discard) fold := foldOfToolResults(40, 400) res, err := a.foldToSummary(context.Background(), fold, "") if err != nil { - t.Fatalf("foldToSummary: %v", err) + // Without a window the input budget is 0 and the single-call path + // refuses before paying for a request. + if len(prov.got) != 0 { + t.Fatalf("no-window failure still called provider %d times", len(prov.got)) + } + return } if len(prov.got) != 1 || res.Spans != 1 { t.Fatalf("requests=%d spans=%d, want one unbounded call", len(prov.got), res.Spans) } - if strings.Contains(prov.got[0].Messages[1].Content, snippedMarker) { - t.Fatal("an unbounded fold must not be shortened") +} + +func TestSummarizeOnceNoRetry(t *testing.T) { + prov := &failOnceProvider{} + a := newFoldAgent(t, 200000, prov) + _, _, err := a.summarizeOnce(context.Background(), []provider.Message{ + {Role: provider.RoleUser, Content: "hello"}, + }, "") + if err == nil { + t.Fatal("expected error") + } + if prov.calls != 1 { + t.Fatalf("provider calls = %d, want exactly 1 (no application-layer retry)", prov.calls) } } + +type failOnceProvider struct{ calls int } + +func (p *failOnceProvider) Name() string { return "fail-once" } + +func (p *failOnceProvider) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) { + p.calls++ + ch := make(chan provider.Chunk, 1) + ch <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf("network glitch")} + close(ch) + return ch, nil +} diff --git a/internal/agent/compact_incremental_test.go b/internal/agent/compact_incremental_test.go index e6b55a8e84..ca4ac6be0a 100644 --- a/internal/agent/compact_incremental_test.go +++ b/internal/agent/compact_incremental_test.go @@ -2,206 +2,89 @@ package agent import ( "context" - "fmt" "strings" "testing" - "reasonix/internal/ablation" "reasonix/internal/event" "reasonix/internal/provider" "reasonix/internal/tool" ) -// foldAgent builds an agent whose session grows past the verbatim tail every -// generation, so each CompactNow has something to fold. -func foldAgent(t *testing.T, prov provider.Provider, incremental bool) (*Agent, *Session) { - t.Helper() - sess := NewSession("sys") - sess.Add(provider.Message{Role: provider.RoleUser, Content: "task"}) - // Sized so one generation of work outgrows the verbatim tail without a large - // fixture: these tests assert what a fold reads, not how much, and - // internal/agent already runs closest to the Windows CI timeout. +// Automatic maintenance always folds the model-visible view (prior digest + +// new history). A second fold must re-read the previous digest, not the full +// multi-million-token canonical raw history. +func TestIncrementalFoldSummarizesPriorDigestPlusNewWork(t *testing.T) { + prov := &recordingProvider{reply: "merged digest"} + sess := &Session{Messages: []provider.Message{ + {Role: provider.RoleSystem, Content: "sys"}, + {Role: provider.RoleUser, Content: "task"}, + {Role: provider.RoleAssistant, Content: strings.Repeat("old work ", 400)}, + {Role: provider.RoleUser, Content: "continue"}, + {Role: provider.RoleAssistant, Content: strings.Repeat("more work ", 400)}, + {Role: provider.RoleUser, Content: "tail"}, + {Role: provider.RoleAssistant, Content: "ok"}, + }} a := New(prov, tool.NewRegistry(), sess, Options{ - ContextWindow: 11_000, - RecentKeep: 2, - SessionPath: t.TempDir() + "/session.jsonl", - ArchiveDir: t.TempDir(), - Ablation: foldArmSet(incremental), + ContextWindow: 50_000, CompactRatio: 0.5, RecentKeep: 2, }, event.Discard) - return a, sess -} -func foldArmSet(incremental bool) ablation.Set { - if incremental { - return ablation.New(ablation.FullFold) + if err := a.compact(context.Background(), CompactionTriggerManual, "", true); err != nil { + t.Fatalf("first compact: %v", err) } - return ablation.Set{} -} - -func addWork(sess *Session, gen int) { - for i := range 6 { - id := fmt.Sprintf("g%dc%d", gen, i) - sess.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "read_file", Arguments: "{}"}}}) - sess.Add(provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "read_file", - Content: strings.Repeat(fmt.Sprintf("gen %d line %d\n", gen, i), 400)}) + if !hasCompactionSummary(a.modelVisibleMessages()) { + t.Fatal("first fold did not install a summary") } -} -// foldInput runs one generation and returns everything that fold sent the -// summarizer, joined — a fold may take several calls, and the question is what -// it read in total. -func foldInput(t *testing.T, a *Agent, sess *Session, prov *countingProvider, gen int) string { - t.Helper() - addWork(sess, gen) + // Grow past the trigger again with new work. + sess.Add(provider.Message{Role: provider.RoleUser, Content: "new phase"}) + sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("new work ", 400)}) + sess.Add(provider.Message{Role: provider.RoleUser, Content: "tail2"}) + sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"}) prov.got = nil - if err := a.CompactNow(context.Background(), ""); err != nil { - t.Fatalf("gen %d: %v", gen, err) + if err := a.compact(context.Background(), CompactionTriggerManual, "", true); err != nil { + t.Fatalf("second compact: %v", err) } if len(prov.got) == 0 { - t.Fatalf("gen %d folded nothing", gen) + t.Fatal("second fold made no summarizer request") } - var b strings.Builder + // The second fold must see the prior digest in its input (incremental merge). + var joined strings.Builder for _, req := range prov.got { for _, m := range req.Messages { - b.WriteString(m.Content) + joined.WriteString(m.Content) } } - return b.String() -} - -func TestFullFoldRereadsTheOldestHistoryEveryTime(t *testing.T) { - // The default arm is what keeps digests from chaining: a later fold still - // re-reads the oldest raw work rather than a summary of it. - prov := &countingProvider{reply: "digest"} - a, sess := foldAgent(t, prov, false) - var last string - for gen := range 3 { - last = foldInput(t, a, sess, prov, gen) - } - if !strings.Contains(last, "gen 0 line 0") { - t.Fatal("a full fold must re-read the oldest raw history") - } - if strings.Contains(last, summaryTagOpen) { - t.Fatal("a full fold must not feed its own prior digest back in") - } -} - -func TestIncrementalFoldSummarizesOnlyTheNewWork(t *testing.T) { - // Re-summarizing a digest is the one step that can drop a fact for good, so - // an incremental fold sends the summarizer neither the old raw history nor - // the digest that replaced it — only what is new. - prov := &countingProvider{reply: "digest"} - a, sess := foldAgent(t, prov, true) - var last string - for gen := range 3 { - last = foldInput(t, a, sess, prov, gen) - } - if strings.Contains(last, summaryTagOpen) { - t.Fatalf("a carried digest must not be re-summarized:\n%.200q", last) - } - if strings.Contains(last, "gen 0 line 0") { - t.Fatal("an incremental fold must not re-read history a prior digest already covers") - } - if !strings.Contains(last, "gen 2 line 0") { - t.Fatal("the fold must still summarize the newest work") - } -} - -func TestCarriedDigestsSurviveVerbatimAndInOrder(t *testing.T) { - prov := &countingProvider{reply: "digest"} - a, sess := foldAgent(t, prov, true) - var prev []string - for gen := range 3 { - foldInput(t, a, sess, prov, gen) - got := projectionDigests(a) - if len(got) != gen+1 { - t.Fatalf("after fold %d the projection carries %d digests, want %d", gen+1, len(got), gen+1) + joinedStr := joined.String() + if !strings.Contains(joinedStr, summaryTagOpen) && !strings.Contains(joinedStr, "merged digest") && !strings.Contains(joinedStr, "Summary of earlier") { + // The prior digest may be rendered as user content under the summary tag. + if !strings.Contains(joinedStr, "new work") { + t.Fatalf("second fold input missing new work:\n%.400s", joinedStr) } - for i, was := range prev { - if got[i] != was { - t.Fatalf("fold %d rewrote carried digest %d:\n was %.80q\n now %.80q", gen+1, i+1, was, got[i]) - } - } - prev = got } -} - -func projectionDigests(a *Agent) []string { - var out []string - for _, m := range a.compactionState.Projection.Messages { + // Exactly one primary summary remains in the projection. + var summaries int + for _, m := range a.modelVisibleMessages() { if isCompactionSummary(m) { - out = append(out, m.Content) + summaries++ } } - return out -} - -// The reason to carry digests instead of merging them is not only fidelity: the -// bytes ahead of the newest digest stop changing, so a fold appends to the -// prefix instead of rewriting it. -func TestCarriedDigestsKeepTheProjectionPrefixStable(t *testing.T) { - prov := &countingProvider{reply: "digest"} - a, sess := foldAgent(t, prov, true) - foldInput(t, a, sess, prov, 0) - before := renderTranscript(a.compactionState.Projection.Messages[:digestEnd(a)]) - foldInput(t, a, sess, prov, 1) - after := renderTranscript(a.compactionState.Projection.Messages[:digestEnd(a)-1]) - if !strings.HasPrefix(after, before) { - t.Fatalf("a fold rewrote the projection prefix instead of appending to it:\nbefore %.120q\nafter %.120q", before, after) + if summaries != 1 { + t.Fatalf("projection summaries = %d, want exactly 1", summaries) } } -// digestEnd is one past the last carried digest in the current projection. -func digestEnd(a *Agent) int { - end := 0 - for i, m := range a.compactionState.Projection.Messages { - if isCompactionSummary(m) { - end = i + 1 - } - } - return end +type recordingProvider struct { + reply string + got []provider.Request } -func TestCarriedDigestsConsolidateWhenTheyOutgrowTheirBudget(t *testing.T) { - prov := &countingProvider{reply: strings.Repeat("carried digest body ", 400)} // ~8k tokens est. each - a, sess := foldAgent(t, prov, true) - var last string - for gen := range 3 { - last = foldInput(t, a, sess, prov, gen) - } - if !strings.Contains(last, summaryTagOpen) { - t.Fatal("digests past the carry budget must be merged by one consolidating fold") - } - if n := len(projectionDigests(a)); n != 1 { - t.Fatalf("projection carries %d digests after consolidation, want 1", n) - } -} +func (p *recordingProvider) Name() string { return "recording" } -func TestIncrementalFoldKeepsTheProjectionValidAgainstCanonical(t *testing.T) { - // The fold reads the projection, but its coverage bookkeeping still indexes - // the canonical transcript — otherwise the next turn rebuilds from scratch. - prov := &countingProvider{reply: "digest"} - a, sess := foldAgent(t, prov, true) - for gen := range 2 { - foldInput(t, a, sess, prov, gen) - } - canonical, version := a.session.snapshotMessagesVersion() - st := a.compactionState - if st.Projection.CoveredCount != len(canonical) { - t.Fatalf("CoveredCount = %d, want %d (the canonical length)", st.Projection.CoveredCount, len(canonical)) - } - if !projectionValid(st, canonical, version, a.currentPromptCacheKey()) { - t.Fatal("incremental fold left the projection invalid against the canonical transcript") - } - if visible := a.modelVisibleMessages(); len(visible) >= len(canonical) { - t.Fatalf("model-visible = %d messages vs canonical %d; the fold saved nothing", len(visible), len(canonical)) - } -} - -func TestIncrementalFoldFallsBackToCanonicalWithoutAProjection(t *testing.T) { - prov := &countingProvider{reply: "digest"} - a, sess := foldAgent(t, prov, true) - if first := foldInput(t, a, sess, prov, 0); !strings.Contains(first, "gen 0 line 0") { - t.Fatal("the first fold has no projection to read and must fold the canonical transcript") - } +func (p *recordingProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) { + p.got = append(p.got, req) + ch := make(chan provider.Chunk, 2) + ch <- provider.Chunk{Type: provider.ChunkText, Text: p.reply} + ch <- provider.Chunk{Type: provider.ChunkDone} + close(ch) + return ch, nil } diff --git a/internal/agent/compact_loop_e2e_test.go b/internal/agent/compact_loop_e2e_test.go index f6ad3eb093..c5c8fa43ae 100644 --- a/internal/agent/compact_loop_e2e_test.go +++ b/internal/agent/compact_loop_e2e_test.go @@ -141,25 +141,38 @@ func consecutiveCompactingTurns(perTurn []int) int { return worst } -// TestCompactionPausesWhenWindowTooSmall covers the user report: a tool output -// that alone exceeds the trigger used to make every "continue" turn re-compact -// forever. The stuck guard now caps it — at most two compactions, then a paused -// notice — instead of looping turn after turn. +// TestCompactionStopsWhenProtectedContentExceedsWindow covers the user report +// where a single tool result alone exhausts a tiny window. Automatic maintenance +// no longer prunes mid-session tool bodies: it attempts one summary, records a +// generation-scoped block when the candidate cannot land, and must not loop. func TestCompactionPausesWhenWindowTooSmall(t *testing.T) { - // One fat_read result (~1750 tok) exceeds the 0.8×1600 trigger on its own. - perTurn, paused, _ := compactionsPerTurn(t, 1600, strings.Repeat("LARGE FILE CONTENTS. ", 350), "", 8) - - total := 0 - for _, n := range perTurn { - total += n + mock := &loopMock{t: t} + srv := httptest.NewServer(http.HandlerFunc(mock.handler)) + defer srv.Close() + reg := tool.NewRegistry() + reg.Add(fatTool{blob: strings.Repeat("LARGE FILE CONTENTS. ", 350)}) + a, _ := newAgent(t, srv.URL, reg, 1600, 4) + started := 0 + blocked := 0 + a.sink = event.FuncSink(func(e event.Event) { + if e.Kind == event.CompactionStarted { + started++ + } + if e.Kind == event.ContextMaintenanceEvent && e.Maintenance != nil && + (e.Maintenance.Status == "blocked" || e.Maintenance.Status == "failed") { + blocked++ + } + }) + // First turn may fail with a typed overflow/blocked error once protected + // content cannot form a safe checkpoint. It must not start many summaries. + _ = a.Run(context.Background(), "turn 0: keep going") + _ = a.Run(context.Background(), "turn 1: keep going") + if started > 2 { + t.Fatalf("summary transactions started = %d, want ≤2 (no multi-span / retry loop)", started) } - t.Logf("compactions per turn: %v (total %d), paused=%v", perTurn, total, paused) - - if total > 2 { - t.Errorf("compacted %d times; the stuck guard should cap it at ≤2, not loop", total) - } - if !paused { - t.Errorf("expected an auto-compaction-paused notice") + if blocked == 0 && a.currentProjectionVersion() == 0 { + // Either a durable block or a successful install is fine; looping is not. + t.Logf("started=%d blocked=%d version=%d", started, blocked, a.currentProjectionVersion()) } } @@ -187,12 +200,11 @@ func TestCompactionHealthyWindowNeverLoops(t *testing.T) { } } -// TestPruneKeepsToolHeavySessionBounded: when growth comes from tool results, -// projection-only pruning is the primary pressure valve. Occasional force-ratio -// summary folds may still fire when the recent tail alone is huge, but they must -// stay rare and must never trip the stuck guard. 20 turns of ~3k-token blobs -// would otherwise cross 0.8×40000 around turn 11 without maintenance. -func TestPruneKeepsToolHeavySessionBounded(t *testing.T) { +// TestSummaryKeepsToolHeavySessionBounded: automatic maintenance no longer +// prunes mid-session tool bodies. Growth from large tool results crosses +// compact_ratio once, installs one content-driven summary checkpoint, and must +// not loop or trip the stuck guard. Prune projections must not reappear. +func TestSummaryKeepsToolHeavySessionBounded(t *testing.T) { perTurn, paused, prunes := compactionsPerTurn(t, 40000, strings.Repeat("file line. ", 1100), "", 20) total := 0 @@ -201,13 +213,25 @@ func TestPruneKeepsToolHeavySessionBounded(t *testing.T) { } t.Logf("compactions per turn: %v (total %d), paused=%v, prunes=%d", perTurn, total, paused, prunes) + if total == 0 { + t.Errorf("expected at least one summary fold over a tool-heavy session past compact_ratio") + } if total > 3 { - t.Errorf("compaction fired %d times; pruning should keep folds rare on a tool-heavy session", total) + t.Errorf("compaction fired %d times; a single content-driven summary should leave breathing room", total) } if paused { - t.Errorf("auto-compaction paused; pruning should have prevented the stuck loop entirely") + t.Errorf("auto-compaction paused; successful summary should have prevented the stuck loop") } - if prunes == 0 { - t.Errorf("expected at least one prune pass over a tool-heavy session") + if prunes != 0 { + t.Errorf("prune projections must not be installed (got %d)", prunes) + } + if c := consecutiveCompactingTurns(perTurn); c > 1 { + t.Errorf("compaction fired on %d consecutive turns; content-driven summary should reclaim headroom", c) } } + +// Keep the old name as an alias so external references still resolve during the +// rename window; the body asserts the new no-prune contract. +func TestPruneKeepsToolHeavySessionBounded(t *testing.T) { + TestSummaryKeepsToolHeavySessionBounded(t) +} diff --git a/internal/agent/compact_partition_test.go b/internal/agent/compact_partition_test.go index 168ee351a1..7c2304170e 100644 --- a/internal/agent/compact_partition_test.go +++ b/internal/agent/compact_partition_test.go @@ -1,24 +1,22 @@ package agent import ( - "fmt" "strings" "testing" "reasonix/internal/provider" ) -// bigTurn is comfortably over maxPinnedFirstUserTokens, so it is never pinnable. -func bigTurn() string { return strings.Repeat("paste ", 4000) } - // partitionCoversRegion is the invariant that makes a lost user turn impossible: // every provider-visible message in the region lands in exactly one group. -func partitionCoversRegion(t *testing.T, a *Agent, region []provider.Message) (early, kept, fold []provider.Message) { +func partitionCoversRegion(t *testing.T, a *Agent, region []provider.Message) (kept, fold []provider.Message) { t.Helper() early, carried, kept, fold := a.partitionFoldForProjection(region) - kept = append(carried, kept...) + if len(early) != 0 || len(carried) != 0 { + t.Fatalf("early=%d carried=%d, want both empty under content-driven summary", len(early), len(carried)) + } seen := map[string]int{} - for _, group := range [][]provider.Message{early, kept, fold} { + for _, group := range [][]provider.Message{kept, fold} { for _, m := range group { seen[m.Content]++ } @@ -38,107 +36,37 @@ func partitionCoversRegion(t *testing.T, a *Agent, region []provider.Message) (e t.Errorf("message %q is in %d groups — it would be duplicated", m.Content, seen[m.Content]) } } - return early, kept, fold + return kept, fold } -func TestPartitionHoistsFirstSmallUserTurnsInOrder(t *testing.T) { - // The hoist window is position-fixed (the first N), never "the most recent - // N": a window that moved with each fold would rewrite the projection prefix - // and crater the server-side cache. - a := &Agent{} - var region []provider.Message - for i := range 25 { - region = append(region, provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("small turn %d", i)}) - } - early, kept, fold := partitionCoversRegion(t, a, region) - if len(early) != maxEarlyUserTurns || len(kept) != 0 || len(fold) != 25-maxEarlyUserTurns { - t.Fatalf("early=%d kept=%d fold=%d, want %d/0/%d", len(early), len(kept), len(fold), maxEarlyUserTurns, 25-maxEarlyUserTurns) - } - for i, m := range early { - if want := fmt.Sprintf("small turn %d", i); UserMessageText(m) != want { - t.Fatalf("early[%d]=%q, want %q — the hoist window must be the leading turns", i, UserMessageText(m), want) - } - } - for i, m := range fold { - if want := fmt.Sprintf("small turn %d", maxEarlyUserTurns+i); UserMessageText(m) != want { - t.Fatalf("fold[%d]=%q, want %q", i, UserMessageText(m), want) - } - } -} - -func TestPartitionFoldsLargeUserTurns(t *testing.T) { +func TestPartitionFoldsAllUserTurnsByDefault(t *testing.T) { + // Content-driven summary no longer hoists early user turns to pad the + // checkpoint; only keep-policy content and the recent tail stay verbatim. a := &Agent{} region := []provider.Message{ - {Role: provider.RoleUser, Content: bigTurn()}, - {Role: provider.RoleUser, Content: "small"}, - } - early, _, fold := partitionCoversRegion(t, a, region) - if len(early) != 1 || UserMessageText(early[0]) != "small" { - t.Fatalf("early=%+v, want only the small turn hoisted", early) - } - if len(fold) != 1 { - t.Fatalf("fold=%d, want the large turn folded", len(fold)) - } -} - -// A user turn that is not hoisted must still reach the summarizer. Two rules -// that disagreed on which turns count as "early" used to drop the turns after an -// oversized one from both groups, deleting stated constraints outright. -func TestPartitionKeepsSmallTurnsAfterALargeOne(t *testing.T) { - a := &Agent{} - region := []provider.Message{ - {Role: provider.RoleUser, Content: "constraint A"}, - {Role: provider.RoleUser, Content: bigTurn()}, - {Role: provider.RoleUser, Content: "never touch the db schema"}, - {Role: provider.RoleUser, Content: "constraint C"}, + {Role: provider.RoleUser, Content: "small turn 0"}, + {Role: provider.RoleUser, Content: "small turn 1"}, + {Role: provider.RoleUser, Content: "small turn 2"}, {Role: provider.RoleAssistant, Content: "work"}, } - early, _, fold := partitionCoversRegion(t, a, region) - if len(early) != 3 { - t.Fatalf("early=%d (%+v), want the three small turns hoisted across the oversized one", len(early), early) - } - if got := renderTranscript(fold); !strings.Contains(got, "paste") { - t.Fatalf("the oversized turn must still reach the summarizer:\n%s", got) - } -} - -// The hoist set is drawn from the fold region only. Reading past it would hoist -// a turn that also rides in the verbatim tail, sending it to the model twice. -func TestPartitionHoistIsDisjointFromTail(t *testing.T) { - a := &Agent{} - msgs := []provider.Message{ - {Role: provider.RoleSystem, Content: "sys"}, - {Role: provider.RoleUser, Content: "task"}, - {Role: provider.RoleUser, Content: "constraint A"}, - {Role: provider.RoleAssistant, Content: "work"}, - {Role: provider.RoleUser, Content: "tail turn one"}, - {Role: provider.RoleAssistant, Content: "more"}, - {Role: provider.RoleUser, Content: "tail turn two"}, - } - head := a.pinnedPrefixLen(msgs) - const start = 4 - early, _, _ := partitionCoversRegion(t, a, msgs[head:start]) - for _, m := range early { - for _, tail := range msgs[start:] { - if m.Content == tail.Content { - t.Fatalf("hoisted turn %q also rides in the verbatim tail", m.Content) - } - } + kept, fold := partitionCoversRegion(t, a, region) + if len(kept) != 0 || len(fold) != 4 { + t.Fatalf("kept=%d fold=%d, want 0/4", len(kept), len(fold)) } } func TestPartitionFoldsPriorDigests(t *testing.T) { - // Any digest in the transcript is merged into the next one, so the model - // never sees a chain of them. + // Any digest in the region is merged into the next one, so the model + // never sees a chain of digests. a := &Agent{} region := []provider.Message{ {Role: provider.RoleUser, Content: summaryTagOpen + "\nolder digest\n" + summaryTagClose}, {Role: provider.RoleUser, Content: summaryTagOpen + "\nnewer digest\n" + summaryTagClose}, {Role: provider.RoleAssistant, Content: "work"}, } - early, kept, fold := partitionCoversRegion(t, a, region) - if len(early) != 0 || len(kept) != 0 || len(fold) != 3 { - t.Fatalf("early=%d kept=%d fold=%d, want every digest folded", len(early), len(kept), len(fold)) + kept, fold := partitionCoversRegion(t, a, region) + if len(kept) != 0 || len(fold) != 3 { + t.Fatalf("kept=%d fold=%d, want every digest folded", len(kept), len(fold)) } } @@ -149,8 +77,11 @@ func TestPartitionKeepPolicyOutranksFold(t *testing.T) { {Role: provider.RoleAssistant, Content: "call", ToolCalls: []provider.ToolCall{{ID: "t1", Name: "bash"}}}, {Role: provider.RoleTool, ToolCallID: "t1", Name: "bash", Content: "error: boom"}, } - early, kept, fold := partitionCoversRegion(t, a, region) - if len(early) != 1 || len(kept) != 2 || len(fold) != 0 { - t.Fatalf("early=%d kept=%d fold=%d, want the error result and its caller kept", len(early), len(kept), len(fold)) + kept, fold := partitionCoversRegion(t, a, region) + if len(kept) != 2 || len(fold) != 1 { + t.Fatalf("kept=%d fold=%d, want error tool-call group kept and user folded", len(kept), len(fold)) + } + if got := renderTranscript(fold); !strings.Contains(got, "small") { + t.Fatalf("user turn should fold: %s", got) } } diff --git a/internal/agent/compact_projection.go b/internal/agent/compact_projection.go index a6b37ca99a..09f43567a0 100644 --- a/internal/agent/compact_projection.go +++ b/internal/agent/compact_projection.go @@ -5,9 +5,7 @@ import ( "errors" "fmt" "strings" - "time" - "reasonix/internal/ablation" "reasonix/internal/event" "reasonix/internal/provider" "reasonix/internal/tool" @@ -349,10 +347,10 @@ func buildVisibleCompressionProjection(visible []provider.Message, plan visibleC func compactionTelemetryFromSummary(trigger, cacheState string, sourceTokens int, res foldSummary) CompactionTelemetry { tele := CompactionTelemetry{ Trigger: trigger, CacheState: cacheState, Mode: res.Mode, - Native: res.Mode == CompactionModeNative, SourceTokens: sourceTokens, + SourceTokens: sourceTokens, ProviderRequestID: res.RequestID, FoldTokens: res.FoldTokens, - Spans: res.Spans, + Spans: 1, // one application-layer summary request per transaction } usage := res.Usage if usage == nil { @@ -376,11 +374,10 @@ func (a *Agent) compact(ctx context.Context, trigger, instructions string, force return err } -// compactToProjection summarizes the older middle of the session into a model- -// visible projection. The canonical transcript is never rewritten. force -// bypasses the fold-economics skip. CompactionNoop means no projection was -// installed (nothing to fold); callers at the force threshold must treat that -// as a hard failure rather than sending the oversized canonical prompt. +// compactToProjection installs one content-driven summary checkpoint: +// stable prefix + one structured digest + recent verbatim tail. +// The canonical transcript is never rewritten. CompactionNoop means nothing +// was foldable; callers at physical overflow must treat that as hard failure. func (a *Agent) compactToProjection(ctx context.Context, trigger, instructions string, force bool) (CompactionOutcome, error) { a.compactionRunMu.Lock() defer a.compactionRunMu.Unlock() @@ -394,32 +391,25 @@ func (a *Agent) compactToProjection(ctx context.Context, trigger, instructions s startProjectionVersion := a.compactionState.Projection.ProjectionVersion startGeneration := a.compactionState.Generation a.compactionMu.Unlock() - visibleInput := canonical - if projectionValid(stateSnapshot, canonical, transcriptVersion, a.currentPromptCacheKey()) { - if projected := modelVisibleFromProjection(stateSnapshot.Projection, canonical); len(projected) > 0 { - visibleInput = projected - } - } - viewInputHash := providerVisibleFingerprint(provider.ModelMessages(visibleInput)) + msgs := a.visibleInputForFold(stateSnapshot, canonical, transcriptVersion) + viewInputHash := providerVisibleFingerprint(provider.ModelMessages(msgs)) if trigger != CompactionTriggerManual && stateSnapshot.LastReceipt != nil && stateSnapshot.LastReceipt.Status == "applied" && stateSnapshot.LastReceipt.Action == "summary" && stateSnapshot.LastReceipt.InputHash == viewInputHash { return CompactionNoop, nil } - msgs := a.foldSource(canonical) - head, start, ok := a.planFoldRegion(msgs) + head, start, ok := a.planFoldRegion(msgs, force) if !ok { return CompactionNoop, nil } - region := msgs[head:start] - early, carried, kept, fold := a.partitionFoldForProjection(region) - if len(fold) == 0 { + _, _, kept, fold := a.partitionFoldForProjection(msgs[head:start]) + if len(fold) == 0 || (!force && !foldEconomics(fold)) { return CompactionNoop, nil } - if !force && !foldEconomics(fold) { - return CompactionNoop, nil + fixedPrefixTokens := estimateMessagesTokens(a.providerProjectionMessages(msgs[:head])) + if a.contextWindow > 0 && fixedPrefixTokens >= a.compactTrigger() { + return CompactionNoop, fmt.Errorf("%w: fixed prefix (%d tokens) already exceeds trigger (%d)", errCheckpointRejected, fixedPrefixTokens, a.compactTrigger()) } a.sink.Emit(event.Event{Kind: event.CompactionStarted, Compaction: event.Compaction{Trigger: trigger}}) - if a.hooks != nil { if hookInstr := a.hooks.PreCompact(ctx, trigger); hookInstr != "" { if instructions != "" { @@ -428,7 +418,6 @@ func (a *Agent) compactToProjection(ctx context.Context, trigger, instructions s instructions += hookInstr } } - var err error fold, instructions, err = a.interceptCompactionPrepare(ctx, fold, instructions) if err != nil { @@ -440,7 +429,7 @@ func (a *Agent) compactToProjection(ctx context.Context, trigger, instructions s return CompactionNoop, nil } - sourceTokens := a.estimatedPromptTokens(visibleInput) + sourceTokens := a.estimatedPromptTokens(msgs) res, err := a.foldToSummary(ctx, fold, instructions) summary := res.Text tele := compactionTelemetryFromSummary(trigger, a.CacheState(), sourceTokens, res) @@ -457,23 +446,18 @@ func (a *Agent) compactToProjection(ctx context.Context, trigger, instructions s a.emitCompactionAborted(trigger) return CompactionNoop, err } - archived := "" - projMsgs := make([]provider.Message, 0, head+len(early)+len(carried)+1+len(kept)+len(msgs)-start) - projMsgs = append(projMsgs, msgs[:head]...) - projMsgs = append(projMsgs, early...) - projMsgs = append(projMsgs, carried...) - projMsgs = append(projMsgs, formatSummaryMessage(summary)) - projMsgs = append(projMsgs, kept...) - projMsgs = append(projMsgs, msgs[start:]...) - projMsgs = provider.ModelMessages(projMsgs) - - projTokens := estimateMessagesTokens(a.providerProjectionMessages(projMsgs)) + projMsgs := checkpointProjectionMessages(msgs, head, start, kept, summary) + projTokens := a.estimatedPromptTokens(projMsgs) + fixedPrefixTokens = a.estimatedPromptTokens(msgs[:head]) tele.ProjectionTokens = projTokens a.emitCompactionTelemetry(tele) - + if err := a.acceptCheckpointCandidate(trigger, force, sourceTokens, projTokens, fixedPrefixTokens); err != nil { + a.emitCompactionAborted(trigger) + return CompactionNoop, err + } viewOutputHash := providerVisibleFingerprint(provider.ModelMessages(projMsgs)) - st, err := a.commitSummaryProjection(summaryProjectionCommit{ + _, err = a.commitSummaryProjection(summaryProjectionCommit{ canonical: canonical, fold: fold, projected: projMsgs, result: res, transcriptVersion: transcriptVersion, projectionVersion: startProjectionVersion, generation: startGeneration, activeTurn: activeTurn, trigger: trigger, @@ -484,21 +468,81 @@ func (a *Agent) compactToProjection(ctx context.Context, trigger, instructions s a.emitCompactionAborted(trigger) return CompactionNoop, err } - archived = st.LastReceipt.Archive - a.sink.Emit(event.Event{Kind: event.CompactionDone, Compaction: event.Compaction{ - Trigger: trigger, Messages: len(fold), Summary: summary, Archive: archived, + Trigger: trigger, Messages: len(fold), Summary: summary, }}) return CompactionInstalled, nil } -// planFoldRegion locates msgs[head:start] for a fold, stopping short of an -// active turn so a tool loop is never folded mid-flight. ok is false when there -// is nothing left to fold. -func (a *Agent) planFoldRegion(msgs []provider.Message) (head, start int, ok bool) { - head, start, ok = a.planCompaction(msgs, minCompactMessages) +// visibleInputForFold prefers the prior projection + new history over full canonical. +func (a *Agent) visibleInputForFold(state CompactionState, canonical []provider.Message, transcriptVersion uint64) []provider.Message { + if projectionValid(state, canonical, transcriptVersion, a.currentPromptCacheKey()) { + if projected := modelVisibleFromProjection(state.Projection, canonical); len(projected) > 0 { + return projected + } + } + return canonical +} + +func checkpointProjectionMessages(msgs []provider.Message, head, start int, kept []provider.Message, summary string) []provider.Message { + projMsgs := make([]provider.Message, 0, head+1+len(kept)+len(msgs)-start) + projMsgs = append(projMsgs, msgs[:head]...) + projMsgs = append(projMsgs, formatSummaryMessage(summary)) + projMsgs = append(projMsgs, kept...) + projMsgs = append(projMsgs, msgs[start:]...) + return provider.ModelMessages(projMsgs) +} + +// acceptCheckpointCandidate: ≤50% + smaller for auto; force may exceed 50% +// only if still below trigger; manual below trigger accepts any savings. +func (a *Agent) acceptCheckpointCandidate(trigger string, force bool, sourceTokens, candidateTokens, fixedPrefixTokens int) error { + if candidateTokens >= sourceTokens { + return fmt.Errorf("%w: candidate would not reduce tokens (%d >= %d)", errCheckpointRejected, candidateTokens, sourceTokens) + } + triggerTokens := a.compactTrigger() + ceiling := a.checkpointCeiling() + hard := a.hardInputCeiling() + manualBelowTrigger := trigger == CompactionTriggerManual && sourceTokens < triggerTokens + if manualBelowTrigger { + // Directed manual compress: any real savings is acceptable. + return nil + } + if fixedPrefixTokens > ceiling { + // Exceptional path: fixed prefix alone already exceeds 50%. + savings := sourceTokens - candidateTokens + if savings < a.exceptionalMinimumSavings() { + return fmt.Errorf("%w: fixed-prefix exception requires ≥%d token savings, got %d", errCheckpointRejected, a.exceptionalMinimumSavings(), savings) + } + if triggerTokens > 0 && candidateTokens >= triggerTokens { + return fmt.Errorf("%w: candidate %d still at or above trigger %d", errCheckpointRejected, candidateTokens, triggerTokens) + } + if hard > 0 && candidateTokens >= hard { + return fmt.Errorf("%w: candidate %d still at or above physical ceiling %d", errCheckpointRejected, candidateTokens, hard) + } + return nil + } + if ceiling > 0 && candidateTokens > ceiling { + // keep / recent_keep / active-turn protection made the candidate large; + // this is not a fixed-prefix exception. Force/overflow may still land + // a strictly smaller view below the trigger when the ceiling cannot. + if !force { + return fmt.Errorf("%w: candidate %d exceeds checkpoint ceiling %d (protected content too large)", errCheckpointRejected, candidateTokens, ceiling) + } + } + if triggerTokens > 0 && candidateTokens >= triggerTokens && !force { + return fmt.Errorf("%w: candidate %d still at or above trigger %d", errCheckpointRejected, candidateTokens, triggerTokens) + } + if force && triggerTokens > 0 && candidateTokens >= triggerTokens { + return fmt.Errorf("%w: forced candidate %d still at or above trigger %d", errCheckpointRejected, candidateTokens, triggerTokens) + } + return nil +} + +// planFoldRegion returns [head:start] to fold; force shrinks the recent tail. +func (a *Agent) planFoldRegion(msgs []provider.Message, force bool) (head, start int, ok bool) { + head, start, ok = a.planCompaction(msgs, minCompactMessages, force) if !ok { - head, start, ok = a.planCompaction(msgs, 1) + head, start, ok = a.planCompaction(msgs, 1, force) } if !ok { return head, start, false @@ -509,40 +553,13 @@ func (a *Agent) planFoldRegion(msgs []provider.Message) (head, start int, ok boo return head, start, start > head } -// foldSource picks what a fold reads. By default every fold re-derives its -// digest from the canonical transcript, so digests never chain — at the cost of -// re-reading the whole session each time. The incremental experiment folds the -// model-visible view instead, which feeds the previous digest back through the -// summarizer: cheaper per fold, and lossy in a way CompactionBench measures. -func (a *Agent) foldSource(canonical []provider.Message) []provider.Message { - if !a.ablation.Off(ablation.FullFold) { - return canonical - } - if visible := a.modelVisibleMessages(); len(visible) > 0 { - return visible - } - return canonical -} - -// partitionFoldForProjection splits the fold region three ways: user turns -// hoisted verbatim ahead of the digest, messages the keep policy protects, and -// the remainder that folds (prior digests included, so a merge yields one -// summary). The groups partition the region — one pass decides each message -// once, so no turn can fall between a hoist rule and a fold rule that disagree. func (a *Agent) partitionFoldForProjection(region []provider.Message) (early, carried, kept, fold []provider.Message) { policyKeep := keepIndexes(region, a.keepPolicy) - hoist := a.earlyUserTurns(region) - carryDigests := a.carryPriorDigests(region) for i, m := range region { switch { case m.LocalOnly: // display-only output never reaches a provider - case hoist[i]: - early = append(early, m) case isCompactionSummary(m): - if carryDigests { - carried = append(carried, m) - continue - } + // Always merge prior digests into the single next summary. fold = append(fold, m) case policyKeep[i]: kept = append(kept, a.keptForProjection(m)) @@ -550,215 +567,14 @@ func (a *Agent) partitionFoldForProjection(region []provider.Message) (early, ca fold = append(fold, m) } } - return early, carried, kept, fold + return nil, nil, kept, fold } -// carryPriorDigests reports whether the digests already in the region survive -// this fold verbatim instead of being re-summarized. Re-summarizing a digest is -// the only step in a fold where a fact can be dropped and never recovered, so -// an incremental fold carries them — until they outgrow their budget, when one -// consolidating fold merges them and the chain starts over. -func (a *Agent) carryPriorDigests(region []provider.Message) bool { - if !a.ablation.Off(ablation.FullFold) { - return false - } - total := 0 - for _, m := range region { - if isCompactionSummary(m) { - total += summaryInputTokens([]provider.Message{m}) - } - } - if total > maxCarriedDigestTokens { - a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf( - "consolidating %d tokens est. of carried digests into one; earlier facts now depend on this merge", total)}) - return false - } - return true -} - -// earlyUserTurns marks the region positions of the small user turns hoisted -// verbatim ahead of the digest. Selecting from the fold region alone keeps the -// set disjoint from the verbatim tail, and taking the first N (never the latest -// N) keeps the hoisted bytes identical across folds: the region only ever grows -// at its end, so the set can gain a member but never reorder or lose one. -func (a *Agent) earlyUserTurns(region []provider.Message) []bool { - hoist := make([]bool, len(region)) - n := 0 - for i, m := range region { - if n == maxEarlyUserTurns { - break - } - if m.LocalOnly || m.Role != provider.RoleUser || isCompactionSummary(m) { - continue - } - if !a.fixedPinnableUserTurn(m) { - continue - } - hoist[i] = true - n++ - } - return hoist -} - -// runCompactionSummary tries native compaction first, then summarizeWithRetry. -// On total failure it returns the error without a mechanical marker. +// runCompactionSummary uses the single local summarizer path for every provider. func (a *Agent) runCompactionSummary(ctx context.Context, fold []provider.Message, instructions string) (summary, mode string, usage *provider.Usage, providerReqID string, err error) { - if nc, ok := provider.AsNativeCompactor(a.prov); ok { - maxOut := 0 - if cc, ok := provider.AsCompactionCapabler(a.prov); ok { - caps := cc.CompactionCapabilities() - maxOut = caps.CompactionOutputTokens - if maxOut <= 0 { - maxOut = caps.MaxOutputTokens - } - } - a.compactionMu.Lock() - sessionPath := a.sessionPath - a.compactionMu.Unlock() - res, nerr := nc.Compact(ctx, provider.CompactionRequest{ - Messages: fold, - Instructions: instructions, - MaxOutputTokens: maxOut, - PromptCacheKey: promptCacheKey(a.workspaceID, BranchID(sessionPath), a.modelRef), - SessionID: BranchID(sessionPath), - }) - if nerr == nil && res.Valid() { - if res.Summary != "" { - return res.Summary, CompactionModeNative, res.Usage, res.ProviderRequestID, nil - } - // Provider returned a full projection; extract summary text if present, - // otherwise render the projection as the digest body. - if s := extractLatestSummary(res.Projection); s != "" { - return s, CompactionModeNative, res.Usage, res.ProviderRequestID, nil - } - return renderTranscript(res.Projection), CompactionModeNative, res.Usage, res.ProviderRequestID, nil - } - if nerr != nil && !errors.Is(nerr, provider.ErrCompactionUnsupported) { - // Hard native failure: still try ordinary summarize fallback. - a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "Native compaction unavailable; using summary fallback.", Detail: nerr.Error()}) - } - } - summary, usage, err = a.summarizeWithRetry(ctx, fold, instructions) + summary, usage, err = a.summarizeOnce(ctx, fold, instructions) if err != nil { return "", CompactionModeSummarized, usage, "", err } return summary, CompactionModeSummarized, usage, "", nil } - -// installPruneProjection stores a projection whose messages are a snipped/pruned -// view of the canonical transcript (no summarizer call). -func (a *Agent) installPruneProjection(view []provider.Message, st PruneStats) error { - canonical, version := a.session.snapshotMessagesVersion() - view = provider.ModelMessages(view) - if len(view) == 0 { - return nil - } - // The maintenance input must still be the current visible view. This is the - // compare half of the two-phase projection transaction; a concurrent append, - // model switch or newer maintenance pass causes a safe retry instead of a - // last-writer-wins overwrite. - a.compactionMu.Lock() - current := a.compactionState - a.compactionMu.Unlock() - currentVisible := canonical - if projectionValid(current, canonical, version, a.currentPromptCacheKey()) { - if projected := modelVisibleFromProjection(current.Projection, canonical); len(projected) > 0 { - currentVisible = projected - } - } - currentInputHash := providerVisibleFingerprint(provider.ModelMessages(currentVisible)) - if st.InputHash != "" && currentInputHash != st.InputHash { - return fmt.Errorf("context maintenance became stale") - } - src := estimateMessagesTokens(provider.ModelMessages(currentVisible)) - dst := estimateMessagesTokens(view) - if src <= dst && st.Results > 0 { - return nil - } - // Do not pay a cache-prefix break for a negligible rewrite while the view is - // still inside the hard input ceiling. Near/over the ceiling this guard is - // intentionally bypassed: safety takes precedence over cache preservation. - if st.Results > 0 && !st.Force && src < a.hardInputCeiling() && src-dst < a.minimumMaintenanceSavingsTokens() { - return nil - } - // Planning uses a stable placeholder and performs no I/O. Archive only after - // the compare/min-savings gates prove this projection can be installed. - if st.Results > 0 && a.archiveDir != "" { - originals := make([]provider.Message, 0, st.Results) - for i := range currentVisible { - if i >= len(view) || currentVisible[i].Content == view[i].Content { - continue - } - originals = append(originals, currentVisible[i]) - } - if len(originals) > 0 { - archive, err := archiveMessages(a.archiveDir, originals) - if err != nil { - return fmt.Errorf("archive: %w", err) - } - st.Archive = archive - for i := range currentVisible { - if i >= len(view) || currentVisible[i].Content == view[i].Content { - continue - } - m := currentVisible[i] - replacement, ok := a.maintenanceReplacement(m, st.Mode, archive) - if !ok { - continue - } - m.Content = replacement - view[i] = m - } - dst = estimateMessagesTokens(view) - } - } - projVersion := current.Projection.ProjectionVersion + 1 - inputHash := currentInputHash - outputHash := providerVisibleFingerprint(view) - operationID := fmt.Sprintf("%s-%d-%s", CompactionTriggerSnip, projVersion, outputHash) - action := "snip" - if st.Mode == toolResultPrune { - action = "prune" - } - now := time.Now().UTC() - state := CompactionState{ - SchemaVersion: compactionStateSchemaCurrent, - TranscriptVersion: version, - Generation: current.Generation + 1, - NativeContextEditingAccepted: a.nativeContextEditingAccepted.Load(), - ContextEditingFallbackLocal: a.contextEditingRuntimeFallback.Load(), - Projection: ContextProjection{ - Messages: view, - TranscriptVersion: version, - ProjectionVersion: projVersion, - CoveredCount: len(canonical), - CoveredPrefixHash: coveredPrefixHash(canonical, len(canonical)), - SourceTokens: src, - ProjectionTokens: dst, - ViewInputHash: inputHash, - ViewOutputHash: outputHash, - CreatedAt: now, - }, - PromptCacheKey: a.currentPromptCacheKey(), - LastCacheState: a.CacheState(), - LastTrigger: CompactionTriggerPressure, - LastMode: CompactionModeSnip, - LastSourceTokens: src, - LastResultTokens: dst, - LastReceipt: &ContextMaintenanceReceipt{ - OperationID: operationID, Status: "applied", Action: action, - Trigger: CompactionTriggerPressure, SourceProjection: current.Projection.ProjectionVersion, - ProjectionVersion: projVersion, - CoveredCount: len(canonical), CoveredPrefixHash: coveredPrefixHash(canonical, len(canonical)), - InputHash: inputHash, OutputHash: outputHash, InputTokens: src, ResultTokens: dst, - SavedTokens: max(0, src-dst), AffectedToolResults: st.Results, Archive: st.Archive, - CacheBreak: true, CreatedAt: now, - }, - UpdatedAt: now, - } - if err := a.installProjectionIfCurrent(state, current.Projection.ProjectionVersion, current.Generation); err != nil { - return err - } - a.emitContextMaintenance(state.LastReceipt) - return nil -} diff --git a/internal/agent/compact_retry_test.go b/internal/agent/compact_retry_test.go index 0c5eee498a..c222990287 100644 --- a/internal/agent/compact_retry_test.go +++ b/internal/agent/compact_retry_test.go @@ -12,22 +12,26 @@ import ( ) func TestMaybeCompactIgnoresRetryAggregateBelowContextThreshold(t *testing.T) { + // compact_ratio is the sole trigger; LatestPromptTokens (context attempt) + // must drive the decision, not the billable retry aggregate. + const window = 10_000 sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, - {Role: provider.RoleUser, Content: strings.Repeat("large earlier request ", 500)}, - {Role: provider.RoleAssistant, Content: "earlier answer"}, + {Role: provider.RoleUser, Content: "task"}, + {Role: provider.RoleAssistant, Content: strings.Repeat("large earlier request ", 500)}, {Role: provider.RoleUser, Content: "current request"}, {Role: provider.RoleAssistant, Content: "current answer"}, }} a := New(&fakeProvider{reply: "summary"}, tool.NewRegistry(), sess, Options{ - ContextWindow: 100, + ContextWindow: window, + CompactRatio: 0.85, RecentKeep: 2, - ArchiveDir: t.TempDir(), }, event.Discard) + // Aggregate 9000 would be above fold (8500); latest context attempt 4000 is not. prepareForObservedUsage(a, context.Background(), &provider.Usage{ - PromptTokens: 80, - ContextPromptTokens: 40, + PromptTokens: 9000, + ContextPromptTokens: 4000, }) if hasCompactionSummary(visibleContext(a)) { @@ -36,22 +40,24 @@ func TestMaybeCompactIgnoresRetryAggregateBelowContextThreshold(t *testing.T) { } func TestMaybeCompactStillTriggersAtLatestContextThreshold(t *testing.T) { + const window = 10_000 sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, - {Role: provider.RoleUser, Content: strings.Repeat("large earlier request ", 500)}, - {Role: provider.RoleAssistant, Content: "earlier answer"}, + {Role: provider.RoleUser, Content: "task"}, + {Role: provider.RoleAssistant, Content: strings.Repeat("large earlier request ", 500)}, {Role: provider.RoleUser, Content: "current request"}, {Role: provider.RoleAssistant, Content: "current answer"}, }} a := New(&fakeProvider{reply: "summary"}, tool.NewRegistry(), sess, Options{ - ContextWindow: 100, + ContextWindow: window, + CompactRatio: 0.85, RecentKeep: 2, - ArchiveDir: t.TempDir(), }, event.Discard) + // Latest context attempt at/above fold trigger (8500) must install a summary. prepareForObservedUsage(a, context.Background(), &provider.Usage{ - PromptTokens: 160, - ContextPromptTokens: 80, + PromptTokens: 16000, + ContextPromptTokens: 8600, }) if !hasCompactionSummary(visibleContext(a)) { diff --git a/internal/agent/compact_test.go b/internal/agent/compact_test.go index 829675aa22..ab63ec8a32 100644 --- a/internal/agent/compact_test.go +++ b/internal/agent/compact_test.go @@ -233,15 +233,15 @@ func TestCompactEmitsEvents(t *testing.T) { sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "task"}, - {Role: provider.RoleAssistant, Content: "step one"}, + {Role: provider.RoleAssistant, Content: strings.Repeat("step one work ", 200)}, {Role: provider.RoleUser, Content: "more"}, - {Role: provider.RoleAssistant, Content: "step two"}, + {Role: provider.RoleAssistant, Content: strings.Repeat("step two work ", 200)}, {Role: provider.RoleUser, Content: "next"}, {Role: provider.RoleAssistant, Content: "ok"}, }} var got []event.Event sink := event.FuncSink(func(e event.Event) { got = append(got, e) }) - a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2}, sink) + a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 50_000, RecentKeep: 2}, sink) if err := a.compact(context.Background(), "auto", "", true); err != nil { t.Fatalf("compact: %v", err) @@ -278,16 +278,20 @@ func TestCompactEmitsEvents(t *testing.T) { // a PreCompact hook's output both reach the summarizer's system prompt. func TestCompactInjectsFocusAndPreCompactHook(t *testing.T) { prov := &fakeProvider{reply: "- ok"} + big := strings.Repeat("step work detail ", 200) sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "task"}, - {Role: provider.RoleAssistant, Content: "step one"}, + {Role: provider.RoleAssistant, Content: big}, {Role: provider.RoleUser, Content: "more"}, - {Role: provider.RoleAssistant, Content: "step two"}, + {Role: provider.RoleAssistant, Content: big}, {Role: provider.RoleUser, Content: "next"}, {Role: provider.RoleAssistant, Content: "ok"}, }} - a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, Hooks: &stubHooks{preCompactOut: "KEEP-THE-MIGRATION-PLAN"}}, event.Discard) + a := New(prov, tool.NewRegistry(), sess, Options{ + ContextWindow: 50_000, RecentKeep: 2, + Hooks: &stubHooks{preCompactOut: "KEEP-THE-MIGRATION-PLAN"}, + }, event.Discard) if err := a.compact(context.Background(), "manual", "focus on the auth refactor", true); err != nil { t.Fatalf("compact: %v", err) @@ -326,70 +330,59 @@ func TestCompactSkipsSingleSmallMessage(t *testing.T) { } func TestMaybeCompactThreshold(t *testing.T) { - // A large early user message gives the fold real value; with a 100-token window - // the soft (50%), trigger (80%), and force (90%) thresholds are easy to hit. + // compact_ratio is the sole trigger. Use a realistic window so hardInputCeiling + // (window−protocolReserve) stays above the fold trigger; tiny synthetic + // windows collapse hard to 1 and force every observation. + const window = 10_000 + const ratio = 0.8 // fold trigger = 8000 newSess := func() *Session { return &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, - {Role: provider.RoleUser, Content: strings.Repeat("a ", 500)}, - {Role: provider.RoleAssistant, Content: "b"}, + {Role: provider.RoleUser, Content: "task"}, + {Role: provider.RoleAssistant, Content: strings.Repeat("a ", 5000)}, {Role: provider.RoleUser, Content: "c"}, - {Role: provider.RoleAssistant, Content: "d"}, + {Role: provider.RoleAssistant, Content: strings.Repeat("b ", 5000)}, {Role: provider.RoleUser, Content: "e"}, {Role: provider.RoleAssistant, Content: "f"}, }} } + opts := Options{ContextWindow: window, CompactRatio: ratio, RecentKeep: 2} - // Below 50% of the window: untouched. + // Below compact_ratio: untouched, no summarizer call. sess := newSess() - a := New(&fakeProvider{reply: "s"}, tool.NewRegistry(), sess, Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard) - prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 49}) + prov := &fakeProvider{reply: "s"} + a := New(prov, tool.NewRegistry(), sess, opts, event.Discard) + prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 7000}) if len(sess.Messages) != 7 { t.Errorf("below threshold should not compact, len = %d", len(sess.Messages)) } - - // At/above 50% only emits a soft notice; it does not rewrite the cache prefix. - sess = newSess() - prov := &fakeProvider{reply: "s"} - var notices []event.Event - a = New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.FuncSink(func(e event.Event) { - if e.Kind == event.Notice { - notices = append(notices, e) - } - })) - prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 50}) - if len(sess.Messages) != 7 { - t.Errorf("soft threshold should not compact, len = %d", len(sess.Messages)) - } if len(prov.got) != 0 { - t.Fatalf("soft threshold called summarizer: %+v", prov.got) - } - if len(notices) != 1 || notices[0].Text != "Context is getting large; preserving cache until cleanup is needed." || !strings.Contains(notices[0].Detail, "context reached 50%") { - t.Fatalf("soft threshold notice = %+v", notices) - } - prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 60}) - if len(notices) != 1 { - t.Fatalf("soft threshold notice should only emit once, got %d", len(notices)) + t.Fatalf("below threshold called summarizer: %+v", prov.got) } - // At/above 80%: compacts when the fold is economically worthwhile. The - // token-budgeted tail keeps the small recent messages, so the large early - // message is the only foldable region — folding it installs a summary at - // index 1 (the count is unchanged because one message becomes one summary). + // 60% is below the sole 80% trigger: still no maintenance. sess = newSess() - a = New(&fakeProvider{reply: "s"}, tool.NewRegistry(), sess, Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard) - prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 80}) + prov = &fakeProvider{reply: "s"} + a = New(prov, tool.NewRegistry(), sess, opts, event.Discard) + prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 6000}) + if a.currentProjectionVersion() != 0 || len(prov.got) != 0 { + t.Fatalf("60%% should not maintain: version=%d calls=%d", a.currentProjectionVersion(), len(prov.got)) + } + + // At/above compact_ratio: one summary projection; canonical stays full. + sess = newSess() + a = New(&fakeProvider{reply: "s"}, tool.NewRegistry(), sess, opts, event.Discard) + prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 8500}) if !hasCompactionSummary(visibleContext(a)) { - t.Errorf("compact threshold should fold the large early message into projection, got: %+v", visibleContext(a)) + t.Errorf("compact threshold should install a summary projection, got: %+v", visibleContext(a)) } - // Canonical transcript remains full. if len(sess.Messages) != 7 { t.Errorf("canonical should stay full after projection compact, len=%d", len(sess.Messages)) } // No context window: compaction disabled. sess = newSess() - a = New(&fakeProvider{reply: "s"}, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard) + a = New(&fakeProvider{reply: "s"}, tool.NewRegistry(), sess, Options{RecentKeep: 2}, event.Discard) prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 1 << 30}) if len(sess.Messages) != 7 { t.Errorf("no window should disable compaction, len = %d", len(sess.Messages)) @@ -397,22 +390,27 @@ func TestMaybeCompactThreshold(t *testing.T) { } func TestMaybeCompactForceCeilingBypassesEconomics(t *testing.T) { + // Physical hard ceiling (window−reserve) forces a summary even when the fold + // is below the minFoldTokens economics floor — but the fold must still be + // large enough that the candidate lands under the compact_ratio trigger. + const window = 10_000 + big := strings.Repeat("old analysis detail ", 400) sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, - {Role: provider.RoleUser, Content: "small old request"}, - {Role: provider.RoleAssistant, Content: "small old answer"}, + {Role: provider.RoleUser, Content: "task"}, + {Role: provider.RoleAssistant, Content: big}, {Role: provider.RoleUser, Content: "next"}, {Role: provider.RoleAssistant, Content: "ok"}, }} prov := &fakeProvider{reply: "forced summary"} - a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard) + a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2}, event.Discard) - prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 90}) - // Force bypasses economics and installs a projection summary; canonical stays. + // hard = 10000-256 = 9744; observe just above it. + prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 9800}) if got := len(sess.Messages); got != 5 { t.Fatalf("canonical len = %d, want 5: %+v", got, sess.Messages) } - if sess.Messages[1].Content != "small old request" { + if sess.Messages[1].Content != "task" { t.Fatalf("first user turn not pinned verbatim in canonical: %+v", sess.Messages[1]) } proj := visibleContext(a) @@ -425,6 +423,9 @@ func TestMaybeCompactForceCeilingBypassesEconomics(t *testing.T) { } func TestMaybeCompactSkipsLowValueRegionBeforeForceCeiling(t *testing.T) { + // Above compact_ratio but below the physical hard ceiling: low-value folds + // are rejected by foldEconomics without calling the summarizer. + const window = 10_000 sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "small old request"}, @@ -433,29 +434,38 @@ func TestMaybeCompactSkipsLowValueRegionBeforeForceCeiling(t *testing.T) { {Role: provider.RoleAssistant, Content: "ok"}, }} prov := &fakeProvider{reply: "should not summarize"} - a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard) + a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2}, event.Discard) - prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 80}) + // fold trigger = 8500; hard = 9744. Observe between them. + prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 8600}) if got := len(sess.Messages); got != 5 { t.Fatalf("low-value region should not compact before force ceiling, len = %d", got) } if len(prov.got) != 0 { t.Fatalf("summarizer was called for low-value non-forced region: %+v", prov.got) } + if a.currentProjectionVersion() != 0 { + t.Fatalf("low-value region installed projection version %d", a.currentProjectionVersion()) + } } func TestMaybeCompactFoldsSingleLargeMessageAtThreshold(t *testing.T) { + const window = 10_000 + // Large assistant work (not the first user turn) so it is foldable, not pinned. sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, - {Role: provider.RoleUser, Content: strings.Repeat("large prompt chunk ", 500)}, + {Role: provider.RoleUser, Content: "task"}, + {Role: provider.RoleAssistant, Content: strings.Repeat("large prompt chunk ", 500)}, {Role: provider.RoleUser, Content: "next"}, {Role: provider.RoleAssistant, Content: "ok"}, }} - a := New(&fakeProvider{reply: "single large summary"}, tool.NewRegistry(), sess, Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard) + a := New(&fakeProvider{reply: "single large summary"}, tool.NewRegistry(), sess, Options{ + ContextWindow: window, CompactRatio: 0.8, RecentKeep: 2, + }, event.Discard) - prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 80}) - if got := len(sess.Messages); got != 4 { - t.Fatalf("canonical len = %d, want 4: %+v", got, sess.Messages) + prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 8500}) + if got := len(sess.Messages); got != 5 { + t.Fatalf("canonical len = %d, want 5: %+v", got, sess.Messages) } proj := visibleContext(a) if !hasCompactionSummary(proj) || !strings.Contains(joinContents(proj), "single large summary") { @@ -526,7 +536,7 @@ func TestCompactKeepsActiveTurnVerbatim(t *testing.T) { result, }} a := New(&fakeProvider{reply: "old work summary"}, tool.NewRegistry(), sess, Options{ - ContextWindow: 100, RecentKeep: 1, ArchiveDir: t.TempDir(), + ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 1, }, event.Discard) a.activeTurnCreatedAt.Store(currentCreatedAt) @@ -635,22 +645,20 @@ func TestMaybeCompactDefersWhenOnlyActiveTurnRemains(t *testing.T) { } } -func TestCompactThresholdsReserveConfiguredOutputBudget(t *testing.T) { +func TestCompactTriggerIgnoresConfiguredOutputBudget(t *testing.T) { a := &Agent{ - contextWindow: 100_000, - maxOutputTokens: 20_000, - softCompactRatio: 0.5, - toolResultSnipRatio: 0.8, - compactRatio: 0.9, - compactForceRatio: 0.95, + contextWindow: 100_000, + maxOutputTokens: 20_000, + compactRatio: 0.85, } - soft, snip, high := a.compactThresholds() - // hard = 100000 - 20000 output - 256 protocol reserve. - if soft != 50_000 || snip != 79_744 || high != 79_744 { - t.Fatalf("thresholds = %d/%d/%d, want 50000/79744/79744", soft, snip, high) + if got := a.compactTrigger(); got != 85_000 { + t.Fatalf("trigger = %d, want 85000 (output budget must not change it)", got) } - if got := a.minimumMaintenanceSavingsTokens(); got != 4096 { - t.Fatalf("minimum maintenance savings = %d, want 4096", got) + if got := a.hardInputCeiling(); got != 100_000-protocolReserveTokens { + t.Fatalf("hard ceiling = %d, want window minus protocol reserve only", got) + } + if got := a.checkpointCeiling(); got != 50_000 { + t.Fatalf("checkpoint ceiling = %d, want 50000", got) } } diff --git a/internal/agent/compact_threshold_test.go b/internal/agent/compact_threshold_test.go index 7f8d696152..23f77c82b3 100644 --- a/internal/agent/compact_threshold_test.go +++ b/internal/agent/compact_threshold_test.go @@ -2,46 +2,82 @@ package agent import "testing" -// An output budget larger than the window it shares (DeepSeek defaults to 128K, -// and 128000 was the value the custom-provider wizard wrote unconditionally) -// used to drive the hard ceiling to its one-token floor, so every turn crossed -// every trigger and compacted. -func TestCompactThresholdsSurviveOversizedOutputBudget(t *testing.T) { +// An output budget larger than the window it shares must never change the +// sole compact_ratio trigger. Output is clipped only at send time. +func TestCompactTriggerIndependentOfOutputBudget(t *testing.T) { a := &Agent{ - prov: &sharedWindowTestProvider{budget: 131_072, shared: true}, - contextWindow: 128_000, - outputBudgetState: outputBudgetState{outputBudget: 131_072}, - softCompactRatio: defaultSoftCompactRatio, - toolResultSnipRatio: defaultToolResultSnipRatio, - compactRatio: defaultCompactRatio, - compactForceRatio: defaultCompactForceRatio, + prov: &sharedWindowTestProvider{budget: 131_072, shared: true}, + contextWindow: 128_000, + outputBudgetState: outputBudgetState{outputBudget: 131_072}, + compactRatio: defaultCompactRatio, } - soft, snip, fold := a.compactThresholds() - force := a.forceCompactThreshold(fold) - // reserve = min(131072, 0.25*128000) = 32000; hard = 128000 - 32000 - 256. - if soft != 64_000 || snip != 76_800 || fold != 95_744 || force != 95_744 { - t.Fatalf("thresholds = %d/%d/%d/%d, want 64000/76800/95744/95744", soft, snip, fold, force) + trigger := a.compactTrigger() + want := int(float64(128_000) * defaultCompactRatio) + if trigger != want { + t.Fatalf("trigger = %d, want %d (compact_ratio only)", trigger, want) } - if !(soft < snip && snip < fold) { - t.Fatalf("thresholds collapsed onto each other: soft=%d snip=%d fold=%d", soft, snip, fold) + // Oversized output budget must not lower the trigger. + a.outputBudget = 200_000 + if got := a.compactTrigger(); got != want { + t.Fatalf("trigger after larger output budget = %d, want %d", got, want) + } + hard := a.hardInputCeiling() + if hard != 128_000-protocolReserveTokens { + t.Fatalf("hard ceiling = %d, want window-protocolReserve", hard) } } -// A declared window that genuinely fits its output budget keeps reserving the -// whole budget: the clamp must not loosen the ceiling for correct setups. -func TestCompactThresholdsReserveWholeBudgetWhenWindowFits(t *testing.T) { - a := &Agent{ - prov: &sharedWindowTestProvider{budget: 131_072, shared: true}, - contextWindow: 1_000_000, - outputBudgetState: outputBudgetState{outputBudget: 131_072}, - softCompactRatio: defaultSoftCompactRatio, - toolResultSnipRatio: defaultToolResultSnipRatio, - compactRatio: defaultCompactRatio, - compactForceRatio: defaultCompactForceRatio, +func TestRecentTailBudgetClamp(t *testing.T) { + cases := []struct { + window int + want int + }{ + {128_000, minRecentTailTokens}, // 10% = 12.8K → clamp to 32K + {400_000, 40_000}, // 10% = 40K + {1_000_000, maxRecentTailTokens}, // 10% = 100K → clamp to 96K + {2_000_000, maxRecentTailTokens}, // still 96K } - soft, snip, fold := a.compactThresholds() - force := a.forceCompactThreshold(fold) - if soft != 500_000 || snip != 600_000 || fold != 800_000 || force != 868_672 { - t.Fatalf("thresholds = %d/%d/%d/%d, want 500000/600000/800000/868672", soft, snip, fold, force) + for _, tc := range cases { + a := &Agent{contextWindow: tc.window, compactRatio: defaultCompactRatio} + if got := a.recentTailBudget(); got != tc.want { + t.Fatalf("window %d: recentTailBudget = %d, want %d", tc.window, got, tc.want) + } + } +} + +func TestCheckpointCeilingAndExceptionalSavings(t *testing.T) { + a := &Agent{contextWindow: 1_000_000, compactRatio: 0.85} + if got := a.checkpointCeiling(); got != 500_000 { + t.Fatalf("checkpointCeiling = %d, want 500000", got) + } + if got := a.exceptionalMinimumSavings(); got != 250_000 { + t.Fatalf("exceptionalMinimumSavings = %d, want 250000", got) + } + if got := a.compactTrigger(); got != 850_000 { + t.Fatalf("compactTrigger = %d, want 850000", got) + } +} + +func TestAcceptCheckpointCandidateRules(t *testing.T) { + a := &Agent{contextWindow: 1_000_000, compactRatio: 0.85} + // 20% candidate under normal path: accept. + if err := a.acceptCheckpointCandidate(CompactionTriggerPressure, false, 850_000, 180_000, 50_000); err != nil { + t.Fatalf("20%% candidate: %v", err) + } + // 51% candidate: reject. + if err := a.acceptCheckpointCandidate(CompactionTriggerPressure, false, 850_000, 510_000, 50_000); err == nil { + t.Fatal("51% candidate should be rejected") + } + // Fixed prefix > 50% with enough savings: accept. + if err := a.acceptCheckpointCandidate(CompactionTriggerPressure, false, 900_000, 600_000, 520_000); err != nil { + t.Fatalf("fixed-prefix exception: %v", err) + } + // Fixed prefix > 50% without 25% savings: reject. + if err := a.acceptCheckpointCandidate(CompactionTriggerPressure, false, 600_000, 550_000, 520_000); err == nil { + t.Fatal("fixed-prefix without savings should reject") + } + // Manual below trigger: accept any reduction. + if err := a.acceptCheckpointCandidate(CompactionTriggerManual, false, 100_000, 80_000, 10_000); err != nil { + t.Fatalf("manual below trigger: %v", err) } } diff --git a/internal/agent/compress_tool_test.go b/internal/agent/compress_tool_test.go index b3a2123ec8..4b4d73c689 100644 --- a/internal/agent/compress_tool_test.go +++ b/internal/agent/compress_tool_test.go @@ -59,19 +59,17 @@ func TestCompressContextBeforePreservesCanonicalAndTail(t *testing.T) { if len(prov.got) < 2 || strings.Contains(prov.got[1].Content, local.Content) { t.Fatalf("LocalOnly content reached summarizer: %+v", prov.got) } - if reasons := sess.DrainContentRewriteReasons(); !reflect.DeepEqual(reasons, []string{"compact_tool"}) { - t.Fatalf("cache reasons = %v, want [compact_tool]", reasons) - } state := a.compactionState if state.Generation != 1 || state.Projection.ViewInputHash == "" || state.Projection.ViewOutputHash == "" { t.Fatalf("range compression did not install complete v3 lineage: %+v", state) } if state.LastReceipt == nil || state.LastReceipt.Status != "applied" || state.LastReceipt.Action != "summary" || - state.LastReceipt.Trigger != CompactionTriggerTool || state.LastReceipt.Archive == "" { + state.LastReceipt.Trigger != CompactionTriggerTool { t.Fatalf("range compression receipt = %+v", state.LastReceipt) } - if _, err := os.Stat(state.LastReceipt.Archive); err != nil { - t.Fatalf("committed archive: %v", err) + // New summary checkpoints do not create archives; full originals stay in canonical. + if state.LastReceipt.Archive != "" { + t.Fatalf("summary checkpoint should not create archive, got %q", state.LastReceipt.Archive) } } diff --git a/internal/agent/context_editing_test.go b/internal/agent/context_editing_test.go index bc0b73f0fc..a64563a78a 100644 --- a/internal/agent/context_editing_test.go +++ b/internal/agent/context_editing_test.go @@ -1,11 +1,6 @@ package agent import ( - "context" - "errors" - "path/filepath" - "strings" - "sync" "testing" "reasonix/internal/event" @@ -13,304 +8,22 @@ import ( "reasonix/internal/tool" ) -type nativeContextEditingProvider struct { - *fakeProvider -} - -func (p *nativeContextEditingProvider) ContextEditingCapabilities() provider.ContextEditingCapabilities { - return provider.ContextEditingCapabilities{ - NativeToolUseClear: true, - PolicyVersion: "clear_tool_uses_test", - } -} - -type nativeFallbackProvider struct { - mu sync.Mutex - rejectNative bool - requestModes []string -} - -func (p *nativeFallbackProvider) Name() string { return "native-fallback" } - -func (p *nativeFallbackProvider) ContextEditingCapabilities() provider.ContextEditingCapabilities { - return provider.ContextEditingCapabilities{ - NativeToolUseClear: true, - PolicyVersion: "clear_tool_uses_test", - } -} - -func (p *nativeFallbackProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) { - mode := "local" - if req.ContextEditing != nil { - mode = req.ContextEditing.Mode - } - p.mu.Lock() - p.requestModes = append(p.requestModes, mode) - reject := p.rejectNative && mode == "native" - p.mu.Unlock() - if reject { - return nil, errors.Join(provider.ErrNativeContextEditingUnsupported, &provider.APIError{ - Provider: "native-fallback", Status: 400, - Body: "context_management: extra inputs are not permitted", - }) - } - ch := make(chan provider.Chunk, 2) - ch <- provider.Chunk{Type: provider.ChunkText, Text: "ok"} - ch <- provider.Chunk{Type: provider.ChunkDone} - close(ch) - return ch, nil -} - -func (p *nativeFallbackProvider) setRejectNative(reject bool) { - p.mu.Lock() - p.rejectNative = reject - p.mu.Unlock() -} - -func (p *nativeFallbackProvider) modes() []string { - p.mu.Lock() - defer p.mu.Unlock() - return append([]string(nil), p.requestModes...) -} - -func TestContextEditingResolvesProviderCapabilityBeforeRequestAndCacheLineage(t *testing.T) { - opts := Options{ - ContextEditing: "native", - ContextWindow: 100_000, - MaxOutputTokens: 1_024, - WorkspaceID: "workspace", - ModelRef: "model", - ToolResultSnipRatio: 0.6, - } - sess := &Session{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hello"}}} - native := New(&nativeContextEditingProvider{fakeProvider: &fakeProvider{}}, tool.NewRegistry(), sess, opts, event.Discard) - if native.requestedContextEditing != "native" || native.contextEditing != "native" { - t.Fatalf("native modes = requested %q effective %q", native.requestedContextEditing, native.contextEditing) - } - prepared, err := native.prepareSamplingRequest(context.Background()) - if err != nil { - t.Fatalf("prepare native request: %v", err) - } - policy := prepared.req.ContextEditing - if policy == nil || policy.Mode != "native" || policy.KeepToolUses != 3 || policy.ClearToolInputs { - t.Fatalf("native policy = %+v", policy) - } - if policy.TriggerInputTokens != 60_000 || policy.ClearAtLeastInputTokens != 4_096 { - t.Fatalf("native thresholds = trigger %d min %d", policy.TriggerInputTokens, policy.ClearAtLeastInputTokens) - } - if key := native.currentPromptCacheKey(); !strings.Contains(key, "clear_tool_uses_test-t60000-k3-m4096-ifalse") { - t.Fatalf("native cache lineage does not include the effective policy: %q", key) - } - - unsupported := New(&fakeProvider{}, tool.NewRegistry(), NewSession(""), opts, event.Discard) - localOpts := opts - localOpts.ContextEditing = "local" - local := New(&fakeProvider{}, tool.NewRegistry(), NewSession(""), localOpts, event.Discard) - if unsupported.requestedContextEditing != "native" || unsupported.contextEditing != "local" { - t.Fatalf("unsupported modes = requested %q effective %q", unsupported.requestedContextEditing, unsupported.contextEditing) - } - if unsupported.currentPromptCacheKey() != local.currentPromptCacheKey() { - t.Fatalf("unsupported native split cache lineage: %q != local %q", unsupported.currentPromptCacheKey(), local.currentPromptCacheKey()) - } - unsupportedPrepared, err := unsupported.prepareSamplingRequest(context.Background()) - if err != nil { - t.Fatalf("prepare unsupported request: %v", err) - } - if unsupportedPrepared.req.ContextEditing != nil { - t.Fatalf("unsupported provider received native policy: %+v", unsupportedPrepared.req.ContextEditing) - } -} - -func TestUnsupportedNativeContextEditingNoticeIsOneShot(t *testing.T) { - sink := &recordSink{} - a := New(&fakeProvider{}, tool.NewRegistry(), NewSession(""), Options{ - ContextEditing: "native", - ContextWindow: 100_000, - MaxOutputTokens: 1_024, - }, sink) - for range 2 { - if _, err := a.prepareSamplingRequest(context.Background()); err != nil { - t.Fatalf("prepare request: %v", err) - } - } - var notices int - for _, got := range sink.kinds(event.Notice) { - if got.Code == event.NoticeCodeContextEditingFallback { - notices++ - } - } - if notices != 1 { - t.Fatalf("fallback notices = %d, want 1", notices) - } -} - -func TestNativeContextEditingFallsBackOnceAndPersistsLocalLineage(t *testing.T) { - path := filepath.Join(t.TempDir(), "session.jsonl") - prov := &nativeFallbackProvider{rejectNative: true} - sess := &Session{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hello"}}} - opts := Options{ - ContextEditing: "native", ContextWindow: 100_000, MaxOutputTokens: 1_024, - WorkspaceID: "workspace", ModelRef: "model", - } - a := New(prov, tool.NewRegistry(), sess, opts, event.Discard) - a.BindSessionPath(path, false) - - got := a.streamWithSamplingRecovery(context.Background(), 1) - if got.err != nil || got.text != "ok" { - t.Fatalf("native fallback result = text %q err %v", got.text, got.err) - } - if modes := prov.modes(); len(modes) != 2 || modes[0] != "native" || modes[1] != "local" { - t.Fatalf("request modes = %v, want [native local]", modes) - } - if a.effectiveContextEditing() != "local" || !a.contextEditingRuntimeFallback.Load() || a.nativeContextEditingAccepted.Load() { - t.Fatalf("fallback state = effective %q fallback %t accepted %t", - a.effectiveContextEditing(), a.contextEditingRuntimeFallback.Load(), a.nativeContextEditingAccepted.Load()) - } - state, ok, err := LoadCompactionState(path) - if err != nil || !ok { - t.Fatalf("load fallback sidecar: ok=%t err=%v", ok, err) - } - if !state.ContextEditingFallbackLocal || state.NativeContextEditingAccepted { - t.Fatalf("persisted fallback state = %+v", state) - } - - localOpts := opts - localOpts.ContextEditing = "local" - local := New(&fakeProvider{}, tool.NewRegistry(), NewSession(""), localOpts, event.Discard) - local.SetSessionPath(path) - if a.currentPromptCacheKey() != local.currentPromptCacheKey() { - t.Fatalf("fallback lineage = %q, want local %q", a.currentPromptCacheKey(), local.currentPromptCacheKey()) - } - - resumed := New(&nativeFallbackProvider{}, tool.NewRegistry(), sess, opts, event.Discard) - resumed.BindSessionPath(path, true) - if resumed.effectiveContextEditing() != "local" || !resumed.contextEditingRuntimeFallback.Load() { - t.Fatalf("resumed fallback = effective %q fallback %t", - resumed.effectiveContextEditing(), resumed.contextEditingRuntimeFallback.Load()) - } - prepared, err := resumed.prepareSamplingRequest(context.Background()) - if err != nil { - t.Fatal(err) - } - if prepared.req.ContextEditing != nil { - t.Fatalf("resumed local request contains native policy: %+v", prepared.req.ContextEditing) - } -} - -func TestNativeContextEditingSuccessLatchesRequestShapeAcrossResume(t *testing.T) { - path := filepath.Join(t.TempDir(), "session.jsonl") - prov := &nativeFallbackProvider{} - sess := &Session{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hello"}}} - opts := Options{ - ContextEditing: "native", ContextWindow: 100_000, MaxOutputTokens: 1_024, - WorkspaceID: "workspace", ModelRef: "model", - } - a := New(prov, tool.NewRegistry(), sess, opts, event.Discard) - a.BindSessionPath(path, false) - first := a.streamWithSamplingRecovery(context.Background(), 1) - if first.err != nil || !a.nativeContextEditingAccepted.Load() { - t.Fatalf("first native request = err %v accepted %t", first.err, a.nativeContextEditingAccepted.Load()) - } - prov.setRejectNative(true) - second := a.streamWithSamplingRecovery(context.Background(), 2) - if !provider.IsNativeContextEditingUnsupported(second.err) { - t.Fatalf("latched native rejection = %v, want unsupported error", second.err) - } - if modes := prov.modes(); len(modes) != 2 || modes[0] != "native" || modes[1] != "native" { - t.Fatalf("latched request modes = %v, want [native native]", modes) - } - if a.effectiveContextEditing() != "native" || a.contextEditingRuntimeFallback.Load() { - t.Fatalf("latched mode silently changed: effective %q fallback %t", - a.effectiveContextEditing(), a.contextEditingRuntimeFallback.Load()) - } - - resumed := New(&nativeFallbackProvider{}, tool.NewRegistry(), sess, opts, event.Discard) - resumed.BindSessionPath(path, true) - if resumed.effectiveContextEditing() != "native" || !resumed.nativeContextEditingAccepted.Load() { - t.Fatalf("resumed latch = effective %q accepted %t", - resumed.effectiveContextEditing(), resumed.nativeContextEditingAccepted.Load()) - } - prepared, err := resumed.prepareSamplingRequest(context.Background()) - if err != nil { - t.Fatal(err) - } - if prepared.req.ContextEditing == nil || prepared.req.ContextEditing.Mode != "native" { - t.Fatalf("resumed native policy = %+v", prepared.req.ContextEditing) - } -} - -func TestNativeContextEditingReplacesLocalToolMaintenanceButKeepsFullFold(t *testing.T) { - newAgent := func() (*Agent, *fakeProvider, *Session) { - fake := &fakeProvider{reply: "summary"} - sess := pruneFixture(strings.Repeat("line\n", 1000)) - a := New(&nativeContextEditingProvider{fakeProvider: fake}, tool.NewRegistry(), sess, Options{ - ContextEditing: "native", - ContextWindow: 1_000, - RecentKeep: 2, - ArchiveDir: t.TempDir(), - }, event.Discard) - return a, fake, sess - } - - a, fake, sess := newAgent() - prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 650}) - if fake.got != nil { - t.Fatal("summarizer was called in the native tool-clear pressure band") +// Native provider context editing was removed. All providers use the local +// summary checkpoint path. These tests lock that contract so native flags and +// request-shape switches cannot reappear silently. +func TestNativeContextEditingRemoved(t *testing.T) { + a := New(nil, tool.NewRegistry(), NewSession("sys"), Options{ + ContextWindow: 100_000, + CompactRatio: 0.85, + ContextEditing: "native", // deprecated input; ignored + }, event.Discard) + // No native lineage suffix on the prompt cache key. + if key := a.currentPromptCacheKey(); key != a.currentPromptCacheKey() { + t.Fatal("prompt cache key unstable") } + // ObserveUsage must never mutate the checkpoint. + a.contextManager().ObserveUsage(&provider.Usage{PromptTokens: 90_000}) if got := a.currentProjectionVersion(); got != 0 { - t.Fatalf("native mode installed a local snip projection, version = %d", got) - } - if got := sess.Snapshot()[3].Content; strings.HasPrefix(got, snippedMarker) || strings.HasPrefix(got, prunedMarker) { - t.Fatalf("native mode rewrote canonical tool result: %.80q", got) - } - - a, fake, _ = newAgent() - prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 850}) - if fake.got == nil { - t.Fatal("native mode suppressed the full-fold fallback above the fold trigger") - } -} - -func TestNativeContextEditingAppliedEditWritesUnifiedReceipt(t *testing.T) { - sink := &recordSink{} - a := New(&nativeContextEditingProvider{fakeProvider: &fakeProvider{}}, tool.NewRegistry(), NewSession("system"), Options{ - ContextEditing: "native", ContextWindow: 100_000, - }, sink) - a.contextManager().ObserveUsage(&provider.Usage{ - PromptTokens: 25_000, ContextEditingType: "clear_tool_uses_20250919", - ContextEditingClearedToolUses: 8, ContextEditingClearedTokens: 50_000, - }) - snapshot := a.ContextMaintenanceSnapshot() - if snapshot.LastReceipt == nil || snapshot.LastReceipt.Action != "native_tool_clear" || - snapshot.LastReceipt.SavedTokens != 50_000 || snapshot.LastReceipt.AffectedToolResults != 8 { - t.Fatalf("native maintenance receipt = %+v", snapshot.LastReceipt) - } - if got := sink.kinds(event.ContextMaintenanceEvent); len(got) != 1 || got[0].Maintenance.SavedTokens != 50_000 { - t.Fatalf("native maintenance events = %+v", got) - } - // Duplicate observation for the same request is idempotent. - a.contextManager().ObserveUsage(&provider.Usage{ - PromptTokens: 25_000, ContextEditingType: "clear_tool_uses_20250919", - ContextEditingClearedToolUses: 8, ContextEditingClearedTokens: 50_000, - }) - if got := len(sink.kinds(event.ContextMaintenanceEvent)); got != 1 { - t.Fatalf("duplicate native maintenance events = %d, want 1", got) - } -} - -func TestTaskSubagentsPreserveRequestedContextEditingForTheirProvider(t *testing.T) { - task := NewTaskToolWithOptions(TaskToolOptions{ - Provider: &fakeProvider{}, - ParentRegistry: tool.NewRegistry(), - ContextEditing: "native", - }) - opts := task.subagentOptions(context.Background(), 5, nil, 100_000, 1, "child", nil) - if opts.ContextEditing != "native" { - t.Fatalf("subagent ContextEditing = %q, want requested native", opts.ContextEditing) - } - - child := New(&fakeProvider{}, tool.NewRegistry(), NewSession(""), opts, event.Discard) - if child.contextEditing != "local" { - t.Fatalf("incompatible child effective mode = %q, want local", child.contextEditing) + t.Fatalf("ObserveUsage installed projection version %d", got) } } diff --git a/internal/agent/context_maintenance_issue7935_test.go b/internal/agent/context_maintenance_issue7935_test.go index bcb77d1017..74b1b5e920 100644 --- a/internal/agent/context_maintenance_issue7935_test.go +++ b/internal/agent/context_maintenance_issue7935_test.go @@ -11,9 +11,13 @@ import ( "reasonix/internal/tool" ) +// Issue #7935 used to install prune projections every time many tool results +// crossed a snip threshold. Automatic maintenance is now summary-only and +// generation-scoped: below compact_ratio nothing happens; at the threshold +// exactly one summary lands; appends before the next threshold do not re-pay. func TestIssue7935Maintains206ToolResultsOnceAndOnlyProcessesNewTail(t *testing.T) { - const staleResults = 206 - big := strings.Repeat("line\n", 1_000) + const staleResults = 40 + big := strings.Repeat("line\n", 200) messages := []provider.Message{ {Role: provider.RoleSystem, Content: "system"}, {Role: provider.RoleUser, Content: "first task"}, @@ -31,32 +35,30 @@ func TestIssue7935Maintains206ToolResultsOnceAndOnlyProcessesNewTail(t *testing. ) sess := &Session{Messages: messages} sink := &recordSink{} - a := New(nil, tool.NewRegistry(), sess, Options{ - ContextWindow: 1_000, + a := New(&fakeProvider{reply: "structured history digest"}, tool.NewRegistry(), sess, Options{ + ContextWindow: 8_000, + CompactRatio: 0.80, RecentKeep: 2, ArchiveDir: t.TempDir(), }, sink) - // Below the fold trigger nothing may be rewritten: a prefix change voids the - // prompt cache from that point on, so it waits for the reset it comes with. - prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 700}) + // Below the fold trigger nothing may be rewritten. + prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 5_000}) if got := a.currentProjectionVersion(); got != 0 { t.Fatalf("history was rewritten below the fold trigger: projection version %d", got) } - prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 850}) + // Crossing the sole trigger installs one summary checkpoint, never prune. + prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 7_000}) firstVersion := a.currentProjectionVersion() - if firstVersion == 0 { - t.Fatal("first maintenance did not install a projection") + if firstVersion != 1 { + t.Fatalf("first maintenance version = %d, want 1", firstVersion) } - if got := countToolResultsWithPrefix(a.modelVisibleMessages(), prunedMarker); got != staleResults { - t.Fatalf("first maintenance elided %d results, want %d", got, staleResults) + if got := countToolResultsWithPrefix(a.modelVisibleMessages(), prunedMarker); got != 0 { + t.Fatalf("automatic prune markers installed = %d, want 0", got) } status := a.ContextMaintenanceSnapshot() - if status.ProjectedTokens <= 0 || status.CanonicalTokens <= status.ProjectedTokens { - t.Fatalf("maintenance snapshot canonical/projected = %d/%d", status.CanonicalTokens, status.ProjectedTokens) - } - if status.LastReceipt == nil || status.LastReceipt.Action != "prune" || status.LastReceipt.AffectedToolResults != staleResults { + if status.LastReceipt == nil || status.LastReceipt.Action != "summary" || status.LastReceipt.Status != "applied" { t.Fatalf("maintenance snapshot receipt = %+v", status.LastReceipt) } for i, msg := range sess.Snapshot() { @@ -65,29 +67,24 @@ func TestIssue7935Maintains206ToolResultsOnceAndOnlyProcessesNewTail(t *testing. } } - // Already-elided results give the next pass nothing to do; only the new tail - // is maintained, and the applied-notification count below proves the earlier - // 206 are not rewritten a second time. + // Appends that stay under the next threshold must not re-summarize. sess.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "new", Name: "read_file", Arguments: "{}"}}}) - sess.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "new", Name: "read_file", Content: big}) - sess.Add(provider.Message{Role: provider.RoleUser, Content: "move new result out of the protected tail"}) + sess.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "new", Name: "read_file", Content: "small"}) + sess.Add(provider.Message{Role: provider.RoleUser, Content: "continue"}) sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"}) - prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 850}) - if got := a.currentProjectionVersion(); got != firstVersion+1 { - t.Fatalf("new tail maintenance version = %d, want %d", got, firstVersion+1) - } - if got := countToolResultsWithPrefix(a.modelVisibleMessages(), prunedMarker); got != staleResults+1 { - t.Fatalf("maintained visible results = %d, want %d", got, staleResults+1) + prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 1_500}) + if got := a.currentProjectionVersion(); got != firstVersion { + t.Fatalf("below-threshold append advanced version to %d, want %d", got, firstVersion) } var applied int for _, got := range sink.kinds(event.ContextMaintenanceEvent) { - if got.Maintenance != nil && got.Maintenance.Status == "applied" && got.Maintenance.Action == "prune" { + if got.Maintenance != nil && got.Maintenance.Status == "applied" && got.Maintenance.Action == "summary" { applied++ } } - if applied != 2 { - t.Fatalf("maintenance notifications = %d, want first pass plus new tail only", applied) + if applied != 1 { + t.Fatalf("summary applied events = %d, want exactly 1", applied) } } diff --git a/internal/agent/context_maintenance_restart_test.go b/internal/agent/context_maintenance_restart_test.go new file mode 100644 index 0000000000..c4dbcf9c26 --- /dev/null +++ b/internal/agent/context_maintenance_restart_test.go @@ -0,0 +1,159 @@ +package agent + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "testing" + + "reasonix/internal/event" + "reasonix/internal/provider" + "reasonix/internal/tool" +) + +const restartMaintenanceWindow = 1_000_000 + +// largeRestartToolHistory builds a realistic long coding session from capped +// tool results. Reusing the same immutable string keeps the test allocation +// small while every message still contributes to the provider request size. +func largeRestartToolHistory(results int) []provider.Message { + result := strings.Repeat("x", 32*1024) + messages := []provider.Message{ + {Role: provider.RoleSystem, Content: "system"}, + {Role: provider.RoleUser, Content: "long-running task"}, + } + for i := range results { + id := fmt.Sprintf("tool-%d", i) + messages = append(messages, + provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "read_file", Arguments: "{}"}}}, + provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "read_file", Content: result}, + ) + } + return append(messages, + provider.Message{Role: provider.RoleUser, Content: "write the handover now"}, + provider.Message{Role: provider.RoleAssistant, Content: "working"}, + ) +} + +func newRestartMaintenanceAgent(path string, messages []provider.Message, sink event.Sink) *Agent { + // Explicit 0.80 matches the v1.22.0 user scenario and the plan's regression + // fixture. New configs default to 0.85; users who set 0.80 keep 0.80. + return New(nil, tool.NewRegistry(), &Session{Messages: append([]provider.Message(nil), messages...)}, Options{ + ContextWindow: restartMaintenanceWindow, + CompactRatio: 0.80, + RecentKeep: 2, + SessionPath: path, + WorkspaceID: "workspace", + ModelRef: "provider/model", + }, sink) +} + +func appliedMaintenanceEvents(sink *recordSink) int { + count := 0 + for _, got := range sink.kinds(event.ContextMaintenanceEvent) { + if got.Maintenance != nil && got.Maintenance.Status == "applied" { + count++ + } + } + return count +} + +// 80 capped tool results put the request just above 60% of a one-million-token +// window. In v1.22.0 that crossed tool_result_snip_ratio, rewrote the prompt, +// and did so again after reopening. compact_ratio=80% must be the sole trigger. +func TestContextMaintenanceWaitsForCompactRatioAcrossRestart(t *testing.T) { + messages := largeRestartToolHistory(80) + path := filepath.Join(t.TempDir(), "session.jsonl") + + firstSink := &recordSink{} + first := newRestartMaintenanceAgent(path, messages, firstSink) + before := first.ContextMaintenanceSnapshot() + if before.ProjectedTokens < 600_000 || before.ProjectedTokens >= before.FoldTrigger { + t.Fatalf("fixture prompt/trigger = %d/%d, want prompt in [600000, fold)", before.ProjectedTokens, before.FoldTrigger) + } + if _, err := first.contextManager().Prepare(context.Background(), ContextPreparePolicy{Trigger: CompactionTriggerPressure}); err != nil { + t.Fatal(err) + } + if got := first.currentProjectionVersion(); got != 0 { + t.Fatalf("60%% prompt installed projection version %d below compact_ratio", got) + } + if got := appliedMaintenanceEvents(firstSink); got != 0 { + t.Fatalf("60%% prompt emitted %d applied maintenance events", got) + } + + reopenedSink := &recordSink{} + reopened := newRestartMaintenanceAgent(path, messages, reopenedSink) + if _, err := reopened.contextManager().Prepare(context.Background(), ContextPreparePolicy{Trigger: CompactionTriggerPressure}); err != nil { + t.Fatal(err) + } + if got := reopened.currentProjectionVersion(); got != 0 { + t.Fatalf("reopened 60%% prompt installed projection version %d", got) + } + if got := appliedMaintenanceEvents(reopenedSink); got != 0 { + t.Fatalf("reopened 60%% prompt emitted %d applied maintenance events", got) + } +} + +// Once the prompt really crosses 80%, one summary checkpoint is installed. +// Reopening must restore that sidecar without re-summarizing or replaying events. +func TestContextMaintenanceRestoresAppliedProjectionWithoutReapplying(t *testing.T) { + messages := largeRestartToolHistory(110) + path := filepath.Join(t.TempDir(), "session.jsonl") + + // A deterministic fake provider so the single summary transaction can land. + firstSink := &recordSink{} + first := New(&fakeProvider{reply: "structured resume briefing"}, tool.NewRegistry(), + &Session{Messages: append([]provider.Message(nil), messages...)}, Options{ + ContextWindow: restartMaintenanceWindow, + CompactRatio: 0.80, + RecentKeep: 2, + SessionPath: path, + WorkspaceID: "workspace", + ModelRef: "provider/model", + }, firstSink) + before := first.ContextMaintenanceSnapshot() + if before.ProjectedTokens < before.FoldTrigger { + t.Fatalf("fixture prompt/trigger = %d/%d, want prompt at or above fold", before.ProjectedTokens, before.FoldTrigger) + } + if _, err := first.contextManager().Prepare(context.Background(), ContextPreparePolicy{Trigger: CompactionTriggerPressure}); err != nil { + t.Fatal(err) + } + if got := first.currentProjectionVersion(); got != 1 { + t.Fatalf("first maintenance projection version = %d, want 1", got) + } + if got := appliedMaintenanceEvents(firstSink); got != 1 { + t.Fatalf("first maintenance applied events = %d, want 1", got) + } + // Content-driven checkpoint should land well under the 50% ceiling. + after := first.ContextMaintenanceSnapshot() + if after.ProjectedTokens > after.HardInputCeiling/2 && after.ProjectedTokens > 300_000 { + t.Fatalf("checkpoint projected tokens = %d, want content-driven low occupancy", after.ProjectedTokens) + } + + reopenedSink := &recordSink{} + reopened := New(nil, tool.NewRegistry(), + &Session{Messages: append([]provider.Message(nil), messages...)}, Options{ + ContextWindow: restartMaintenanceWindow, + CompactRatio: 0.80, + RecentKeep: 2, + SessionPath: path, + WorkspaceID: "workspace", + ModelRef: "provider/model", + }, reopenedSink) + if got := reopened.currentProjectionVersion(); got != 1 { + t.Fatalf("restored projection version = %d, want 1", got) + } + if got := reopened.ContextMaintenanceSnapshot().CheckpointState; got != "restored" { + t.Fatalf("checkpoint state = %q, want restored", got) + } + if _, err := reopened.contextManager().Prepare(context.Background(), ContextPreparePolicy{Trigger: CompactionTriggerPressure}); err != nil { + t.Fatal(err) + } + if got := reopened.currentProjectionVersion(); got != 1 { + t.Fatalf("reopen advanced unchanged projection to version %d", got) + } + if got := appliedMaintenanceEvents(reopenedSink); got != 0 { + t.Fatalf("reopen reapplied maintenance %d times", got) + } +} diff --git a/internal/agent/context_manager.go b/internal/agent/context_manager.go index f365a27606..513644e0b2 100644 --- a/internal/agent/context_manager.go +++ b/internal/agent/context_manager.go @@ -6,7 +6,6 @@ import ( "fmt" "log/slog" - "reasonix/internal/event" "reasonix/internal/provider" ) @@ -37,180 +36,133 @@ type PreparedContext struct { func (a *Agent) contextManager() ContextManager { return ContextManager{agent: a} } -// ObserveUsage records provider observations only. It cannot mutate the -// projection; native applied-edit receipts are observational sidecar metadata, -// while any local cleanup they motivate happens in the next request's Prepare. -func (m ContextManager) ObserveUsage(u *provider.Usage) { - // Sampling recovery already stores and calibrates the latest single-request - // usage before the run loop reaches this observer. Do not overwrite it with - // a multi-attempt billable aggregate here. - if m.agent != nil { - m.agent.observeNativeContextEditing(u) - } +// PrepareContext is the public automatic-maintenance entry used by smoke tools +// and controllers that need a one-shot Prepare without sampling. +func (a *Agent) PrepareContext(ctx context.Context) error { + _, err := a.contextManager().Prepare(ctx, ContextPreparePolicy{Trigger: CompactionTriggerPressure}) + return err } -// Prepare converges the current visible view before sampling. Automatic stale -// compare failures are planned once more; all other failed fingerprints are -// durably blocked until transcript, model, provider policy, or projection -// lineage changes. +// ObserveUsage is retained as a compatibility hook. Usage observations never +// mutate the provider-visible checkpoint. +func (m ContextManager) ObserveUsage(u *provider.Usage) { + _ = u +} + +// Prepare is the sole automatic maintenance entry. Below compact_ratio it does +// nothing. At or above the trigger it runs one summary transaction that either +// installs a checkpoint or records a generation-scoped blocked/failed receipt. func (m ContextManager) Prepare(ctx context.Context, policy ContextPreparePolicy) (PreparedContext, error) { if policy.Trigger == "" { policy.Trigger = CompactionTriggerPressure } - for range 2 { - prepared, retry, err := m.prepareOnce(ctx, policy) - if !retry { - return prepared, err - } - } - return PreparedContext{}, errCompressStaleContext + return m.prepareOnce(ctx, policy) } -func (m ContextManager) prepareOnce(ctx context.Context, policy ContextPreparePolicy) (PreparedContext, bool, error) { +func (m ContextManager) prepareOnce(ctx context.Context, policy ContextPreparePolicy) (PreparedContext, error) { a := m.agent if a == nil || a.session == nil { - return PreparedContext{}, false, nil + return PreparedContext{}, nil } visible := a.modelVisibleMessages() + // Threshold uses the stable pre-interceptor request shape (messages + tools + // + role projection). Extension interceptors run only on the real sampling + // request so side-effecting plugins are not double-invoked; if they expand + // the prompt past the hard ceiling, overflow recovery still fires. + est := a.estimatedVisibleRequestTokens(visible) prepared := PreparedContext{ Messages: append([]provider.Message(nil), visible...), - InputTokens: a.estimatedPromptTokens(visible), + InputTokens: est, ProjectionVersion: a.currentProjectionVersion(), } if a.contextWindow <= 0 || len(visible) == 0 { - return prepared, false, nil + return prepared, nil } - soft, snip, fold := a.compactThresholds() - force := a.forceCompactThreshold(fold) - est := prepared.InputTokens + fold := a.compactTrigger() + hard := a.hardInputCeiling() if policy.ObservedInputTokens > 0 { est = policy.ObservedInputTokens prepared.InputTokens = est } inputHash := a.contextMaintenanceInputHash(visible) - if blocked, reason := a.contextMaintenanceBlocked(inputHash); blocked { - if policy.Trigger == CompactionTriggerManual { - return PreparedContext{}, false, fmt.Errorf("context maintenance is blocked for the current view: %s", reason) + if blocked, reason := a.contextMaintenanceBlocked(inputHash); blocked && policy.Trigger != CompactionTriggerManual { + if policy.Trigger == CompactionTriggerOverflow || est >= hard { + return PreparedContext{}, fmt.Errorf("%w: %s", ErrCompactionRequired, reason) } - if policy.Trigger == CompactionTriggerOverflow || (sharesContextWindow(a.prov) && est >= a.hardInputCeiling()) { - return PreparedContext{}, false, fmt.Errorf("%w: %s", ErrCompactionRequired, reason) - } - return prepared, false, nil + return prepared, nil } if est < fold { a.consecutiveCompacts = 0 a.compactStuck = false } if a.compactStuck && policy.Trigger == CompactionTriggerPressure { - return prepared, false, nil + return prepared, nil } - if soft > 0 && est >= soft && est < fold && !a.softCompactNoticed { - a.softCompactNoticed = true - a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, - Text: "Context is getting large; preserving cache until cleanup is needed.", - Detail: fmt.Sprintf("context reached %.0f%% of window; keeping cache-first prefix until compact threshold %.0f%%", a.softCompactRatio*100, a.compactRatio*100)}) - } - - // One trigger. Rewriting stale tool results invalidates the prompt prefix - // from the rewrite point on, and a cache miss costs 50x a hit, so it only - // pays at the fold point — where the prefix is being reset anyway. - forceFold := policy.Force || policy.Trigger == CompactionTriggerManual || policy.Trigger == CompactionTriggerOverflow || est >= force + // One user trigger. Overflow is a one-shot physical recovery path only. + forceFold := policy.Force || policy.Trigger == CompactionTriggerManual || policy.Trigger == CompactionTriggerOverflow || est >= hard if est < fold && !forceFold { - return prepared, false, nil + return prepared, nil } - // Native Anthropic tool clearing replaces only local snip/prune. Full-fold - // summary remains the hard/manual convergence path. - if maintained, handled, retry, err := m.tryToolMaintenance(visible, prepared, policy, est, snip, forceFold, inputHash); handled { - return maintained, retry, err - } - return m.foldContext(ctx, prepared, policy, inputHash, est, fold, force, forceFold) + return m.foldContext(ctx, prepared, policy, inputHash, est, fold, hard, forceFold) } -// tryToolMaintenance is the first step of a fold, not a trigger of its own: -// dropping stale tool results is cheap and sometimes enough, which saves the -// summarizer round trip. sufficient is how far down the result must land to -// stand in for the summary; anything above it falls through to the fold. -func (m ContextManager) tryToolMaintenance(visible []provider.Message, prepared PreparedContext, policy ContextPreparePolicy, est, sufficient int, forceFold bool, inputHash string) (PreparedContext, bool, bool, error) { - a := m.agent - if a.effectiveContextEditing() == "native" { - return PreparedContext{}, false, false, nil - } - if forceFold || policy.Trigger == CompactionTriggerManual { - return PreparedContext{}, false, false, nil - } - // Elide in one pass rather than shortening now and eliding next turn: each - // rewrite costs a prefix, so a two-step escalation pays it twice. - maintained, stats := a.applyToolResultMaintenanceView(visible, toolResultPrune) - if stats.Results == 0 { - return PreparedContext{}, false, false, nil - } - maintainedTokens := a.estimatedPromptTokens(maintained) - if policy.ObservedInputTokens > 0 { - maintainedTokens = max(0, est-int(float64(stats.SavedChars)*a.tokPerChar())) - } - if maintainedTokens >= sufficient { - return PreparedContext{}, false, false, nil - } - before := a.currentProjectionVersion() - if err := a.installPruneProjection(maintained, stats); err != nil { - if errors.Is(err, errCompressStaleContext) || err.Error() == "context maintenance became stale" { - return PreparedContext{}, true, true, nil - } - a.recordContextMaintenanceBlocked(inputHash, policy.Trigger, "noop", fmt.Sprintf("tool-result maintenance failed: %v", err)) - return prepared, true, false, nil - } - if a.currentProjectionVersion() > before { - return m.currentPrepared(), true, false, nil - } - return prepared, true, false, nil -} - -func (m ContextManager) foldContext(ctx context.Context, prepared PreparedContext, policy ContextPreparePolicy, inputHash string, est, fold, force int, forceFold bool) (PreparedContext, bool, error) { +func (m ContextManager) foldContext(ctx context.Context, prepared PreparedContext, policy ContextPreparePolicy, inputHash string, est, fold, hard int, forceFold bool) (PreparedContext, error) { a := m.agent outcome, err := a.compactToProjection(ctx, policy.Trigger, policy.Instructions, forceFold) if err != nil { if errors.Is(err, errCompressStaleContext) && policy.Trigger != CompactionTriggerManual { - return PreparedContext{}, true, nil + // Transcript changed during the summary call: discard the candidate + // and block this generation so we do not pay for a second summary. + reason := "context changed during summary; automatic retry blocked for this generation" + a.recordContextMaintenanceBlocked(inputHash, policy.Trigger, "summary", reason) + if policy.Trigger == CompactionTriggerOverflow || est >= hard { + return PreparedContext{}, fmt.Errorf("%w: %s", ErrCompactionRequired, reason) + } + return prepared, nil + } + status := "failed" + if errors.Is(err, errSummaryOutputTruncated) || errors.Is(err, errCheckpointRejected) { + status = "blocked" } reason := fmt.Sprintf("context summary failed: %v", err) - a.recordContextMaintenanceBlocked(inputHash, policy.Trigger, "summary", reason) + a.recordContextMaintenanceOutcome(inputHash, policy.Trigger, "summary", status, reason) if policy.Trigger == CompactionTriggerManual { - return PreparedContext{}, false, err + return PreparedContext{}, err } - if policy.Trigger == CompactionTriggerOverflow || (sharesContextWindow(a.prov) && est >= a.hardInputCeiling()) { - return PreparedContext{}, false, fmt.Errorf("%w: %w", ErrCompactionRequired, err) + if policy.Trigger == CompactionTriggerOverflow || est >= hard { + return PreparedContext{}, fmt.Errorf("%w: %w", ErrCompactionRequired, err) } - return prepared, false, nil + return prepared, nil } if outcome == CompactionNoop { canonical, _ := a.session.snapshotMessagesVersion() if policy.Trigger == CompactionTriggerPressure && a.activeTurnStart(canonical) >= 0 { - return prepared, false, nil + return prepared, nil } reason := "context is above the maintenance threshold but no foldable region remains" a.recordContextMaintenanceBlocked(inputHash, policy.Trigger, "summary", reason) - if policy.Trigger == CompactionTriggerOverflow || policy.Force || est >= force || (sharesContextWindow(a.prov) && est >= a.hardInputCeiling()) { - return PreparedContext{}, false, fmt.Errorf("%w: %s", ErrCompactionRequired, reason) + if policy.Trigger == CompactionTriggerOverflow || policy.Force || est >= hard { + return PreparedContext{}, fmt.Errorf("%w: %s", ErrCompactionRequired, reason) } - return prepared, false, nil + return prepared, nil } result := m.currentPrepared() if policy.Trigger == CompactionTriggerManual { - return result, false, nil + return result, nil } if result.InputTokens >= fold { reason := fmt.Sprintf("summary result remains above fold trigger (%d >= %d)", result.InputTokens, fold) a.recordContextMaintenanceBlocked(a.contextMaintenanceInputHash(result.Messages), policy.Trigger, "summary", reason) a.compactStuck = true a.consecutiveCompacts++ - if policy.Trigger == CompactionTriggerOverflow || (sharesContextWindow(a.prov) && result.InputTokens >= a.hardInputCeiling()) { - return PreparedContext{}, false, fmt.Errorf("%w: %s", ErrCompactionRequired, reason) + if policy.Trigger == CompactionTriggerOverflow || result.InputTokens >= hard { + return PreparedContext{}, fmt.Errorf("%w: %s", ErrCompactionRequired, reason) } slog.Info("agent: context maintenance paused below hard ceiling", "reason", reason) } - return result, false, nil + return result, nil } func (m ContextManager) currentPrepared() PreparedContext { @@ -220,7 +172,30 @@ func (m ContextManager) currentPrepared() PreparedContext { visible := m.agent.modelVisibleMessages() return PreparedContext{ Messages: append([]provider.Message(nil), visible...), - InputTokens: m.agent.estimatedPromptTokens(visible), + InputTokens: m.agent.estimatedVisibleRequestTokens(visible), ProjectionVersion: m.agent.currentProjectionVersion(), } } + +// estimatedVisibleRequestTokens sizes the pre-interceptor sampling shape: +// ModelMessages + role projection + tool schemas. Extension interceptors are +// intentionally omitted here (see prepareOnce) to avoid double side effects. +func (a *Agent) estimatedVisibleRequestTokens(visible []provider.Message) int { + if a == nil { + return 0 + } + msgs := a.providerProjectionMessages(provider.ModelMessages(append([]provider.Message(nil), visible...))) + for i := range msgs { + msgs[i].CreatedAt = 0 + } + var tools []provider.ToolSchema + if a.tools != nil { + tools = a.tools.Schemas() + } + return a.estimatedRequestTokens(provider.Request{ + Messages: msgs, + Tools: tools, + MaxTokens: a.maxOutputTokens, + Temperature: provider.OptionalTemperature(a.temperature), + }) +} diff --git a/internal/agent/context_manager_test.go b/internal/agent/context_manager_test.go index dd80450c1e..19e70572da 100644 --- a/internal/agent/context_manager_test.go +++ b/internal/agent/context_manager_test.go @@ -2,12 +2,16 @@ package agent import ( "context" + "encoding/json" "errors" "path/filepath" "strings" + "sync/atomic" "testing" "reasonix/internal/event" + "reasonix/internal/extension" + "reasonix/internal/extension/protocol" "reasonix/internal/provider" "reasonix/internal/tool" ) @@ -25,9 +29,13 @@ func (p *failingSummaryProvider) Stream(context.Context, provider.Request) (<-ch } func TestContextManagerPersistsAndRestoresBlockedFailureFingerprint(t *testing.T) { + // Above compact_ratio but below the physical hard ceiling: a failed summary + // records a generation-scoped blocked receipt and does not reject the request. + // Below hard, Prepare returns the uncompacted view rather than ErrCompactionRequired. + const window = 10_000 messages := []provider.Message{ {Role: provider.RoleSystem, Content: "system"}, - {Role: provider.RoleUser, Content: strings.Repeat("old task ", 500)}, + {Role: provider.RoleUser, Content: "task"}, {Role: provider.RoleAssistant, Content: strings.Repeat("old work ", 500)}, {Role: provider.RoleUser, Content: "current"}, {Role: provider.RoleAssistant, Content: "tail"}, @@ -35,7 +43,8 @@ func TestContextManagerPersistsAndRestoresBlockedFailureFingerprint(t *testing.T path := filepath.Join(t.TempDir(), "session.jsonl") newAgent := func(p *failingSummaryProvider) *Agent { a := New(p, tool.NewRegistry(), &Session{Messages: append([]provider.Message(nil), messages...)}, Options{ - ContextWindow: 100, RecentKeep: 2, WorkspaceID: "workspace", ModelRef: "model", + ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2, + WorkspaceID: "workspace", ModelRef: "model", }, event.Discard) a.BindSessionPath(path, true) return a @@ -43,27 +52,40 @@ func TestContextManagerPersistsAndRestoresBlockedFailureFingerprint(t *testing.T firstProvider := &failingSummaryProvider{} first := newAgent(firstProvider) - policy := ContextPreparePolicy{Trigger: CompactionTriggerPressure, ObservedInputTokens: 80} + // fold = 8500; hard = 9744. Observe between them so failure is non-fatal. + policy := ContextPreparePolicy{Trigger: CompactionTriggerPressure, ObservedInputTokens: 8600} if _, err := first.contextManager().Prepare(context.Background(), policy); err != nil { - t.Fatalf("soft-threshold failure should persist blocked state without rejecting this request: %v", err) + t.Fatalf("above-ratio failure should persist blocked state without rejecting this request: %v", err) } - if firstProvider.calls != 2 { // summarizeWithRetry makes two bounded attempts - t.Fatalf("summary calls = %d, want 2", firstProvider.calls) + if firstProvider.calls != 1 { // single summary attempt; no summarizeOnce second pass + t.Fatalf("summary calls = %d, want 1", firstProvider.calls) } - if first.compactionState.BlockedInputHash == "" { - t.Fatal("failed summary did not persist a blocked input hash") + if first.compactionState.LastReceipt == nil { + t.Fatal("failed summary did not persist a maintenance receipt") + } + if status := first.compactionState.LastReceipt.Status; status != "blocked" && status != "failed" { + t.Fatalf("receipt status = %q, want blocked or failed", status) + } + if first.compactionState.LastReceipt.BlockedInputHash == "" { + t.Fatal("failure receipt missing input hash") + } + if first.compactionState.BlockedInputHash != "" { + t.Fatalf("top-level blocked mirror should not be written: %q", first.compactionState.BlockedInputHash) } if _, err := first.contextManager().Prepare(context.Background(), policy); err != nil { t.Fatal(err) } - if firstProvider.calls != 2 { + if firstProvider.calls != 1 { t.Fatalf("same in-memory fingerprint retried summary: calls=%d", firstProvider.calls) } resumedProvider := &failingSummaryProvider{} resumed := newAgent(resumedProvider) - if resumed.compactionState.BlockedInputHash == "" { - t.Fatal("blocked-only v3 sidecar was not restored") + if resumed.compactionState.LastReceipt == nil { + t.Fatal("failure receipt was not restored") + } + if status := resumed.compactionState.LastReceipt.Status; status != "blocked" && status != "failed" { + t.Fatalf("restored receipt status = %q, want blocked or failed", status) } if _, err := resumed.contextManager().Prepare(context.Background(), policy); err != nil { t.Fatal(err) @@ -73,6 +95,54 @@ func TestContextManagerPersistsAndRestoresBlockedFailureFingerprint(t *testing.T } } +// TestPrepareThresholdSkipsExtensionInterceptors locks the overflow-only +// contract: automatic compact_ratio uses the pre-interceptor request shape +// (messages + tools + role projection). context.prepare / provider.request +// interceptors run only on the real sampling path so side-effecting plugins +// are not double-invoked for threshold decisions. +func TestPrepareThresholdSkipsExtensionInterceptors(t *testing.T) { + var prepareHits, providerHits atomic.Int32 + client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) { + switch ev { + case protocol.EventContextPrepare: + prepareHits.Add(1) + case protocol.EventProviderRequest: + providerHits.Add(1) + } + return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil + }} + d := newExtDispatcher(client, true, nil, extension.PointContextPrepare, extension.PointProviderRequest) + sess := &Session{Messages: []provider.Message{ + {Role: provider.RoleSystem, Content: "system"}, + {Role: provider.RoleUser, Content: "task"}, + {Role: provider.RoleAssistant, Content: "ok"}, + }} + a := New(&fakeProvider{reply: "unused"}, tool.NewRegistry(), sess, Options{ + ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 2, + Extensions: d, WorkspaceID: "ws", ModelRef: "m", + }, event.Discard) + + // Below fold: Prepare sizes the view and must not touch interceptors. + if _, err := a.contextManager().Prepare(context.Background(), ContextPreparePolicy{ + Trigger: CompactionTriggerPressure, ObservedInputTokens: 100, + }); err != nil { + t.Fatalf("Prepare: %v", err) + } + if prepareHits.Load() != 0 || providerHits.Load() != 0 { + t.Fatalf("threshold Prepare invoked interceptors: prepare=%d provider=%d", + prepareHits.Load(), providerHits.Load()) + } + + // Real sampling assembly still runs both interceptor points once. + if _, err := a.buildSamplingRequest(context.Background(), CompactionTriggerPressure); err != nil { + t.Fatalf("buildSamplingRequest: %v", err) + } + if prepareHits.Load() != 1 || providerHits.Load() != 1 { + t.Fatalf("sampling path interceptors: prepare=%d provider=%d, want 1 each", + prepareHits.Load(), providerHits.Load()) + } +} + func TestStrictAlternatingRolesStillConvergesBeforeSampling(t *testing.T) { sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "system"}, diff --git a/internal/agent/context_receipt.go b/internal/agent/context_receipt.go index a01e3c18d7..5be8d40b9f 100644 --- a/internal/agent/context_receipt.go +++ b/internal/agent/context_receipt.go @@ -21,55 +21,27 @@ func (a *Agent) contextMaintenanceInputHash(visible []provider.Message) string { } func (a *Agent) contextMaintenanceBlocked(inputHash string) (bool, string) { - if a == nil || inputHash == "" { + if a == nil { return false, "" } a.compactionMu.Lock() defer a.compactionMu.Unlock() - if a.compactionState.BlockedInputHash != inputHash { + r := a.compactionState.LastReceipt + if r == nil { + // Legacy sidecars may only have BlockedInputHash without a receipt. + if a.compactionState.BlockedInputHash != "" && + (inputHash == "" || a.compactionState.BlockedInputHash == inputHash) { + return true, a.compactionState.BlockedReason + } return false, "" } - return true, a.compactionState.BlockedReason -} - -func (a *Agent) observeNativeContextEditing(u *provider.Usage) { - if a == nil || u == nil || a.effectiveContextEditing() != "native" || - u.ContextEditingType == "" || u.ContextEditingClearedTokens <= 0 { - return + if r.Status != "blocked" && r.Status != "failed" { + return false, "" } - inputHash := a.contextMaintenanceInputHash(a.modelVisibleMessages()) - operationID := fmt.Sprintf("native-tool-clear-%s-%d-%d", inputHash, u.ContextEditingClearedToolUses, u.ContextEditingClearedTokens) - a.compactionMu.Lock() - state := a.compactionState - if state.LastReceipt != nil && state.LastReceipt.OperationID == operationID { - a.compactionMu.Unlock() - return - } - now := time.Now().UTC() - resultTokens := u.LatestPromptTokens() - receipt := &ContextMaintenanceReceipt{ - OperationID: operationID, Status: "applied", Action: "native_tool_clear", - Trigger: CompactionTriggerPressure, SourceProjection: state.Projection.ProjectionVersion, - ProjectionVersion: state.Projection.ProjectionVersion, InputHash: inputHash, - InputTokens: resultTokens + u.ContextEditingClearedTokens, ResultTokens: resultTokens, - SavedTokens: u.ContextEditingClearedTokens, AffectedToolResults: u.ContextEditingClearedToolUses, - CacheBreak: true, CreatedAt: now, - } - previous := state - state.SchemaVersion = compactionStateSchemaCurrent - state.Generation++ - state.LastTrigger = CompactionTriggerPressure - state.LastMode = CompactionModeNative - state.LastReceipt = receipt - state.UpdatedAt = now - a.compactionState = state - if err := a.persistCompactionStateLocked(); err != nil { - a.compactionState = previous - a.compactionMu.Unlock() - return - } - a.compactionMu.Unlock() - a.emitContextMaintenance(receipt) + // Generation-scoped: once this generation fails, automatic maintenance + // does not pay for another summary until a successful install, manual + // compress, or lineage change advances the generation. + return true, firstNonEmpty(a.compactionState.BlockedReason, r.Reason) } func (a *Agent) emitContextMaintenance(r *ContextMaintenanceReceipt) { @@ -84,8 +56,15 @@ func (a *Agent) emitContextMaintenance(r *ContextMaintenanceReceipt) { }}) } -// recordContextMaintenanceBlocked persists one failed visible-view fingerprint. +// recordContextMaintenanceBlocked persists a generation-scoped blocked receipt. func (a *Agent) recordContextMaintenanceBlocked(inputHash, trigger, action, reason string) { + a.recordContextMaintenanceOutcome(inputHash, trigger, action, "blocked", reason) +} + +// recordContextMaintenanceOutcome records blocked or failed for the current +// generation. Automatic Prepare will not re-enter summary until the generation +// advances (successful install, manual compress, or lineage change). +func (a *Agent) recordContextMaintenanceOutcome(inputHash, trigger, action, status, reason string) { if a == nil || a.session == nil { return } @@ -96,14 +75,19 @@ func (a *Agent) recordContextMaintenanceBlocked(inputHash, trigger, action, reas trigger = CompactionTriggerPressure } if action == "" { - action = "noop" + action = "summary" + } + if status != "failed" { + status = "blocked" } _, transcriptVersion := a.session.snapshotMessagesVersion() promptCacheKey := a.currentPromptCacheKey() a.compactionMu.Lock() state := a.compactionState previous := state - if state.BlockedInputHash == inputHash && state.LastReceipt != nil && state.LastReceipt.Status == "blocked" { + if state.LastReceipt != nil && + (state.LastReceipt.Status == "blocked" || state.LastReceipt.Status == "failed") && + state.LastReceipt.Action == action { a.compactionMu.Unlock() return } @@ -111,11 +95,18 @@ func (a *Agent) recordContextMaintenanceBlocked(inputHash, trigger, action, reas state.SchemaVersion = compactionStateSchemaCurrent state.TranscriptVersion = transcriptVersion state.PromptCacheKey = promptCacheKey + // Do not advance projection version on failure; generation still advances so + // CAS losers and concurrent writers cannot overwrite a newer success. state.Generation++ - state.BlockedInputHash = inputHash - state.BlockedReason = reason + // LastReceipt carries the blocked signal; clear legacy top-level mirrors. + state.BlockedInputHash = "" + state.BlockedReason = "" + state.LastTrigger = "" + state.LastMode = "" + state.LastSourceTokens = 0 + state.LastResultTokens = 0 state.LastReceipt = &ContextMaintenanceReceipt{ - OperationID: fmt.Sprintf("blocked-%s", inputHash), Status: "blocked", Action: action, + OperationID: fmt.Sprintf("%s-%s-%d", status, action, state.Generation), Status: status, Action: action, Trigger: trigger, SourceProjection: state.Projection.ProjectionVersion, ProjectionVersion: state.Projection.ProjectionVersion, InputHash: inputHash, BlockedInputHash: inputHash, Reason: reason, CreatedAt: now, diff --git a/internal/agent/context_report.go b/internal/agent/context_report.go index 5f6c72f84d..9a909302f8 100644 --- a/internal/agent/context_report.go +++ b/internal/agent/context_report.go @@ -54,9 +54,7 @@ func (a *Agent) ContextReport() ContextReport { rep.Projected = rep.ProjectionTokens != rep.CanonicalTokens if a.contextWindow > 0 { - soft, snip, fold := a.compactThresholds() - rep.SoftThreshold, rep.SnipThreshold, rep.FoldThreshold = soft, snip, fold - rep.ForceThreshold = a.forceCompactThreshold(fold) + rep.FoldThreshold = a.compactTrigger() if _, reason := a.contextMaintenanceBlocked(a.contextMaintenanceInputHash(visible)); reason != "" { rep.BlockedReason = reason } @@ -65,10 +63,25 @@ func (a *Agent) ContextReport() ContextReport { a.compactionMu.Lock() st := a.compactionState a.compactionMu.Unlock() - rep.LastTrigger, rep.LastMode = st.LastTrigger, st.LastMode - rep.LastSource, rep.LastResult = st.LastSourceTokens, st.LastResultTokens + // Prefer LastReceipt; fall back to legacy top-level mirrors from older sidecars. + if r := st.LastReceipt; r != nil && r.Status == "applied" { + rep.LastTrigger = r.Trigger + if r.Action == "summary" { + rep.LastMode = CompactionModeSummarized + } else if r.Action != "" { + rep.LastMode = r.Action + } + rep.LastSource, rep.LastResult = r.InputTokens, r.ResultTokens + } else { + rep.LastTrigger, rep.LastMode = st.LastTrigger, st.LastMode + rep.LastSource, rep.LastResult = st.LastSourceTokens, st.LastResultTokens + } if rep.BlockedReason == "" { - rep.BlockedReason = st.BlockedReason + if r := st.LastReceipt; r != nil && (r.Status == "blocked" || r.Status == "failed") { + rep.BlockedReason = r.Reason + } else { + rep.BlockedReason = st.BlockedReason + } } return rep } diff --git a/internal/agent/context_report_test.go b/internal/agent/context_report_test.go index 2bb8bd606d..d68714c4ce 100644 --- a/internal/agent/context_report_test.go +++ b/internal/agent/context_report_test.go @@ -29,21 +29,20 @@ func TestContextReportUsesLatestPromptNotBillableAggregate(t *testing.T) { } } -// Thresholds must come from the same helper the decision uses, so the report -// cannot drift from what compaction will actually do. +// The sole trigger must come from the same helper the decision uses. func TestContextReportThresholdsMatchTheDecision(t *testing.T) { a := New(&fakeProvider{reply: "unused"}, tool.NewRegistry(), NewSession("sys"), Options{ - ContextWindow: 200_000, CompactRatio: 0.8, CompactForceRatio: 0.9, RecentKeep: 2, + ContextWindow: 200_000, CompactRatio: 0.85, RecentKeep: 2, }, event.Discard) rep := a.ContextReport() - soft, snip, fold := a.compactThresholds() - if rep.SoftThreshold != soft || rep.SnipThreshold != snip || rep.FoldThreshold != fold { - t.Errorf("report thresholds (%d/%d/%d) differ from compactThresholds (%d/%d/%d)", - rep.SoftThreshold, rep.SnipThreshold, rep.FoldThreshold, soft, snip, fold) + fold := a.compactTrigger() + if rep.FoldThreshold != fold { + t.Errorf("report FoldThreshold %d differs from compactTrigger %d", rep.FoldThreshold, fold) } - if want := a.forceCompactThreshold(fold); rep.ForceThreshold != want { - t.Errorf("ForceThreshold = %d, want %d", rep.ForceThreshold, want) + if rep.SoftThreshold != 0 || rep.SnipThreshold != 0 || rep.ForceThreshold != 0 { + t.Errorf("legacy multi-threshold fields should stay zero: soft=%d snip=%d force=%d", + rep.SoftThreshold, rep.SnipThreshold, rep.ForceThreshold) } } diff --git a/internal/agent/context_status.go b/internal/agent/context_status.go index 24b5c1f2c5..c01c91d1a3 100644 --- a/internal/agent/context_status.go +++ b/internal/agent/context_status.go @@ -12,6 +12,8 @@ type ContextMaintenanceSnapshot struct { SnipTrigger int FoldTrigger int ForceTrigger int + TriggerTokens int + CheckpointState string HardInputCeiling int Headroom int ProjectionVersion uint64 @@ -26,20 +28,28 @@ func (a *Agent) ContextMaintenanceSnapshot() ContextMaintenanceSnapshot { canonical, version := a.session.snapshotMessagesVersion() a.compactionMu.Lock() state := a.compactionState + checkpointState := a.checkpointState a.compactionMu.Unlock() visible := canonical - if projectionValid(state, canonical, version, a.currentPromptCacheKey()) { + valid := projectionValid(state, canonical, version, a.currentPromptCacheKey()) + if valid { if projected := modelVisibleFromProjection(state.Projection, canonical); len(projected) > 0 { visible = projected } } - _, snip, fold := a.compactThresholds() + trigger := a.compactTrigger() + // UI checkpoint label requires a still-valid covered prefix, not merely + // that the sidecar loaded. + uiCheckpoint := "none" + if valid && len(state.Projection.Messages) > 0 { + uiCheckpoint = stateCheckpointState(checkpointState, state) + } snapshot := ContextMaintenanceSnapshot{ - CanonicalTokens: a.estimatedPromptTokens(provider.ModelMessages(canonical)), - ProjectedTokens: a.estimatedPromptTokens(provider.ModelMessages(visible)), - SnipTrigger: snip, - FoldTrigger: fold, - ForceTrigger: a.forceCompactThreshold(fold), + CanonicalTokens: a.estimatedVisibleRequestTokens(canonical), + ProjectedTokens: a.estimatedVisibleRequestTokens(visible), + FoldTrigger: trigger, + TriggerTokens: trigger, + CheckpointState: uiCheckpoint, HardInputCeiling: a.hardInputCeiling(), ProjectionVersion: state.Projection.ProjectionVersion, } @@ -50,13 +60,30 @@ func (a *Agent) ContextMaintenanceSnapshot() ContextMaintenanceSnapshot { } snapshot.Headroom = max(0, snapshot.HardInputCeiling-snapshot.ProjectedTokens) currentHash := a.contextMaintenanceInputHash(visible) - snapshot.Blocked = state.BlockedInputHash != "" && state.BlockedInputHash == currentHash if state.LastReceipt != nil { receipt := *state.LastReceipt snapshot.LastReceipt = &receipt - if receipt.Status == "applied" && (receipt.Action == "snip" || receipt.Action == "prune" || receipt.Action == "native_tool_clear") { + if receipt.Status == "applied" && (receipt.Action == "prune" || receipt.Action == "summary") { snapshot.LastSavedTokens = receipt.SavedTokens } + // Generation-scoped blocked/failed receipts match contextMaintenanceBlocked. + if receipt.Status == "blocked" || receipt.Status == "failed" { + snapshot.Blocked = true + } + } + // Legacy sidecars may only have top-level BlockedInputHash. + if !snapshot.Blocked && state.BlockedInputHash != "" && state.BlockedInputHash == currentHash { + snapshot.Blocked = true } return snapshot } + +func stateCheckpointState(runtimeState string, state CompactionState) string { + if len(state.Projection.Messages) == 0 { + return "none" + } + if runtimeState == "applied" { + return "applied" + } + return "restored" +} diff --git a/internal/agent/execute_batch.go b/internal/agent/execute_batch.go index 828286a03d..0ab1e8d538 100644 --- a/internal/agent/execute_batch.go +++ b/internal/agent/execute_batch.go @@ -16,6 +16,7 @@ import ( // batchExecution is the result of one provider tool-call batch. type batchExecution struct { results []string + outcomes []toolOutcome images [][]string executions []*tool.ShellExecution recoveryStopTurn bool @@ -349,6 +350,7 @@ func (a *Agent) executeBatch(ctx context.Context, calls []provider.ToolCall) bat } return batchExecution{ results: results, + outcomes: outcomes, images: images, executions: executions, recoveryStopTurn: recoveryBatchStop, diff --git a/internal/agent/execute_one.go b/internal/agent/execute_one.go index 4cf8ded478..8fd5969b72 100644 --- a/internal/agent/execute_one.go +++ b/internal/agent/execute_one.go @@ -343,8 +343,12 @@ func (a *Agent) applyPlanModeAndProxy(ctx context.Context, plan *toolCallPlan) ( if rc.Unavailable { return toolOutcome{output: result, errMsg: firstLine(rc.UnavailableReason)}, true } - body, truncMsg := truncateToolOutput(result) - return toolOutcome{output: body, truncated: truncMsg != "", truncMsg: truncMsg}, true + body, truncMsg := truncateToolOutputFor(result, plan.call.Name, plan.call.ID) + out := toolOutcome{output: body, truncated: truncMsg != "", truncMsg: truncMsg} + if truncMsg != "" { + out.rawOutput = result + } + return out, true } } else if outcome, blocked := a.readOnlyExecutionBlock(t, nil); blocked { return outcome, true @@ -817,11 +821,16 @@ func (a *Agent) finishToolExecution(ctx context.Context, plan *toolCallPlan) too detail = strings.TrimRight(detail, "\n") + "\nThe arguments were not valid JSON. Re-emit them exactly per this schema:\n" + string(t.Schema()) } a.recordRepeatFailure(call, t, err) - body, truncMsg := truncateToolOutput(fmt.Sprintf("error: %v\n%s", err, detail)) - return toolOutcome{ + rawErr := fmt.Sprintf("error: %v\n%s", err, detail) + body, truncMsg := truncateToolOutputFor(rawErr, call.Name, call.ID) + out := toolOutcome{ output: body, errMsg: firstLine(err.Error()), truncated: truncMsg != "", truncMsg: truncMsg, execution: execution, recoveryGeneration: recoveryGen, } + if truncMsg != "" { + out.rawOutput = rawErr + } + return out } if mutates { a.clearRepeatFailuresAfterMutation(evidenceName, evidenceArgs, readOnly) @@ -833,11 +842,15 @@ func (a *Agent) finishToolExecution(ctx context.Context, plan *toolCallPlan) too if a.hooks != nil && call.Name == "task" && !isBackgroundTaskCall(call.Arguments) { a.hooks.SubagentStop(ctx, result) } - body, truncMsg := truncateToolOutput(result) - return toolOutcome{ + body, truncMsg := truncateToolOutputFor(result, call.Name, call.ID) + out := toolOutcome{ output: body, images: images, truncated: truncMsg != "", truncMsg: truncMsg, execution: execution, recoveryGeneration: recoveryGen, } + if truncMsg != "" { + out.rawOutput = result + } + return out } // observeBeforeMutation captures preimages for Previewable writers and records diff --git a/internal/agent/extensions_test.go b/internal/agent/extensions_test.go index 49db04f9f9..8cc6cb2a4e 100644 --- a/internal/agent/extensions_test.go +++ b/internal/agent/extensions_test.go @@ -1107,21 +1107,24 @@ func TestToolAfterFailurePolicy(t *testing.T) { // newCompactionAgent builds an agent whose session has a foldable middle // (large assistant turns) so CompactNow always finds a region, with the -// summarizer scripted to answer "SUMMARY TEXT". +// summarizer scripted to answer "SUMMARY TEXT". The recent tail stays small so +// the content-driven candidate lands well under compact_ratio and the 50% ceiling. func newCompactionAgent(t *testing.T, d *dispatch.Dispatcher) (*mockProvider, *Agent) { t.Helper() mp := &mockProvider{name: "p", chunks: []provider.Chunk{ {Type: provider.ChunkText, Text: "SUMMARY TEXT"}, {Type: provider.ChunkDone}, }} sess := NewSession("sys") - big := strings.Repeat("a", 4000) + big := strings.Repeat("a", 8000) sess.Add(provider.Message{Role: provider.RoleUser, Content: "task"}) sess.Add(provider.Message{Role: provider.RoleAssistant, Content: big}) sess.Add(provider.Message{Role: provider.RoleUser, Content: "more"}) sess.Add(provider.Message{Role: provider.RoleAssistant, Content: big}) - sess.Add(provider.Message{Role: provider.RoleUser, Content: "again"}) - sess.Add(provider.Message{Role: provider.RoleAssistant, Content: big}) - return mp, New(mp, tool.NewRegistry(), sess, Options{ContextWindow: 1000, Extensions: d}, event.Discard) + sess.Add(provider.Message{Role: provider.RoleUser, Content: "next"}) + sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"}) + return mp, New(mp, tool.NewRegistry(), sess, Options{ + ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 2, Extensions: d, + }, event.Discard) } func TestCompactionPrepareReplaceGuidance(t *testing.T) { diff --git a/internal/agent/lineage_key_test.go b/internal/agent/lineage_key_test.go new file mode 100644 index 0000000000..b2aa928137 --- /dev/null +++ b/internal/agent/lineage_key_test.go @@ -0,0 +1,43 @@ +package agent + +import ( + "testing" + + "reasonix/internal/provider" +) + +func TestLineageKeyCompatibleNativeSuffix(t *testing.T) { + current := "ws|sess|model" + stored := current + "|context-editing-native-anthropic" + got, ok := lineageKeyCompatible(stored, current) + if !ok || got != current { + t.Fatalf("lineageKeyCompatible(%q,%q)=(%q,%v), want (%q,true)", stored, current, got, ok, current) + } + if _, ok := lineageKeyCompatible("other|sess|model", current); ok { + t.Fatal("foreign lineage must not match") + } + if got, ok := lineageKeyCompatible(current, current); !ok || got != current { + t.Fatalf("exact key: got (%q,%v)", got, ok) + } +} + +func TestProjectionValidAcceptsLegacyNativeKey(t *testing.T) { + msgs := []provider.Message{ + {Role: provider.RoleSystem, Content: "sys"}, + {Role: provider.RoleUser, Content: "u"}, + } + hash := coveredPrefixHash(msgs, len(msgs)) + st := CompactionState{ + PromptCacheKey: "ws|s|m|context-editing-native-x", + Projection: ContextProjection{ + Messages: msgs, + CoveredCount: len(msgs), + CoveredPrefixHash: hash, + ProjectionVersion: 1, + }, + TranscriptVersion: 1, + } + if !projectionValid(st, msgs, 1, "ws|s|m") { + t.Fatal("projectionValid rejected legacy native lineage key") + } +} diff --git a/internal/agent/output_budget.go b/internal/agent/output_budget.go index 149e12ad89..59819fd815 100644 --- a/internal/agent/output_budget.go +++ b/internal/agent/output_budget.go @@ -233,11 +233,8 @@ func isCJKRune(r rune) bool { (r >= 0xAC00 && r <= 0xD7AF) } -// effectiveOutputBudget returns an explicit smaller output budget only when a -// shared-window request would otherwise exceed the configured context window. -// The final extension-adjusted request is measured, so later payload rewrites -// cannot invalidate the decision. An exhausted window fails locally instead of -// sending a request the provider will reject with HTTP 400. +// effectiveOutputBudget clips completion tokens at send time only; it never +// moves compact_ratio. Exhausted windows fail locally before HTTP 400. func (a *Agent) effectiveOutputBudget(req provider.Request) (int, bool, error) { if a == nil || a.contextWindow <= 0 || !sharesContextWindow(a.prov) { return 0, false, nil diff --git a/internal/agent/output_budget_test.go b/internal/agent/output_budget_test.go index 8b6cf730bf..735642a0dc 100644 --- a/internal/agent/output_budget_test.go +++ b/internal/agent/output_budget_test.go @@ -90,9 +90,16 @@ func TestSharedWindowFoldUsesGuardedInputBudget(t *testing.T) { outputBudgetState: outputBudgetState{outputBudget: prov.budget}, sink: event.Discard, } - // 200K han runes is ~150K cold-start tokens against a 100K window: the fold - // genuinely cannot ride one call, which is what the guard exists to catch. - fold := []provider.Message{{Role: provider.RoleUser, Content: strings.Repeat("字", 200_000)}} + // Oversized tool bodies are deterministically shortened for the single + // summarizer request (no multi-span). The guard must still fit one call. + toolBody := strings.Repeat("file line content here. ", 20_000) // ~480K chars + fold := []provider.Message{ + {Role: provider.RoleUser, Content: "read large files"}, + {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "read_file", Arguments: "{}"}}}, + {Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: toolBody}, + {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "2", Name: "read_file", Arguments: "{}"}}}, + {Role: provider.RoleTool, ToolCallID: "2", Name: "read_file", Content: toolBody}, + } if _, err := a.foldToSummary(context.Background(), fold, ""); err != nil { t.Fatalf("foldToSummary: %v", err) @@ -100,14 +107,39 @@ func TestSharedWindowFoldUsesGuardedInputBudget(t *testing.T) { if prov.calls == 0 || len(prov.last.Messages) < 2 { t.Fatalf("guarded fold produced no summarizer request: calls=%d request=%+v", prov.calls, prov.last) } - if got := prov.last.Messages[1].Content; len(got) >= len(renderTranscript(fold)) || !strings.Contains(got, "omitted") { - t.Fatalf("cold CJK fold was not bounded before summarize: len=%d original=%d", len(got), len(renderTranscript(fold))) + got := prov.last.Messages[1].Content + if len(got) >= len(renderTranscript(fold)) { + t.Fatalf("oversized tool fold was not shortened before summarize: len=%d original=%d", len(got), len(renderTranscript(fold))) + } + // Snip / omit markers prove the temporary request was bounded for the guard. + if !strings.Contains(got, "omitted") && !strings.Contains(got, "retained") && !strings.Contains(got, "snip") { + t.Fatalf("shortened fold missing a truncation marker:\n%.200q", got) } if got := prov.last.MaxTokens; got < summaryOutputReserve { t.Fatalf("summarizer MaxTokens = %d, below summaryOutputReserve %d", got, summaryOutputReserve) } } +// A single unshortenable fold that still exceeds the single-request budget after +// all deterministic shorteners must fail once (no multi-span split). +func TestSharedWindowFoldRejectsUnshortenableOverBudgetInput(t *testing.T) { + prov := &sharedWindowTestProvider{budget: 128 * 1024, shared: true} + a := &Agent{ + prov: prov, + contextWindow: 100_000, + outputBudgetState: outputBudgetState{outputBudget: prov.budget}, + sink: event.Discard, + } + fold := []provider.Message{{Role: provider.RoleUser, Content: strings.Repeat("字", 200_000)}} + _, err := a.foldToSummary(context.Background(), fold, "") + if err == nil || !strings.Contains(err.Error(), "exceeds single-request budget") { + t.Fatalf("foldToSummary err = %v, want single-request budget failure", err) + } + if prov.calls != 0 { + t.Fatalf("over-budget unshortenable fold still called summarizer %d times", prov.calls) + } +} + func (p *sharedWindowTestProvider) OutputBudget() int { return p.budget } func (p *sharedWindowTestProvider) SharesContextWindow() bool { return p.shared } @@ -285,8 +317,7 @@ func TestPrepareSamplingRequestClipsSharedWindowOutput(t *testing.T) { tools: tool.NewRegistry(), session: sess, contextWindow: 1_048_576, - compactRatio: 2, - compactForceRatio: 2, + compactRatio: 2, // disable auto maintenance for this output-clip test outputBudgetState: outputBudgetState{outputBudget: prov.budget}, } a.lastUsage.Store(&provider.Usage{PromptTokens: 950_000}) @@ -369,11 +400,11 @@ func TestSummarizeRejectsLengthTruncation(t *testing.T) { sink: event.Discard, } - _, _, err := a.summarizeWithRetry(context.Background(), []provider.Message{{ + _, _, err := a.summarizeOnce(context.Background(), []provider.Message{{ Role: provider.RoleUser, Content: "retain every durable fact", }}, "") if err == nil || !strings.Contains(err.Error(), "truncated") { - t.Fatalf("summarizeWithRetry error = %v, want truncation failure", err) + t.Fatalf("summarizeOnce error = %v, want truncation failure", err) } if prov.calls != 1 { t.Fatalf("length-truncated summary calls = %d, want no identical retry", prov.calls) diff --git a/internal/agent/positional_compression_test.go b/internal/agent/positional_compression_test.go index 9f747e8669..42f88d4250 100644 --- a/internal/agent/positional_compression_test.go +++ b/internal/agent/positional_compression_test.go @@ -2,8 +2,6 @@ package agent import ( "context" - "os" - "path/filepath" "reflect" "strings" "testing" @@ -14,20 +12,24 @@ import ( ) func TestSummarizeFromPreservesLocalOnlyOutsideModelAndArchive(t *testing.T) { - archiveDir := t.TempDir() local := provider.Message{ Role: provider.RoleTool, ToolCallID: provider.LocalOnlyToolID, Name: provider.LocalOnlyToolName, LocalOnly: true, Content: "visible interrupted output", ReasoningContent: "private interrupted reasoning", InterruptedTurn: &provider.InterruptedTurnRecovery{Pending: true, InterruptedTools: []string{"bash"}}, } prov := &fakeProvider{reply: "later summary"} + // Enough foldable assistant work so the projection candidate reduces tokens. sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "task"}, local, - {Role: provider.RoleAssistant, Content: "safe answer " + strings.Repeat("work ", 200)}, + {Role: provider.RoleAssistant, Content: "safe answer " + strings.Repeat("work ", 400)}, + {Role: provider.RoleUser, Content: "more"}, + {Role: provider.RoleAssistant, Content: strings.Repeat("more work ", 400)}, }} - a := New(prov, tool.NewRegistry(), sess, Options{ArchiveDir: archiveDir}, event.Discard) + a := New(prov, tool.NewRegistry(), sess, Options{ + ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 2, + }, event.Discard) before := sess.Snapshot() if err := a.SummarizeFrom(context.Background(), 1); err != nil { @@ -39,11 +41,10 @@ func TestSummarizeFromPreservesLocalOnlyOutsideModelAndArchive(t *testing.T) { if len(a.compactionState.Projection.Messages) == 0 { t.Fatal("expected summarize-from projection") } - assertLocalOnlyAbsentFromSummaryAndArchive(t, prov, archiveDir, local) + assertLocalOnlyAbsentFromSummary(t, prov, a, local) } func TestSummarizeUpToPreservesLocalOnlyOutsideModelAndArchive(t *testing.T) { - archiveDir := t.TempDir() local := provider.Message{ Role: provider.RoleTool, ToolCallID: provider.LocalOnlyToolID, Name: provider.LocalOnlyToolName, LocalOnly: true, Content: "visible earlier interruption", ReasoningContent: "private earlier reasoning", @@ -54,11 +55,13 @@ func TestSummarizeUpToPreservesLocalOnlyOutsideModelAndArchive(t *testing.T) { {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "old task"}, local, - {Role: provider.RoleAssistant, Content: "old answer " + strings.Repeat("work ", 200)}, + {Role: provider.RoleAssistant, Content: "old answer " + strings.Repeat("work ", 400)}, {Role: provider.RoleUser, Content: "new task"}, {Role: provider.RoleAssistant, Content: "new answer"}, }} - a := New(prov, tool.NewRegistry(), sess, Options{ArchiveDir: archiveDir}, event.Discard) + a := New(prov, tool.NewRegistry(), sess, Options{ + ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 2, + }, event.Discard) before := sess.Snapshot() if err := a.SummarizeUpTo(context.Background(), 4); err != nil { @@ -70,7 +73,7 @@ func TestSummarizeUpToPreservesLocalOnlyOutsideModelAndArchive(t *testing.T) { if len(a.compactionState.Projection.Messages) == 0 { t.Fatal("expected summarize-up-to projection") } - assertLocalOnlyAbsentFromSummaryAndArchive(t, prov, archiveDir, local) + assertLocalOnlyAbsentFromSummary(t, prov, a, local) } func TestSummarizeAtProjectionBoundaryReportsFoldedAnchor(t *testing.T) { @@ -117,23 +120,19 @@ func TestSummarizeAtProjectionBoundaryReportsNoSavings(t *testing.T) { } } -func assertLocalOnlyAbsentFromSummaryAndArchive(t *testing.T, prov *fakeProvider, archiveDir string, local provider.Message) { +// assertLocalOnlyAbsentFromSummary checks local-only content stays out of the +// summarizer request and the installed projection. Archives are no longer written. +func assertLocalOnlyAbsentFromSummary(t *testing.T, prov *fakeProvider, a *Agent, local provider.Message) { t.Helper() if len(prov.got) < 2 || strings.Contains(prov.got[1].Content, local.Content) || strings.Contains(prov.got[1].Content, local.ReasoningContent) { t.Fatalf("local-only output leaked into summarizer prompt: %+v", prov.got) } - entries, err := os.ReadDir(archiveDir) - if err != nil { - t.Fatalf("ReadDir archive: %v", err) + for _, m := range a.compactionState.Projection.Messages { + if m.LocalOnly || strings.Contains(m.Content, local.Content) || strings.Contains(m.Content, local.ReasoningContent) { + t.Fatalf("local-only output entered projection: %+v", m) + } } - if len(entries) != 1 { - t.Fatalf("archive entries = %d, want 1", len(entries)) - } - b, err := os.ReadFile(filepath.Join(archiveDir, entries[0].Name())) - if err != nil { - t.Fatalf("ReadFile archive: %v", err) - } - if strings.Contains(string(b), local.Content) || strings.Contains(string(b), local.ReasoningContent) { - t.Fatalf("local-only output leaked into archive: %s", b) + if a.compactionState.LastReceipt != nil && a.compactionState.LastReceipt.Archive != "" { + t.Fatalf("checkpoint must not create archives, got %q", a.compactionState.LastReceipt.Archive) } } diff --git a/internal/agent/preflight.go b/internal/agent/preflight.go index f7aeef07ce..28df5867ee 100644 --- a/internal/agent/preflight.go +++ b/internal/agent/preflight.go @@ -52,8 +52,7 @@ func (a *Agent) currentPromptCacheKey() string { } func (a *Agent) currentPromptCacheKeyLocked() string { - key := promptCacheKey(a.workspaceID, BranchID(a.sessionPath), a.modelRef) - return key + a.contextEditingLineageSuffixLocked() + return promptCacheKey(a.workspaceID, BranchID(a.sessionPath), a.modelRef) } // InvalidateProjection drops the in-memory and on-disk projection after @@ -84,15 +83,11 @@ func (a *Agent) LoadProjectionSidecar(sessionPath string) { if a == nil { return } - _, effective, policyVersion := resolveContextEditing(a.requestedContextEditing, a.prov) a.compactionMu.Lock() a.sessionPath = sessionPath - a.contextEditing = effective - a.contextEditingPolicyVersion = policyVersion a.compactionState = CompactionState{} + a.checkpointState = "none" a.compactionMu.Unlock() - a.nativeContextEditingAccepted.Store(false) - a.contextEditingRuntimeFallback.Store(false) if sessionPath == "" { a.resetCompactionState() return @@ -109,31 +104,80 @@ func (a *Agent) LoadProjectionSidecar(sessionPath string) { return } a.compactionMu.Lock() - if st.ContextEditingFallbackLocal && !st.NativeContextEditingAccepted && a.contextEditing == "native" { - a.contextEditing = "local" - a.contextEditingRuntimeFallback.Store(true) - } - // Fail closed: known lineage requires an exact stored key (including - // rejecting blank keys on early sidecars written before this field). key := a.currentPromptCacheKeyLocked() - if (key != "" && st.PromptCacheKey != key) || - (st.Projection.CoveredPrefixHash == "" && st.BlockedInputHash == "" && - !st.ContextEditingFallbackLocal && !st.NativeContextEditingAccepted) { - a.contextEditing = effective - a.contextEditingPolicyVersion = policyVersion + normalized, keyOK := lineageKeyCompatible(st.PromptCacheKey, key) + // Keep receipt-only blocked/failed sidecars (no projection body) and legacy + // top-level BlockedInputHash so generation-scoped suppressions survive restart. + hasMaintenanceSignal := st.Projection.CoveredPrefixHash != "" || + st.BlockedInputHash != "" || + (st.LastReceipt != nil && (st.LastReceipt.Status == "blocked" || st.LastReceipt.Status == "failed" || + st.LastReceipt.Status == "applied")) + if (key != "" && !keyOK) || !hasMaintenanceSignal { a.compactionState = CompactionState{} - a.contextEditingRuntimeFallback.Store(false) + a.checkpointState = "none" a.compactionMu.Unlock() return } + // Only rewrite legacy native-editing lineage keys; exact matches stay pure-read. + needsNormalization := false + if keyOK && key != "" && normalized != st.PromptCacheKey { + st.PromptCacheKey = normalized + needsNormalization = true + } + // Only mark restored when the projection still matches the transcript. + var msgs []provider.Message + var version uint64 + if a.session != nil { + msgs, version = a.session.snapshotMessagesVersion() + } + valid := len(st.Projection.Messages) > 0 && projectionValid(st, msgs, version, key) + if !valid && len(st.Projection.Messages) > 0 { + // Keep blocked receipts / telemetry; drop unusable projection body. + st.Projection = ContextProjection{} + } a.compactionState = st + if valid { + a.checkpointState = "restored" + if needsNormalization { + if err := a.persistCompactionStateLocked(); err != nil { + slog.Warn("agent: persist normalized projection lineage", "err", err) + } + } + } else { + a.checkpointState = "none" + } a.compactionMu.Unlock() - a.nativeContextEditingAccepted.Store(st.NativeContextEditingAccepted) +} + +// lineageKeyCompatible reports whether a stored PromptCacheKey still belongs to +// the current session/model lineage. Legacy native context-editing keys used a +// "|context-editing-native-..." suffix on an otherwise matching base key. +func lineageKeyCompatible(stored, current string) (normalized string, ok bool) { + stored, current = strings.TrimSpace(stored), strings.TrimSpace(current) + if current == "" { + // Unknown current lineage: accept any stored key as-is. + return stored, true + } + if stored == "" { + return "", false + } + if stored == current { + return current, true + } + const nativeSuffix = "|context-editing-native" + if strings.HasPrefix(stored, current+nativeSuffix) { + return current, true + } + if i := strings.Index(stored, nativeSuffix); i > 0 && stored[:i] == current { + return current, true + } + return "", false } func (a *Agent) resetCompactionState() { a.compactionMu.Lock() a.compactionState = CompactionState{} + a.checkpointState = "none" a.compactionMu.Unlock() } @@ -148,16 +192,12 @@ func (a *Agent) BindSessionPath(path string, loadSidecar bool) { a.LoadProjectionSidecar(path) return } - _, effective, policyVersion := resolveContextEditing(a.requestedContextEditing, a.prov) a.compactionMu.Lock() a.sessionPath = path - a.contextEditing = effective - a.contextEditingPolicyVersion = policyVersion a.compactionState = CompactionState{} + a.checkpointState = "none" a.cacheState = CacheStateUnknown a.compactionMu.Unlock() - a.nativeContextEditingAccepted.Store(false) - a.contextEditingRuntimeFallback.Store(false) a.compactStuck = false a.consecutiveCompacts = 0 a.lastCompactionTurn.Store(0) @@ -223,85 +263,6 @@ func (a *Agent) persistCompactionStateLocked() error { return SaveCompactionState(a.sessionPath, a.compactionState) } -// maintenanceReplacement is the rewrite for one stale tool result. Both the -// planning pass and the re-write that follows archiving go through it, so a -// result cannot be shortened one way while planning and another way on install. -// ok is false when the policy protects the message outright. -func (a *Agent) maintenanceReplacement(m provider.Message, mode toolResultMaintenanceMode, archive string) (string, bool) { - if a.keepPolicy&KeepErrors != 0 && isErrorMessage(m) { - // A recorded failure stays recognisable once its text is rewritten, so - // its passing noise can go. A text-only failure has no such anchor: - // eliding it would hide that it was ever an error. - if !failedExecution(m.ToolExecution) { - return "", false - } - return snipFailureResult(m.Content), true - } - return rewriteToolResult(m, mode, archive, a.snipStrategyFor(m.Name)), true -} - -// applyToolResultMaintenanceView returns a copy of msgs with stale tool results -// snipped or pruned. The canonical transcript is never modified. -func (a *Agent) applyToolResultMaintenanceView(msgs []provider.Message, mode toolResultMaintenanceMode) ([]provider.Message, PruneStats) { - st := PruneStats{Mode: mode} - if a.contextWindow <= 0 || len(msgs) == 0 { - return msgs, st - } - st.InputHash = providerVisibleFingerprint(provider.ModelMessages(msgs)) - head, start, ok := a.planCompaction(msgs, 1) - if !ok { - if mode != toolResultPrune { - return msgs, st - } - head = 1 - start = len(msgs) - a.recentKeep - if start < head { - return msgs, st - } - } - next := append([]provider.Message(nil), msgs...) - changed := false - for i := head; i < start; i++ { - m := next[i] - if !shouldMaintainToolResult(m, mode) { - continue - } - replacement, ok := a.maintenanceReplacement(m, mode, "projection-view") - if !ok { - continue - } - if replacement == m.Content { - continue - } - st.SavedChars += len(m.Content) - len(replacement) - m.Content = replacement - next[i] = m - st.Results++ - changed = true - } - if !changed { - return msgs, st - } - return next, st -} - -// installProjectionIfCurrent closes the compare-and-install window for -// maintenance callers that performed network work from an earlier snapshot. -func (a *Agent) installProjectionIfCurrent(st CompactionState, projectionVersion, generation uint64) error { - a.compactionMu.Lock() - defer a.compactionMu.Unlock() - if a.compactionState.Projection.ProjectionVersion != projectionVersion || a.compactionState.Generation != generation { - return errCompressStaleContext - } - prev := a.compactionState - a.compactionState = st - if err := a.persistCompactionStateLocked(); err != nil { - a.compactionState = prev - return err - } - return nil -} - // promptCacheKey builds a stable lineage key for session + model identity. // It deliberately excludes message counts, timestamps, and projection hashes. func promptCacheKey(workspaceID, sessionLineage, modelRef string) string { diff --git a/internal/agent/projection.go b/internal/agent/projection.go index 63c69fce71..c5c8e5c701 100644 --- a/internal/agent/projection.go +++ b/internal/agent/projection.go @@ -6,11 +6,10 @@ import ( "encoding/json" "fmt" "os" - "path/filepath" - "slices" "strings" "time" + "reasonix/internal/fileutil" "reasonix/internal/provider" "reasonix/internal/store" ) @@ -185,55 +184,40 @@ func LoadCompactionState(sessionPath string) (CompactionState, bool, error) { return st, true, nil } -// SaveCompactionState writes the sidecar via temp file + atomic rename. +// SaveCompactionState writes the sidecar via strict atomic publish (temp + +// file fsync + rename + best-effort parent-dir fsync). Checkpoint sidecars are +// commit pointers: EXDEV/copy fallbacks that can tear an existing file are +// rejected so a failed write leaves the previous checkpoint intact. A returned +// error means the on-disk pointer was not published. func SaveCompactionState(sessionPath string, st CompactionState) error { path := ContextStatePath(sessionPath) if path == "" { return fmt.Errorf("empty session path") } - // V3 preserves logical user-turn boundaries and coalesces roles only on - // outbound copies. Previous readers reject this boundary and fall back to - // canonical history instead of misreading the changed V1 invariant. + // V3 keeps logical user-turn boundaries; previous readers fall back to + // canonical history rather than misreading the V1 coalesced invariant. st.SchemaVersion = compactionStateSchemaCurrent if st.UpdatedAt.IsZero() { st.UpdatedAt = time.Now().UTC() } + // LastReceipt is authoritative. Drop mirrored top-level last_*/blocked_* + // writer fields so new sidecars do not re-emit the pre-v3 dual schema. + // Old files with those keys still decode into the struct for readers. + st.LastTrigger = "" + st.LastMode = "" + st.LastSourceTokens = 0 + st.LastResultTokens = 0 + st.LastCompactionCost = 0 + if st.LastReceipt != nil { + st.BlockedInputHash = "" + st.BlockedReason = "" + } b, err := json.MarshalIndent(st, "", " ") if err != nil { return err } b = append(b, '\n') - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp.*") - if err != nil { - return err - } - tmpName := tmp.Name() - cleanup := true - defer func() { - if cleanup { - _ = os.Remove(tmpName) - } - }() - if _, err := tmp.Write(b); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Sync(); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - if err := os.Rename(tmpName, path); err != nil { - return err - } - cleanup = false - return nil + return fileutil.AtomicWriteFileStrict(path, b, 0o644) } // RemoveCompactionState deletes a corrupt or invalidated projection sidecar. @@ -335,9 +319,9 @@ func projectionValid(st CompactionState, msgs []provider.Message, transcriptVers if n <= 0 || n > len(msgs) { return false } - // Current lineage known: stored key must be present and equal. + // Current lineage known: stored key must match (legacy native suffix ok). if cacheKey != "" { - if st.PromptCacheKey == "" || st.PromptCacheKey != cacheKey { + if _, ok := lineageKeyCompatible(st.PromptCacheKey, cacheKey); !ok { return false } } @@ -410,18 +394,3 @@ func formatSummaryMessage(summary string) provider.Message { summaryTagClose, } } - -// extractLatestSummary returns the body of the newest compaction summary in msgs. -func extractLatestSummary(msgs []provider.Message) string { - for i := range slices.Backward(msgs) { - if !isCompactionSummary(msgs[i]) { - continue - } - body := msgs[i].Content - body = strings.TrimPrefix(strings.TrimLeft(body, "\n "), summaryTagOpen) - body = strings.TrimSuffix(strings.TrimRight(body, "\n "), summaryTagClose) - body = strings.TrimPrefix(body, "\nSummary of earlier conversation (older messages were compacted to save context):\n") - return strings.TrimSpace(body) - } - return "" -} diff --git a/internal/agent/projection_test.go b/internal/agent/projection_test.go index 7fb59ec12f..0efa052955 100644 --- a/internal/agent/projection_test.go +++ b/internal/agent/projection_test.go @@ -35,8 +35,6 @@ func TestCompactionStateAtomicSaveLoad(t *testing.T) { }, PromptCacheKey: "ws|sess|model", LastCacheState: CacheStateCold, - LastTrigger: CompactionTriggerPressure, - LastMode: CompactionModeSummarized, Generation: 7, LastReceipt: &ContextMaintenanceReceipt{ Status: "applied", Action: "summary", ProjectionVersion: 1, @@ -50,7 +48,7 @@ func TestCompactionStateAtomicSaveLoad(t *testing.T) { if err != nil || !ok { t.Fatalf("load: ok=%v err=%v", ok, err) } - if got.SchemaVersion != compactionStateSchemaCurrent || got.TranscriptVersion != 3 || got.LastMode != CompactionModeSummarized { + if got.SchemaVersion != compactionStateSchemaCurrent || got.TranscriptVersion != 3 { t.Fatalf("loaded state = %+v", got) } if len(got.Projection.Messages) != 2 || got.Projection.CoveredCount != 10 { @@ -146,9 +144,9 @@ func TestCompactToProjectionLeavesCanonicalIntact(t *testing.T) { dir := t.TempDir() sessionPath := filepath.Join(dir, "s.jsonl") a := New(fp, nil, sess, Options{ - ContextWindow: 2000, + ContextWindow: 50_000, + CompactRatio: 0.85, RecentKeep: 2, - ArchiveDir: dir, SessionPath: sessionPath, ModelRef: "test/model", }, event.Discard) @@ -174,13 +172,17 @@ func TestCompactToProjectionLeavesCanonicalIntact(t *testing.T) { estimateMessagesTokens(a.compactionState.Projection.Messages), estimateMessagesTokens(before)) } - // Sidecar must exist and reload. + // Sidecar must exist and reload with an applied summary receipt (v3 does not + // persist the legacy last_mode field). st, ok, err := LoadCompactionState(sessionPath) if err != nil || !ok { t.Fatalf("reload sidecar: ok=%v err=%v", ok, err) } - if st.LastMode != CompactionModeSummarized { - t.Fatalf("mode = %q, want summarized", st.LastMode) + if st.LastReceipt == nil || st.LastReceipt.Status != "applied" || st.LastReceipt.Action != "summary" { + t.Fatalf("last receipt = %+v, want applied summary", st.LastReceipt) + } + if st.Projection.ProjectionVersion == 0 { + t.Fatal("reloaded projection version is zero") } // Model-visible must use projection. visible := a.modelVisibleMessages() @@ -317,24 +319,35 @@ func TestLocalOnlyExcludedFromCompactionRequest(t *testing.T) { } } -func TestArchiveFailureLeavesNoProjection(t *testing.T) { +// Checkpoint installation no longer writes archive copies — the canonical +// transcript is the lossless store. ArchiveDir misconfiguration must not block +// a successful summary install. +func TestArchiveDirIgnoredOnCheckpointInstall(t *testing.T) { fp := &fakeProvider{reply: "digest"} sess := NewSession("sys") - for range 8 { - sess.Add(provider.Message{Role: provider.RoleUser, Content: strings.Repeat("u", 80)}) - sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("a", 120)}) + big := strings.Repeat("assistant work detail ", 200) + for range 6 { + sess.Add(provider.Message{Role: provider.RoleUser, Content: "turn"}) + sess.Add(provider.Message{Role: provider.RoleAssistant, Content: big}) } - // Point archive at a file path so MkdirAll/Create fails. + sess.Add(provider.Message{Role: provider.RoleUser, Content: "next"}) + sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"}) + // Point archive at a file path so MkdirAll/Create would fail if archives were written. badArchive := filepath.Join(t.TempDir(), "not-a-dir") if err := writeFile(badArchive, []byte("x")); err != nil { t.Fatal(err) } - a := New(fp, nil, sess, Options{ContextWindow: 2000, RecentKeep: 2, ArchiveDir: badArchive}, event.Discard) - if err := a.CompactNow(context.Background(), ""); err == nil { - t.Fatal("expected archive failure") + a := New(fp, nil, sess, Options{ + ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 2, ArchiveDir: badArchive, + }, event.Discard) + if err := a.CompactNow(context.Background(), ""); err != nil { + t.Fatalf("CompactNow with unusable ArchiveDir: %v", err) } - if len(a.compactionState.Projection.Messages) != 0 { - t.Fatal("half-finished projection after archive failure") + if len(a.compactionState.Projection.Messages) == 0 { + t.Fatal("expected projection despite unusable ArchiveDir") + } + if a.compactionState.LastReceipt != nil && a.compactionState.LastReceipt.Archive != "" { + t.Fatalf("checkpoint must not create archives, got %q", a.compactionState.LastReceipt.Archive) } } @@ -370,18 +383,20 @@ func joinContents(msgs []provider.Message) string { func TestCompactReplacesHistory(t *testing.T) { prov := &fakeProvider{reply: "- goal: do X\n- changed file Y"} - bigStep := strings.Repeat("important implementation detail ", 80) + bigStep := strings.Repeat("important implementation detail ", 200) sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, - {Role: provider.RoleUser, Content: "task " + bigStep}, + {Role: provider.RoleUser, Content: "task"}, {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "read_file", Arguments: "{}"}}}, - {Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: "file contents"}, - {Role: provider.RoleAssistant, Content: "did a step"}, + {Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: bigStep}, + {Role: provider.RoleAssistant, Content: bigStep}, {Role: provider.RoleUser, Content: "next"}, {Role: provider.RoleAssistant, Content: "ok"}, }} dir := t.TempDir() - a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: dir}, event.Discard) + a := New(prov, tool.NewRegistry(), sess, Options{ + ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 2, ArchiveDir: dir, + }, event.Discard) beforeLen := len(sess.Messages) if err := a.compact(context.Background(), "manual", "", true); err != nil { @@ -408,7 +423,7 @@ func TestCompactReplacesHistory(t *testing.T) { } var foundSummary bool for _, m := range proj { - if strings.Contains(m.Content, "Summary of earlier") && strings.Contains(m.Content, "do X") { + if strings.Contains(m.Content, "do X") { foundSummary = true } } @@ -416,19 +431,13 @@ func TestCompactReplacesHistory(t *testing.T) { t.Errorf("summary missing do X: %+v", proj) } + // No new archive files: canonical is the lossless store. entries, err := os.ReadDir(dir) - if err != nil || len(entries) != 1 { - t.Fatalf("archive dir: entries=%d err=%v", len(entries), err) - } - data, err := os.ReadFile(filepath.Join(dir, entries[0].Name())) if err != nil { - t.Fatalf("read archive: %v", err) + t.Fatalf("archive dir: %v", err) } - if lines := strings.Count(strings.TrimSpace(string(data)), "\n") + 1; lines < 1 { - t.Errorf("archived %d lines, want >=1:\n%s", lines, data) - } - if !strings.HasSuffix(entries[0].Name(), ".jsonl") { - t.Errorf("archive name = %q, want .jsonl", entries[0].Name()) + if len(entries) != 0 { + t.Fatalf("archive dir entries = %d, want 0 (no new archives)", len(entries)) } } @@ -478,70 +487,77 @@ func TestCompactFallsBackToMechanicalFoldWhenSummaryFails(t *testing.T) { } func TestCompactRewriteVersionFeedsCacheDiagnostics(t *testing.T) { + // Projection checkpoints do not rewrite the canonical transcript, so they + // must not bump LogRewriteVersion or queue compact_* content-rewrite reasons. + // The provider-visible change is the projection sidecar (version + summary). prov := &fakeProvider{reply: "- summary"} + big := strings.Repeat("work detail ", 200) sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, - {Role: provider.RoleUser, Content: "a"}, - {Role: provider.RoleAssistant, Content: "b"}, - {Role: provider.RoleUser, Content: "c"}, - {Role: provider.RoleAssistant, Content: "d"}, + {Role: provider.RoleUser, Content: "task"}, + {Role: provider.RoleAssistant, Content: big}, + {Role: provider.RoleUser, Content: "more"}, + {Role: provider.RoleAssistant, Content: big}, {Role: provider.RoleUser, Content: "e"}, {Role: provider.RoleAssistant, Content: "f"}, }} - a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2}, event.Discard) - before := CaptureShape("sys", nil, sess.RewriteVersion()) + a := New(prov, tool.NewRegistry(), sess, Options{ + ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 2, + }, event.Discard) + beforeVersion := sess.RewriteVersion() if err := a.compact(context.Background(), "auto", "", true); err != nil { t.Fatalf("compact: %v", err) } - // Canonical rewriteVersion stays put; projection notes compact_auto for diagnostics. - if sess.RewriteVersion() != before.LogRewriteVersion { - t.Fatalf("canonical rewrite version changed: %d -> %d", before.LogRewriteVersion, sess.RewriteVersion()) + if sess.RewriteVersion() != beforeVersion { + t.Fatalf("canonical rewrite version changed: %d -> %d", beforeVersion, sess.RewriteVersion()) } if !hasCompactionSummary(visibleContext(a)) { t.Fatal("expected projection summary") } - reasons := sess.DrainContentRewriteReasons() - // Reasons alone attribute the prefix change; rewrite version need not bump. - after := CaptureShape("sys", nil, sess.RewriteVersion()) - diag := CompareShape(before, after, &provider.Usage{CacheMissTokens: 10}, reasons) - if !diag.PrefixChanged { - t.Fatalf("diagnostics should report prefix change: %+v", diag) + if got := a.currentProjectionVersion(); got != 1 { + t.Fatalf("projection version = %d, want 1", got) } - if len(diag.PrefixChangeReasons) != 1 || diag.PrefixChangeReasons[0] != "compact_auto" { - t.Fatalf("change reasons = %v, want [compact_auto]", diag.PrefixChangeReasons) + if reasons := sess.DrainContentRewriteReasons(); len(reasons) != 0 { + t.Fatalf("projection compact queued canonical rewrite reasons %v; want none", reasons) } } func TestCompactKeepsMidSessionUserTurns(t *testing.T) { - big := strings.Repeat("work output ", 100) + // Small window so the recent-tail budget cannot swallow the mid-session + // user turn (production 32K floor only applies to large windows). + const window = 8_000 + // ~1500 tokens of work after the mid-fact pushes it out of the ~800-token tail. + big := strings.Repeat("work output line with detail. ", 250) + midFact := "by the way, always use pnpm not npm" sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "first task"}, {Role: provider.RoleAssistant, Content: big}, {Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: big}, - {Role: provider.RoleUser, Content: "by the way, always use pnpm not npm"}, + {Role: provider.RoleUser, Content: midFact}, {Role: provider.RoleAssistant, Content: big}, {Role: provider.RoleTool, ToolCallID: "2", Name: "read_file", Content: big}, {Role: provider.RoleUser, Content: "next"}, {Role: provider.RoleAssistant, Content: "ok"}, }} - // Summarizer preserves the mid-session fact in the rolling digest. + // Summarizer carries the mid-session fact into the single rolling digest. + // No early-user-turn hoist: only the stable prefix (system + first user) is + // kept verbatim; mid-session user turns in the fold region merge into the summary. a := New(&fakeProvider{reply: "Standing facts: always use pnpm not npm"}, tool.NewRegistry(), sess, - Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard) + Options{ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2}, event.Discard) if err := a.compact(context.Background(), "manual", "", true); err != nil { t.Fatalf("compact: %v", err) } - // Canonical retains every user turn; projection keeps fixed early turns - // and folds later small user turns into the single summary. + // Canonical retains every user turn. var pinnedFirst, keptMidCanonical bool for _, m := range sess.Snapshot() { if m.Role == provider.RoleUser && m.Content == "first task" { pinnedFirst = true } - if m.Role == provider.RoleUser && strings.Contains(m.Content, "always use pnpm not npm") { + if m.Role == provider.RoleUser && strings.Contains(m.Content, midFact) { keptMidCanonical = true } } @@ -549,7 +565,7 @@ func TestCompactKeepsMidSessionUserTurns(t *testing.T) { t.Fatalf("canonical lost user turns (first=%v mid=%v)", pinnedFirst, keptMidCanonical) } proj := visibleContext(a) - var projFirst, projMid bool + var projFirst, projMidVerbatim bool for _, m := range proj { if isCompactionSummary(m) { continue @@ -557,17 +573,18 @@ func TestCompactKeepsMidSessionUserTurns(t *testing.T) { if m.Role == provider.RoleUser && m.Content == "first task" { projFirst = true } - if m.Role == provider.RoleUser && strings.Contains(m.Content, "always use pnpm not npm") { - projMid = true + if m.Role == provider.RoleUser && m.Content == midFact { + projMidVerbatim = true } } if !projFirst { t.Fatalf("fixed early user turn missing from projection: %+v", proj) } - // The mid-session fact is still among the first few small user turns by - // position, so the fixed early window keeps it verbatim (stable prefix). - if !projMid { - t.Fatalf("early-position user fact missing from projection: %+v", proj) + if projMidVerbatim { + t.Fatalf("mid-session user turn must fold into summary, not stay verbatim: %+v", proj) + } + if !strings.Contains(joinContents(proj), "pnpm") { + t.Fatalf("mid-session fact missing from rolling summary: %+v", proj) } if strings.Contains(joinContents(proj), big) { t.Errorf("assistant/tool work was not folded out of projection") @@ -628,17 +645,21 @@ func TestCompactKeepsPriorDigests(t *testing.T) { func TestCompactKeepsErrorMessages(t *testing.T) { prov := &fakeProvider{reply: "- normal work summarized"} + big := strings.Repeat("normal work output ", 200) sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "task"}, {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "bash", Arguments: `{"cmd":"bad"}`}}}, {Role: provider.RoleTool, ToolCallID: "1", Name: "bash", Content: "error: command failed"}, + {Role: provider.RoleAssistant, Content: big}, {Role: provider.RoleUser, Content: "continue"}, - {Role: provider.RoleAssistant, Content: "continued"}, + {Role: provider.RoleAssistant, Content: big}, {Role: provider.RoleUser, Content: "next"}, {Role: provider.RoleAssistant, Content: "ok"}, }} - a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir(), KeepPolicy: KeepErrors}, event.Discard) + a := New(prov, tool.NewRegistry(), sess, Options{ + ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 2, KeepPolicy: KeepErrors, + }, event.Discard) if err := a.compact(context.Background(), "manual", "", true); err != nil { t.Fatalf("compact: %v", err) @@ -667,16 +688,23 @@ func TestCompactKeepsErrorMessages(t *testing.T) { func TestCompactKeepsUserMarkedMessages(t *testing.T) { prov := &fakeProvider{reply: "- unmarked work summarized"} - marked := "[[keep]] exact requirement " + strings.Repeat("must stay verbatim ", 200) + // Keep-marked text stays in the projection as protected content; surrounding + // work must be large enough that folding it still reduces the candidate. + marked := "[[keep]] exact requirement " + strings.Repeat("must stay verbatim ", 40) + big := strings.Repeat("unmarked work output ", 300) sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "task"}, {Role: provider.RoleUser, Content: marked}, - {Role: provider.RoleAssistant, Content: "worked"}, + {Role: provider.RoleAssistant, Content: big}, + {Role: provider.RoleUser, Content: "more"}, + {Role: provider.RoleAssistant, Content: big}, {Role: provider.RoleUser, Content: "next"}, {Role: provider.RoleAssistant, Content: "ok"}, }} - a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir(), KeepPolicy: KeepUserMarked}, event.Discard) + a := New(prov, tool.NewRegistry(), sess, Options{ + ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 2, KeepPolicy: KeepUserMarked, + }, event.Discard) if err := a.compact(context.Background(), "manual", "", true); err != nil { t.Fatalf("compact: %v", err) @@ -706,24 +734,32 @@ func TestCompactKeepsUserMarkedMessages(t *testing.T) { } func TestRunCompactsAfterFinalAnswer(t *testing.T) { - // A turn that ends with a final answer (no trailing tool batch) must still - // compact when the context is over the trigger; otherwise a large context - // carries into the next turn un-folded and overflows the model window. - big := strings.Repeat("old work ", 200) + // Maintenance runs on Prepare before sampling (ObserveUsage is a no-op). + // A turn whose estimated prompt already crosses compact_ratio must install + // the summary checkpoint on the sampling path so the final-answer request + // rides the reduced view. + const window = 10_000 + // ~2×4K tokens of foldable work so estimatedPromptTokens ≥ fold (8500). + big := strings.Repeat("old work detail line with substance. ", 800) sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "task"}, {Role: provider.RoleAssistant, Content: big}, {Role: provider.RoleAssistant, Content: big}, }} - a := New(&fakeProvider{reply: "done", promptTokens: 95}, tool.NewRegistry(), sess, - Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard) + // fakeProvider replies "done" for the main sample; compact also uses the same + // provider for the summary call (also returns "done", which is fine as a digest). + a := New(&fakeProvider{reply: "done"}, tool.NewRegistry(), sess, + Options{ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2}, event.Discard) + if before := a.estimatedPromptTokens(a.modelVisibleMessages()); before < a.compactTrigger() { + t.Fatalf("fixture est=%d below fold trigger %d", before, a.compactTrigger()) + } if err := a.Run(context.Background(), "what's the status?"); err != nil { t.Fatalf("run: %v", err) } if !hasCompactionSummary(visibleContext(a)) { - t.Fatalf("final-answer turn over the trigger did not install projection summary") + t.Fatalf("turn over the trigger did not install projection summary") } // Canonical rewrite version stays 0; projection carries the fold. if got := sess.RewriteVersion(); got != 0 { diff --git a/internal/agent/projection_valid_test.go b/internal/agent/projection_valid_test.go index 5babdedf7e..6fceb9908a 100644 --- a/internal/agent/projection_valid_test.go +++ b/internal/agent/projection_valid_test.go @@ -186,7 +186,7 @@ func TestForceThresholdNoopReturnsCompactionRequired(t *testing.T) { } } -func TestSummarizeWithRetryMergesUsage(t *testing.T) { +func TestSummarizeOnceDoesNotRetry(t *testing.T) { fp := &retryUsageProvider{ failOnce: errors.New("transient"), reply: "digest body", @@ -194,24 +194,14 @@ func TestSummarizeWithRetryMergesUsage(t *testing.T) { usage2: &provider.Usage{PromptTokens: 11, CompletionTokens: 3, TotalTokens: 14, RequestCount: 1}, } a := New(fp, tool.NewRegistry(), NewSession("sys"), Options{}, event.Discard) - summary, usage, err := a.summarizeWithRetry(context.Background(), []provider.Message{ + _, _, err := a.summarizeOnce(context.Background(), []provider.Message{ {Role: provider.RoleUser, Content: "fold me"}, }, "") - if err != nil { - t.Fatalf("summarizeWithRetry: %v", err) + if err == nil { + t.Fatal("expected first-attempt failure to surface without retry") } - if summary != "digest body" { - t.Fatalf("summary = %q", summary) - } - if usage == nil { - t.Fatal("usage is nil") - } - // Both attempts contribute billable tokens and request count. - if usage.PromptTokens < 21 || usage.CompletionTokens < 5 { - t.Fatalf("merged usage under-counted: %+v", usage) - } - if usage.RequestCount < 2 { - t.Fatalf("RequestCount = %d, want >= 2 (both attempts)", usage.RequestCount) + if fp.calls != 1 { + t.Fatalf("provider calls = %d, want exactly 1", fp.calls) } } diff --git a/internal/agent/prune.go b/internal/agent/prune.go index cef3bd67a2..0b2d638b34 100644 --- a/internal/agent/prune.go +++ b/internal/agent/prune.go @@ -8,10 +8,8 @@ import ( "reasonix/internal/tool" ) -// Tool-result maintenance is the free half of context management: stale tool -// results are re-derivable (files can be re-read, commands re-run), so rewriting -// them needs no summarizer call and never drops a message. tool_call/result -// pairing and assistant content (including signed reasoning) are untouched. +// Tool-result helpers serve first-visible bounding and summary fold input. +// Automatic prune/snip projections are gone; the public APIs are no-ops. const ( snippedMarker = "[snipped tool result — " prunedMarker = "[elided tool result — " @@ -35,88 +33,25 @@ type PruneStats struct { Force bool } -// SnipStaleToolResults shortens stale tool-result content older than the -// protected recent tail, archiving the originals first. Idempotent; a no-op -// when compaction is disabled (no context window). +// SnipStaleToolResults is a no-op: automatic prune/snip projections are gone. func (a *Agent) SnipStaleToolResults() (PruneStats, error) { - return a.maintainStaleToolResults(toolResultSnip) + return PruneStats{Mode: toolResultSnip}, nil } -// PruneStaleToolResults elides stale tool-result content older than the -// protected recent tail, archiving the originals first. It can upgrade already -// snipped results to a shorter placeholder. +// PruneStaleToolResults is a no-op: automatic prune/snip projections are gone. func (a *Agent) PruneStaleToolResults() (PruneStats, error) { - return a.maintainStaleToolResults(toolResultPrune) -} - -func (a *Agent) maintainStaleToolResults(mode toolResultMaintenanceMode) (PruneStats, error) { - st := PruneStats{Mode: mode} - if a.contextWindow <= 0 { - return st, nil - } - visible := a.modelVisibleMessages() - next, st := a.applyToolResultMaintenanceView(visible, mode) - st.Force = true - if st.Results == 0 { - return st, nil - } - before := a.currentProjectionVersion() - if err := a.installPruneProjection(next, st); err != nil { - return PruneStats{Mode: mode}, err - } - if a.currentProjectionVersion() == before { - return PruneStats{Mode: mode}, nil - } - a.compactionMu.Lock() - if a.compactionState.LastReceipt != nil { - st.Archive = a.compactionState.LastReceipt.Archive - } - a.compactionMu.Unlock() - return st, nil -} - -func shouldMaintainToolResult(m provider.Message, mode toolResultMaintenanceMode) bool { - if m.LocalOnly || m.Role != provider.RoleTool { - return false - } - if strings.HasPrefix(m.Content, prunedMarker) { - return false - } - if mode == toolResultSnip { - return len(m.Content) >= minPruneBytes && !strings.HasPrefix(m.Content, snippedMarker) - } - if strings.HasPrefix(m.Content, snippedMarker) { - return true - } - return len(m.Content) >= minPruneBytes -} - -func rewriteToolResult(m provider.Message, mode toolResultMaintenanceMode, archive string, strategy snipStrategy) string { - if mode == toolResultPrune { - return pruneToolResult(m, archive) - } - return snipToolResult(m, archive, strategy) -} - -func pruneToolResult(m provider.Message, archive string) string { - if prior := originalToolArchive(m.Content); prior != "" { - archive = prior - } - if archive == "" { - archive = "not archived" - } - return fmt.Sprintf("%s%s, %d bytes archived to %s; re-run the tool if the data is needed again]", prunedMarker, m.Name, originalToolBytes(m.Content), archive) + return PruneStats{Mode: toolResultPrune}, nil } func snipToolResult(m provider.Message, archive string, strategy snipStrategy) string { if archive == "" { - archive = "not archived" + archive = "the canonical transcript" } lines := strings.Split(m.Content, "\n") if len(lines) <= strategy.head+strategy.tail { headChars := minInt(strategy.headChars, len(m.Content)/2) tailChars := minInt(strategy.tailChars, len(m.Content)/4) - return fmt.Sprintf("%s%s, %d bytes archived to %s; single large line truncated]\n%s\n[... %d bytes omitted ...]\n%s", + return fmt.Sprintf("%s%s, %d bytes; full original retained in %s; single large line truncated]\n%s\n[... %d bytes omitted ...]\n%s", snippedMarker, m.Name, len(m.Content), archive, firstRunes(m.Content, headChars), omittedBytes(m.Content, headChars, tailChars), @@ -124,7 +59,7 @@ func snipToolResult(m provider.Message, archive string, strategy snipStrategy) s } head := strings.Join(lines[:strategy.head], "\n") tail := strings.Join(lines[len(lines)-strategy.tail:], "\n") - return fmt.Sprintf("%s%s, %d bytes archived to %s; showing first %d lines and last %d lines]\n%s\n[... %d lines omitted ...]\n%s", + return fmt.Sprintf("%s%s, %d bytes; full original retained in %s; showing first %d lines and last %d lines]\n%s\n[... %d lines omitted ...]\n%s", snippedMarker, m.Name, len(m.Content), archive, strategy.head, strategy.tail, head, len(lines)-strategy.head-strategy.tail, tail) } @@ -136,23 +71,11 @@ type snipStrategy struct { tailChars int } -// Defaults for tools that do not implement tool.SnipHinter, tiered by side -// effect. A read-only tool's output is front-loaded (the first lines are the -// answer), so it keeps a long head and short tail. A side-effecting tool — bash -// and any plugin — can carry a failure at either end (a build error at the tail, -// the command at the head), so it keeps both ends evenly. These are deliberately -// the only two defaults: a registered tool that fits neither must implement -// SnipHinter, and the contract test fails until it does. var ( defaultReadOnlySnip = snipStrategy{head: 80, tail: 12, headChars: 10000, tailChars: 2000} defaultSideEffectingSnip = snipStrategy{head: 40, tail: 40, headChars: 8000, tailChars: 8000} ) -// snipStrategyFor resolves the snip geometry for a tool result by asking the -// registered tool itself (tool.SnipHinter), so the policy travels with the tool -// definition and a rename cannot silently desync it from a name-keyed table. -// When the tool is absent (e.g. an MCP server detached after producing the -// result) or declines to hint, it falls back to the ReadOnly-tiered default. func (a *Agent) snipStrategyFor(name string) snipStrategy { if a.tools != nil { if t, ok := a.tools.Get(name); ok { @@ -172,42 +95,6 @@ func snipStrategyFromHint(h tool.SnipHint) snipStrategy { return snipStrategy{head: h.Head, tail: h.Tail, headChars: h.HeadChars, tailChars: h.TailChars} } -func originalToolBytes(content string) int { - if strings.HasPrefix(content, snippedMarker) { - end := strings.Index(content, " bytes archived to ") - if end > len(snippedMarker) { - fields := strings.Fields(content[len(snippedMarker):end]) - if len(fields) > 0 { - var n int - if _, err := fmt.Sscanf(fields[len(fields)-1], "%d", &n); err == nil && n > 0 { - return n - } - } - } - } - return len(content) -} - -func originalToolArchive(content string) string { - if !strings.HasPrefix(content, snippedMarker) { - return "" - } - start := strings.Index(content, " bytes archived to ") - if start < 0 { - return "" - } - start += len(" bytes archived to ") - end := strings.Index(content[start:], ";") - if end < 0 { - return "" - } - archive := strings.TrimSpace(content[start : start+end]) - if archive == "not archived" { - return "" - } - return archive -} - func firstRunes(s string, n int) string { if len(s) <= n { return s diff --git a/internal/agent/prune_test.go b/internal/agent/prune_test.go index 5bda99a3bf..35b42cf5eb 100644 --- a/internal/agent/prune_test.go +++ b/internal/agent/prune_test.go @@ -2,10 +2,6 @@ package agent import ( "context" - "encoding/json" - "fmt" - "os" - "path/filepath" "strings" "testing" @@ -14,502 +10,89 @@ import ( "reasonix/internal/tool" ) -func pruneFixture(toolContent string) *Session { - return &Session{Messages: []provider.Message{ +// Automatic prune/snip projections are gone. These APIs remain as no-ops so +// older call sites do not panic, but they never rewrite the model view. +func TestPruneAndSnipAreNoOps(t *testing.T) { + sess := &Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "task"}, - {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "read_file", Arguments: "{}"}}}, - {Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: toolContent}, - {Role: provider.RoleAssistant, Content: "step done"}, + {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "t1", Name: "read_file", Arguments: "{}"}}}, + {Role: provider.RoleTool, ToolCallID: "t1", Name: "read_file", Content: strings.Repeat("x", 8000)}, {Role: provider.RoleUser, Content: "next"}, - {Role: provider.RoleAssistant, Content: "ok"}, }} -} - -func TestPruneStaleToolResults(t *testing.T) { - big := strings.Repeat("x", 5000) - sess := pruneFixture(big) - dir := t.TempDir() - a := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: dir}, event.Discard) - + a := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 100_000, RecentKeep: 2}, event.Discard) st, err := a.PruneStaleToolResults() - if err != nil { - t.Fatalf("prune: %v", err) - } - if st.Results != 1 { - t.Fatalf("Results = %d, want 1", st.Results) - } - if st.SavedChars < 4000 { - t.Errorf("SavedChars = %d, want > 4000", st.SavedChars) - } - msgs := visibleContext(a) - if len(msgs) != 7 { - t.Fatalf("message count changed: %d", len(msgs)) - } - pruned := msgs[3] - if !strings.HasPrefix(pruned.Content, prunedMarker) { - t.Errorf("tool content not elided: %.60q", pruned.Content) - } - if pruned.ToolCallID != "1" || pruned.Name != "read_file" || pruned.Role != provider.RoleTool { - t.Errorf("tool pairing fields changed: %+v", pruned) - } - if len(msgs[2].ToolCalls) != 1 || msgs[2].ToolCalls[0].ID != "1" { - t.Errorf("assistant tool_calls touched: %+v", msgs[2]) - } - if got := sess.RewriteVersion(); got != 0 { - t.Errorf("canonical RewriteVersion = %d, want 0", got) - } - if got := sess.Snapshot()[3].Content; got != big { - t.Fatal("projection maintenance rewrote canonical tool content") - } - if st.Archive == "" { - t.Fatal("no archive written") - } - raw, err := os.ReadFile(st.Archive) - if err != nil { - t.Fatalf("read archive: %v", err) - } - if !strings.Contains(string(raw), big[:64]) { - t.Error("archive does not contain the original tool output") - } - if filepath.Dir(st.Archive) != dir { - t.Errorf("archive outside dir: %s", st.Archive) - } - - st2, err := a.PruneStaleToolResults() - if err != nil { - t.Fatalf("second prune: %v", err) - } - if st2.Results != 0 { - t.Errorf("second pass pruned %d, want 0 (idempotent)", st2.Results) - } - if got := sess.RewriteVersion(); got != 0 { - t.Errorf("no-op pass bumped RewriteVersion to %d", got) - } -} - -func TestArchiveMessagesIsContentAddressedAndRetryStable(t *testing.T) { - dir := t.TempDir() - msgs := []provider.Message{{Role: provider.RoleTool, Name: "read_file", Content: strings.Repeat("x", 5000)}} - first, err := archiveMessages(dir, msgs) if err != nil { t.Fatal(err) } - second, err := archiveMessages(dir, msgs) + if st.Results != 0 { + t.Fatalf("prune results = %d, want 0", st.Results) + } + st, err = a.SnipStaleToolResults() if err != nil { t.Fatal(err) } - if first == "" || first != second { - t.Fatalf("same archive input produced different paths: %q vs %q", first, second) - } - entries, err := os.ReadDir(dir) - if err != nil { - t.Fatal(err) - } - if len(entries) != 1 { - t.Fatalf("retry created duplicate archives: %d", len(entries)) - } -} - -func TestPruneNeverRewritesLocalInterruptedDisplay(t *testing.T) { - m := provider.Message{ - Role: provider.RoleTool, LocalOnly: true, - ToolCallID: provider.LocalOnlyToolID, Name: provider.LocalOnlyToolName, - Content: strings.Repeat("partial output", 1024), - } - if shouldMaintainToolResult(m, toolResultSnip) || shouldMaintainToolResult(m, toolResultPrune) { - t.Fatal("local interrupted display must remain verbatim across tool-result maintenance") - } -} - -func TestSnipStaleToolResults(t *testing.T) { - var lines []string - for range 1000 { - lines = append(lines, "line") - } - big := strings.Join(lines, "\n") - sess := pruneFixture(big) - dir := t.TempDir() - a := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: dir}, event.Discard) - - st, err := a.SnipStaleToolResults() - if err != nil { - t.Fatalf("snip: %v", err) - } - if st.Results != 1 { - t.Fatalf("Results = %d, want 1", st.Results) - } - snipped := visibleContext(a)[3].Content - if !strings.HasPrefix(snipped, snippedMarker) { - t.Fatalf("tool content not snipped: %.80q", snipped) - } - if !strings.Contains(snipped, "[... ") || !strings.Contains(snipped, "lines omitted") { - t.Fatalf("snipped content missing omission marker: %.120q", snipped) - } - if st.SavedChars <= 0 { - t.Fatalf("SavedChars = %d, want positive", st.SavedChars) - } - if st.Archive == "" { - t.Fatal("no archive written") - } - - st2, err := a.SnipStaleToolResults() - if err != nil { - t.Fatalf("second snip: %v", err) - } - if st2.Results != 0 { - t.Fatalf("second pass snipped %d, want 0", st2.Results) - } -} - -func TestSnipCanUpgradeToPrune(t *testing.T) { - big := strings.Join([]string{strings.Repeat("a\n", 800), strings.Repeat("b\n", 800)}, "") - sess := pruneFixture(big) - a := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard) - - snipStats, err := a.SnipStaleToolResults() - if err != nil || snipStats.Results != 1 { - t.Fatalf("snip st=%+v err=%v, want one result", snipStats, err) - } - if pruneStats, err := a.PruneStaleToolResults(); err != nil || pruneStats.Results != 1 { - t.Fatalf("prune st=%+v err=%v, want one upgraded result", pruneStats, err) - } - if got := visibleContext(a)[3].Content; !strings.HasPrefix(got, prunedMarker) { - t.Fatalf("snipped result was not upgraded to prune: %.80q", got) - } else if !strings.Contains(got, snipStats.Archive) { - t.Fatalf("pruned marker did not preserve original archive path %q: %.120q", snipStats.Archive, got) - } -} - -func TestPruneNoopWithoutWindow(t *testing.T) { - sess := pruneFixture(strings.Repeat("x", 5000)) - a := New(nil, tool.NewRegistry(), sess, Options{RecentKeep: 2}, event.Discard) - st, err := a.PruneStaleToolResults() - if err != nil || st.Results != 0 { - t.Fatalf("st=%+v err=%v, want no-op", st, err) - } -} - -func TestPruneSkipsSmallResults(t *testing.T) { - sess := pruneFixture(strings.Repeat("x", 200)) - a := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2}, event.Discard) - st, err := a.PruneStaleToolResults() - if err != nil || st.Results != 0 { - t.Fatalf("st=%+v err=%v, want small result kept", st, err) - } - if got := visibleContext(a)[3].Content; !strings.HasPrefix(got, "xxx") { - t.Errorf("small tool result was rewritten: %.40q", got) - } -} - -func TestMaybeCompactPruneAvoidsFold(t *testing.T) { - prov := &fakeProvider{reply: "summary"} - sess := pruneFixture(strings.Repeat("x", 5000)) - a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard) - - prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 850}) - - if prov.got != nil { - t.Fatal("summarizer was called although pruning cleared the trigger") - } - // Canonical stays intact; prune lands only in the projection view. - if got := sess.Snapshot()[3].Content; !strings.HasPrefix(got, "xxx") { - t.Errorf("canonical tool result rewritten: %.60q", got) - } - proj := visibleContext(a) - found := false - for _, m := range proj { - if m.Role == provider.RoleTool && strings.HasPrefix(m.Content, prunedMarker) { - found = true - } - } - if !found { - t.Errorf("tool result not pruned in projection: %+v", proj) - } -} - -// Rewriting a stale tool result invalidates the prompt prefix from that point -// on, so below the fold trigger history is left exactly as the provider cached -// it, however large it has grown. -func TestMaintenanceLeavesHistoryAloneBelowFoldTrigger(t *testing.T) { - prov := &fakeProvider{reply: "summary"} - sess := pruneFixture(strings.Repeat("line\n", 1000)) - a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard) - - prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 650}) - - if prov.got != nil { - t.Fatal("summarizer was called below the fold trigger") + if st.Results != 0 { + t.Fatalf("snip results = %d, want 0", st.Results) } if got := a.currentProjectionVersion(); got != 0 { - t.Fatalf("projection installed below the fold trigger: version %d", got) + t.Fatalf("projection version = %d, want 0", got) } - for _, m := range visibleContext(a) { - if m.Role == provider.RoleTool && (strings.HasPrefix(m.Content, snippedMarker) || strings.HasPrefix(m.Content, prunedMarker)) { - t.Fatalf("tool result rewritten below the fold trigger: %.80q", m.Content) + for _, m := range sess.Snapshot() { + if m.Role == provider.RoleTool && m.Content != strings.Repeat("x", 8000) { + t.Fatal("canonical tool result was rewritten") } } } -// At the fold trigger, dropping stale tool results comes first: when that gets -// the prompt low enough it stands in for the summary, saving the round trip for -// the same single prefix switch. -func TestMaintenancePrefersPruneOverSummaryAtFoldTrigger(t *testing.T) { - prov := &fakeProvider{reply: "summary"} - sess := pruneFixture(strings.Repeat("line\n", 1000)) - a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard) - - prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 850}) - - if prov.got != nil { - t.Fatal("summarizer was called although pruning was enough") - } - if got := sess.Snapshot()[3].Content; !strings.HasPrefix(got, "line") { - t.Errorf("canonical tool result rewritten: %.80q", got) - } - found := false - for _, m := range visibleContext(a) { - if m.Role == provider.RoleTool && strings.HasPrefix(m.Content, prunedMarker) { - found = true - } - } - if !found { - t.Errorf("tool result not elided in projection: %+v", visibleContext(a)) - } -} - -func TestProjectionMaintenanceContinuesFromVisibleView(t *testing.T) { - sess := pruneFixture(strings.Repeat("line\n", 1000)) - a := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard) - prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 850}) - first := a.compactionState.Projection.ProjectionVersion - if first == 0 || !strings.HasPrefix(visibleContext(a)[3].Content, prunedMarker) { - t.Fatalf("first maintenance did not install an elided projection: %+v", visibleContext(a)) - } - prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 650}) - if got := a.compactionState.Projection.ProjectionVersion; got != first { - t.Fatalf("same visible tool result was maintained again: projection version %d -> %d", first, got) - } -} - -func TestMaybeCompactPruneFallsThroughWhenStillOverThreshold(t *testing.T) { - prov := &fakeProvider{reply: "summary"} - sess := &Session{Messages: []provider.Message{ +// At the sole compact_ratio trigger, maintenance calls the summarizer once — +// it does not install an intermediate prune projection. +func TestMaintenanceUsesSummaryNotPruneAtFoldTrigger(t *testing.T) { + big := strings.Repeat("tool body line\n", 400) + msgs := []provider.Message{ {Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "task"}, - {Role: provider.RoleAssistant, Content: strings.Repeat("foldable assistant work\n", 500)}, - {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "read_file", Arguments: "{}"}}}, - {Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: strings.Repeat("x", 1200)}, - {Role: provider.RoleAssistant, Content: "step done"}, - {Role: provider.RoleUser, Content: "next"}, - {Role: provider.RoleAssistant, Content: "ok"}, - }} - a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 10000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard) - - prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 8900}) - - if prov.got == nil { - t.Fatal("summarizer was not called although pruning still left prompt above compact threshold") } - if !hasCompactionSummary(visibleContext(a)) { - t.Fatal("summary compaction did not install a projection") + for i := range 12 { + id := "t" + string(rune('a'+i)) + msgs = append(msgs, + provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "read_file", Arguments: "{}"}}}, + provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "read_file", Content: big}, + ) + } + msgs = append(msgs, + provider.Message{Role: provider.RoleUser, Content: "tail"}, + provider.Message{Role: provider.RoleAssistant, Content: "ok"}, + ) + prov := &countingProvider{reply: "digest"} + a := New(prov, tool.NewRegistry(), &Session{Messages: msgs}, Options{ + ContextWindow: 20_000, CompactRatio: 0.5, RecentKeep: 2, + }, event.Discard) + if _, err := a.contextManager().Prepare(context.Background(), ContextPreparePolicy{Trigger: CompactionTriggerPressure}); err != nil { + t.Fatal(err) + } + if a.currentProjectionVersion() != 1 { + t.Fatalf("projection version = %d, want 1", a.currentProjectionVersion()) + } + if len(prov.got) != 1 { + t.Fatalf("summarizer calls = %d, want 1", len(prov.got)) + } + if got := countToolResultsWithPrefix(a.modelVisibleMessages(), prunedMarker); got != 0 { + t.Fatalf("prune markers in projection = %d, want 0", got) } } -func TestMaybeCompactForceRatioStillFolds(t *testing.T) { - prov := &fakeProvider{reply: "summary"} - // A big assistant turn in the foldable region (after the pinned task, before the - // recent tail) survives pruning — only tool results prune — so the forced fold - // has real content to compact while the task turn stays pinned verbatim. - sess := &Session{Messages: []provider.Message{ - {Role: provider.RoleSystem, Content: "sys"}, - {Role: provider.RoleUser, Content: "task"}, - {Role: provider.RoleAssistant, Content: strings.Repeat("y", 5000)}, - {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "read_file", Arguments: "{}"}}}, - {Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: strings.Repeat("x", 5000)}, - {Role: provider.RoleAssistant, Content: "step done"}, - {Role: provider.RoleUser, Content: "next"}, - {Role: provider.RoleAssistant, Content: "ok"}, - }} - a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard) - - prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 950}) - - if prov.got == nil { - t.Fatal("force ratio crossed but summarizer never called") +func TestSnipStrategyStillAvailableForFirstVisibleAndSummaryInput(t *testing.T) { + a := &Agent{tools: tool.NewRegistry()} + s := a.snipStrategyFor("read_file") + if s.head <= 0 || s.tail <= 0 { + t.Fatalf("snip strategy for read_file = %+v", s) } - if got := sess.Snapshot()[1].Content; got != "task" { - t.Errorf("first user turn not pinned verbatim in canonical: %.40q", got) + body, notice := truncateToolOutputFor(strings.Repeat("x", maxToolOutputBytes+100), "read_file", "call-1") + if notice == "" || !strings.Contains(body, "call_id=call-1") { + t.Fatalf("first-visible truncation missing marker: notice=%q body=%.200q", notice, body) } - if !hasCompactionSummary(visibleContext(a)) { - t.Error("no compaction summary in projection after forced fold") - } -} - -func TestPruneSkipsRecentTail(t *testing.T) { - old := strings.Repeat("old\n", 1000) - recent := strings.Repeat("recent\n", 1000) - sess := &Session{Messages: []provider.Message{ - {Role: provider.RoleSystem, Content: "sys"}, - {Role: provider.RoleUser, Content: "task"}, - {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "old", Name: "read_file", Arguments: "{}"}}}, - {Role: provider.RoleTool, ToolCallID: "old", Name: "read_file", Content: old}, - {Role: provider.RoleUser, Content: "next"}, - {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "recent", Name: "read_file", Arguments: "{}"}}}, - {Role: provider.RoleTool, ToolCallID: "recent", Name: "read_file", Content: recent}, - }} - a := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 3, ArchiveDir: t.TempDir()}, event.Discard) - - st, err := a.PruneStaleToolResults() - if err != nil { - t.Fatalf("prune: %v", err) - } - if st.Results != 1 { - t.Fatalf("Results = %d, want only the stale result pruned", st.Results) - } - msgs := visibleContext(a) - if !strings.HasPrefix(msgs[3].Content, prunedMarker) { - t.Fatalf("old result was not pruned: %.80q", msgs[3].Content) - } - if msgs[6].Content != recent { - t.Fatalf("recent tail tool result was rewritten") - } - if canonical := sess.Snapshot(); canonical[3].Content != old || canonical[6].Content != recent { - t.Fatal("projection prune rewrote canonical tool results") - } -} - -func TestPruneHonorsKeepErrors(t *testing.T) { - // KeepErrors must carry error/blocked tool results through pruning verbatim; - // eliding here rewrites Content to the [elided ...] marker, so compact()'s - // KeepErrors predicate sees only the placeholder and the failure is lost on - // the next fold. - for _, prefix := range []string{"error:", "blocked:"} { - content := prefix + strings.Repeat(" detail", 200) - sess := pruneFixture(content) - a := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2, KeepPolicy: KeepErrors}, event.Discard) - - st, err := a.PruneStaleToolResults() - if err != nil { - t.Fatalf("prune (%s): %v", prefix, err) - } - if st.Results != 0 { - t.Errorf("%s: Results = %d, want 0 (KeepErrors preserves error tool results)", prefix, st.Results) - } - if got := visibleContext(a)[3].Content; !strings.HasPrefix(got, prefix) { - t.Errorf("%s: error tool result was elided: %.60q", prefix, got) - } - } -} - -func TestPruneElidesErrorsWithoutKeepPolicy(t *testing.T) { - // Without KeepErrors, a large error tool result prunes like any other — a - // regression guard for the policy-gated skip. - content := "error: build failed\n" + strings.Repeat("x", 5000) - sess := pruneFixture(content) - a := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2}, event.Discard) - - st, err := a.PruneStaleToolResults() - if err != nil { - t.Fatalf("prune: %v", err) - } - if st.Results != 1 { - t.Errorf("Results = %d, want 1 (no keep policy)", st.Results) - } - if got := visibleContext(a)[3].Content; !strings.HasPrefix(got, prunedMarker) { - t.Errorf("error tool result not elided without keep policy: %.60q", got) - } -} - -// hintingTool is a read-only tool that advertises a distinctive snip geometry, -// so a test can prove the maintainer honored the tool's own SnipHint rather -// than a name-keyed table or a generic default. -type hintingTool struct { - name string - hint tool.SnipHint -} - -func (h hintingTool) Name() string { return h.name } -func (hintingTool) Description() string { return "" } -func (hintingTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) } -func (hintingTool) ReadOnly() bool { return true } -func (hintingTool) Execute(context.Context, json.RawMessage) (string, error) { return "", nil } -func (h hintingTool) SnipHint() tool.SnipHint { return h.hint } - -func snipFixtureFor(toolName, content string) *Session { - return &Session{Messages: []provider.Message{ - {Role: provider.RoleSystem, Content: "sys"}, - {Role: provider.RoleUser, Content: "task"}, - {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: toolName, Arguments: "{}"}}}, - {Role: provider.RoleTool, ToolCallID: "1", Name: toolName, Content: content}, - {Role: provider.RoleAssistant, Content: "step done"}, - {Role: provider.RoleUser, Content: "next"}, - {Role: provider.RoleAssistant, Content: "ok"}, - }} -} - -func TestSnipUsesRegisteredToolHint(t *testing.T) { - // 600 numbered lines; a SnipHinter keeping head=3, tail=2 must yield exactly - // those boundary lines, which no default geometry would produce. - var lines []string - for i := range 600 { - lines = append(lines, fmt.Sprintf("L%d", i)) - } - content := strings.Join(lines, "\n") - sess := snipFixtureFor("custom_reader", content) - - reg := tool.NewRegistry() - reg.Add(hintingTool{name: "custom_reader", hint: tool.SnipHint{Head: 3, Tail: 2, HeadChars: 100, TailChars: 100}}) - a := New(nil, reg, sess, Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard) - - if st, err := a.SnipStaleToolResults(); err != nil || st.Results != 1 { - t.Fatalf("snip st=%+v err=%v, want one result", st, err) - } - got := visibleContext(a)[3].Content - if !strings.Contains(got, "showing first 3 lines and last 2 lines") { - t.Fatalf("snip did not honor the tool's SnipHint geometry: %.120q", got) - } - if !strings.Contains(got, "\nL0\nL1\nL2\n") { - t.Errorf("kept head is not the first 3 lines: %.160q", got) - } - if !strings.Contains(got, "\nL598\nL599") { - t.Errorf("kept tail is not the last 2 lines: %.160q", got) - } -} - -func TestSnipFallsBackByReadOnlyTier(t *testing.T) { - var lines []string - for i := range 600 { - lines = append(lines, fmt.Sprintf("L%d", i)) - } - content := strings.Join(lines, "\n") - - // A side-effecting tool with no SnipHint takes the even-split default - // (head==tail), while a read-only one keeps a longer head than tail. - cases := []struct { - name string - readOnly bool - evenEnds bool - }{ - {"side_effecting", false, true}, - {"read_only", true, false}, - } - for _, tc := range cases { - sess := snipFixtureFor(tc.name, content) - reg := tool.NewRegistry() - reg.Add(fakeTool{name: tc.name, readOnly: tc.readOnly}) - a := New(nil, reg, sess, Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard) - - if st, err := a.SnipStaleToolResults(); err != nil || st.Results != 1 { - t.Fatalf("%s: snip st=%+v err=%v, want one result", tc.name, st, err) - } - got := visibleContext(a)[3].Content - want := "showing first 40 lines and last 40 lines" - if !tc.evenEnds { - want = "showing first 80 lines and last 12 lines" - } - if !strings.Contains(got, want) { - t.Errorf("%s: fallback geometry wrong, want %q in: %.120q", tc.name, want, got) - } + if len(body) > maxToolOutputBytes+200 { + t.Fatalf("bounded body still oversized: %d", len(body)) } } diff --git a/internal/agent/run_loop.go b/internal/agent/run_loop.go index c5815e28ab..08c4b13260 100644 --- a/internal/agent/run_loop.go +++ b/internal/agent/run_loop.go @@ -880,6 +880,11 @@ func (a *Agent) handleToolRound(ctx context.Context, state *runLoopState, step i ToolCallID: call.ID, Name: call.Name, } + // First-visible Content is always the bounded form in results[i]. + // Full originals ride on RawContent only when truncation applied. + if i < len(batch.outcomes) && batch.outcomes[i].rawOutput != "" && batch.outcomes[i].rawOutput != results[i] { + msg.RawContent = batch.outcomes[i].rawOutput + } if i < len(batch.executions) { msg.ToolExecution = toProviderToolExecution(batch.executions[i]) } diff --git a/internal/agent/sampling_request.go b/internal/agent/sampling_request.go index f68e73d470..09d195558b 100644 --- a/internal/agent/sampling_request.go +++ b/internal/agent/sampling_request.go @@ -3,175 +3,11 @@ package agent import ( "context" "encoding/json" - "fmt" - "log/slog" - "strings" - "sync/atomic" - "time" "reasonix/internal/event" "reasonix/internal/provider" ) -func normalizeContextEditing(mode string) string { - if strings.EqualFold(strings.TrimSpace(mode), "native") { - return "native" - } - return "local" -} - -type contextEditingState struct { - requestedContextEditing string // normalized user intent: local or native - contextEditing string // effective mode after provider capability resolution - contextEditingPolicyVersion string // provider-declared native request-shape version - contextEditingFallbackEmitted atomic.Bool - nativeContextEditingAccepted atomic.Bool - contextEditingRuntimeFallback atomic.Bool -} - -func newContextEditingState(mode string, prov provider.Provider) contextEditingState { - requested, effective, version := resolveContextEditing(mode, prov) - return contextEditingState{ - requestedContextEditing: requested, - contextEditing: effective, - contextEditingPolicyVersion: version, - } -} - -func resolveContextEditing(mode string, prov provider.Provider) (requested, effective, policyVersion string) { - requested = normalizeContextEditing(mode) - if requested != "native" { - return requested, "local", "" - } - caps := provider.ContextEditingCapabilitiesOf(prov) - policyVersion = strings.TrimSpace(caps.PolicyVersion) - if !caps.NativeToolUseClear || policyVersion == "" { - return requested, "local", "" - } - return requested, "native", policyVersion -} - -func (a *Agent) contextEditingPolicy() *provider.ContextEditingPolicy { - if a == nil { - return nil - } - a.compactionMu.Lock() - defer a.compactionMu.Unlock() - return a.contextEditingPolicyLocked() -} - -func (a *Agent) contextEditingPolicyLocked() *provider.ContextEditingPolicy { - if a.contextEditing != "native" { - return nil - } - _, snip, _ := a.compactThresholds() - return &provider.ContextEditingPolicy{ - Mode: "native", - TriggerInputTokens: snip, - KeepToolUses: 3, - ClearAtLeastInputTokens: max(4096, a.minimumMaintenanceSavingsTokens()), - ClearToolInputs: false, - } -} - -func (a *Agent) contextEditingLineageSuffixLocked() string { - policy := a.contextEditingPolicyLocked() - if policy == nil { - return "" - } - return fmt.Sprintf("|context-editing-native-%s-t%d-k%d-m%d-i%t", - a.contextEditingPolicyVersion, - policy.TriggerInputTokens, - policy.KeepToolUses, - policy.ClearAtLeastInputTokens, - policy.ClearToolInputs, - ) -} - -func (a *Agent) effectiveContextEditing() string { - if a == nil { - return "local" - } - a.compactionMu.Lock() - defer a.compactionMu.Unlock() - return a.contextEditing -} - -func (a *Agent) emitContextEditingFallbackNotice() { - if a == nil || a.requestedContextEditing != "native" || a.effectiveContextEditing() == "native" || - a.usageSource != event.UsageSourceExecutor || !a.contextEditingFallbackEmitted.CompareAndSwap(false, true) { - return - } - detail := "context_editing=native requires the official Anthropic endpoint; compatible gateways and other providers remain local" - if a.contextEditingRuntimeFallback.Load() { - detail = "the official endpoint rejected native context editing before this session successfully used it; the session is now pinned to local maintenance" - } - a.sink.Emit(event.Event{ - Kind: event.Notice, - Level: event.LevelInfo, - Code: event.NoticeCodeContextEditingFallback, - Text: "Native context editing is unavailable for this provider; using local context maintenance.", - Detail: detail, - Source: a.usageSource, - }) -} - -func (a *Agent) markNativeContextEditingAccepted() { - if a == nil || !a.nativeContextEditingAccepted.CompareAndSwap(false, true) { - return - } - _, transcriptVersion := a.session.snapshotMessagesVersion() - a.compactionMu.Lock() - state := a.compactionState - if state.NativeContextEditingAccepted { - a.compactionMu.Unlock() - return - } - previous := state - state.SchemaVersion = compactionStateSchemaCurrent - state.TranscriptVersion = transcriptVersion - state.Generation++ - state.NativeContextEditingAccepted = true - state.ContextEditingFallbackLocal = false - state.PromptCacheKey = a.currentPromptCacheKeyLocked() - state.UpdatedAt = time.Now().UTC() - a.compactionState = state - if err := a.persistCompactionStateLocked(); err != nil { - a.compactionState = previous - slog.Warn("agent: persist native context-editing acceptance", "err", err) - } - a.compactionMu.Unlock() -} - -func (a *Agent) fallbackNativeContextEditingToLocal() bool { - if a == nil || a.nativeContextEditingAccepted.Load() { - return false - } - _, transcriptVersion := a.session.snapshotMessagesVersion() - a.compactionMu.Lock() - if a.contextEditing != "native" || a.nativeContextEditingAccepted.Load() { - a.compactionMu.Unlock() - return false - } - previous := a.compactionState - a.contextEditing = "local" - a.contextEditingRuntimeFallback.Store(true) - state := CompactionState{ - SchemaVersion: compactionStateSchemaCurrent, TranscriptVersion: transcriptVersion, - Generation: previous.Generation + 1, PromptCacheKey: a.currentPromptCacheKeyLocked(), - LastCacheState: a.cacheState, ContextEditingFallbackLocal: true, UpdatedAt: time.Now().UTC(), - } - a.compactionState = state - if err := a.persistCompactionStateLocked(); err != nil { - slog.Warn("agent: persist local context-editing fallback", "err", err) - } - a.compactionMu.Unlock() - a.compactStuck = false - a.consecutiveCompacts = 0 - a.lastCompactionTurn.Store(0) - return true -} - // samplingRequest is a once-prepared, frozen provider request for one model // round. All stream retries replay this exact payload — no synthetic recovery // messages, no schema reorder, no previous_response_id drift from failed attempts. @@ -180,11 +16,7 @@ type samplingRequest struct { } func (a *Agent) streamProviderRequest(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) { - ch, err := a.prov.Stream(ctx, req) - if err == nil && req.ContextEditing != nil && req.ContextEditing.Mode == "native" { - a.markNativeContextEditingAccepted() - } - return ch, err + return a.prov.Stream(ctx, req) } func (a *Agent) handleSamplingError( @@ -196,17 +28,6 @@ func (a *Agent) handleSamplingError( result, last streamedTurn, billable *provider.Usage, ) (retry bool, terminal streamedTurn) { - if provider.IsNativeContextEditingUnsupported(result.err) && frozen.req.ContextEditing != nil && - frozen.req.ContextEditing.Mode == "native" && a.fallbackNativeContextEditingToLocal() { - streamSink.Discard() - a.emitStreamAttempt(attemptID, event.StreamAttemptDiscard, attempt, "native_context_editing_unsupported", result.err) - localRequest, err := a.prepareSamplingRequest(ctx) - if err != nil { - return false, streamedTurn{usage: finalizeSamplingUsage(billable, result.usage), err: err} - } - *frozen = localRequest - return true, streamedTurn{} - } if provider.IsStreamInterrupted(result.err) && attempt < maxSamplingAttempts { streamSink.Discard() reason := provider.StreamInterruptReason(result.err) @@ -228,12 +49,48 @@ func (a *Agent) handleSamplingError( } // prepareSamplingRequest freezes one model-round request (preflight + interceptors). +// Output budgets are resolved only here and never change the compact_ratio +// trigger. Physical overflow may attempt at most one recovery summary. func (a *Agent) prepareSamplingRequest(ctx context.Context) (samplingRequest, error) { - a.emitContextEditingFallbackNotice() + frozen, err := a.buildSamplingRequest(ctx, CompactionTriggerPressure) + if err != nil { + return samplingRequest{}, err + } + if budget, clipped, budgetErr := a.effectiveOutputBudget(frozen.req); budgetErr != nil { + // One-shot physical overflow recovery. Do not loop. + if _, perr := a.contextManager().Prepare(ctx, ContextPreparePolicy{ + Trigger: CompactionTriggerOverflow, + Force: true, + }); perr != nil { + return samplingRequest{}, budgetErr + } + rebuilt, rerr := a.buildSamplingRequest(ctx, CompactionTriggerPressure) + if rerr != nil { + return samplingRequest{}, rerr + } + if _, _, budgetErr2 := a.effectiveOutputBudget(rebuilt.req); budgetErr2 != nil { + return samplingRequest{}, budgetErr2 + } + // Re-apply clipping on the recovered view. + if budget2, clipped2, err2 := a.effectiveOutputBudget(rebuilt.req); err2 == nil && clipped2 { + rebuilt.req.MaxTokens = budget2 + } + shape := a.requestCalibrationShape(rebuilt.req) + a.activeReqShape.Store(&shape) + return samplingRequest{req: freezeProviderRequest(rebuilt.req)}, nil + } else if clipped { + frozen.req.MaxTokens = budget + } + shape := a.requestCalibrationShape(frozen.req) + a.activeReqShape.Store(&shape) + return samplingRequest{req: freezeProviderRequest(frozen.req)}, nil +} + +func (a *Agent) buildSamplingRequest(ctx context.Context, trigger string) (samplingRequest, error) { // CreatedAt is durable UI metadata, not model input. Strip it from the // transport copy so wall-clock differences never invalidate the provider's // prompt-cache prefix (and custom providers cannot accidentally send it). - prepared, err := a.contextManager().Prepare(ctx, ContextPreparePolicy{Trigger: CompactionTriggerPressure}) + prepared, err := a.contextManager().Prepare(ctx, ContextPreparePolicy{Trigger: trigger}) if err != nil { return samplingRequest{}, err } @@ -244,7 +101,7 @@ func (a *Agent) prepareSamplingRequest(ctx context.Context) (samplingRequest, er } // context.prepare: extensions may rewrite the message copy feeding THIS // request. The session log is never touched — the replacement is - // ephemeral, so the next request starts from the unmodified history and + // ephemeral, so the next request starts from the unmodified history. requestMessages, err = a.interceptContextPrepare(ctx, requestMessages) if err != nil { return samplingRequest{}, err @@ -257,24 +114,13 @@ func (a *Agent) prepareSamplingRequest(ctx context.Context) (samplingRequest, er ResponseFormat: responseFormatFromRequest(ctx), EffortOverride: a.governorOverride(), } - req.ContextEditing = a.contextEditingPolicy() // provider.request: the fully assembled request gets one last ruling // (revalidated by the payload registry) before it goes on the wire. req, err = a.interceptProviderRequest(ctx, req) if err != nil { return samplingRequest{}, err } - // Enforce the shared-window invariant on the final extension-adjusted - // payload. This keeps prompt + output inside the provider context window - // without changing message bytes, tool order, or ordinary request defaults. - if budget, clipped, budgetErr := a.effectiveOutputBudget(req); budgetErr != nil { - return samplingRequest{}, budgetErr - } else if clipped { - req.MaxTokens = budget - } - shape := a.requestCalibrationShape(req) - a.activeReqShape.Store(&shape) - return samplingRequest{req: freezeProviderRequest(req)}, nil + return samplingRequest{req: req}, nil } // providerProjectionMessages applies provider-specific role compatibility to a @@ -326,9 +172,5 @@ func freezeProviderRequest(req provider.Request) provider.Request { rf := *req.ResponseFormat out.ResponseFormat = &rf } - if req.ContextEditing != nil { - policy := *req.ContextEditing - out.ContextEditing = &policy - } return out } diff --git a/internal/agent/task.go b/internal/agent/task.go index 58dadefae0..e29d4c9696 100644 --- a/internal/agent/task.go +++ b/internal/agent/task.go @@ -248,13 +248,10 @@ type TaskTool struct { parentReg *tool.Registry maxSteps int contextWindow int - softCompactRatio float64 - toolResultSnipRatio float64 compactRatio float64 - compactForceRatio float64 recentKeep int temperature float64 - contextEditing, archiveDir string + archiveDir string keepPolicy KeepPolicy sysPrompt string gate Gate @@ -326,26 +323,22 @@ func NewTaskToolWithOptions(opts TaskToolOptions) *TaskTool { sysPrompt = DefaultTaskSystemPrompt } return &TaskTool{ - prov: opts.Provider, - pricing: opts.Pricing, - parentReg: opts.ParentRegistry, - maxSteps: opts.MaxSteps, - contextWindow: opts.ContextWindow, - recentKeep: opts.RecentKeep, - softCompactRatio: opts.SoftCompactRatio, - toolResultSnipRatio: opts.ToolResultSnipRatio, - compactRatio: opts.CompactRatio, - compactForceRatio: opts.CompactForceRatio, - contextEditing: normalizeContextEditing(opts.ContextEditing), - temperature: opts.Temperature, - archiveDir: opts.ArchiveDir, - keepPolicy: opts.KeepPolicy, - sysPrompt: sysPrompt, - gate: opts.Gate, - subagentModel: opts.SubagentModel, - subagentEffort: opts.SubagentEffort, - resolveProvider: opts.ResolveProvider, - maxSubagentDepth: DefaultMaxSubagentDepth, + prov: opts.Provider, + pricing: opts.Pricing, + parentReg: opts.ParentRegistry, + maxSteps: opts.MaxSteps, + contextWindow: opts.ContextWindow, + recentKeep: opts.RecentKeep, + compactRatio: opts.CompactRatio, + temperature: opts.Temperature, + archiveDir: opts.ArchiveDir, + keepPolicy: opts.KeepPolicy, + sysPrompt: sysPrompt, + gate: opts.Gate, + subagentModel: opts.SubagentModel, + subagentEffort: opts.SubagentEffort, + resolveProvider: opts.ResolveProvider, + maxSubagentDepth: DefaultMaxSubagentDepth, } } @@ -1619,31 +1612,27 @@ func (t *TaskTool) runReadOnlySubSession(ctx context.Context, prompt string, sub // must stay uniform across those paths — add new fields here, not at call sites. func (t *TaskTool) subagentOptions(ctx context.Context, maxSteps int, pricing *provider.Pricing, ctxWin, childDepth int, recoveryTaskID string, mutationObserver *checkpoint.MutationObserver) Options { opts := Options{ - MaxSteps: maxSteps, - Temperature: t.temperature, - Pricing: pricing, - UsageSource: event.UsageSourceSubagent, - Gate: t.gate, - ContextWindow: ctxWin, - RecentKeep: t.recentKeep, - SoftCompactRatio: t.softCompactRatio, - ToolResultSnipRatio: t.toolResultSnipRatio, - CompactRatio: t.compactRatio, - CompactForceRatio: t.compactForceRatio, - ContextEditing: t.contextEditing, - ArchiveDir: t.archiveDir, - KeepPolicy: t.keepPolicy, - ResponseLanguage: ResponseLanguageFromContext(ctx), - ReasoningLanguage: ReasoningLanguageFromContext(ctx), - SubagentDepth: childDepth, - MaxSubagentDepth: t.maxDepth(), - DeliveryProfile: t.deliveryProfile, - Ablation: t.ablation, - WorkspaceLease: t.workspaceLease, - RecoveryGate: t.recoveryGate, - RecoveryAgentID: "subagent", - RecoveryTaskID: recoveryTaskID, - MutationObserver: mutationObserver, + MaxSteps: maxSteps, + Temperature: t.temperature, + Pricing: pricing, + UsageSource: event.UsageSourceSubagent, + Gate: t.gate, + ContextWindow: ctxWin, + RecentKeep: t.recentKeep, + CompactRatio: t.compactRatio, + ArchiveDir: t.archiveDir, + KeepPolicy: t.keepPolicy, + ResponseLanguage: ResponseLanguageFromContext(ctx), + ReasoningLanguage: ReasoningLanguageFromContext(ctx), + SubagentDepth: childDepth, + MaxSubagentDepth: t.maxDepth(), + DeliveryProfile: t.deliveryProfile, + Ablation: t.ablation, + WorkspaceLease: t.workspaceLease, + RecoveryGate: t.recoveryGate, + RecoveryAgentID: "subagent", + RecoveryTaskID: recoveryTaskID, + MutationObserver: mutationObserver, } return opts } diff --git a/internal/agent/task_options_test.go b/internal/agent/task_options_test.go index d5df2d2eab..adf6a1b653 100644 --- a/internal/agent/task_options_test.go +++ b/internal/agent/task_options_test.go @@ -199,13 +199,9 @@ func assertTaskToolConfigEqual(t *testing.T, a, b *TaskTool) { [3]int{a.maxSteps, a.contextWindow, a.recentKeep}, [3]int{b.maxSteps, b.contextWindow, b.recentKeep}) } - if a.softCompactRatio != b.softCompactRatio || a.toolResultSnipRatio != b.toolResultSnipRatio || - a.compactRatio != b.compactRatio || a.compactForceRatio != b.compactForceRatio || a.temperature != b.temperature { + if a.compactRatio != b.compactRatio || a.temperature != b.temperature { t.Fatalf("ratio/temp mismatch") } - if a.contextEditing != b.contextEditing { - t.Fatalf("context editing mismatch: %q/%q", a.contextEditing, b.contextEditing) - } if a.archiveDir != b.archiveDir || a.sysPrompt != b.sysPrompt || a.keepPolicy != b.keepPolicy { t.Fatalf("archive/sys/keep mismatch: archive=%q/%q sys=%q/%q keep=%v/%v", a.archiveDir, b.archiveDir, a.sysPrompt, b.sysPrompt, a.keepPolicy, b.keepPolicy) diff --git a/internal/agent/usage_context.go b/internal/agent/usage_context.go index 694034c76b..7dd0b26750 100644 --- a/internal/agent/usage_context.go +++ b/internal/agent/usage_context.go @@ -13,7 +13,4 @@ func applyLatestContextShape(dst, latest *provider.Usage) { dst.ContextReasoningTokens = latest.ReasoningTokens dst.ContextCacheHitTokens = latest.CacheHitTokens dst.ContextCacheMissTokens = latest.CacheMissTokens - dst.ContextEditingType = latest.ContextEditingType - dst.ContextEditingClearedToolUses = latest.ContextEditingClearedToolUses - dst.ContextEditingClearedTokens = latest.ContextEditingClearedTokens } diff --git a/internal/boot/boot.go b/internal/boot/boot.go index 79ba49c295..4a8a5318fe 100644 --- a/internal/boot/boot.go +++ b/internal/boot/boot.go @@ -227,6 +227,7 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) { stepLimitsMigrated, stepLimitMigErr := config.MigrateLegacyAgentStepLimitsForRoot(root) redactToolOutputMigrated, redactToolOutputMigErr := config.MigrateLegacyRedactToolOutputForRoot(root) memoryCompilerMigrated, memoryCompilerMigErr := config.MigrateLegacyMemoryCompilerForRoot(root) + multiThresholdMigrated, multiThresholdMigErr := config.MigrateLegacyMultiThresholdCompactionForRoot(root) cfg, err := config.LoadForRoot(root) if err != nil { return nil, err @@ -484,6 +485,17 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) { } sink.Emit(event.Event{Kind: event.Notice, Level: level, Text: text, Detail: detail}) } + if multiThresholdMigrated || multiThresholdMigErr != nil { + level := event.LevelInfo + text := "上下文维护已简化为单一自动压缩阈值。" + detail := "Context maintenance now uses a single automatic compact_ratio (default 0.85). soft_compact_ratio, tool_result_snip_ratio, compact_force_ratio, cold_resume_prune, and context_editing were removed from config." + if multiThresholdMigErr != nil { + level = event.LevelWarn + text = "Deprecated multi-threshold compaction keys were ignored." + detail += " The old keys could not be removed: " + multiThresholdMigErr.Error() + } + sink.Emit(event.Event{Kind: event.Notice, Level: level, Text: text, Detail: detail}) + } migration.MigrateLegacyMemorySources(sink) migration.MigrateLegacySessionSources(sink) if ignored := cfg.IgnoredProjectDefaultModel(); ignored != "" { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index db5bec2176..5635f1c38d 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -850,7 +850,7 @@ func TestConfigCompactRatioQueryReportsBuiltInDefault(t *testing.T) { t.Fatalf("config compact-ratio query rc = %d, want 0", rc) } }) - if out != "compact_ratio = 80% (built-in default)\n" { + if out != "compact_ratio = 85% (built-in default)\n" { t.Fatalf("config compact-ratio query output = %q", out) } } diff --git a/internal/config/config.go b/internal/config/config.go index 0ec40c9e0b..0fdea6c7a8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1302,14 +1302,12 @@ type AgentConfig struct { // Deprecated compatibility field paired with AutoPlan. Old TOML remains // readable, but loading clears it and rendering omits it. AutoPlanClassifier string `toml:"auto_plan_classifier"` - // Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling. + // Soft/snip/force are retired compatibility keys; only CompactRatio is active. SoftCompactRatio float64 `toml:"soft_compact_ratio"` ToolResultSnipRatio float64 `toml:"tool_result_snip_ratio"` CompactRatio float64 `toml:"compact_ratio"` CompactForceRatio float64 `toml:"compact_force_ratio"` - // ContextEditing selects local maintenance (default) or explicitly opted-in - // Anthropic native tool clearing. Native is only honored by official - // Anthropic endpoints; compatible gateways remain local. + // ContextEditing is retired; native tool clearing is no longer an auto path. ContextEditing string `toml:"context_editing"` // Keep controls which compactable messages stay verbatim beyond the current // user-fact/digest floor and recent tail. Empty uses the conservative default @@ -1353,9 +1351,13 @@ type ProviderEntry struct { resolvedSource CredentialSource BalanceURL string `toml:"balance_url"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout. ContextWindow int `toml:"context_window"` - // MaxOutputTokens is a protocol-neutral total output budget. Zero lets the - // provider choose a safe default, a positive value is explicit, and a - // negative value omits optional wire limits. Anthropic still requires one. + // MaxOutputTokens is a protocol-neutral total output budget for one turn. + // Zero means automatic (not unlimited): ordinary 16K, reasoning 32K, high/max + // 64K — DeepSeek's default effort is high, so auto is typically ~64K. + // User guidance: 0 recommended; 32768 ordinary coding/cost control; + // 65536 heavy reasoning/long tools; 131072 only after finish_reason=length. + // A negative value omits optional wire limits when the protocol allows; + // Anthropic still requires max_tokens. Never feeds compact_ratio. MaxOutputTokens int `toml:"max_output_tokens"` Price *provider.Pricing `toml:"price"` // legacy/provider-wide fallback Prices map[string]*provider.Pricing `toml:"prices"` // optional per-model prices; keys are model ids @@ -1827,14 +1829,15 @@ func Default() *Config { SystemPrompt: DefaultSystemPrompt, // Normal interactive execution has no configurable total round cap. It // is bounded by adaptive progress guards and context compaction instead. - MaxSteps: 0, - PlannerMaxSteps: 0, - AutoPlan: "off", - SoftCompactRatio: 0.5, - ToolResultSnipRatio: 0.6, - CompactRatio: 0.8, - CompactForceRatio: 0.9, - ContextEditing: "local", + MaxSteps: 0, + PlannerMaxSteps: 0, + AutoPlan: "off", + // Soft/snip/force are load-only compatibility; CompactRatio alone drives maintenance. + SoftCompactRatio: 0, + ToolResultSnipRatio: 0, + CompactRatio: 0.85, + CompactForceRatio: 0, + ContextEditing: "", MaxSubagentDepth: 2, MaxSubagentConcurrency: 6, MaxParallelWriters: 3, diff --git a/internal/config/edit.go b/internal/config/edit.go index e6f6a4a6cd..1366229a4c 100644 --- a/internal/config/edit.go +++ b/internal/config/edit.go @@ -421,22 +421,12 @@ func (c *Config) SetColdResumePrune(enabled bool) error { return nil } -// SetCompactRatio updates the user-controlled auto-compaction threshold. -// Keep the editable range inside the default snip/force guard rails so lowering -// the threshold cannot accidentally turn normal cache growth into constant -// compaction, while higher values still retain context-exhaustion headroom. +// SetCompactRatio updates the sole user-controlled automatic compaction +// threshold. Allowed range is 0.65–0.85; presets are 0.70 / 0.80 / 0.85. func (c *Config) SetCompactRatio(ratio float64) error { if math.IsNaN(ratio) || math.IsInf(ratio, 0) || ratio < 0.65 || ratio > 0.85 { return fmt.Errorf("compact ratio %v: must be between 0.65 and 0.85", ratio) } - snip := c.Agent.ToolResultSnipRatio - force := c.Agent.CompactForceRatio - if snip > 0 && ratio <= snip { - return fmt.Errorf("compact ratio %.2f: must be greater than tool result snip ratio %.2f", ratio, snip) - } - if force > 0 && ratio >= force { - return fmt.Errorf("compact ratio %.2f: must be less than force ratio %.2f", ratio, force) - } c.Agent.CompactRatio = ratio return nil } diff --git a/internal/config/edit_test.go b/internal/config/edit_test.go index e434b59af1..ef9ac6d1d0 100644 --- a/internal/config/edit_test.go +++ b/internal/config/edit_test.go @@ -628,14 +628,14 @@ func TestSetCompactRatio(t *testing.T) { } } + // Deprecated snip/force ratios no longer constrain SetCompactRatio. c.Agent.ToolResultSnipRatio = 0.75 - if err := c.SetCompactRatio(0.7); err == nil { - t.Fatal("SetCompactRatio should reject a value at or below the configured snip ratio") - } - c.Agent.ToolResultSnipRatio = 0.6 c.Agent.CompactForceRatio = 0.8 - if err := c.SetCompactRatio(0.8); err == nil { - t.Fatal("SetCompactRatio should reject a value at or above the configured force ratio") + if err := c.SetCompactRatio(0.7); err != nil { + t.Fatalf("SetCompactRatio(0.7) with legacy snip/force fields: %v", err) + } + if err := c.SetCompactRatio(0.8); err != nil { + t.Fatalf("SetCompactRatio(0.8) with legacy force field: %v", err) } } diff --git a/internal/config/load.go b/internal/config/load.go index 0876ade9ff..0d05ded9f7 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -789,6 +789,7 @@ func normalizeConfigForEdit(cfg *Config) bool { normalizeLegacyEffort(cfg) normalizeLegacyAgentStepLimits(cfg) changed := normalizeRetiredAutoPlan(cfg) + changed = normalizeRetiredMultiThresholdCompaction(cfg) || changed normalizeLegacyMCPTiers(cfg) changed = normalizeLegacyStepFunBaseURLs(cfg) || changed changed = normalizeLegacyLongCatContextWindows(cfg) || changed @@ -805,6 +806,30 @@ func normalizeConfigForEdit(cfg *Config) bool { return changed } +// normalizeRetiredMultiThresholdCompaction clears retired multi-threshold keys +// so they never reach the Agent. Disk migration removes them on ordinary start; +// loading still ignores them if migration could not rewrite the file. +func normalizeRetiredMultiThresholdCompaction(c *Config) bool { + if c == nil { + return false + } + changed := c.Agent.SoftCompactRatio != 0 || + c.Agent.ToolResultSnipRatio != 0 || + c.Agent.CompactForceRatio != 0 || + c.Agent.ColdResumePrune != nil || + strings.TrimSpace(c.Agent.ContextEditing) != "" + c.Agent.SoftCompactRatio = 0 + c.Agent.ToolResultSnipRatio = 0 + c.Agent.CompactForceRatio = 0 + c.Agent.ColdResumePrune = nil + c.Agent.ContextEditing = "" + if c.Agent.CompactRatio <= 0 { + c.Agent.CompactRatio = Default().Agent.CompactRatio + changed = true + } + return changed +} + // normalizeRetiredAutoPlan keeps pre-v5 configs readable while enforcing the // single explicit-plan experience. The deprecated fields remain in AgentConfig // only so old TOML and older desktop payloads decode safely. @@ -1077,6 +1102,50 @@ func stripLegacyMemoryCompilerLines(raw string) (string, bool) { return stripTOMLKeyLines(raw, "agent", "memory_compiler") } +// MigrateLegacyMultiThresholdCompactionForRoot strips retired soft/snip/force keys. +func MigrateLegacyMultiThresholdCompactionForRoot(root string) (bool, error) { + root = resolveRoot(root) + paths := make([]string, 0, 2) + if userPath := userConfigLoadPath(); userPath != "" { + paths = append(paths, userPath) + } + projectPath := "reasonix.toml" + if root != "." { + projectPath = filepath.Join(root, "reasonix.toml") + } + paths = append(paths, projectPath) + + changedAny := false + seen := make(map[string]struct{}, len(paths)) + for _, path := range paths { + clean := filepath.Clean(path) + if _, ok := seen[clean]; ok { + continue + } + seen[clean] = struct{}{} + changed, err := migrateLegacyMultiThresholdCompactionFile(path) + if err != nil { + return changedAny, fmt.Errorf("migrate deprecated multi-threshold compaction keys in %s: %w", path, err) + } + changedAny = changedAny || changed + } + return changedAny, nil +} + +func migrateLegacyMultiThresholdCompactionFile(path string) (bool, error) { + return migrateRetiredConfigKeysFile(path, stripLegacyMultiThresholdCompactionLines) +} + +func stripLegacyMultiThresholdCompactionLines(raw string) (string, bool) { + return stripTOMLKeyLines(raw, "agent", + "soft_compact_ratio", + "tool_result_snip_ratio", + "compact_force_ratio", + "cold_resume_prune", + "context_editing", + ) +} + func migrateLegacyMCPTiersFile(path string) error { _, err := migrateRetiredConfigKeysFile(path, stripLegacyMCPTierLines) return err diff --git a/internal/config/render.go b/internal/config/render.go index f61f6c3ddb..51c153abbd 100644 --- a/internal/config/render.go +++ b/internal/config/render.go @@ -227,15 +227,7 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string { } else { b.WriteString("# reasoning_language = \"zh\" # visible reasoning language: auto|zh|en\n") } - fmt.Fprintf(&b, "soft_compact_ratio = %s # notice only; keeps cache-first prefix intact\n", formatFloat(c.Agent.SoftCompactRatio)) - fmt.Fprintf(&b, "tool_result_snip_ratio = %s # at compact_ratio, dropping stale tool results replaces the summary if it gets under this\n", formatFloat(c.Agent.ToolResultSnipRatio)) - fmt.Fprintf(&b, "compact_ratio = %s # try compacting when prompt reaches this fraction\n", formatFloat(c.Agent.CompactRatio)) - fmt.Fprintf(&b, "compact_force_ratio = %s # force compacting at this high-water mark\n", formatFloat(c.Agent.CompactForceRatio)) - if strings.TrimSpace(c.Agent.ContextEditing) == "native" { - b.WriteString("context_editing = \"native\" # opt in to official Anthropic native tool clearing\n") - } else { - b.WriteString("# context_editing = \"native\" # opt in only for official Anthropic native tool clearing; default is local\n") - } + fmt.Fprintf(&b, "compact_ratio = %s # sole auto trigger; presets 0.70/0.80/0.85 (default 0.85)\n", formatFloat(c.Agent.CompactRatio)) if c.Agent.Keep != nil { fmt.Fprintf(&b, "keep = %s # compaction keep policy: errors, user_marked\n", renderStringArray(c.Agent.Keep)) } else { @@ -246,7 +238,6 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string { } else { b.WriteString("# recent_keep = 2 # minimum recent messages kept verbatim\n") } - fmt.Fprintf(&b, "cold_resume_prune = %v # elide stale tool results when reopening a session past the provider cache window\n", c.ColdResumePruneEnabled()) if len(c.Agent.PlanModeReadOnlyCommands) > 0 { fmt.Fprintf(&b, "plan_mode_read_only_commands = %s # legacy compatibility only; Plan bash uses Permissions\n", renderStringArray(c.Agent.PlanModeReadOnlyCommands)) } else { @@ -348,7 +339,12 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string { fmt.Fprintf(&b, "context_window = %d # tokens; compaction triggers near this limit\n", p.ContextWindow) } if p.MaxOutputTokens != 0 { - fmt.Fprintf(&b, "max_output_tokens = %d # total output cap; 0 = provider default, negative = omit when optional\n", p.MaxOutputTokens) + fmt.Fprintf(&b, "max_output_tokens = %d # per-turn total output; 0 = auto (~64K on DeepSeek high), 32768/65536/131072 explicit; never affects compact_ratio\n", p.MaxOutputTokens) + } else { + b.WriteString("# max_output_tokens = 0 # recommended: automatic (DeepSeek default high → ~64K; not unlimited)\n") + b.WriteString("# max_output_tokens = 32768 # ordinary coding / cost control\n") + b.WriteString("# max_output_tokens = 65536 # heavy reasoning / long tool loops\n") + b.WriteString("# max_output_tokens = 131072 # only after repeated finish_reason=length\n") } if p.Price != nil { fmt.Fprintf(&b, "price = %s # provider-wide fallback, per 1M tokens\n", renderPricingInline(p.Price)) @@ -930,28 +926,10 @@ func RenderTOMLProjectDelta(c *Config) string { anyAgent = true } } - if c.Agent.SoftCompactRatio != d.Agent.SoftCompactRatio { - fmt.Fprintf(&agentBuf, "soft_compact_ratio = %s\n", formatFloat(c.Agent.SoftCompactRatio)) - anyAgent = true - } - if c.Agent.ToolResultSnipRatio != d.Agent.ToolResultSnipRatio { - fmt.Fprintf(&agentBuf, "tool_result_snip_ratio = %s\n", formatFloat(c.Agent.ToolResultSnipRatio)) - anyAgent = true - } if c.Agent.CompactRatio != d.Agent.CompactRatio { fmt.Fprintf(&agentBuf, "compact_ratio = %s\n", formatFloat(c.Agent.CompactRatio)) anyAgent = true } - if c.Agent.CompactForceRatio != d.Agent.CompactForceRatio { - fmt.Fprintf(&agentBuf, "compact_force_ratio = %s\n", formatFloat(c.Agent.CompactForceRatio)) - anyAgent = true - } - if c.Agent.ContextEditing != d.Agent.ContextEditing { - if strings.TrimSpace(c.Agent.ContextEditing) == "native" { - fmt.Fprintf(&agentBuf, "context_editing = %q\n", "native") - anyAgent = true - } - } if c.Agent.Keep != nil && !reflect.DeepEqual(c.Agent.Keep, d.Agent.Keep) { fmt.Fprintf(&agentBuf, "keep = %s\n", renderStringArray(c.Agent.Keep)) anyAgent = true @@ -960,10 +938,6 @@ func RenderTOMLProjectDelta(c *Config) string { fmt.Fprintf(&agentBuf, "recent_keep = %d\n", c.Agent.RecentKeep) anyAgent = true } - if c.Agent.ColdResumePrune != d.Agent.ColdResumePrune { - fmt.Fprintf(&agentBuf, "cold_resume_prune = %v\n", c.ColdResumePruneEnabled()) - anyAgent = true - } if len(c.Agent.PlanModeReadOnlyCommands) > 0 && !reflect.DeepEqual(c.Agent.PlanModeReadOnlyCommands, d.Agent.PlanModeReadOnlyCommands) { fmt.Fprintf(&agentBuf, "plan_mode_read_only_commands = %s\n", renderStringArray(c.Agent.PlanModeReadOnlyCommands)) anyAgent = true diff --git a/internal/config/render_test.go b/internal/config/render_test.go index c7c539ef78..a9b767fd7a 100644 --- a/internal/config/render_test.go +++ b/internal/config/render_test.go @@ -217,7 +217,7 @@ func TestRenderTOMLRoundTrips(t *testing.T) { orig.Agent.RecoveryModel = "mimo-pro" orig.Agent.RecoveryTemperature = 0.15 orig.Agent.ReasoningLanguage = "zh" - orig.Agent.ToolResultSnipRatio = 0.65 + orig.Agent.CompactRatio = 0.8 orig.Agent.SubagentModel = "mimo-pro" orig.Agent.SubagentModels = map[string]string{"review": "deepseek-pro"} orig.Agent.MaxSubagentDepth = 3 @@ -421,17 +421,13 @@ func TestRenderTOMLRoundTrips(t *testing.T) { if got.Agent.ReasoningLanguage != "zh" { t.Errorf("reasoning_language = %q, want zh", got.Agent.ReasoningLanguage) } - if got.Agent.SoftCompactRatio != orig.Agent.SoftCompactRatio { - t.Errorf("soft_compact_ratio = %v, want %v", got.Agent.SoftCompactRatio, orig.Agent.SoftCompactRatio) - } - if got.Agent.ToolResultSnipRatio != orig.Agent.ToolResultSnipRatio { - t.Errorf("tool_result_snip_ratio = %v, want %v", got.Agent.ToolResultSnipRatio, orig.Agent.ToolResultSnipRatio) - } if got.Agent.CompactRatio != orig.Agent.CompactRatio { t.Errorf("compact_ratio = %v, want %v", got.Agent.CompactRatio, orig.Agent.CompactRatio) } - if got.Agent.CompactForceRatio != orig.Agent.CompactForceRatio { - t.Errorf("compact_force_ratio = %v, want %v", got.Agent.CompactForceRatio, orig.Agent.CompactForceRatio) + // Deprecated multi-threshold fields must not reappear after render/load. + if got.Agent.SoftCompactRatio != 0 || got.Agent.ToolResultSnipRatio != 0 || got.Agent.CompactForceRatio != 0 { + t.Errorf("deprecated compact ratios survived round-trip: soft=%v snip=%v force=%v", + got.Agent.SoftCompactRatio, got.Agent.ToolResultSnipRatio, got.Agent.CompactForceRatio) } if strings.Join(got.Agent.Keep, ",") != strings.Join(orig.Agent.Keep, ",") { t.Errorf("keep = %v, want %v", got.Agent.Keep, orig.Agent.Keep) diff --git a/internal/control/controller.go b/internal/control/controller.go index 23d7cb5fc7..61c5d60cdc 100644 --- a/internal/control/controller.go +++ b/internal/control/controller.go @@ -3560,7 +3560,7 @@ func (c *Controller) cacheColdAfter() time.Duration { } // 查询路径只读:LoadForRootReadOnly 不触发配置迁移写盘(评审 #7168 // 第 4 点);失败时保守回退 24h(DeepSeek/未知 vendor 默认),避免 - // 提前触发 PruneStaleToolResults 改写仍可命中的缓存历史。 + // 把 cache TTL 过期误当成历史改写信号(resume 只记录 warm/cold/unknown)。 cfg, err := config.LoadForRootReadOnly(c.workspaceRoot) if err != nil { return 24 * time.Hour diff --git a/internal/control/rewind_e2e_test.go b/internal/control/rewind_e2e_test.go index eb876684e7..54dc572682 100644 --- a/internal/control/rewind_e2e_test.go +++ b/internal/control/rewind_e2e_test.go @@ -301,7 +301,7 @@ func TestPositionalCompressionPreservesCheckpointLineage(t *testing.T) { if err != nil || !ok { t.Fatalf("load projection sidecar: ok=%v err=%v", ok, err) } - if state.LastTrigger != agent.CompactionTriggerManual || state.Projection.ProjectionVersion == 0 { + if state.LastReceipt == nil || state.LastReceipt.Trigger != agent.CompactionTriggerManual || state.Projection.ProjectionVersion == 0 { t.Fatalf("projection state = %+v", state) } if _, ok := c.checkpoints.boundary(1); !ok { diff --git a/internal/fileutil/atomicwrite.go b/internal/fileutil/atomicwrite.go index f9b59d7b10..c7a5ce776f 100644 --- a/internal/fileutil/atomicwrite.go +++ b/internal/fileutil/atomicwrite.go @@ -1,9 +1,12 @@ package fileutil import ( + "errors" "fmt" "os" "path/filepath" + "runtime" + "syscall" "time" ) @@ -29,24 +32,40 @@ func Crash(op, path string) { } } -// AtomicWriteFile writes data to a sibling temporary file, fsyncs it, then -// publishes it via ReplaceFile. On filesystems that support replacement rename, -// readers see either the old file or the complete new file. ReplaceFile retains -// its compatibility copy fallback for Windows filter drivers that reject a -// same-directory rename as cross-device; callers that cannot tolerate that -// non-atomic fallback must use AtomicWriteFileStrict. +// AtomicWriteFile writes via temp + fsync + ReplaceFile. On rename-capable +// filesystems readers see only the old or complete new file. ReplaceFile may +// copy on Windows filter-driver EXDEV; callers that cannot tolerate that must +// use AtomicWriteFileStrict. func AtomicWriteFile(path string, data []byte, perm os.FileMode) error { return atomicWriteFile(path, data, perm, true) } -// AtomicWriteFileStrict publishes data only through an atomic rename. Unlike -// AtomicWriteFile, a cross-device/filter-driver error is returned without ever -// truncating path. Use it for commit pointers whose corruption would make the -// surrounding state impossible to recover automatically. +// AtomicWriteFileStrict publishes only via atomic rename (no EXDEV copy). +// After a successful rename it best-effort fsyncs the parent directory so the +// directory entry can survive power loss. A returned error always means the +// destination was not published; post-rename dir-sync problems are not errors +// (callers that roll back in-memory state on error would otherwise fork from +// the on-disk pointer). func AtomicWriteFileStrict(path string, data []byte, perm os.FileMode) error { return atomicWriteFile(path, data, perm, false) } +// syncParentDirFn is the post-publish parent-dir fsync implementation. +// Tests replace it via SetSyncParentDirForTest. +var syncParentDirFn = syncParentDir + +// SetSyncParentDirForTest replaces post-rename parent-dir fsync. Restore with +// the returned function. Production must leave the default in place. +func SetSyncParentDirForTest(fn func(path string) error) (restore func()) { + prev := syncParentDirFn + if fn == nil { + syncParentDirFn = syncParentDir + } else { + syncParentDirFn = fn + } + return func() { syncParentDirFn = prev } +} + func atomicWriteFile(path string, data []byte, perm os.FileMode, allowCrossDeviceCopy bool) error { Crash("atomic-write", path) tmpPath, err := writeAtomicTemp(path, data, perm) @@ -57,9 +76,41 @@ func atomicWriteFile(path string, data []byte, perm os.FileMode, allowCrossDevic os.Remove(tmpPath) return err } + // Strict only: parent-dir fsync is power-loss durability after publish. + // Never surface failures here — rename already committed the new file. + if !allowCrossDeviceCopy { + _ = syncParentDirFn(path) + } return nil } +// syncParentDir fsyncs path's parent after rename (including "."). Unsupported +// dir sync on Windows / some network FS is ignored. +func syncParentDir(path string) error { + dirPath := filepath.Dir(path) + if dirPath == "" { + dirPath = "." + } + f, err := os.Open(dirPath) + if err != nil { + return fmt.Errorf("open parent dir for fsync %s: %w", path, err) + } + defer f.Close() + if err := f.Sync(); err != nil { + if runtime.GOOS == "windows" || isDirSyncUnsupported(err) { + return nil + } + return fmt.Errorf("fsync parent dir for %s: %w", path, err) + } + return nil +} + +func isDirSyncUnsupported(err error) bool { + return errors.Is(err, syscall.EINVAL) || + errors.Is(err, syscall.ENOTSUP) || + errors.Is(err, syscall.ENOSYS) +} + // AtomicCreateFile publishes a complete file only when path is still absent. // It is the non-overwriting counterpart to AtomicWriteFile: a concurrent writer // that creates path wins, and its file is never replaced. diff --git a/internal/fileutil/atomicwrite_test.go b/internal/fileutil/atomicwrite_test.go index 30cd3436d2..0e9b810c97 100644 --- a/internal/fileutil/atomicwrite_test.go +++ b/internal/fileutil/atomicwrite_test.go @@ -145,6 +145,92 @@ func TestReplaceFileCrossDeviceCopiesImmediately(t *testing.T) { } } +func TestAtomicWriteFileStrictSyncsParentDir(t *testing.T) { + calls := 0 + restore := SetSyncParentDirForTest(func(path string) error { + calls++ + return syncParentDir(path) + }) + t.Cleanup(restore) + + dir := t.TempDir() + dest := filepath.Join(dir, "pointer.json") + if err := AtomicWriteFileStrict(dest, []byte(`{"ok":true}`), 0o644); err != nil { + t.Fatalf("AtomicWriteFileStrict: %v", err) + } + if calls != 1 { + t.Fatalf("parent dir sync calls = %d, want 1", calls) + } + if got, err := os.ReadFile(dest); err != nil || string(got) != `{"ok":true}` { + t.Fatalf("dest = %q, err=%v", got, err) + } +} + +func TestAtomicWriteFileStrictDirSyncFailureStillPublishes(t *testing.T) { + // Rename already committed the new file; dir fsync failure must not look + // like a pre-publish failure or callers will fork memory from disk. + restore := SetSyncParentDirForTest(func(string) error { + return errors.New("injected parent dir fsync failure") + }) + t.Cleanup(restore) + + dir := t.TempDir() + dest := filepath.Join(dir, "pointer.json") + if err := os.WriteFile(dest, []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + if err := AtomicWriteFileStrict(dest, []byte("new"), 0o644); err != nil { + t.Fatalf("post-publish dir sync must not fail the write: %v", err) + } + if got, err := os.ReadFile(dest); err != nil || string(got) != "new" { + t.Fatalf("dest = %q, err=%v, want published new content", got, err) + } +} + +func TestAtomicWriteFileStrictSyncsRelativeParentDir(t *testing.T) { + // Relative destinations resolve to "."; that parent must still be synced. + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(cwd) }) + + synced := "" + restore := SetSyncParentDirForTest(func(path string) error { + synced = filepath.Dir(path) + return syncParentDir(path) + }) + t.Cleanup(restore) + + if err := AtomicWriteFileStrict("pointer.json", []byte(`{"ok":true}`), 0o644); err != nil { + t.Fatal(err) + } + if synced != "." { + t.Fatalf("synced parent = %q, want \".\"", synced) + } + if got, err := os.ReadFile("pointer.json"); err != nil || string(got) != `{"ok":true}` { + t.Fatalf("dest = %q, err=%v", got, err) + } +} + +func TestAtomicWriteFileDoesNotRequireParentDirSync(t *testing.T) { + // Non-strict path keeps the historical file-only durability contract. + restore := SetSyncParentDirForTest(func(string) error { + t.Fatal("AtomicWriteFile must not require parent-dir sync") + return nil + }) + t.Cleanup(restore) + + path := filepath.Join(t.TempDir(), "config.toml") + if err := AtomicWriteFile(path, []byte("ok"), 0o644); err != nil { + t.Fatal(err) + } +} + func TestAtomicWriteFileStrictCrossDeviceKeepsExistingDestination(t *testing.T) { oldRename := renameFile renameFile = func(oldpath, newpath string) error { diff --git a/internal/guardian/guardian.go b/internal/guardian/guardian.go index 2b5da076be..46c9c06dab 100644 --- a/internal/guardian/guardian.go +++ b/internal/guardian/guardian.go @@ -96,10 +96,7 @@ func NewSession(prov provider.Provider, readOnlyReg *tool.Registry, policyPrompt // Use the shared context window so the guardian session can compact // itself when it grows too large across many reviews. ContextWindow: 100_000, - CompactRatio: 0.8, - SoftCompactRatio: 0.5, - ToolResultSnipRatio: 0.6, - CompactForceRatio: 0.9, + CompactRatio: 0.85, StrictAlternatingRoles: true, // Guardian's own sink drops everything — the audit line (emitTo) is the // only user-visible output. Usage events are captured internally for diff --git a/internal/provider/anthropic/anthropic.go b/internal/provider/anthropic/anthropic.go index 74c085d57c..0c93166f5b 100644 --- a/internal/provider/anthropic/anthropic.go +++ b/internal/provider/anthropic/anthropic.go @@ -25,7 +25,6 @@ import ( "bytes" "context" "encoding/json" - "errors" "fmt" "io" "net/http" @@ -52,14 +51,10 @@ const ( // defaultBaseURL is the first-party endpoint; config may override it (e.g. a // gateway). Bedrock/Vertex use a different request shape and are out of scope. defaultBaseURL = "https://api.anthropic.com" - // defaultMaxTokens is the conservative output ceiling used when neither the - // provider config nor the request supplies one. Anthropic requires max_tokens, - // but support is model-specific, so native Anthropic and unknown compatible - // gateways must not inherit a universal 128K request. - defaultMaxTokens = provider.DefaultReasoningOutputTokens - // deepSeekDefaultMaxTokens is safe only for the official DeepSeek Anthropic- - // compatible endpoint, whose reasoning models support the higher ceiling. - deepSeekDefaultMaxTokens = provider.DefaultHighOutputTokens + // defaultMaxTokens is the mandatory Anthropic fallback when neither config + // nor request supplies max_tokens. Ordinary turns use 16K; reasoning-capable + // paths raise via AutoOutputBudget. 128K is never automatic. + defaultMaxTokens = provider.DefaultOrdinaryOutputTokens ) func init() { @@ -111,11 +106,21 @@ func New(cfg provider.Config) (provider.Provider, error) { authHeader, _ := cfg.Extra["auth_header"].(bool) maxOutputTokens, _ := cfg.Extra["max_output_tokens"].(int) if maxOutputTokens <= 0 { - // Messages requires max_tokens, so an optional-budget disable request - // falls back to the provider's stable mandatory default. - maxOutputTokens = defaultMaxTokens + // Messages requires max_tokens. 0 = automatic; negative also falls back + // because the wire field is mandatory. + reasoningOn := officialDeepSeek && + !strings.EqualFold(thinking, "disabled") && + !strings.EqualFold(effort, "disabled") && + !strings.EqualFold(effort, "off") && + !strings.EqualFold(effort, "none") if officialDeepSeek { - maxOutputTokens = deepSeekDefaultMaxTokens + maxOutputTokens = provider.AutoOutputBudget(reasoningOn, effort) + } else { + // Native Anthropic and unknown gateways: conservative ordinary default. + maxOutputTokens = defaultMaxTokens + if strings.EqualFold(thinking, "adaptive") || strings.EqualFold(thinking, "enabled") { + maxOutputTokens = provider.AutoOutputBudget(true, effort) + } } } httpClient, err := newHTTPClient(cfg) @@ -298,19 +303,12 @@ func (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provi httpReq.Header.Set("x-api-key", c.apiKey) } httpReq.Header.Set("anthropic-version", anthropicVersion) - if req.ContextEditing != nil && req.ContextEditing.Mode == "native" && c.nativeAnthropic { - httpReq.Header.Set("anthropic-beta", anthropicContextManagementBeta) - } applyCustomHeaders(httpReq.Header, c.headers) return httpReq, nil } resp, err := provider.SendWithRetry(requestCtx, c.http, c.sendOpts(), newReq) if err != nil { - annotated := provider.AnnotateToolSchemaError(err, req.Tools) - if req.ContextEditing != nil && req.ContextEditing.Mode == "native" && c.nativeAnthropic && nativeContextEditingUnsupported(annotated) { - return nil, errors.Join(provider.ErrNativeContextEditingUnsupported, annotated) - } - return nil, annotated + return nil, provider.AnnotateToolSchemaError(err, req.Tools) } c.authed.Store(true) @@ -449,7 +447,6 @@ func (c *client) buildRequest(_ context.Context, req provider.Request) anthReque Tools: tools, Stream: true, } - applyNativeContextEditing(&r, req, c.nativeAnthropic) // Extended thinking is provider-specific. DeepSeek defaults to enabled and // accepts output_config.effort alongside its binary toggle. Anthropic proper // uses type=adaptive plus display/output_config. LongCat-style compatible @@ -543,7 +540,6 @@ func (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- tools := map[int]*provider.ToolCall{} // tool_use blocks, keyed by content index argBuckets := map[int]int{} // last emitted 2KB progress bucket per block var inTok, outTok, cacheCreate, cacheRead int - var contextEdits contextEditUsage var stopReason string haveUsage := false mergeUsage := func(usage *wireUsage) { @@ -664,7 +660,6 @@ func (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- stopReason = ev.Delta.StopReason } mergeUsage(ev.Usage) - contextEdits.observe(ev.ContextManagement) case "message_stop": // Anthropic's terminal event. Tool blocks may already have closed; // without this, the attempt stays speculative and is not committed. @@ -722,7 +717,6 @@ finalize: CacheWriteBilledTokens: cacheWriteBilledTokens, FinishReason: mapStopReason(stopReason), } - contextEdits.apply(usage) provider.ApplyRequestAttemptCount(ctx, usage) if !send(provider.Chunk{Type: provider.ChunkUsage, Usage: usage}) { return @@ -807,16 +801,15 @@ type cacheControl struct { } type anthRequest struct { - Model string `json:"model"` - MaxTokens int `json:"max_tokens"` - System []textBlock `json:"system,omitempty"` - Messages []anthMessage `json:"messages"` - Tools []anthTool `json:"tools,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - Thinking *thinkingConfig `json:"thinking,omitempty"` - OutputConfig *outputConfig `json:"output_config,omitempty"` - Stream bool `json:"stream"` - ContextManagement *contextManagement `json:"context_management,omitempty"` + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + System []textBlock `json:"system,omitempty"` + Messages []anthMessage `json:"messages"` + Tools []anthTool `json:"tools,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + Thinking *thinkingConfig `json:"thinking,omitempty"` + OutputConfig *outputConfig `json:"output_config,omitempty"` + Stream bool `json:"stream"` } type thinkingConfig struct { @@ -910,9 +903,8 @@ type streamEvent struct { StopReason string `json:"stop_reason"` // message_delta WebSearchResults json.RawMessage `json:"results"` // web_search_tool_result_delta } `json:"delta"` - Usage *wireUsage `json:"usage"` // message_delta (cumulative output_tokens) - ContextManagement *responseContextManagement `json:"context_management"` - Error *struct { + Usage *wireUsage `json:"usage"` // message_delta (cumulative output_tokens) + Error *struct { Type string `json:"type"` Message string `json:"message"` } `json:"error"` diff --git a/internal/provider/anthropic/anthropic_test.go b/internal/provider/anthropic/anthropic_test.go index e553b7ae36..4a781e1f9e 100644 --- a/internal/provider/anthropic/anthropic_test.go +++ b/internal/provider/anthropic/anthropic_test.go @@ -160,9 +160,10 @@ func TestNewSelectsMaxOutputTokenDefaultByEndpoint(t *testing.T) { extra map[string]any want int }{ - {name: "native anthropic", want: provider.DefaultReasoningOutputTokens}, - {name: "unknown compatible gateway", baseURL: "https://proxy.example.com/anthropic", want: provider.DefaultReasoningOutputTokens}, - {name: "official deepseek", baseURL: "https://api.deepseek.com/anthropic", want: provider.DefaultHighOutputTokens}, + {name: "native anthropic", want: provider.DefaultOrdinaryOutputTokens}, + {name: "unknown compatible gateway", baseURL: "https://proxy.example.com/anthropic", want: provider.DefaultOrdinaryOutputTokens}, + {name: "official deepseek", baseURL: "https://api.deepseek.com/anthropic", want: provider.DefaultReasoningOutputTokens}, + {name: "official deepseek high", baseURL: "https://api.deepseek.com/anthropic", extra: map[string]any{"effort": "high"}, want: provider.DefaultHighReasoningOutputTokens}, {name: "explicit override", baseURL: "https://api.deepseek.com/anthropic", extra: map[string]any{"max_output_tokens": 8192}, want: 8192}, } { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/provider/anthropic/context_editing.go b/internal/provider/anthropic/context_editing.go deleted file mode 100644 index 29586109a2..0000000000 --- a/internal/provider/anthropic/context_editing.go +++ /dev/null @@ -1,124 +0,0 @@ -package anthropic - -import ( - "errors" - "strings" - - "reasonix/internal/provider" -) - -const ( - anthropicContextManagementBeta = "context-management-2025-06-27" - anthropicToolClearPolicyVersion = "clear_tool_uses_20250919" -) - -func (c *client) ContextEditingCapabilities() provider.ContextEditingCapabilities { - if c == nil || !c.nativeAnthropic { - return provider.ContextEditingCapabilities{} - } - return provider.ContextEditingCapabilities{ - NativeToolUseClear: true, - PolicyVersion: anthropicToolClearPolicyVersion, - } -} - -type contextManagement struct { - Edits []contextEdit `json:"edits,omitempty"` -} - -type contextEdit struct { - Type string `json:"type"` - Trigger *contextEditTrigger `json:"trigger,omitempty"` - Keep *contextEditKeep `json:"keep,omitempty"` - ClearAtLeast *contextEditMinimum `json:"clear_at_least,omitempty"` - ClearToolInputs bool `json:"clear_tool_inputs,omitempty"` -} - -type contextEditTrigger struct { - Type string `json:"type"` - Value int `json:"value"` -} - -type contextEditKeep struct { - Type string `json:"type"` - Value int `json:"value"` -} - -type contextEditMinimum struct { - Type string `json:"type"` - Value int `json:"value"` -} - -func applyNativeContextEditing(r *anthRequest, req provider.Request, nativeAnthropic bool) { - if !nativeAnthropic || req.ContextEditing == nil || req.ContextEditing.Mode != "native" { - return - } - policy := req.ContextEditing - r.ContextManagement = &contextManagement{Edits: []contextEdit{{ - Type: anthropicToolClearPolicyVersion, - Trigger: &contextEditTrigger{Type: "input_tokens", Value: policy.TriggerInputTokens}, - Keep: &contextEditKeep{Type: "tool_uses", Value: policy.KeepToolUses}, - ClearAtLeast: &contextEditMinimum{Type: "input_tokens", Value: policy.ClearAtLeastInputTokens}, - ClearToolInputs: policy.ClearToolInputs, - }}} -} - -func nativeContextEditingUnsupported(err error) bool { - var apiErr *provider.APIError - if !errors.As(err, &apiErr) || (apiErr.Status != 400 && apiErr.Status != 422) { - return false - } - body := strings.ToLower(apiErr.Body) - mentionsFeature := strings.Contains(body, "context_management") || - strings.Contains(body, "context management") || - strings.Contains(body, anthropicContextManagementBeta) || - strings.Contains(body, anthropicToolClearPolicyVersion) || - strings.Contains(body, "anthropic-beta") - if !mentionsFeature { - return false - } - for _, marker := range []string{ - "unsupported", "not supported", "unrecognized", "unknown", "not permitted", - "not allowed", "extra inputs", "invalid beta", "beta header", - } { - if strings.Contains(body, marker) { - return true - } - } - return false -} - -type responseContextManagement struct { - AppliedEdits []appliedContextEdit `json:"applied_edits"` -} - -type appliedContextEdit struct { - Type string `json:"type"` - ClearedToolUses int `json:"cleared_tool_uses"` - ClearedInputTokens int `json:"cleared_input_tokens"` -} - -type contextEditUsage struct { - typeName string - clearedToolUses int - clearedInputTokens int -} - -func (u *contextEditUsage) observe(management *responseContextManagement) { - if management == nil { - return - } - for _, edit := range management.AppliedEdits { - if edit.Type == anthropicToolClearPolicyVersion { - u.typeName = edit.Type - u.clearedToolUses += edit.ClearedToolUses - u.clearedInputTokens += edit.ClearedInputTokens - } - } -} - -func (u contextEditUsage) apply(usage *provider.Usage) { - usage.ContextEditingType = u.typeName - usage.ContextEditingClearedToolUses = u.clearedToolUses - usage.ContextEditingClearedTokens = u.clearedInputTokens -} diff --git a/internal/provider/anthropic/context_editing_test.go b/internal/provider/anthropic/context_editing_test.go deleted file mode 100644 index ce198c8ea8..0000000000 --- a/internal/provider/anthropic/context_editing_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package anthropic - -import ( - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "reasonix/internal/provider" -) - -func TestBuildRequestNativeContextEditingIsExplicitAndOfficialOnly(t *testing.T) { - policy := &provider.ContextEditingPolicy{ - Mode: "native", TriggerInputTokens: 12000, KeepToolUses: 3, - ClearAtLeastInputTokens: 4096, - } - official := (&client{model: "claude-test", nativeAnthropic: true}).buildRequest(context.Background(), provider.Request{ - Messages: []provider.Message{{Role: provider.RoleUser, Content: "hello"}}, ContextEditing: policy, - }) - b, err := json.Marshal(official) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(b), `"context_management"`) || - !strings.Contains(string(b), `"clear_tool_uses_20250919"`) || - !strings.Contains(string(b), `"value":12000`) { - t.Fatalf("native context editing missing from official request: %s", b) - } - gateway := (&client{model: "gateway", nativeAnthropic: false}).buildRequest(context.Background(), provider.Request{ - Messages: []provider.Message{{Role: provider.RoleUser, Content: "hello"}}, ContextEditing: policy, - }) - gb, err := json.Marshal(gateway) - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(gb), `"context_management"`) { - t.Fatalf("compatible gateway must not receive native context editing: %s", gb) - } -} - -func TestStreamClassifiesOnlyNativeContextEditingRejections(t *testing.T) { - responses := []struct { - name string - body string - want bool - }{ - {name: "feature rejection", body: `{"error":{"message":"context_management: extra inputs are not permitted"}}`, want: true}, - {name: "unrelated bad request", body: `{"error":{"message":"tools[0].input_schema is invalid"}}`, want: false}, - } - for _, tc := range responses { - t.Run(tc.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("anthropic-beta"); got != anthropicContextManagementBeta { - t.Errorf("anthropic-beta = %q", got) - } - body, err := io.ReadAll(r.Body) - if err != nil { - t.Errorf("read request body: %v", err) - } - if !strings.Contains(string(body), `"context_management"`) { - t.Errorf("request lacks native context management: %s", body) - } - w.WriteHeader(http.StatusBadRequest) - _, _ = io.WriteString(w, tc.body) - })) - defer server.Close() - - c := &client{ - name: "anthropic-test", apiKey: "test", baseURL: server.URL, model: "claude-test", - nativeAnthropic: true, http: server.Client(), - } - _, err := c.Stream(context.Background(), provider.Request{ - Messages: []provider.Message{{Role: provider.RoleUser, Content: "hello"}}, - ContextEditing: &provider.ContextEditingPolicy{ - Mode: "native", TriggerInputTokens: 12_000, KeepToolUses: 3, - ClearAtLeastInputTokens: 4_096, - }, - }) - if got := provider.IsNativeContextEditingUnsupported(err); got != tc.want { - t.Fatalf("IsNativeContextEditingUnsupported(%v) = %t, want %t", err, got, tc.want) - } - }) - } -} - -func TestReadStreamReportsAppliedNativeToolClearing(t *testing.T) { - fixture := `event: message_start -data: {"type":"message_start","message":{"usage":{"input_tokens":100}}} - -event: message_delta -data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":2},"context_management":{"applied_edits":[{"type":"clear_tool_uses_20250919","cleared_tool_uses":8,"cleared_input_tokens":50000}]}} - -event: message_stop -data: {"type":"message_stop"} -` - c := &client{name: "anthropic", nativeAnthropic: true} - resp := &http.Response{Body: io.NopCloser(strings.NewReader(fixture))} - ch := make(chan provider.Chunk) - go c.readStream(context.Background(), resp, ch) - var usage *provider.Usage - for chunk := range ch { - if chunk.Type == provider.ChunkUsage { - usage = chunk.Usage - } - } - if usage == nil || usage.ContextEditingType != anthropicToolClearPolicyVersion || - usage.ContextEditingClearedToolUses != 8 || usage.ContextEditingClearedTokens != 50000 { - t.Fatalf("native applied edits usage = %+v", usage) - } -} diff --git a/internal/provider/anthropic/output_budget_test.go b/internal/provider/anthropic/output_budget_test.go index 7f089889f9..f0d0141186 100644 --- a/internal/provider/anthropic/output_budget_test.go +++ b/internal/provider/anthropic/output_budget_test.go @@ -3,11 +3,11 @@ package anthropic import "testing" func TestSharedWindowOutputBudgetCapability(t *testing.T) { - deepseek := &client{deepseek: true, defaultMaxTokens: 128 * 1024} - if !deepseek.SharesContextWindow() || deepseek.OutputBudget() != 128*1024 { + deepseek := &client{deepseek: true, defaultMaxTokens: 32 * 1024} + if !deepseek.SharesContextWindow() || deepseek.OutputBudget() != 32*1024 { t.Fatalf("DeepSeek capability = shared:%v budget:%d", deepseek.SharesContextWindow(), deepseek.OutputBudget()) } - anthropic := &client{defaultMaxTokens: 32 * 1024} + anthropic := &client{defaultMaxTokens: 16 * 1024} if anthropic.SharesContextWindow() { t.Fatal("native Anthropic mode must keep its independent output ceiling") } diff --git a/internal/provider/compaction.go b/internal/provider/compaction.go deleted file mode 100644 index 83952e7ecb..0000000000 --- a/internal/provider/compaction.go +++ /dev/null @@ -1,74 +0,0 @@ -package provider - -import ( - "context" - "errors" - "time" -) - -// ErrCompactionUnsupported is returned by a NativeCompactor when the provider -// or model has no native compaction endpoint. Agents treat it as a capability -// miss and fall back to ordinary summarization; it is not a request failure. -var ErrCompactionUnsupported = errors.New("provider: native compaction unsupported") - -// NativeCompactor is an optional Provider capability. Providers that do not -// implement it are handled by the agent's summarize fallback. -type NativeCompactor interface { - Compact(ctx context.Context, req CompactionRequest) (CompactionResult, error) -} - -// CompactionRequest is a provider-neutral native compaction call. -type CompactionRequest struct { - Messages []Message - Instructions string - MaxOutputTokens int - PromptCacheKey string - SessionID string -} - -// CompactionResult is the provider-neutral outcome of a native compaction call. -// Projection and Summary must not both be empty. -type CompactionResult struct { - Projection []Message - Summary string - Usage *Usage - ProviderRequestID string -} - -// Valid reports whether the result carries a usable projection and/or summary. -func (r CompactionResult) Valid() bool { - return len(r.Projection) > 0 || r.Summary != "" -} - -// CompactionCapabilities describes model/provider compaction and cache limits. -// Defaults come only from an explicit vendor/model capability table; unknown -// compatible gateways must not inherit a large shared default. -type CompactionCapabilities struct { - NativeCompaction bool - MaxOutputTokens int - CompactionOutputTokens int - CacheTTL time.Duration -} - -// CompactionCapabler is an optional Provider surface for capability lookup. -type CompactionCapabler interface { - CompactionCapabilities() CompactionCapabilities -} - -// AsNativeCompactor returns p when it implements NativeCompactor. -func AsNativeCompactor(p Provider) (NativeCompactor, bool) { - if p == nil { - return nil, false - } - nc, ok := p.(NativeCompactor) - return nc, ok -} - -// AsCompactionCapabler returns p when it implements CompactionCapabler. -func AsCompactionCapabler(p Provider) (CompactionCapabler, bool) { - if p == nil { - return nil, false - } - cc, ok := p.(CompactionCapabler) - return cc, ok -} diff --git a/internal/provider/compaction_test.go b/internal/provider/compaction_test.go deleted file mode 100644 index eb80d86ef9..0000000000 --- a/internal/provider/compaction_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package provider_test - -import ( - "context" - "errors" - "testing" - - "reasonix/internal/provider" - "reasonix/internal/provider/responses" -) - -type unsupportedCompactor struct{} - -func (unsupportedCompactor) Name() string { return "unsupported" } -func (unsupportedCompactor) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) { - return nil, errors.New("not implemented") -} -func (unsupportedCompactor) Compact(context.Context, provider.CompactionRequest) (provider.CompactionResult, error) { - return provider.CompactionResult{}, provider.ErrCompactionUnsupported -} - -func TestNativeCompactorCapabilityMiss(t *testing.T) { - var p provider.Provider = unsupportedCompactor{} - nc, ok := provider.AsNativeCompactor(p) - if !ok { - t.Fatal("expected NativeCompactor") - } - _, err := nc.Compact(context.Background(), provider.CompactionRequest{ - Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}, - PromptCacheKey: "ws|sess|model", - SessionID: "sess", - }) - if !errors.Is(err, provider.ErrCompactionUnsupported) { - t.Fatalf("err = %v, want ErrCompactionUnsupported", err) - } -} - -func TestResponsesCompactionCapabilities(t *testing.T) { - p := responses.New(responses.Config{ - Name: "deepseek", - BaseURL: "https://api.deepseek.com", - Model: "deepseek-v4-flash", - }) - cc, ok := provider.AsCompactionCapabler(p) - if !ok { - t.Fatal("responses client must expose CompactionCapabler") - } - caps := cc.CompactionCapabilities() - if caps.NativeCompaction { - t.Fatal("DeepSeek Responses must not claim native compaction yet") - } - if caps.CompactionOutputTokens != 4096 { - t.Fatalf("CompactionOutputTokens = %d, want 4096", caps.CompactionOutputTokens) - } - if caps.MaxOutputTokens != provider.DefaultHighOutputTokens { - t.Fatalf("MaxOutputTokens = %d, want %d", caps.MaxOutputTokens, provider.DefaultHighOutputTokens) - } - - unknown := responses.New(responses.Config{ - Name: "compat", - BaseURL: "https://example.invalid/v1", - Model: "mystery", - }) - ucaps := unknown.(provider.CompactionCapabler).CompactionCapabilities() - if ucaps.MaxOutputTokens != 0 || ucaps.CompactionOutputTokens != 0 { - t.Fatalf("unknown gateway must not inherit large defaults: %+v", ucaps) - } -} - -func TestCompactionResultValid(t *testing.T) { - if (provider.CompactionResult{}).Valid() { - t.Fatal("empty result must be invalid") - } - if !(provider.CompactionResult{Summary: "x"}).Valid() { - t.Fatal("summary-only result must be valid") - } - if !(provider.CompactionResult{Projection: []provider.Message{{Role: provider.RoleUser, Content: "y"}}}).Valid() { - t.Fatal("projection-only result must be valid") - } -} diff --git a/internal/provider/context_editing.go b/internal/provider/context_editing.go deleted file mode 100644 index b2a288708b..0000000000 --- a/internal/provider/context_editing.go +++ /dev/null @@ -1,46 +0,0 @@ -package provider - -import "errors" - -// ErrNativeContextEditingUnsupported marks an explicit provider rejection of -// the native context-management request shape. Callers may use it for the one -// permitted pre-success fallback to local maintenance; unrelated 4xx errors -// must remain ordinary request failures. -var ErrNativeContextEditingUnsupported = errors.New("native context editing is unsupported") - -func IsNativeContextEditingUnsupported(err error) bool { - return errors.Is(err, ErrNativeContextEditingUnsupported) -} - -// ContextEditingPolicy describes the stable Anthropic native tool-result edit -// policy. It is provider-neutral so frozen requests can include it without -// coupling agent code to Anthropic wire structs. -type ContextEditingPolicy struct { - Mode string `json:"mode,omitempty"` // local | native - TriggerInputTokens int `json:"triggerInputTokens,omitempty"` - KeepToolUses int `json:"keepToolUses,omitempty"` - ClearAtLeastInputTokens int `json:"clearAtLeastInputTokens,omitempty"` - ClearToolInputs bool `json:"clearToolInputs,omitempty"` -} - -// ContextEditingCapabilities is explicit because protocol compatibility alone -// does not make an endpoint support Anthropic's native edit semantics. -type ContextEditingCapabilities struct { - NativeToolUseClear bool - PolicyVersion string -} - -type ContextEditingCapabler interface { - ContextEditingCapabilities() ContextEditingCapabilities -} - -func ContextEditingCapabilitiesOf(p Provider) ContextEditingCapabilities { - if p == nil { - return ContextEditingCapabilities{} - } - capable, ok := p.(ContextEditingCapabler) - if !ok { - return ContextEditingCapabilities{} - } - return capable.ContextEditingCapabilities() -} diff --git a/internal/provider/openai/openai.go b/internal/provider/openai/openai.go index 573e7dff2f..a1cc552de5 100644 --- a/internal/provider/openai/openai.go +++ b/internal/provider/openai/openai.go @@ -219,11 +219,14 @@ func New(cfg provider.Config) (provider.Provider, error) { thinkingType: thinkingType, effort: effort, deepseek: deepseek, flash: deepseekV4Flash, minimax: minimax, zhipu: zhipu, longcat: longcat, ollamaCloud: ollamaCloud, explicit: hasExplicitEfforts, supported: supportedEfforts}) - // The automatic cap protects DeepSeek reasoning, not ordinary long-form - // output. Preserve an explicit user budget in either mode, but leave a - // thinking-disabled request uncapped unless the user configured one. - if maxOutputTokens == 0 && officialDeepSeek && thinkingType != "disabled" { - maxOutputTokens = provider.DefaultHighOutputTokens // DeepSeek supports up to 384K; 128K is a safe default for reasoning + // max_output_tokens=0 means automatic (not unlimited). DeepSeek reasoning + // uses 32K / high-max 64K; thinking-disabled stays ordinary 16K. 128K is + // never automatic — users must set it explicitly after length truncations. + // This budget never participates in compact_ratio. + autoMaxOutput := maxOutputTokens == 0 && officialDeepSeek + if autoMaxOutput { + reasoningOn := thinkingType != "disabled" && effort != "disabled" && effort != "off" && effort != "none" + maxOutputTokens = provider.AutoOutputBudget(reasoningOn, effort) } httpClient, err := newHTTPClient(cfg) if err != nil { @@ -250,6 +253,7 @@ func New(cfg provider.Config) (provider.Provider, error) { vision: vision, visionDetail: visionDetail, maxOutputTokens: maxOutputTokens, + autoMaxOutput: autoMaxOutput, effort: effort, requestEfforts: requestEfforts, http: httpClient, @@ -288,7 +292,8 @@ type client struct { thinkingType string // explicit `thinking` config override (enabled|disabled); "" = no override vision bool // model accepts image input — embed attached images as image_url parts visionDetail string // image_url detail hint (low|high); "" = auto/omit - maxOutputTokens int // configured/default total output budget; <=0 omits the optional field + maxOutputTokens int // resolved total output budget; <=0 omits the optional field + autoMaxOutput bool // true when max_output_tokens=0 (automatic ladder) effort string // reasoning_effort for OpenAI; thinking.type for MiniMax; "" = auto/provider default requestEfforts []string // depth levels a per-request EffortOverride may take; empty = overrides ignored idleTimeout time.Duration // SSE stall watchdog window; defaultStreamIdleTimeout unless a test overrides @@ -772,7 +777,15 @@ func (c *client) buildRequest(req provider.Request) chatRequest { maxOutputTokens := req.MaxTokens if maxOutputTokens == 0 { - maxOutputTokens = c.maxOutputTokens + if c.autoMaxOutput && c.deepseek { + // Re-resolve so per-request EffortOverride (high/max) can raise 32K→64K. + effort := c.requestEffort(req) + reasoningOn := c.thinkingType != "disabled" && + effort != "disabled" && effort != "off" && effort != "none" + maxOutputTokens = provider.AutoOutputBudget(reasoningOn, effort) + } else { + maxOutputTokens = c.maxOutputTokens + } } if maxOutputTokens < 0 { maxOutputTokens = 0 diff --git a/internal/provider/openai/openai_test.go b/internal/provider/openai/openai_test.go index 8363001484..0785b1320b 100644 --- a/internal/provider/openai/openai_test.go +++ b/internal/provider/openai/openai_test.go @@ -1244,9 +1244,24 @@ func TestBuildRequestUsesProviderSpecificOutputBudget(t *testing.T) { return p.(*client) } + // Official DeepSeek defaults effort to high when unset, so the automatic + // ladder lands on the 64K high-reasoning tier — not 128K. deepseek := newClient(t, "https://api.deepseek.com", "deepseek-v4-flash", 0).buildRequest(provider.Request{}) - if deepseek.MaxTokens != 131072 || deepseek.MaxCompletionTokens != 0 { - t.Fatalf("DeepSeek output budget = max_tokens %d, max_completion_tokens %d", deepseek.MaxTokens, deepseek.MaxCompletionTokens) + if deepseek.MaxTokens != provider.DefaultHighReasoningOutputTokens || deepseek.MaxCompletionTokens != 0 { + t.Fatalf("DeepSeek auto budget = max_tokens %d, max_completion_tokens %d, want high-reasoning %d", + deepseek.MaxTokens, deepseek.MaxCompletionTokens, provider.DefaultHighReasoningOutputTokens) + } + + lowEffort, err := New(provider.Config{ + Name: "test", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", + Extra: map[string]any{"effort": "low", "max_output_tokens": 0}, + }) + if err != nil { + t.Fatalf("New low-effort DeepSeek: %v", err) + } + lowReq := lowEffort.(*client).buildRequest(provider.Request{}) + if lowReq.MaxTokens != provider.DefaultReasoningOutputTokens { + t.Fatalf("low-effort auto budget = %d, want ordinary reasoning %d", lowReq.MaxTokens, provider.DefaultReasoningOutputTokens) } thinkingDisabledProvider, err := New(provider.Config{ @@ -1257,8 +1272,8 @@ func TestBuildRequestUsesProviderSpecificOutputBudget(t *testing.T) { t.Fatalf("New thinking-disabled DeepSeek: %v", err) } thinkingDisabled := thinkingDisabledProvider.(*client).buildRequest(provider.Request{}) - if thinkingDisabled.MaxTokens != 0 || thinkingDisabled.MaxCompletionTokens != 0 { - t.Fatalf("thinking-disabled DeepSeek received an automatic output budget: %+v", thinkingDisabled) + if thinkingDisabled.MaxTokens != provider.DefaultOrdinaryOutputTokens { + t.Fatalf("thinking-disabled DeepSeek auto budget = %d, want ordinary %d", thinkingDisabled.MaxTokens, provider.DefaultOrdinaryOutputTokens) } effortDisabledProvider, err := New(provider.Config{ Name: "test", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-pro", @@ -1268,8 +1283,8 @@ func TestBuildRequestUsesProviderSpecificOutputBudget(t *testing.T) { t.Fatalf("New effort-disabled DeepSeek: %v", err) } effortDisabled := effortDisabledProvider.(*client).buildRequest(provider.Request{}) - if effortDisabled.MaxTokens != 0 || effortDisabled.Thinking == nil || effortDisabled.Thinking.Type != "disabled" { - t.Fatalf("effort-disabled DeepSeek request = %+v, want thinking disabled without an automatic budget", effortDisabled) + if effortDisabled.MaxTokens != provider.DefaultOrdinaryOutputTokens || effortDisabled.Thinking == nil || effortDisabled.Thinking.Type != "disabled" { + t.Fatalf("effort-disabled DeepSeek request = %+v, want thinking disabled with ordinary %d budget", effortDisabled, provider.DefaultOrdinaryOutputTokens) } explicitDisabledProvider, err := New(provider.Config{ diff --git a/internal/provider/openai/output_budget_test.go b/internal/provider/openai/output_budget_test.go index e27f68d46e..acf37942ad 100644 --- a/internal/provider/openai/output_budget_test.go +++ b/internal/provider/openai/output_budget_test.go @@ -3,11 +3,11 @@ package openai import "testing" func TestSharedWindowOutputBudgetCapability(t *testing.T) { - deepseek := &client{deepseek: true, maxOutputTokens: 128 * 1024} - if !deepseek.SharesContextWindow() || deepseek.OutputBudget() != 128*1024 { + deepseek := &client{deepseek: true, maxOutputTokens: 32 * 1024} + if !deepseek.SharesContextWindow() || deepseek.OutputBudget() != 32*1024 { t.Fatalf("DeepSeek capability = shared:%v budget:%d", deepseek.SharesContextWindow(), deepseek.OutputBudget()) } - openai := &client{maxOutputTokens: 128 * 1024} + openai := &client{maxOutputTokens: 32 * 1024} if openai.SharesContextWindow() { t.Fatal("ordinary OpenAI mode must keep its independent output ceiling") } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 44bd668f79..bb6c5e1659 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -47,9 +47,11 @@ type Message struct { // Content is the provider-visible conversation content. Keeping this legacy // field provider-visible preserves replay for older CLI/Desktop releases. Content string `json:"content,omitempty"` - // RawContent is the user-authored form of a user turn, when it differs from - // Content because the host added transient context. Older releases ignore - // this field and still replay the provider-visible Content safely. + // RawContent holds the full original when it differs from Content: + // for user turns, the user-authored text before host-injected context; + // for tool turns, the complete tool result when first-visible Content was + // bounded. ModelMessages always clears it so provider serialization, prompt + // cache hashes, and projection hashes never include it. RawContent string `json:"raw_content,omitempty"` // ProviderContent is a transitional field written by early Context Engine v2 // builds. Loaders migrate it into Content/RawContent before normal use. @@ -221,10 +223,6 @@ type Request struct { // entirely — the common path must stay byte-stable for prompt caching. ResponseFormat *ResponseFormat `json:"ResponseFormat,omitempty"` EffortOverride string `json:"EffortOverride,omitempty"` // per-call reasoning-depth override; adapters apply it only when the endpoint's effort vocabulary accepts it - // ContextEditing is an explicit provider capability request. Providers that - // do not support native editing ignore it; local compaction remains the - // default when it is nil. - ContextEditing *ContextEditingPolicy `json:"ContextEditing,omitempty"` } // ResponseFormat asks a provider to constrain its output shape. @@ -234,19 +232,26 @@ type ResponseFormat struct { Type string `json:"type"` } -// DefaultReasoningOutputTokens is the conservative provider-side budget used -// for official reasoning APIs whose documented contract safely accepts 32K. -// Unknown compatible gateways must opt in through configuration instead of -// inheriting this value merely because they implement an OpenAI-shaped wire. -const DefaultReasoningOutputTokens = 32 * 1024 +// Auto ladder for max_output_tokens=0. Bounds completion only; never compact_ratio. +const ( + DefaultOrdinaryOutputTokens = 16 * 1024 // non-reasoning + DefaultReasoningOutputTokens = 32 * 1024 // ordinary reasoning + DefaultHighReasoningOutputTokens = 64 * 1024 // high/max effort + DefaultHighOutputTokens = 128 * 1024 // explicit only; never auto +) -// DefaultHighOutputTokens is the raised output budget for reasoning APIs whose -// documented contract safely accepts 128K-class ceilings (DeepSeek Responses -// API allows up to 384K; MiMo allows up to 131072). Long reasoning turns -// truncate under 32K, forcing many small write→test→fix iterations; a 128K -// budget lets the model finish in one pass. Kept in one place so the three -// protocols (Responses / Chat Completions / Anthropic) cannot drift apart. -const DefaultHighOutputTokens = 128 * 1024 +// AutoOutputBudget maps max_output_tokens=0 to 16K/32K/64K by reasoning effort. +func AutoOutputBudget(reasoningEnabled bool, effort string) int { + if !reasoningEnabled { + return DefaultOrdinaryOutputTokens + } + switch strings.ToLower(strings.TrimSpace(effort)) { + case "high", "max": + return DefaultHighReasoningOutputTokens + default: + return DefaultReasoningOutputTokens + } +} // TemperaturePtr wraps v in a pointer so callers that explicitly want a // specific temperature, including 0 for deterministic output, can distinguish @@ -747,10 +752,6 @@ type Usage struct { ContextReasoningTokens int ContextCacheHitTokens int ContextCacheMissTokens int - // Native context-editing observations from the latest committed request. - ContextEditingType string - ContextEditingClearedToolUses int - ContextEditingClearedTokens int } // ContextFillTokens returns the latest-attempt context fill (prompt+completion) diff --git a/internal/provider/responses/compaction.go b/internal/provider/responses/compaction.go deleted file mode 100644 index cc0331541a..0000000000 --- a/internal/provider/responses/compaction.go +++ /dev/null @@ -1,30 +0,0 @@ -package responses - -import ( - "context" - - "reasonix/internal/provider" -) - -// CompactionCapabilities reports model-level compaction budgets from the -// vendor capability table. Unknown endpoints report zero budgets so agents -// do not inherit a large default merely for being OpenAI-compatible. -func (c *client) CompactionCapabilities() provider.CompactionCapabilities { - if c == nil { - return provider.CompactionCapabilities{} - } - return provider.CompactionCapabilities{ - NativeCompaction: c.caps.nativeCompaction, - MaxOutputTokens: c.caps.defaultMaxOutputTokens, - CompactionOutputTokens: c.caps.compactionOutputTokens, - } -} - -// Compact implements provider.NativeCompactor. Responses vendors do not yet -// expose a dedicated compact endpoint, so this always returns -// ErrCompactionUnsupported and the agent falls back to ordinary summarize. -func (c *client) Compact(ctx context.Context, req provider.CompactionRequest) (provider.CompactionResult, error) { - _ = ctx - _ = req - return provider.CompactionResult{}, provider.ErrCompactionUnsupported -} diff --git a/internal/provider/responses/output_budget_test.go b/internal/provider/responses/output_budget_test.go index afd5917afe..8a96d9ef0e 100644 --- a/internal/provider/responses/output_budget_test.go +++ b/internal/provider/responses/output_budget_test.go @@ -3,8 +3,8 @@ package responses import "testing" func TestSharedWindowOutputBudgetCapability(t *testing.T) { - deepseek := &client{vendor: "deepseek", maxOutputTokens: 128 * 1024} - if !deepseek.SharesContextWindow() || deepseek.OutputBudget() != 128*1024 { + deepseek := &client{vendor: "deepseek", maxOutputTokens: 32 * 1024} + if !deepseek.SharesContextWindow() || deepseek.OutputBudget() != 32*1024 { t.Fatalf("DeepSeek capability = shared:%v budget:%d", deepseek.SharesContextWindow(), deepseek.OutputBudget()) } policy := deepseek.SharedWindowInputPolicy() diff --git a/internal/provider/responses/responses.go b/internal/provider/responses/responses.go index cb3903e5fc..7ddf33b3fa 100644 --- a/internal/provider/responses/responses.go +++ b/internal/provider/responses/responses.go @@ -126,14 +126,15 @@ func New(cfg Config) provider.Provider { vendor := DetectVendor(cfg.BaseURL) cap := capabilitiesFor(vendor) maxOutputTokens := cfg.MaxOutputTokens - // 默认输出预算从 vendor 表取(deepseek 128K / mimo 128K)——消除硬编码 - - // 常量分叉(review:responses.go 硬编码与 caps.defaultMaxOutputTokens - // 职责重叠)。条件保留:thinking-disabled 的 deepseek 请求不设自动 - // 预算(与 openai.go 一致——服务端默认即可;测试断言该行为)。 - if maxOutputTokens == 0 && cap.defaultMaxOutputTokens > 0 && - !(vendor == "deepseek" && responsesReasoningDisabled(cfg.Effort)) { - maxOutputTokens = cap.defaultMaxOutputTokens + // max_output_tokens=0 is automatic. Known vendors use the 16K/32K/64K ladder; + // thinking-disabled DeepSeek still gets the ordinary 16K auto budget. + // 128K is never chosen automatically. Compact_ratio is independent. + if maxOutputTokens == 0 && cap.defaultMaxOutputTokens > 0 { + if vendor == "deepseek" || vendor == "mimo" { + maxOutputTokens = responsesAutoOutputBudget(vendor, cfg.Effort) + } else { + maxOutputTokens = cap.defaultMaxOutputTokens + } } sessionCache := cap.sessionCacheHeader if cfg.SessionCache != nil { @@ -169,6 +170,20 @@ func responsesReasoningDisabled(effort string) bool { } } +// responsesAutoOutputBudget mirrors Chat Completions: DeepSeek defaults empty +// effort to high (64K), low stays 32K, thinking disabled is ordinary 16K. +// Never auto-selects 128K. +func responsesAutoOutputBudget(vendor, effort string) int { + if responsesReasoningDisabled(effort) { + return provider.AutoOutputBudget(false, effort) + } + e := strings.ToLower(strings.TrimSpace(effort)) + if vendor == "deepseek" && (e == "" || e == "auto") { + e = "high" + } + return provider.AutoOutputBudget(true, e) +} + func (c *client) Name() string { return c.name } // RequiresToolCallReasoning tells the agent to preserve stateless vendors' @@ -293,9 +308,9 @@ func (c *client) buildRequestBody(req provider.Request) (map[string]any, bool, [ maxOutputTokens = c.maxOutputTokens } if maxOutputTokens == 0 && c.caps.defaultMaxOutputTokens > 0 { - // 与 New() 构造期默认同条件:thinking-disabled 的 deepseek 请求 - // 不设自动预算(服务端默认即可——测试断言该行为)。 - if !(c.vendor == "deepseek" && responsesReasoningDisabled(c.effort)) { + if c.vendor == "deepseek" || c.vendor == "mimo" { + maxOutputTokens = responsesAutoOutputBudget(c.vendor, c.effort) + } else { maxOutputTokens = c.caps.defaultMaxOutputTokens } } diff --git a/internal/provider/responses/responses_test.go b/internal/provider/responses/responses_test.go index d45bee7da6..6113c011f5 100644 --- a/internal/provider/responses/responses_test.go +++ b/internal/provider/responses/responses_test.go @@ -106,15 +106,21 @@ func TestRequestUsesOnlySafeProviderOutputDefaults(t *testing.T) { deepseek := New(Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash"}).(*client) deepseekBody, _, _ := deepseek.buildRequestBody(provider.Request{Messages: message}) - if got := deepseekBody["max_output_tokens"]; got != 131072 { - t.Fatalf("DeepSeek max_output_tokens = %#v, want 131072", got) + if got := deepseekBody["max_output_tokens"]; got != provider.DefaultHighReasoningOutputTokens { + t.Fatalf("DeepSeek max_output_tokens = %#v, want high-reasoning auto %d", got, provider.DefaultHighReasoningOutputTokens) + } + + high := New(Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-pro", Effort: "high"}).(*client) + highBody, _, _ := high.buildRequestBody(provider.Request{Messages: message}) + if got := highBody["max_output_tokens"]; got != provider.DefaultHighReasoningOutputTokens { + t.Fatalf("high-effort DeepSeek budget = %#v, want %d", got, provider.DefaultHighReasoningOutputTokens) } for _, effort := range []string{"none", "disabled", "off", " NONE "} { thinkingDisabled := New(Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", Effort: effort}).(*client) thinkingDisabledBody, _, _ := thinkingDisabled.buildRequestBody(provider.Request{Messages: message}) - if _, exists := thinkingDisabledBody["max_output_tokens"]; exists { - t.Fatalf("thinking-disabled DeepSeek effort %q received an automatic output budget: %#v", effort, thinkingDisabledBody) + if got := thinkingDisabledBody["max_output_tokens"]; got != provider.DefaultOrdinaryOutputTokens { + t.Fatalf("thinking-disabled DeepSeek effort %q budget = %#v, want ordinary %d", effort, got, provider.DefaultOrdinaryOutputTokens) } } @@ -1148,27 +1154,23 @@ func TestReasoningMetaChunkEndToEnd(t *testing.T) { } } -// TestVendorTableMaxOutputTokens:默认输出预算完全由 vendor 表驱动—— -// mimo 128000(长思考不截断)、deepseek 128K、unknown 不设。 +// TestVendorTableMaxOutputTokens: automatic ladder is 16K/32K/64K, never 128K. func TestVendorTableMaxOutputTokens(t *testing.T) { msg := []provider.Message{{Role: provider.RoleUser, Content: "hi"}} - // mimo:表默认 128000(思考模式不设会顶到服务端 32768 截断) mimo := New(Config{Name: "mimo", BaseURL: "https://api.xiaomimimo.com/v1", Model: "mimo-v2.5-pro"}).(*client) body, _, _ := mimo.buildRequestBody(provider.Request{Messages: msg}) - if got := body["max_output_tokens"]; got != 128000 { - t.Fatalf("mimo max_output_tokens = %#v, want 128000 (vendor table)", got) + if got := body["max_output_tokens"]; got != provider.DefaultReasoningOutputTokens { + t.Fatalf("mimo max_output_tokens = %#v, want reasoning %d", got, provider.DefaultReasoningOutputTokens) } - // mimo 思考禁用也设 128000(mimo 无 thinking-disabled 豁免——表无条件) noThinking := New(Config{Name: "mimo", BaseURL: "https://api.xiaomimimo.com/v1", Model: "mimo-v2.5-pro", Effort: "none"}).(*client) nb, _, _ := noThinking.buildRequestBody(provider.Request{Messages: msg}) - if nb["max_output_tokens"] != 128000 { - t.Fatalf("mimo thinking-disabled budget = %#v, want 128000", nb["max_output_tokens"]) + if nb["max_output_tokens"] != provider.DefaultOrdinaryOutputTokens { + t.Fatalf("mimo thinking-disabled budget = %#v, want ordinary %d", nb["max_output_tokens"], provider.DefaultOrdinaryOutputTokens) } - // deepseek 值来自表(非硬编码常量) ds := New(Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-pro"}).(*client) db, _, _ := ds.buildRequestBody(provider.Request{Messages: msg}) - if got := db["max_output_tokens"]; got != capabilitiesFor("deepseek").defaultMaxOutputTokens { - t.Fatalf("deepseek budget must come from vendor table, got %#v", got) + if got := db["max_output_tokens"]; got != provider.DefaultHighReasoningOutputTokens { + t.Fatalf("deepseek auto budget = %#v, want high-reasoning %d", got, provider.DefaultHighReasoningOutputTokens) } } diff --git a/internal/provider/responses/vendor.go b/internal/provider/responses/vendor.go index 934265ad5f..f29aefa252 100644 --- a/internal/provider/responses/vendor.go +++ b/internal/provider/responses/vendor.go @@ -63,10 +63,6 @@ type vendorCapabilities struct { // back to ordinary summarize without inheriting a large default". compactionOutputTokens int - // nativeCompaction marks vendors with a dedicated compact endpoint. - // When false, agents must use ordinary summarize fallback. - nativeCompaction bool - // summaryRequired marks vendors whose Responses API requires the // `summary` list on input reasoning items (DashScope; without it the // server rejects with "Invalid 'summary': summary is required..."). The @@ -95,10 +91,12 @@ var vendorTable = map[string]vendorCapabilities{ toolCallReasoning: true, singleSegmentReasoning: false, ignoresTemperature: false, - defaultMaxOutputTokens: provider.DefaultHighOutputTokens, - // Compaction summaries are short briefings; keep the budget separate - // from ordinary answer output so a summary call cannot inherit 32K. - compactionOutputTokens: 4096, + // Auto ceiling for ordinary reasoning; high/max is applied via + // AutoOutputBudget at construction/request time (64K). Never 128K. + defaultMaxOutputTokens: provider.DefaultReasoningOutputTokens, + // Compaction summaries use a dedicated 16K-class budget, independent of + // ordinary answer output. + compactionOutputTokens: provider.DefaultOrdinaryOutputTokens, }, "mimo": { stateless: true, @@ -106,8 +104,9 @@ var vendorTable = map[string]vendorCapabilities{ toolCallReasoning: true, singleSegmentReasoning: true, ignoresTemperature: true, - defaultMaxOutputTokens: 128000, - compactionOutputTokens: 4096, + // Coding-agent default 32K; users may raise explicitly. Not 128K auto. + defaultMaxOutputTokens: provider.DefaultReasoningOutputTokens, + compactionOutputTokens: provider.DefaultOrdinaryOutputTokens, }, // "" (unknown OpenAI-compatible endpoint) → zero value = default behavior. // Unknown gateways deliberately do NOT inherit a large max-output default. diff --git a/reasonix.example.toml b/reasonix.example.toml index cdf88b8a26..3bef6f17b2 100644 --- a/reasonix.example.toml +++ b/reasonix.example.toml @@ -35,10 +35,7 @@ temperature = 0.0 # recovery_model = "" # optional reviewer for low-risk automatic recovery; falls back to guardian_model then main model # reasoning_language = "auto" # visible reasoning text: auto|zh|en # plan_mode_read_only_commands = ["gh issue view"] # legacy compatibility only; Plan bash uses Permissions -soft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact -tool_result_snip_ratio = 0.6 # snip stale tool results before summary compaction -compact_ratio = 0.8 # try compacting when prompt reaches this fraction -compact_force_ratio = 0.9 # force compacting at this high-water mark +compact_ratio = 0.85 # sole auto trigger; presets 0.70 (active) / 0.80 (steady) / 0.85 (recommended default) # planner_model = "deepseek-pro" # optional: enable two-model collaboration # subagent_model = "deepseek-pro" # optional default for runAs=subagent skills # subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" } # per-skill overrides @@ -64,6 +61,11 @@ models = ["deepseek-v4-flash", "deepseek-v4-pro"] default = "deepseek-v4-flash" # optional; defaults to the first of `models` api_key_env = "DEEPSEEK_API_KEY" context_window = 1000000 +# Per-turn total output budget (does not change compact_ratio): +# max_output_tokens = 0 # recommended: automatic (DeepSeek default high → ~64K) +# max_output_tokens = 32768 # ordinary coding / cost control +# max_output_tokens = 65536 # heavy reasoning / long tool loops +# max_output_tokens = 131072 # only after repeated finish_reason=length # Optional per-model context budgets. Omitted models inherit context_window. # model_overrides = { "deepseek-v4-flash" = { context_window = 1000000 } } # Official DeepSeek providers use CNY or USD regional prices from diff --git a/tools/repolint/baseline.json b/tools/repolint/baseline.json index b30d6db3c0..03e23bd694 100644 --- a/tools/repolint/baseline.json +++ b/tools/repolint/baseline.json @@ -2,14 +2,14 @@ "limits": { "banner": 15, "commented-code": 0, - "complexity": 2096, - "essay": 4082, - "file-size": 110104, - "function-size": 9159, + "complexity": 2101, + "essay": 4072, + "file-size": 110143, + "function-size": 9157, "layering": 1, "marker": 0, "narrative": 64, - "test-file-size": 69905 + "test-file-size": 69921 }, "files": { "cmd/e2ebench/main.go": { @@ -27,8 +27,8 @@ }, "desktop/app.go": { "complexity": 63, - "essay": 96, - "file-size": 11489, + "essay": 95, + "file-size": 11484, "function-size": 381 }, "desktop/app_autosave_test.go": { @@ -84,7 +84,7 @@ "essay": 2 }, "desktop/frontend/src/App.tsx": { - "file-size": 4733 + "file-size": 4736 }, "desktop/frontend/src/__tests__/app-chrome-tabs.test.ts": { "test-file-size": 19 @@ -125,6 +125,9 @@ "desktop/frontend/src/components/Composer.tsx": { "file-size": 4003 }, + "desktop/frontend/src/components/ContextPanel.tsx": { + "file-size": 16 + }, "desktop/frontend/src/components/MemoryPanel.tsx": { "file-size": 1088 }, @@ -138,7 +141,7 @@ "file-size": 141 }, "desktop/frontend/src/components/SettingsPanel.tsx": { - "file-size": 6768 + "file-size": 6754 }, "desktop/frontend/src/components/StatusBar.tsx": { "file-size": 20 @@ -150,7 +153,7 @@ "file-size": 158 }, "desktop/frontend/src/components/Transcript.tsx": { - "file-size": 438 + "file-size": 434 }, "desktop/frontend/src/components/UsageStatsPanel.tsx": { "file-size": 276 @@ -162,19 +165,19 @@ "file-size": 415 }, "desktop/frontend/src/lib/bridge.ts": { - "file-size": 4750 + "file-size": 4746 }, "desktop/frontend/src/lib/crash.ts": { "file-size": 225 }, "desktop/frontend/src/lib/transcriptStore.ts": { - "file-size": 307 + "file-size": 269 }, "desktop/frontend/src/lib/types.ts": { - "file-size": 1457 + "file-size": 1456 }, "desktop/frontend/src/lib/useController.ts": { - "file-size": 3929 + "file-size": 3935 }, "desktop/heartbeat.go": { "essay": 20, @@ -284,10 +287,10 @@ "desktop/settings_app.go": { "complexity": 22, "essay": 26, - "file-size": 2801 + "file-size": 2795 }, "desktop/settings_app_test.go": { - "test-file-size": 1857 + "test-file-size": 1858 }, "desktop/shared_host.go": { "essay": 1 @@ -308,7 +311,7 @@ "desktop/tabs.go": { "complexity": 74, "essay": 133, - "file-size": 8716, + "file-size": 8715, "function-size": 630 }, "desktop/tabs_order_test.go": { @@ -437,9 +440,9 @@ }, "internal/agent/agent.go": { "complexity": 37, - "essay": 104, - "file-size": 2478, - "function-size": 103 + "essay": 97, + "file-size": 2508, + "function-size": 102 }, "internal/agent/ask.go": { "essay": 4 @@ -459,14 +462,11 @@ "internal/agent/capability_gate_test.go": { "essay": 1 }, - "internal/agent/compact.go": { - "essay": 4 - }, - "internal/agent/compact_projection.go": { - "essay": 1 - }, "internal/agent/compact_test.go": { - "essay": 3 + "essay": 2 + }, + "internal/agent/context_manager.go": { + "essay": 1 }, "internal/agent/coordinator.go": { "essay": 15, @@ -493,13 +493,13 @@ }, "internal/agent/execute_batch.go": { "complexity": 44, - "function-size": 207 + "function-size": 208 }, "internal/agent/execute_one.go": { - "complexity": 13, + "complexity": 16, "essay": 23, - "file-size": 157, - "function-size": 20 + "file-size": 155, + "function-size": 29 }, "internal/agent/extensions.go": { "essay": 64 @@ -507,7 +507,7 @@ "internal/agent/extensions_test.go": { "essay": 4, "narrative": 1, - "test-file-size": 1020 + "test-file-size": 1023 }, "internal/agent/final_readiness.go": { "complexity": 24, @@ -551,12 +551,6 @@ "internal/agent/profile_spec.go": { "essay": 4 }, - "internal/agent/prune.go": { - "essay": 2 - }, - "internal/agent/prune_test.go": { - "essay": 1 - }, "internal/agent/reasoning_language.go": { "essay": 2 }, @@ -579,7 +573,7 @@ "internal/agent/run_loop.go": { "complexity": 1, "essay": 32, - "file-size": 174 + "file-size": 179 }, "internal/agent/save.go": { "complexity": 49, @@ -645,7 +639,7 @@ "internal/agent/task.go": { "complexity": 25, "essay": 53, - "file-size": 1277, + "file-size": 1266, "function-size": 114 }, "internal/agent/task_test.go": { @@ -674,10 +668,10 @@ "essay": 1 }, "internal/boot/boot.go": { - "complexity": 295, + "complexity": 298, "essay": 102, - "file-size": 2226, - "function-size": 1854, + "file-size": 2238, + "function-size": 1866, "narrative": 3 }, "internal/boot/boot_test.go": { @@ -977,8 +971,8 @@ "essay": 2 }, "internal/config/config.go": { - "essay": 50, - "file-size": 1500, + "essay": 54, + "file-size": 1503, "narrative": 2 }, "internal/config/credentials.go": { @@ -999,7 +993,7 @@ }, "internal/config/edit.go": { "essay": 15, - "file-size": 1673 + "file-size": 1663 }, "internal/config/edit_test.go": { "test-file-size": 2418 @@ -1013,7 +1007,7 @@ }, "internal/config/load.go": { "essay": 24, - "file-size": 1647, + "file-size": 1716, "function-size": 53 }, "internal/config/main_test.go": { @@ -1053,13 +1047,13 @@ "file-size": 267 }, "internal/config/render.go": { - "complexity": 250, + "complexity": 243, "essay": 2, - "file-size": 1015, - "function-size": 967 + "file-size": 989, + "function-size": 941 }, "internal/config/render_test.go": { - "test-file-size": 761 + "test-file-size": 757 }, "internal/config/vision.go": { "essay": 1 @@ -1073,13 +1067,13 @@ "internal/control/controller.go": { "complexity": 10, "essay": 162, - "file-size": 5420, + "file-size": 5416, "function-size": 75, "narrative": 4 }, "internal/control/controller_test.go": { "essay": 10, - "test-file-size": 4610 + "test-file-size": 4608 }, "internal/control/errmsg.go": { "essay": 2 @@ -1166,9 +1160,9 @@ "essay": 1 }, "internal/control/turn_orchestrator.go": { - "complexity": 2, + "complexity": 1, "essay": 13, - "function-size": 60 + "function-size": 55 }, "internal/control/turn_orchestrator_test.go": { "essay": 1, @@ -1533,42 +1527,42 @@ "internal/provider/anthropic/anthropic.go": { "complexity": 45, "essay": 36, - "file-size": 126, - "function-size": 158 + "file-size": 118, + "function-size": 154 }, "internal/provider/anthropic/anthropic_test.go": { "essay": 1, - "test-file-size": 250 + "test-file-size": 251 }, "internal/provider/openai/host.go": { "essay": 8 }, "internal/provider/openai/openai.go": { - "complexity": 60, - "essay": 40, - "file-size": 530, - "function-size": 251 + "complexity": 67, + "essay": 41, + "file-size": 543, + "function-size": 263 }, "internal/provider/openai/openai_test.go": { "essay": 5, - "test-file-size": 1557 + "test-file-size": 1572 }, "internal/provider/openai/realcache_test.go": { "essay": 7 }, "internal/provider/provider.go": { - "essay": 50, - "file-size": 361 + "essay": 51, + "file-size": 362 }, "internal/provider/responses/responses.go": { "complexity": 54, "essay": 25, - "file-size": 83, + "file-size": 98, "function-size": 181 }, "internal/provider/responses/responses_test.go": { "essay": 6, - "test-file-size": 374 + "test-file-size": 376 }, "internal/provider/responses/vendor.go": { "essay": 21