From 76ed2566edc5cf80676abdc8f1c2ae2f56e18fcc Mon Sep 17 00:00:00 2001 From: yhh <359807859@qq.com> Date: Fri, 6 Nov 2026 10:02:32 -0800 Subject: [PATCH] feat(context): a fold archives the whole span and keeps nothing: no first-turn pin, recent tail, kept user turns, user-words list, work index or summary; the pointer is one short message and keep, recent_keep, user_turn_keep_tokens and first_turn_pin_tokens are retired --- benchmarks/compaction/main.go | 4 - benchmarks/context-maintenance-e2e/main.go | 1 - benchmarks/context-retrieval/capture.go | 3 +- benchmarks/context-retrieval/fixture.go | 3 +- benchmarks/context-retrieval/run.go | 3 +- benchmarks/context-retrieval/snippet.go | 4 +- docs/SPEC.md | 54 +-- internal/assembly/boot/boot.go | 17 - internal/assembly/boot/boot_test.go | 13 - internal/assembly/boot/build.go | 6 +- internal/assembly/boot/compaction_budgets.go | 2 - .../compaction_composition_effect_test.go | 99 ----- .../boot/compaction_retention_effect_test.go | 22 +- internal/assembly/boot/delegation.go | 2 - .../instructions_removal_after_fold_test.go | 80 ---- internal/assembly/boot/roles.go | 3 - .../contract/config/agent_roundtrip_test.go | 4 + internal/contract/config/config.go | 18 +- internal/contract/config/render.go | 20 - internal/contract/config/render_agent.go | 8 - internal/contract/config/render_test.go | 6 - internal/runtime/agent/agent.go | 15 - internal/runtime/agent/agent_config.go | 9 +- internal/runtime/agent/agent_options.go | 6 +- internal/runtime/agent/cachehit_e2e_test.go | 1 - internal/runtime/agent/candidate_holds.go | 4 +- internal/runtime/agent/commit_covered_test.go | 2 +- internal/runtime/agent/compact.go | 222 ++-------- .../runtime/agent/compact_ablation_test.go | 126 ------ internal/runtime/agent/compact_archive.go | 5 +- .../runtime/agent/compact_archive_test.go | 42 +- .../runtime/agent/compact_commit_emit_test.go | 6 +- internal/runtime/agent/compact_coverage.go | 160 -------- .../runtime/agent/compact_coverage_test.go | 102 ----- .../agent/compact_failure_code_test.go | 2 +- internal/runtime/agent/compact_fold_input.go | 114 +----- .../runtime/agent/compact_helpers_test.go | 1 - internal/runtime/agent/compact_index.go | 314 -------------- internal/runtime/agent/compact_index_test.go | 145 ------- .../runtime/agent/compact_kept_failures.go | 33 -- internal/runtime/agent/compact_kept_marked.go | 37 -- .../runtime/agent/compact_longhorizon_test.go | 80 ---- .../runtime/agent/compact_loop_e2e_test.go | 40 +- .../agent/compact_manual_scope_test.go | 3 +- .../agent/compact_manual_verdict_test.go | 3 +- .../agent/compact_mechanical_users_test.go | 77 ---- .../runtime/agent/compact_partition_test.go | 160 -------- .../runtime/agent/compact_plan_bench_test.go | 32 +- internal/runtime/agent/compact_plan_test.go | 30 -- internal/runtime/agent/compact_projection.go | 89 +--- .../runtime/agent/compact_remains_test.go | 4 +- internal/runtime/agent/compact_retention.go | 20 - .../agent/compact_retention_trigger_test.go | 19 - .../agent/compact_tail_trigger_test.go | 99 ----- internal/runtime/agent/compact_test.go | 382 +----------------- .../runtime/agent/compact_threshold_test.go | 25 -- internal/runtime/agent/compact_user_turns.go | 104 ----- .../runtime/agent/compact_user_turns_test.go | 160 -------- .../runtime/agent/compaction_budgets_test.go | 80 +--- .../agent/compaction_latch_audit_test.go | 3 +- .../compaction_sidecar_authority_test.go | 3 +- .../agent/context_block_release_test.go | 3 +- .../context_maintenance_issue7935_test.go | 1 - .../agent/context_maintenance_restart_test.go | 3 - internal/runtime/agent/context_manager.go | 6 +- .../runtime/agent/context_manager_test.go | 3 +- .../runtime/agent/context_overflow_test.go | 5 - internal/runtime/agent/context_report.go | 20 +- internal/runtime/agent/context_report_test.go | 9 +- .../agent/context_summary_failure_gap_test.go | 2 +- internal/runtime/agent/context_usage_test.go | 4 +- internal/runtime/agent/context_window_host.go | 3 +- internal/runtime/agent/extensions_test.go | 2 +- .../agent/failure_survives_folds_test.go | 58 --- .../runtime/agent/keep_failure_budget_test.go | 89 ---- .../runtime/agent/keep_group_split_test.go | 54 +-- .../runtime/agent/keep_marked_budget_test.go | 85 ---- internal/runtime/agent/kept_projection.go | 19 - .../runtime/agent/kept_projection_test.go | 97 ----- internal/runtime/agent/projection_test.go | 296 +------------- .../runtime/agent/projection_valid_test.go | 2 - .../agent/revalidate_projection_test.go | 2 +- internal/runtime/agent/touched_paths_test.go | 29 -- internal/runtime/agent/usage_anchor_test.go | 8 +- internal/session/control/adopt_notice_test.go | 2 +- internal/session/control/context_command.go | 8 - .../session/control/fold_not_reused_test.go | 2 +- internal/session/control/resume_prune_test.go | 4 +- 88 files changed, 209 insertions(+), 3738 deletions(-) delete mode 100644 internal/assembly/boot/compaction_composition_effect_test.go delete mode 100644 internal/assembly/boot/instructions_removal_after_fold_test.go delete mode 100644 internal/runtime/agent/compact_ablation_test.go delete mode 100644 internal/runtime/agent/compact_coverage.go delete mode 100644 internal/runtime/agent/compact_coverage_test.go delete mode 100644 internal/runtime/agent/compact_index.go delete mode 100644 internal/runtime/agent/compact_index_test.go delete mode 100644 internal/runtime/agent/compact_kept_failures.go delete mode 100644 internal/runtime/agent/compact_kept_marked.go delete mode 100644 internal/runtime/agent/compact_mechanical_users_test.go delete mode 100644 internal/runtime/agent/compact_partition_test.go delete mode 100644 internal/runtime/agent/compact_retention.go delete mode 100644 internal/runtime/agent/compact_retention_trigger_test.go delete mode 100644 internal/runtime/agent/compact_tail_trigger_test.go delete mode 100644 internal/runtime/agent/compact_user_turns.go delete mode 100644 internal/runtime/agent/compact_user_turns_test.go delete mode 100644 internal/runtime/agent/keep_failure_budget_test.go delete mode 100644 internal/runtime/agent/keep_marked_budget_test.go diff --git a/benchmarks/compaction/main.go b/benchmarks/compaction/main.go index b1cb56d661..ea9a472dc6 100644 --- a/benchmarks/compaction/main.go +++ b/benchmarks/compaction/main.go @@ -103,10 +103,6 @@ func newHarness(t *testingDir, p provider.Provider, window int, rec *callRecorde ContextWindow: window, ArchiveDir: filepath.Join(t.dir, "archive"), SessionPath: path, - RecentKeep: 4, - // boot's default when cfg.Agent.Keep is unset; without it the bench - // would measure a configuration no real session runs. - KeepPolicy: agent.KeepErrors, }, rec.sink()) return &harness{sess: sess, agentA: a, path: path, calls: rec} } diff --git a/benchmarks/context-maintenance-e2e/main.go b/benchmarks/context-maintenance-e2e/main.go index 5bef8eb23c..872b3ec2d5 100644 --- a/benchmarks/context-maintenance-e2e/main.go +++ b/benchmarks/context-maintenance-e2e/main.go @@ -108,7 +108,6 @@ func newAgent(p provider.Provider, sess *sessionstore.Session, path string, sink return agent.New(p, tool.NewRegistry(), sess, agent.Options{ ContextWindow: windowTokens, CompactRatio: compactRatio, - RecentKeep: 2, SessionPath: path, WorkspaceID: "cm-e2e", ModelRef: "deepseek/" + model, diff --git a/benchmarks/context-retrieval/capture.go b/benchmarks/context-retrieval/capture.go index 2bf0443e92..e11aab1a7d 100644 --- a/benchmarks/context-retrieval/capture.go +++ b/benchmarks/context-retrieval/capture.go @@ -62,8 +62,7 @@ func captureRequest(f builtFixture, arm ablation.Set, prompt string) (provider.R cap := &captureProvider{} a := agent.New(cap, reg, sess, agent.Options{ - ContextWindow: fixtureWindow, CompactRatio: 0.5, RecentKeep: 2, - SessionPath: f.Path, KeepPolicy: agent.KeepErrors, Ablation: arm, + ContextWindow: fixtureWindow, CompactRatio: 0.5, SessionPath: f.Path, Ablation: arm, }, event.Discard) a.LoadProjectionSidecar(f.Path) diff --git a/benchmarks/context-retrieval/fixture.go b/benchmarks/context-retrieval/fixture.go index dcc38d2d8a..3b3ad4df21 100644 --- a/benchmarks/context-retrieval/fixture.go +++ b/benchmarks/context-retrieval/fixture.go @@ -83,8 +83,7 @@ func buildFixture(f fixtureInstance, arm ablation.Set, sessionPath string) (buil at := -1 a := agent.New(&digestProvider{}, tool.NewRegistry(), sess, agent.Options{ - ContextWindow: fixtureWindow, CompactRatio: 0.5, RecentKeep: 2, - SessionPath: sessionPath, KeepPolicy: agent.KeepErrors, Ablation: arm, + ContextWindow: fixtureWindow, CompactRatio: 0.5, SessionPath: sessionPath, Ablation: arm, }, event.Discard) for gen := range fixtureGenerations { diff --git a/benchmarks/context-retrieval/run.go b/benchmarks/context-retrieval/run.go index cb055a9ca1..0c6d5fd4a6 100644 --- a/benchmarks/context-retrieval/run.go +++ b/benchmarks/context-retrieval/run.go @@ -134,8 +134,7 @@ func runOne(p provider.Provider, t contextTask, arm ablation.Set, armName, root var usage usageSink a := agent.New(p, reg, sess, agent.Options{ - ContextWindow: fixtureWindow, CompactRatio: 0.5, RecentKeep: 2, - SessionPath: f.Path, KeepPolicy: agent.KeepErrors, Ablation: arm, + ContextWindow: fixtureWindow, CompactRatio: 0.5, SessionPath: f.Path, Ablation: arm, }, &usage) a.LoadProjectionSidecar(f.Path) // From here the transcript lives only in memory. On disk it is the answer. diff --git a/benchmarks/context-retrieval/snippet.go b/benchmarks/context-retrieval/snippet.go index eec069db7b..6861f75cdd 100644 --- a/benchmarks/context-retrieval/snippet.go +++ b/benchmarks/context-retrieval/snippet.go @@ -120,9 +120,7 @@ func reportShippedPath(root string) { continue } a := agent.New(&digestProvider{}, tool.NewRegistry(), f.Session, agent.Options{ - ContextWindow: fixtureWindow, CompactRatio: 0.5, RecentKeep: 2, - SessionPath: f.Path, KeepPolicy: agent.KeepErrors, - }, event.Discard) + ContextWindow: fixtureWindow, CompactRatio: 0.5, SessionPath: f.Path}, event.Discard) a.LoadProjectionSidecar(f.Path) queries := append([]string{inst.ProbeQuery}, historicalQueries[t.ID]...) for _, q := range queries { diff --git a/docs/SPEC.md b/docs/SPEC.md index 51d5f05c65..d8bef800dc 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -300,15 +300,13 @@ when the sole automatic threshold is crossed. **prior digest + new history** into a single digest (no multi-span merge). Failure records a generation-scoped `blocked`/`failed` receipt; the same generation does not pay for another automatic summary. Manual `compress` can retry. -- A summary cut at the output limit is retried once with a larger output cap; - if that is cut too, the failure keeps its typed code and records the cap. - 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. +- The active tool turn is the only protected content. `keep`, `recent_keep`, + `user_turn_keep_tokens` and `first_turn_pin_tokens` are retired and ignored. + Restart restores an existing checkpoint without 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. @@ -347,42 +345,16 @@ when the sole automatic threshold is crossed. See [`SESSION_MEMORY_RETRIEVAL.md`](SESSION_MEMORY_RETRIEVAL.md) for the detailed implementation contract. -**What survives a fold.** Verbatim, at every compaction: the system prompt, the -first user turn when it is small enough to be a brief, **every user turn in the -fold region that fits the retention budget**, and the recent tail. The messages -the keep policy protects also survive, though a failure with a recorded execution -keeps only its failure-carrying lines. Everything else is **best-effort** — it -reaches the summarizer and survives only as well as the digest captured it. - -That protection has to hold across *repeated* folds, which is why a stored -projection keeps the host's `ToolExecution` record while a provider request does -not. `KeepErrors` classifies a failure from that record rather than from text, -because a real `go test` log opens with `=== RUN` and no prefix match can see -it; a projection written without the record would leave the *next* fold unable -to classify what the current one just protected. The strip therefore belongs at -the provider boundary — `ModelMessages` — and not at projection write time, -where `ProjectionMessages` preserves it. - -User turns are held to a different standard than the work they govern. A -constraint stated at turn 4 ("do not change the public API") exists nowhere but -the transcript, while the code it constrains stays re-derivable from the -workspace — so the asymmetry of loss, not the token count, decides. Retention is -bounded rather than unconditional, because hoisting user turns without a budget -is what padded an earlier revision's candidates past the acceptance ceiling, -failing compaction outright instead of degrading it. One turn may spend up to -1500 tokens and all of them together `min(8192, window×5%)`, oldest first — the -recent tail already covers the newest turns, and an old turn has survived more -folds than a new one. Unlike the keep policy this is not scoped to the latest -digest, so a constraint keeps its protection across repeated compaction. - -A turn past those bounds folds like any other content. Prefix it with `[[keep]]` -(keep policy `user_marked`, on by default) to hold it verbatim regardless of -its own size. - -Marked turns have a budget of their own: together they may spend `window×15%`, -newest first, and the oldest fold into the digest past it. A kept turn rides -into every later fold, so an unbounded mark would grow what each fold carries -until its candidate could not land under the trigger. +**What survives a fold.** Nothing of the conversation. A fold writes the whole +folded span, verbatim, to `fold-.md` beside the transcript and leaves one +pointer message that names the file; the model reads it back with `read_file` or +`grep` when it needs something. The system prompt, and the request that began a +running turn together with the tool calls it has not yet closed, are the only +messages a fold cannot move, because a provider rejects a transcript whose calls +never resolve. No summary is written, no user turn is pinned, and no recent tail +is kept. If the archive cannot be written the fold fails with a typed error; no +digest stands in for it. A second fold waits for a tail's worth of new closed +history, so a fold always frees more than it costs. That drop is never silent: the finished-fold notice (`compaction_telemetry`, typed payload) carries `user_turns_kept` and `user_turns_dropped` counts, and a committed checkpoint that had to fold one of your diff --git a/internal/assembly/boot/boot.go b/internal/assembly/boot/boot.go index 6548294396..991fad6018 100644 --- a/internal/assembly/boot/boot.go +++ b/internal/assembly/boot/boot.go @@ -27,7 +27,6 @@ import ( "reasonix/internal/contract/tool" "reasonix/internal/ext/skill" "reasonix/internal/platform/lsp" - "reasonix/internal/runtime/agent" "reasonix/internal/runtime/delegation" "reasonix/internal/safety/permission" "reasonix/internal/safety/sandbox" @@ -41,22 +40,6 @@ import ( // removed provider. Callers can detect it (errors.Is) to re-run setup. var ErrUnknownModel = provider.ErrUnknownModel -func agentKeepPolicy(keep []string) agent.KeepPolicy { - if keep == nil { - return agent.KeepErrors | agent.KeepUserMarked - } - var p agent.KeepPolicy - for _, k := range keep { - switch strings.TrimSpace(k) { - case "errors": - p |= agent.KeepErrors - case "user_marked": - p |= agent.KeepUserMarked - } - } - return p -} - func recoveryHeadlessMode(opts Options) bool { return strings.TrimSpace(opts.HeadlessApprovalMode) != "" } diff --git a/internal/assembly/boot/boot_test.go b/internal/assembly/boot/boot_test.go index 0e695fa5e1..ccfa9ef408 100644 --- a/internal/assembly/boot/boot_test.go +++ b/internal/assembly/boot/boot_test.go @@ -34,7 +34,6 @@ import ( "reasonix/internal/ext/plugin" "reasonix/internal/ext/pluginpkg" "reasonix/internal/ext/skill" - "reasonix/internal/runtime/agent" "reasonix/internal/runtime/agent/testutil" "reasonix/internal/runtime/delegation" "reasonix/internal/safety/sandbox" @@ -53,18 +52,6 @@ func newProviderForTest(e *config.ProviderEntry) (provider.Provider, error) { return NewProviderWithProxy(e, netclient.ProxySpec{Mode: netclient.ModeAuto}) } -func TestAgentKeepPolicyFromConfig(t *testing.T) { - if got := agentKeepPolicy(nil); got != agent.KeepErrors|agent.KeepUserMarked { - t.Fatalf("nil keep policy = %v, want KeepErrors|KeepUserMarked", got) - } - if got := agentKeepPolicy([]string{}); got != 0 { - t.Fatalf("empty keep policy = %v, want 0", got) - } - if got := agentKeepPolicy([]string{"errors", "user_marked"}); got != agent.KeepErrors|agent.KeepUserMarked { - t.Fatalf("combined keep policy = %v, want errors|user_marked", got) - } -} - func TestBuildRecordsRuntimeSelectionEffort(t *testing.T) { home := isolateConfigHome(t) reasonixHome := filepath.Join(home, ".reasonix") diff --git a/internal/assembly/boot/build.go b/internal/assembly/boot/build.go index 54f7ce966e..6c6a0be079 100644 --- a/internal/assembly/boot/build.go +++ b/internal/assembly/boot/build.go @@ -63,7 +63,6 @@ type builder struct { pendingMgr *sidecar.Manager // sidecars no RuntimeSet owns yet providers providerStage model modelSelection - keep agent.KeepPolicy session sessionRuntime balanceClient *http.Client keyedClient *http.Client // carries provider keys; refuses cross-origin redirects @@ -254,7 +253,6 @@ func (b *builder) load() error { if b.model, err = selectModel(opts, cfg, b.providers.extension); err != nil { return err } - b.keep = agentKeepPolicy(cfg.Agent.Keep) migrations.report(b.sink, cfg) b.timer.mark("config") @@ -410,7 +408,7 @@ func (b *builder) wireTools() error { } t.roles = roleWiring{cfg: cfg, roots: b.roots, resolver: b.providers.effective, extension: b.providers.extension, - proxy: b.proxy, sink: b.sink, gate: t.gate, reg: t.reg, keep: b.keep, hooks: t.hookRunner, checkTargetAccess: t.env.targetAccessCheck()} + proxy: b.proxy, sink: b.sink, gate: t.gate, reg: t.reg, hooks: t.hookRunner, checkTargetAccess: t.env.targetAccessCheck()} t.sub = newSubagentConfig(opts, cfg, b.model.entry, b.model.name, b.providers.effective, b.proxy, b.prompt.skillStore) if t.sub.inheritedEffortDropped { report(b.sink, event.Event{ @@ -531,10 +529,8 @@ func (b *builder) executor() *agent.Agent { CapabilityAudit: t.caps.audit, ContextWindow: entry.ContextWindow, CompactRatio: cfg.Agent.CompactRatio, - RecentKeep: cfg.Agent.RecentKeep, CompactionBudgets: compactionBudgets(cfg), ArchiveDir: b.roots.ArchiveDir(), - KeepPolicy: b.keep, ReasoningLanguage: cfg.ReasoningLanguage(), SubagentDepth: 0, MaxSubagentDepth: t.sub.maxDepth, diff --git a/internal/assembly/boot/compaction_budgets.go b/internal/assembly/boot/compaction_budgets.go index eac4f91b6a..8d42f8db55 100644 --- a/internal/assembly/boot/compaction_budgets.go +++ b/internal/assembly/boot/compaction_budgets.go @@ -13,8 +13,6 @@ func compactionBudgets(cfg *config.Config) agent.CompactionBudgets { return agent.CompactionBudgets{} } return agent.CompactionBudgets{ - UserTurnKeepTokens: cfg.Agent.UserTurnKeepTokens, - FirstTurnPinTokens: cfg.Agent.FirstTurnPinTokens, CheckpointCeilingRatio: cfg.Agent.CheckpointCeilingRatio, } } diff --git a/internal/assembly/boot/compaction_composition_effect_test.go b/internal/assembly/boot/compaction_composition_effect_test.go deleted file mode 100644 index 1752cbcf02..0000000000 --- a/internal/assembly/boot/compaction_composition_effect_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package boot - -import ( - "context" - "encoding/json" - "strings" - "sync" - "testing" - - "reasonix/internal/base/testenv" - "reasonix/internal/contract/event" - "reasonix/internal/contract/provider" - "reasonix/internal/runtime/agent" - "reasonix/internal/state/sessionstore" -) - -// A session whose prompt is over the compaction threshold and has nothing left -// to fold used to answer /compact with only "nothing has closed". Through the -// real Build, the frontend sink must also receive what the tokens are. -func TestEffectCompactOverThresholdReportsWhatHoldsTheContext(t *testing.T) { - t.Parallel() - home := statedBootHome(t) - dir := testenv.TempDir(t) - rec := &effectRecordingProvider{} - kind := "boot-composition-" + t.Name() - provider.Register(kind, func(provider.Config) (provider.Provider, error) { return rec, nil }) - writeFile(t, dir, "reasonix.toml", ` -default_model = "test-model" - -[agent] -system_prompt = "BASE" - -[[providers]] -name = "test-model" -kind = "`+kind+`" -model = "x" -context_window = 20000 -`) - approveWorkspaceAt(t, home, dir) - - var mu sync.Mutex - var notices []event.Event - sink := event.FuncSink(func(e event.Event) { - if e.Kind == event.Notice { - mu.Lock() - notices = append(notices, e) - mu.Unlock() - } - }) - ctrl, err := Build(context.Background(), Options{Home: home, WorkspaceRoot: dir, Sink: sink}) - if err != nil { - t.Fatalf("Build: %v", err) - } - defer ctrl.Close() - ctrl.SetSessionPath(sessionstore.NewSessionPath(ctrl.SessionDir(), ctrl.Label())) - - big := func(tokens int) { - t.Helper() - if err := ctrl.Run(context.Background(), "go "+strings.Repeat("log line ", tokens*4/9)); err != nil { - t.Fatalf("turn: %v", err) - } - } - // The first fold leaves a checkpoint; the next turn is then one recent turn - // larger than the threshold, which the fold keeps whole. - big(12000) - if _, err := ctrl.Compact(context.Background(), agent.CompactRequest{}); err != nil { - t.Fatalf("first compact: %v", err) - } - mu.Lock() - notices = nil - mu.Unlock() - big(12000) - if _, err := ctrl.Compact(context.Background(), agent.CompactRequest{}); err != nil && !agent.IsCompactionDeclined(err) { - t.Logf("compact: %v", err) - } - - mu.Lock() - defer mu.Unlock() - for _, n := range notices { - if n.Code != event.NoticeCodeContextComposition { - continue - } - c, ok := decodeCompositionDetail(n.Detail) - if !ok || c.Total < c.At || c.Parts[0].Name != "user" { - t.Fatalf("composition = %+v, %v; want a prompt over its threshold led by the user's message", c, ok) - } - if !strings.Contains(n.Text, "compaction threshold") { - t.Fatalf("notice text %q is not the English fallback sentence", n.Text) - } - return - } - t.Fatalf("the sink never received the composition among %d notices", len(notices)) -} - -func decodeCompositionDetail(detail string) (event.ContextComposition, bool) { - var c event.ContextComposition - err := json.Unmarshal([]byte(detail), &c) - return c, err == nil && c.Total > 0 && len(c.Parts) > 0 -} diff --git a/internal/assembly/boot/compaction_retention_effect_test.go b/internal/assembly/boot/compaction_retention_effect_test.go index dfd6730399..73ef353992 100644 --- a/internal/assembly/boot/compaction_retention_effect_test.go +++ b/internal/assembly/boot/compaction_retention_effect_test.go @@ -2,6 +2,7 @@ package boot import ( "context" + "os" "strings" "sync" "testing" @@ -66,7 +67,6 @@ default_model = "test-model" [agent] system_prompt = "BASE" compact_ratio = 0.5 -recent_keep = 2 [[providers]] name = "test-model" @@ -82,9 +82,6 @@ context_window = 32000 } defer ctrl.Close() - // The constraint is the *second* user turn on purpose: the first one is - // pinned into the stable prefix, so only a later turn exercises the fold- - // region retention this test is about. const constraint = "standing constraint: never change the public API" for _, prompt := range []string{"start the task", constraint, "keep going", "keep going", "keep going", "keep going", "keep going"} { @@ -106,15 +103,22 @@ context_window = 32000 t.Fatalf("no request carried a digest; the fixture never compacted (%d requests)", len(reqs)) } - var found bool + var archive string for _, m := range reqs[compacted].Messages { if strings.Contains(m.Content, constraint) { - found = true + t.Fatalf("a fold retains nothing, yet the post-compaction request still carries the constraint: %s", + messageDigest(reqs[compacted].Messages)) + } + if i := strings.Index(m.Content, "Archive: "); i >= 0 { + archive = strings.TrimSpace(strings.SplitN(m.Content[i+len("Archive: "):], "\n", 2)[0]) } } - if !found { - t.Fatalf("constraint absent from the post-compaction request; the model was never told it.\nmessages=%s", - messageDigest(reqs[compacted].Messages)) + if archive == "" { + t.Fatalf("the post-compaction request names no archive: %s", messageDigest(reqs[compacted].Messages)) + } + body, err := os.ReadFile(archive) + if err != nil || !strings.Contains(string(body), constraint) { + t.Fatalf("the archive %s does not hold the constraint verbatim (%v)", archive, err) } } diff --git a/internal/assembly/boot/delegation.go b/internal/assembly/boot/delegation.go index 48cae4f0b7..2691db4f32 100644 --- a/internal/assembly/boot/delegation.go +++ b/internal/assembly/boot/delegation.go @@ -81,11 +81,9 @@ func (w roleWiring) skillRunOptions(in delegationInputs) func(context.Context, i Gate: w.gate, CheckTargetAccess: w.checkTargetAccess, ContextWindow: ctxWin, - RecentKeep: w.cfg.Agent.RecentKeep, CompactionBudgets: compactionBudgets(w.cfg), CompactRatio: w.cfg.Agent.CompactRatio, ArchiveDir: w.roots.ArchiveDir(), - KeepPolicy: w.keep, ResponseLanguage: langpref.ResponseLanguageFromContext(sctx), ReasoningLanguage: langpref.ReasoningLanguageFromContext(sctx), SubagentDepth: childDepth, diff --git a/internal/assembly/boot/instructions_removal_after_fold_test.go b/internal/assembly/boot/instructions_removal_after_fold_test.go deleted file mode 100644 index bc770c4e11..0000000000 --- a/internal/assembly/boot/instructions_removal_after_fold_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package boot - -// AUDIT (read-only candidate, not a fix): a removal is announced only while the -// controller still remembers having sent the block. A fold that keeps the block -// verbatim — because the turn carrying it is in the retained tail — clears that - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - - "reasonix/internal/contract/event" - "reasonix/internal/contract/provider" - "reasonix/internal/runtime/agent" -) - -// foldMessages reports how many messages the last successful fold summarised, -// so an arm can prove the fold decided something rather than folding nothing. -func foldMessages(h *projectionHarness) (messages int, done bool) { - h.events.mu.Lock() - defer h.events.mu.Unlock() - for _, e := range h.events.full { - if e.Kind == event.CompactionDone { - done = true - messages = e.Compaction.Messages - } - } - return messages, done -} - -func TestAuditRemovalAfterAFoldThatKeptTheBlock(t *testing.T) { - h := newProjectionHarness(t, "audit-removal-after-fold", "\ncompact_ratio = 0.4\n", "\ncontext_window = 12000\n") - filler := strings.Repeat("filler sentence about the ledger. ", 200) - small := strings.Repeat("short filler. ", 20) - for i := range 5 { - h.turn("turn-filler-" + string(rune('a'+i)) + " " + filler + " " + small) - } - - // The block rides the newest turn, which the fold retains verbatim, so the - // fold does not take the model's copy of it out of view. - writeInstructions(t, h.dir, "REASONIX.md", "Rule one: never push on red.\n") - carrier := h.turn("turn-carrier") - if block := blockOf(projectionOf(t, carrier, "turn-carrier"), "project-instructions"); !strings.Contains(block, "Rule one") { - t.Fatalf("the carrier turn did not deliver the instructions, so this arm measures nothing:\n%s", block) - } - - if _, err := h.ctrl.Compact(context.Background(), agent.CompactRequest{}); err != nil { - t.Fatalf("Compact: %v", err) - } - messages, done := foldMessages(h) - if !done || messages == 0 { - t.Fatalf("no fold summarised anything, so this arm measures nothing: done=%v messages=%d", done, messages) - } - - // Precondition: the fold left the carrier turn — and the rules inside it — - // in the model's view. This is what makes silence wrong: the model reads - // the old rules as standing state. - if err := os.Remove(filepath.Join(h.dir, "REASONIX.md")); err != nil { - t.Fatalf("remove the instructions: %v", err) - } - after := h.turn("turn-after-removal") - projection := projectionOf(t, after, "turn-after-removal") - stillVisible := "" - for _, m := range after.Messages { - if m.Role == provider.RoleUser && strings.Contains(m.Content, "Rule one: never push on red.") { - stillVisible = m.Content - break - } - } - if stillVisible == "" { - t.Fatalf("the fold took the carrier turn out of view, so silence would be right and this arm measures nothing") - } - if block := blockOf(projection, "project-instructions"); block == "" { - t.Fatalf("the instructions were removed and the request carries no block saying so, while the model still reads them verbatim:\nstill visible: %q", stillVisible) - } else if strings.Contains(block, "Rule one") { - t.Errorf("the removal re-sent the rule it removed:\n%s", block) - } -} diff --git a/internal/assembly/boot/roles.go b/internal/assembly/boot/roles.go index 34237652dc..13c35033f1 100644 --- a/internal/assembly/boot/roles.go +++ b/internal/assembly/boot/roles.go @@ -34,7 +34,6 @@ type roleWiring struct { sink event.Sink gate *control.SharedHeadlessGate reg *tool.Registry - keep agent.KeepPolicy checkTargetAccess tool.TargetAccessCheck hooks *hook.Runner // each role fires it under a session of its own } @@ -79,10 +78,8 @@ func (w roleWiring) planner(opts Options, executor *agent.Agent, executorModel, ModelRef: modelRefFromEntry(pe), ContextWindow: pe.ContextWindow, CompactRatio: w.cfg.Agent.CompactRatio, - RecentKeep: w.cfg.Agent.RecentKeep, CompactionBudgets: compactionBudgets(w.cfg), ArchiveDir: w.roots.ArchiveDir(), - KeepPolicy: w.keep, ReasoningLanguage: w.cfg.ReasoningLanguage(), CapabilityLedger: plannerLedger, CapabilityAudit: plannerAudit, diff --git a/internal/contract/config/agent_roundtrip_test.go b/internal/contract/config/agent_roundtrip_test.go index 79171b032f..a93a2b61cf 100644 --- a/internal/contract/config/agent_roundtrip_test.go +++ b/internal/contract/config/agent_roundtrip_test.go @@ -23,6 +23,10 @@ var retiredAgentKeys = map[string]bool{ "recovery_temperature": true, "auto_plan": true, "auto_plan_classifier": true, + "keep": true, + "recent_keep": true, + "user_turn_keep_tokens": true, + "first_turn_pin_tokens": true, } // probe fills a field with a value distinguishable from its zero, so a setting diff --git a/internal/contract/config/config.go b/internal/contract/config/config.go index e3b12e058d..bc3f09409d 100644 --- a/internal/contract/config/config.go +++ b/internal/contract/config/config.go @@ -1127,18 +1127,12 @@ type AgentConfig struct { // readable, but loading clears it and rendering omits it. AutoPlanClassifier string `toml:"auto_plan_classifier"` CompactRatio float64 `toml:"compact_ratio"` - // Keep controls which compactable messages stay verbatim beyond the current - // user-fact/digest floor and recent tail. Empty uses the conservative default - // of keeping error tool results. - Keep []string `toml:"keep"` - RecentKeep int `toml:"recent_keep"` - // UserTurnKeepTokens bounds the user's own turns carried verbatim through - // one fold. 0 selects 5% of the context window (floor 1024). Turns past the - // budget survive only through the digest, unless marked [[keep]]. - UserTurnKeepTokens int `toml:"user_turn_keep_tokens"` - // FirstTurnPinTokens bounds pinning the first user turn verbatim into the - // fixed prefix, where it is paid for on every request. 0 selects 1500. - FirstTurnPinTokens int `toml:"first_turn_pin_tokens"` + // Retired: a fold keeps no history verbatim, so these four are read and + // ignored. Old files that name them still load. + Keep []string `toml:"keep"` + RecentKeep int `toml:"recent_keep"` + UserTurnKeepTokens int `toml:"user_turn_keep_tokens"` + FirstTurnPinTokens int `toml:"first_turn_pin_tokens"` // CheckpointCeilingRatio is how small an automatic fold's result must be, // as a fraction of the window, before it is accepted. 0 selects 0.50. CheckpointCeilingRatio float64 `toml:"checkpoint_ceiling_ratio"` diff --git a/internal/contract/config/render.go b/internal/contract/config/render.go index 355c466c00..2b282a4d9f 100644 --- a/internal/contract/config/render.go +++ b/internal/contract/config/render.go @@ -161,31 +161,11 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string { b.WriteString("# turn_narration = \"detailed\" # between tool calls: brief|detailed (detailed asks for a few lines per batch)\n") } fmt.Fprintf(&b, "compact_ratio = %s # fold at this share of the model window (default 0.90)\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 { - b.WriteString("# keep = [\"errors\"] # compaction keep policy: errors, user_marked\n") - } - if c.Agent.UserTurnKeepTokens > 0 { - fmt.Fprintf(&b, "user_turn_keep_tokens = %d # your own turns kept verbatim through a fold\n", c.Agent.UserTurnKeepTokens) - } else { - b.WriteString("# user_turn_keep_tokens = 8192 # your own turns kept verbatim through a fold; unset = 5% of the window\n") - } - if c.Agent.FirstTurnPinTokens > 0 { - fmt.Fprintf(&b, "first_turn_pin_tokens = %d # first turn pinned verbatim into the cached prefix\n", c.Agent.FirstTurnPinTokens) - } else { - b.WriteString("# first_turn_pin_tokens = 1500 # first turn pinned verbatim into the cached prefix; it is paid for every request\n") - } if c.Agent.CheckpointCeilingRatio > 0 { fmt.Fprintf(&b, "checkpoint_ceiling_ratio = %s # how small a fold's result must be to be accepted\n", formatFloat(c.Agent.CheckpointCeilingRatio)) } else { b.WriteString("# checkpoint_ceiling_ratio = 0.50 # how small a fold's result must be to be accepted\n") } - if c.Agent.RecentKeep > 0 { - fmt.Fprintf(&b, "recent_keep = %d # minimum recent messages kept verbatim\n", c.Agent.RecentKeep) - } else { - b.WriteString("# recent_keep = 2 # minimum recent messages kept verbatim\n") - } 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 { diff --git a/internal/contract/config/render_agent.go b/internal/contract/config/render_agent.go index f28d99f58a..949f14ba38 100644 --- a/internal/contract/config/render_agent.go +++ b/internal/contract/config/render_agent.go @@ -42,14 +42,6 @@ func renderAgentDelta(buf *strings.Builder, c, d *Config) bool { fmt.Fprintf(buf, "compact_ratio = %s\n", formatFloat(c.Agent.CompactRatio)) wrote = true } - if c.Agent.Keep != nil && !reflect.DeepEqual(c.Agent.Keep, d.Agent.Keep) { - fmt.Fprintf(buf, "keep = %s\n", renderStringArray(c.Agent.Keep)) - wrote = true - } - if c.Agent.RecentKeep > 0 && c.Agent.RecentKeep != d.Agent.RecentKeep { - fmt.Fprintf(buf, "recent_keep = %d\n", c.Agent.RecentKeep) - wrote = true - } if len(c.Agent.PlanModeReadOnlyCommands) > 0 && !reflect.DeepEqual(c.Agent.PlanModeReadOnlyCommands, d.Agent.PlanModeReadOnlyCommands) { fmt.Fprintf(buf, "plan_mode_read_only_commands = %s\n", renderStringArray(c.Agent.PlanModeReadOnlyCommands)) wrote = true diff --git a/internal/contract/config/render_test.go b/internal/contract/config/render_test.go index 3aa3740419..c8a766ae84 100644 --- a/internal/contract/config/render_test.go +++ b/internal/contract/config/render_test.go @@ -397,12 +397,6 @@ func TestRenderTOMLRoundTrips(t *testing.T) { if got.Agent.CompactRatio != orig.Agent.CompactRatio { t.Errorf("compact_ratio = %v, want %v", got.Agent.CompactRatio, orig.Agent.CompactRatio) } - if strings.Join(got.Agent.Keep, ",") != strings.Join(orig.Agent.Keep, ",") { - t.Errorf("keep = %v, want %v", got.Agent.Keep, orig.Agent.Keep) - } - if got.Agent.RecentKeep != orig.Agent.RecentKeep { - t.Errorf("recent_keep = %d, want %d", got.Agent.RecentKeep, orig.Agent.RecentKeep) - } if got.Agent.SystemPrompt != orig.Agent.SystemPrompt { t.Errorf("system_prompt mismatch:\n got %q\nwant %q", got.Agent.SystemPrompt, orig.Agent.SystemPrompt) } diff --git a/internal/runtime/agent/agent.go b/internal/runtime/agent/agent.go index 7013bb91fa..e4b30bea87 100644 --- a/internal/runtime/agent/agent.go +++ b/internal/runtime/agent/agent.go @@ -370,7 +370,6 @@ type Agent struct { // Context management keeps the canonical transcript immutable and installs // at most one provider-visible checkpoint each time compactRatio is crossed. - keepPolicy KeepPolicy strictAlternatingRoles bool // coalesce adjacent user turns on provider request copies // activeTurnCreatedAt identifies the real/synthetic user message that began // the currently running turn. Compaction may rewrite older history while a @@ -379,15 +378,6 @@ type Agent struct { activeTurnCreatedAt atomic.Int64 } -// KeepPolicy is a bitmask controlling which messages are preserved beyond the -// recent tail during compaction. -type KeepPolicy int - -const ( - KeepErrors KeepPolicy = 1 << iota - KeepUserMarked -) - // SetPlanMode enters or leaves the plan-first workflow. Ordinary calls still // use Permissions/Sandbox; only explicit phase opt-outs are refused. The system // prompt and tool schemas stay untouched, while the caller supplies the @@ -640,9 +630,6 @@ func New(prov provider.Provider, tools *tool.Registry, session *sessionstore.Ses if opts.CompactRatio <= 0 { opts.CompactRatio = defaultCompactRatio } - if opts.RecentKeep <= 0 { - opts.RecentKeep = minRecentKeep - } if nilutil.IsNil(sink) { sink = event.Discard } @@ -709,7 +696,6 @@ func New(prov provider.Provider, tools *tool.Registry, session *sessionstore.Ses providerRetries: opts.ProviderRetries, contextWindow: opts.ContextWindow, compactRatio: opts.CompactRatio, - recentKeep: opts.RecentKeep, budgets: opts.CompactionBudgets, archiveDir: opts.ArchiveDir, }, @@ -731,7 +717,6 @@ func New(prov provider.Provider, tools *tool.Registry, session *sessionstore.Ses ablation: opts.Ablation, capabilityLedger: opts.CapabilityLedger, capabilityAudit: opts.CapabilityAudit, - keepPolicy: opts.KeepPolicy, strictAlternatingRoles: opts.StrictAlternatingRoles, } a.sess.output.outputBudget = outputBudgetOf(prov) diff --git a/internal/runtime/agent/agent_config.go b/internal/runtime/agent/agent_config.go index f9dd2b5cb0..dc126fe67e 100644 --- a/internal/runtime/agent/agent_config.go +++ b/internal/runtime/agent/agent_config.go @@ -45,19 +45,16 @@ type agentConfig struct { maxSubagentDepth int providerRetries int // contextWindow and compactRatio decide when at most one provider-visible - // checkpoint is installed; recentKeep and archiveDir shape what it keeps. + // checkpoint is installed; archiveDir is where the folded span is written. contextWindow int compactRatio float64 - recentKeep int archiveDir string budgets CompactionBudgets } -// CompactionBudgets are the fold's user-owned bounds: what it may hold -// verbatim, and how small its result must be. A zero field selects the +// CompactionBudgets are the fold's user-owned bounds: how small its result +// must be. A zero field selects the // built-in default, so an unconfigured agent behaves exactly as before. type CompactionBudgets struct { - UserTurnKeepTokens int - FirstTurnPinTokens int CheckpointCeilingRatio float64 } diff --git a/internal/runtime/agent/agent_options.go b/internal/runtime/agent/agent_options.go index 6dcbdf3176..33f8962720 100644 --- a/internal/runtime/agent/agent_options.go +++ b/internal/runtime/agent/agent_options.go @@ -93,16 +93,14 @@ type Options struct { // files outside the workspace roots. nil keeps fail-closed behavior. ConfigWriteApprover tool.ConfigWriteApprover - // Context management. ContextWindow <= 0 disables compaction. Ratios and - // RecentKeep fall back to defaults when unset. + // Context management. ContextWindow <= 0 disables compaction. Ratios fall + // back to defaults when unset. ContextWindow int CompactRatio float64 CompactionBudgets CompactionBudgets // Deprecated compatibility inputs. New agents ignore these fields; automatic // maintenance is controlled only by CompactRatio. - RecentKeep int ArchiveDir string - KeepPolicy KeepPolicy 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 diff --git a/internal/runtime/agent/cachehit_e2e_test.go b/internal/runtime/agent/cachehit_e2e_test.go index d56b034894..556e071bf9 100644 --- a/internal/runtime/agent/cachehit_e2e_test.go +++ b/internal/runtime/agent/cachehit_e2e_test.go @@ -567,7 +567,6 @@ func newAgent(t *testing.T, url string, reg *tool.Registry, contextWindow, recen a := New(prov, reg, sessionstore.NewSession(systemPrompt), Options{ Temperature: 0, ContextWindow: contextWindow, - RecentKeep: recentKeep, }, sink) return a, sink } diff --git a/internal/runtime/agent/candidate_holds.go b/internal/runtime/agent/candidate_holds.go index 5c51c35ae0..b05483d0ef 100644 --- a/internal/runtime/agent/candidate_holds.go +++ b/internal/runtime/agent/candidate_holds.go @@ -29,7 +29,7 @@ func (a *contextWindow) acceptAttributed(trigger string, scope compactionScope, // candidate cannot get under the trigger whatever the summary says: the fixed // prefix, the messages the keep policy protects and the recent tail already // reach it. -func (a *contextWindow) foldCannotFit(trigger string, msgs []provider.Message, head, start int, policy KeepPolicy) (CompactionNoopReason, error) { +func (a *contextWindow) foldCannotFit(trigger string, msgs []provider.Message, head, start int) (CompactionNoopReason, error) { prefix := a.estimatedPromptTokens(a.providerProjectionMessages(msgs[:head])) limit := a.compactTrigger() if a.effectiveContextWindow() <= 0 || limit <= 0 { @@ -41,7 +41,7 @@ func (a *contextWindow) foldCannotFit(trigger string, msgs []provider.Message, h if trigger == CompactionTriggerManual && a.estimatedPromptTokens(msgs) < limit { return "", nil } - kept, _, _, _ := a.partitionFoldWith(msgs[head:start], msgs[start:], policy) + kept, _ := a.partitionFold(msgs[head:start], msgs[start:]) held := candidateHolds{Prefix: prefix, Kept: a.estimatedPromptTokens(kept), Tail: a.estimatedPromptTokens(msgs[start:])} if held.Prefix+held.Kept+held.Tail < limit { return "", nil diff --git a/internal/runtime/agent/commit_covered_test.go b/internal/runtime/agent/commit_covered_test.go index b3b603f7c2..7d7337dfef 100644 --- a/internal/runtime/agent/commit_covered_test.go +++ b/internal/runtime/agent/commit_covered_test.go @@ -17,7 +17,7 @@ func commitTestAgent(t *testing.T) *Agent { t.Helper() dir := testenv.TempDir(t) return New(&fakeProvider{reply: "digest"}, tool.NewRegistry(), sessionstore.NewSession("sys"), Options{ - ContextWindow: 2000, RecentKeep: 2, ArchiveDir: dir, + ContextWindow: 2000, ArchiveDir: dir, SessionPath: filepath.Join(dir, "s.jsonl"), WorkspaceID: "ws", ModelRef: "m", }, event.Discard) } diff --git a/internal/runtime/agent/compact.go b/internal/runtime/agent/compact.go index 2ae6b81e62..559ddf1d35 100644 --- a/internal/runtime/agent/compact.go +++ b/internal/runtime/agent/compact.go @@ -20,28 +20,19 @@ import ( // 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 ( - defaultCompactRatio = 0.90 // capacity share of the window before maintenance - 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 - summaryRetryOutputMaxTokens = 2 * summaryOutputMaxTokens // one retry after a truncation may spend up to this - summaryEffort = "low" // lowest depth an endpoint may approve; reasoning shares the output cap - 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 - defaultPinnedFirstUserTokens = 1500 // default 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 - keptUserTurnsWindowFrac = 0.05 // default verbatim user-turn budget, as a fraction of the window - keptUserTurnsFloorTokens = 1024 // ...with a floor so a small window still holds something - protocolReserveTokens = 256 // provider framing and control fields not represented by message estimates + defaultCompactRatio = 0.90 // capacity share of the window before maintenance + checkpointCeilingRatio = 0.50 // normal auto-checkpoint acceptance ceiling + refoldMinTokensRatio = 0.10 // recent verbatim tail as a fraction of the window + minRefoldTokens = 32 * 1024 + maxRefoldTokens = 96 * 1024 + exceptionalMinSavingsRatio = 0.25 // when fixed prefix alone exceeds 50%, require at least this savings + minCompactMessages = 2 // skip compaction below this many compactable messages + fallbackTokPerChar = 0.25 // ~4 chars/token, used before any usage is available to calibrate + protocolReserveTokens = 256 // provider framing and control fields not represented by message estimates ) var ( - errSummaryOutputTruncated = errors.New("summarizer output truncated") - errCheckpointRejected = errors.New("checkpoint candidate rejected") + errCheckpointRejected = errors.New("checkpoint candidate rejected") ) // summaryTag wraps the compaction summary so the model can distinguish it from @@ -104,23 +95,21 @@ func (a *contextWindow) hardInputCeiling() int { return max(1, window-reserve) } -// 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 *contextWindow) recentTailBudget() int { +// refoldMinTokens is how much new closed history a second fold waits for: +// every fold costs the whole prefix cache, so it must free more than it costs. +func (a *contextWindow) refoldMinTokens() int { window := a.effectiveContextWindow() if window <= 0 { - return minRecentTailTokens + return minRefoldTokens } - n := int(float64(window) * recentTailBudgetRatio) - if window >= minRecentTailTokens*2 { - if n < minRecentTailTokens { - n = minRecentTailTokens + n := int(float64(window) * refoldMinTokensRatio) + if window >= minRefoldTokens*2 { + if n < minRefoldTokens { + n = minRefoldTokens } } - if n > maxRecentTailTokens { - n = maxRecentTailTokens + if n > maxRefoldTokens { + n = maxRefoldTokens } if max := window / 2; max > 0 && n > max { n = max @@ -248,37 +237,16 @@ func isCompactionSummary(m provider.Message) bool { strings.HasPrefix(strings.TrimLeft(m.Content, "\n "), SummaryTagOpen) } -// pinnedPrefixLen counts the leading messages a fold keeps verbatim ahead of -// everything else: the system prompt and the first user turn (its task + stated -// facts/constraints) when it is small enough to be a brief. Digests are never -// pinned — any digest in the transcript enters the fold region and is merged -// into the next one, so a session cannot accumulate a chain of them. +// pinnedPrefixLen counts the leading messages a fold leaves in place: the +// system prompt and nothing else. Every conversational message may be archived. func (a *contextWindow) pinnedPrefixLen(msgs []provider.Message) int { i := 0 if i < len(msgs) && msgs[i].Role == provider.RoleSystem { i++ } - if i < len(msgs) && msgs[i].Role == provider.RoleUser && !isCompactionSummary(msgs[i]) && a.fixedPinnableUserTurn(msgs[i]) { - i++ - } return i } -func (a *contextWindow) fixedPinnableUserTurn(m provider.Message) bool { - budget := defaultPinnedFirstUserTokens - if a.budgets.FirstTurnPinTokens > 0 { - budget = a.budgets.FirstTurnPinTokens - } - // The window guard stands whatever the setting: the pinned first turn sits - // in the fixed prefix and is paid for on every request of the session. - if window := a.effectiveContextWindow(); window > 0 { - if f := int(float64(window) * pinnedFirstUserWindowFrac); f < budget { - budget = f - } - } - return fixedTokenEstimate(m) <= budget -} - // closedPrefixEnd is how far into msgs every tool call has its result. A fold // may end only at such a point: anywhere else hands the provider an assistant // turn whose calls never resolve, and hands recovery a transaction whose @@ -300,40 +268,16 @@ func closedPrefixEnd(msgs []provider.Message) int { return end } -func (a *contextWindow) keepIndexes(region []provider.Message, policy KeepPolicy) ([]bool, userTurnRetention) { +// keepIndexes marks what a fold may not archive: the request that began the +// running turn and the tool-call groups it is still executing. +func (a *contextWindow) keepIndexes(region []provider.Message) []bool { keep := make([]bool, len(region)) activeTurn := a.activeTurnCreatedAt.Load() - policyStart := 0 for i, m := range region { - if isCompactionSummary(m) { - policyStart = i + 1 - } - } - // Retention applies only to messages since the latest digest. A failed tool - // result is bounded by its own budget, since last fold's survivors sit past - // the digest and would otherwise be kept again. - var failures, marked []int - for i, m := range region { - if i >= policyStart && shouldKeepMessage(m, policy) { - switch m.Role { - case provider.RoleTool: - failures = append(failures, i) - case provider.RoleUser: - marked = append(marked, i) - default: - keep[i] = true - } - } - // The request that began the running turn states the work the rest of - // the turn is executing. Its closed transactions may fold; it may not. if activeTurn != 0 && m.Role == provider.RoleUser && m.CreatedAt == activeTurn { keep[i] = true } } - a.keepFailuresWithinBudget(region, keep, failures) - markedDropped, markedTokens := a.keepMarkedWithinBudget(region, keep, marked) - retention := a.keepUserTurns(region, keep) - retention.MarkedDropped, retention.MarkedDroppedTokens = markedDropped, markedTokens pinned := slices.Clone(keep) for i, m := range region { if !pinned[i] { @@ -348,7 +292,7 @@ func (a *contextWindow) keepIndexes(region []provider.Message, policy KeepPolicy keepToolCallGroup(region, keep, i) } } - return keep, retention + return keep } // fixedTokenEstimate is what every verbatim-retention decision measures with. @@ -358,16 +302,6 @@ func fixedTokenEstimate(m provider.Message) int { return int(float64(msgChars(m)) * fallbackTokPerChar) } -func shouldKeepMessage(m provider.Message, policy KeepPolicy) bool { - if policy&KeepErrors != 0 && isErrorMessage(m) { - return true - } - if policy&KeepUserMarked != 0 && isUserMarked(m) { - return true - } - return false -} - func isErrorMessage(m provider.Message) bool { if m.Role != provider.RoleTool { return false @@ -398,17 +332,6 @@ func failedExecution(ex *provider.ToolExecution) bool { return ex.Verification == tool.ShellVerificationFailed } -func isUserMarked(m provider.Message) bool { - if m.Role != provider.RoleUser { - return false - } - content := strings.TrimSpace(strings.ToLower(m.Content)) - return strings.HasPrefix(content, "[[keep]]") || - strings.HasPrefix(content, "[keep]") || - strings.HasPrefix(content, "") || - strings.HasPrefix(content, "") -} - func findToolCaller(region []provider.Message, toolIndex int, id string) int { for i := toolIndex - 1; i >= 0; i-- { if region[i].Role != provider.RoleAssistant { @@ -431,94 +354,17 @@ func toolCallIDs(m provider.Message) map[string]bool { return ids } -// planCompaction returns [head:start] to fold; the tail is recentTailBudget -// unless force halves it so CompactNow still reduces mid-size sessions. -func (a *contextWindow) planCompaction(msgs []provider.Message, min int, force bool) (head, start int, ok bool) { +// planCompaction returns [head:start] to fold: everything after the system +// prompt. The caller trims it to the closed prefix of an active turn. +func (a *contextWindow) planCompaction(msgs []provider.Message, min int, _ bool) (head, start int, ok bool) { head = a.pinnedPrefixLen(msgs) - if a.effectiveContextWindow() > 0 { - budget := a.recentTailBudget() - if force { - if half := a.estimatedPromptTokens(provider.ModelMessages(msgs)) / 2; half > 0 && half < budget { - budget = half - } - } - start = tailStart(msgs, head, budget, a.tokPerChar(), a.tailFloor()) - // 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()) - if force || !a.strictAlternatingRoles { - start = a.remeasuredTailStart(msgs, start, floor, budget) - } - } else { - // No window: keep a fixed recent count, aligned off tool results. - start = len(msgs) - a.tailFloor() - for start > head && start < len(msgs) && msgs[start].Role == provider.RoleTool { - start-- - } - } - start = max(start, head) + start = len(msgs) if start-head < min { return head, start, false } return head, start, true } -// remeasuredTailStart walks the cheap tokPerChar boundary forward until the -// verbatim tail fits budget under the calibrated estimate. A request's shape is -// the sum of its messages', so the measurement is carried and decremented per -// message dropped; measuring each candidate whole instead re-scanned the whole -// remaining transcript per step, and copied it whenever a receipt was present. -func (a *contextWindow) remeasuredTailStart(msgs []provider.Message, start, floor, budget int) int { - policy := sharedWindowInputPolicyOf(a.svc.prov) - tail := requestCalibrationShape{} - for i := len(msgs) - 1; i >= start; i-- { - tail = tail.plus(projectedMessageCalibrationShape(msgs[i], policy)) - } - drop := func() { - tail = tail.minus(projectedMessageCalibrationShape(msgs[start], policy)) - start++ - } - for start < floor && a.estimatedShapeTokens(tail) > budget { - drop() - for start < floor && start < len(msgs) && msgs[start].Role == provider.RoleTool { - drop() - } - } - return start -} - -func (a *contextWindow) tailFloor() int { - if a.recentKeep > minRecentKeep { - return a.recentKeep - } - return minRecentKeep -} - -// tailStart walks newest→oldest, growing the verbatim tail until the next -// message would push its token estimate past budgetTokens (but never below -// minKeep messages), then aligns the boundary back off any tool result so the -// tail never begins with an orphan whose assistant tool_calls were summarized -// away. -func tailStart(msgs []provider.Message, head, budgetTokens int, tokPerChar float64, minKeep int) int { - start := len(msgs) - acc := 0 - for i := len(msgs) - 1; i > head; i-- { - c := int(float64(msgChars(msgs[i])) * tokPerChar) - if len(msgs)-i > minKeep && acc+c > budgetTokens { - break - } - acc += c - start = i - } - // start == len(msgs) when nothing fit the tail (a session too small to have a - // message after head); there is no msgs[start] to align off, and the caller's - // minCompactMessages check then no-ops the pass. - for start > head && start < len(msgs) && msgs[start].Role == provider.RoleTool { - start-- - } - return start -} - // tokPerChar derives a tokens-per-character ratio from the last turn's real // usage so per-message estimates track the provider's tokenizer without a local // one. Reasoning content is excluded from the char count to match the prompt @@ -629,11 +475,3 @@ func summarizeToolArgs(args string) string { keys := slices.Sorted(maps.Keys(parsed)) return fmt.Sprintf("{%s} (%d keys)", strings.Join(keys, ", "), len(parsed)) } - -func charsOfMessages(msgs []provider.Message) int { - n := 0 - for _, m := range msgs { - n += msgChars(m) - } - return n -} diff --git a/internal/runtime/agent/compact_ablation_test.go b/internal/runtime/agent/compact_ablation_test.go deleted file mode 100644 index 4cfc8ee33d..0000000000 --- a/internal/runtime/agent/compact_ablation_test.go +++ /dev/null @@ -1,126 +0,0 @@ -package agent - -import ( - "context" - "fmt" - "reasonix/internal/state/sessionstore" - "strings" - "testing" - - "reasonix/internal/base/testenv" - "reasonix/internal/contract/ablation" - "reasonix/internal/contract/event" - "reasonix/internal/contract/provider" - "reasonix/internal/contract/tool" -) - -func armedAgent(t *testing.T, sess *sessionstore.Session, arm ablation.Set) *Agent { - t.Helper() - return New(&fakeProvider{reply: "Work continued."}, tool.NewRegistry(), sess, Options{ - ContextWindow: 5000, CompactRatio: 0.5, RecentKeep: 2, - ArchiveDir: testenv.TempDir(t), Ablation: arm, - }, event.Discard) -} - -// indexHeavySession makes far more index lines than any arm's budget can hold, -// so the four scales are separated by what they trim rather than by what the -// fixture happened to produce. -func indexHeavySession(calls int) *sessionstore.Session { - msgs := []provider.Message{ - {Role: provider.RoleSystem, Content: "sys"}, - {Role: provider.RoleUser, Content: "sweep the tree"}, - } - for i := range calls { - id := fmt.Sprintf("c%d", i) - msgs = append(msgs, - provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{ - {ID: id, Name: "read_file", Arguments: fmt.Sprintf(`{"path":"internal/pkg%03d/service.go"}`, i)}, - }}, - provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "read_file", Content: strings.Repeat("line ", 200)}, - ) - } - return &sessionstore.Session{Messages: msgs} -} - -// The axis has to reach the index, not just the arm name: a scale that changes -// nothing measures nothing. -func TestFoldIndexScaleChangesWhatTheModelSees(t *testing.T) { - sizes := map[ablation.FoldIndexScale]int{} - for _, scale := range []ablation.FoldIndexScale{ - ablation.FoldIndexDefault, ablation.FoldIndexHalf, ablation.FoldIndexQuarter, ablation.FoldIndexOff, - } { - a := New(&fakeProvider{reply: "Swept."}, tool.NewRegistry(), indexHeavySession(400), Options{ - ContextWindow: 120000, CompactRatio: 0.5, RecentKeep: 2, - ArchiveDir: testenv.TempDir(t), Ablation: ablation.Set{}.WithFoldIndex(scale), - }, event.Discard) - if err := prepareContext(context.Background(), a, CompactionTriggerOverflow); err != nil { - t.Fatalf("scale %q prepare = %v", scale, err) - } - canonical, _ := a.sess.conversation.SnapshotMessagesVersion() - lines := 0 - for _, m := range modelVisibleFromProjection(a.sess.win.compactionState.Projection, canonical) { - if _, index := splitFoldIndex(m.Content); index != "" { - lines += len(indexBodyLines(index)) - } - } - sizes[scale] = lines - t.Logf("scale=%-7q budget=%4d index lines=%d", scale, a.window().foldIndexBudget(), lines) - } - if sizes[ablation.FoldIndexOff] != 0 { - t.Errorf("the off arm still published %d index lines", sizes[ablation.FoldIndexOff]) - } - if sizes[ablation.FoldIndexDefault] <= sizes[ablation.FoldIndexQuarter] { - t.Errorf("quarter (%d lines) did not shrink the default (%d lines); the axis is inert", - sizes[ablation.FoldIndexQuarter], sizes[ablation.FoldIndexDefault]) - } - // Monotone, and each step separated: an arm that lands on the same index as - // its neighbour measures the same thing twice. - if sizes[ablation.FoldIndexHalf] >= sizes[ablation.FoldIndexDefault] { - t.Errorf("half (%d) did not shrink the default (%d)", sizes[ablation.FoldIndexHalf], sizes[ablation.FoldIndexDefault]) - } - if sizes[ablation.FoldIndexQuarter] >= sizes[ablation.FoldIndexHalf] { - t.Errorf("quarter (%d) did not shrink half (%d)", sizes[ablation.FoldIndexQuarter], sizes[ablation.FoldIndexHalf]) - } -} - -// An arm without search must not be told about it anywhere: not by the tool it -// holds, and not by the line the index writes when it drops an address. -func TestRecallSearchArmIsToldNothingItCannotUse(t *testing.T) { - sess, _ := horizonSession(retrievalCarriers()[0], 6) - a := armedAgent(t, sess, ablation.New(ablation.RecallSearch)) - if err := prepareContext(context.Background(), a, CompactionTriggerOverflow); err != nil { - t.Fatalf("prepare = %v", err) - } - if hint := a.window().droppedIndexHint(); strings.Contains(hint, "query") { - t.Errorf("the dropped-entries line offers a query to an arm without search: %q", hint) - } - if _, err := a.RecallContext(context.Background(), tool.RecallRequest{Query: "timeout ownership"}); err == nil { - t.Fatal("search ran in an arm that ablated it") - } - // The read half is untouched: an address the index still carries stays - // readable, which is what the pre-search tool could always do. - if _, err := a.RecallContext(context.Background(), tool.RecallRequest{Positions: []int{2}}); err != nil { - t.Errorf("read = %v, want the read half to keep working", err) - } -} - -// The control arm keeps both halves. -func TestControlArmKeepsSearch(t *testing.T) { - sess, at := horizonSession(retrievalCarriers()[0], 6) - a := armedAgent(t, sess, ablation.Set{}) - if err := prepareContext(context.Background(), a, CompactionTriggerOverflow); err != nil { - t.Fatalf("prepare = %v", err) - } - if hint := a.window().droppedIndexHint(); !strings.Contains(hint, "query") { - t.Errorf("the control arm's dropped-entries line does not mention search: %q", hint) - } - res, err := a.RecallContext(context.Background(), tool.RecallRequest{ - Query: "timeout ownership controller middleware cancellation", - }) - if err != nil { - t.Fatalf("search = %v", err) - } - if hitRank(res, at) == 0 { - t.Errorf("control arm search missed #%d: %v", at, hitPositions(res)) - } -} diff --git a/internal/runtime/agent/compact_archive.go b/internal/runtime/agent/compact_archive.go index 278eb4dd2d..b14140d3c7 100644 --- a/internal/runtime/agent/compact_archive.go +++ b/internal/runtime/agent/compact_archive.go @@ -61,11 +61,11 @@ func (a *contextWindow) foldToArchive(fold []provider.Message, instructions stri } return res, fmt.Errorf("archive: %w", werr) } - res.Text = archivePointer(path, len(fold), instructions, foldedUserWords(fold)) + res.Text = archivePointer(path, len(fold), instructions) return res, nil } -func archivePointer(path string, n int, instructions, userWords string) string { +func archivePointer(path string, n int, instructions string) string { var b strings.Builder fmt.Fprintf(&b, "%d earlier message(s) were moved out of this conversation to free context. They were archived, not summarized: the record is complete and verbatim.\n\n", n) fmt.Fprintf(&b, "Archive: %s\n\n", path) @@ -73,6 +73,5 @@ func archivePointer(path string, n int, instructions, userWords string) string { if focus := strings.TrimSpace(instructions); focus != "" { b.WriteString("\n\nWhat the user asked you to keep in focus: " + focus) } - b.WriteString(userWords) return b.String() } diff --git a/internal/runtime/agent/compact_archive_test.go b/internal/runtime/agent/compact_archive_test.go index 49460d492d..6029dbcae1 100644 --- a/internal/runtime/agent/compact_archive_test.go +++ b/internal/runtime/agent/compact_archive_test.go @@ -19,8 +19,7 @@ func archiveAgent(t *testing.T, sess *sessionstore.Session, sink event.Sink) (*A prov := &countingProvider{reply: "## Standing facts\n- a summarizer was asked"} dir := testenv.TempDir(t) a := New(prov, tool.NewRegistry(), sess, Options{ - ContextWindow: 200_000, CompactRatio: 0.8, RecentKeep: 2, - SessionPath: filepath.Join(dir, "session.jsonl"), + ContextWindow: 200_000, CompactRatio: 0.8, SessionPath: filepath.Join(dir, "session.jsonl"), WorkspaceID: "ws", ModelRef: "p/m", ArchiveDir: testenv.TempDir(t), }, sink) @@ -107,3 +106,42 @@ func TestAnArchiveNeverHoldsLocalOnlyOutput(t *testing.T) { } } } + +// Nothing of the conversation is held back: after a fold between turns the +// context is the system prompt and the one pointer, the first user turn, the +// recent turns and the user's own words included, and the archive has them all. +func TestAFoldRetainsNothingButThePointer(t *testing.T) { + sess := &sessionstore.Session{Messages: []provider.Message{ + {Role: provider.RoleSystem, Content: "sys"}, + {Role: provider.RoleUser, Content: "FIRST-TURN codeword blue-whale"}, + {Role: provider.RoleAssistant, Content: "ok " + strings.Repeat("filler ", 200)}, + {Role: provider.RoleUser, Content: "SECOND-TURN deadline friday"}, + {Role: provider.RoleAssistant, Content: "noted " + strings.Repeat("filler ", 200)}, + {Role: provider.RoleUser, Content: "LAST-TURN owner lin"}, + {Role: provider.RoleAssistant, Content: "done " + strings.Repeat("filler ", 200)}, + }} + a, _, dir := archiveAgent(t, sess, event.Discard) + verdict, err := a.CompactNow(context.Background(), CompactRequest{}) + if err != nil || !verdict.Compacted() { + t.Fatalf("CompactNow = %+v, %v; want a fold", verdict, err) + } + visible := a.window().modelVisibleMessages() + if len(visible) != 2 || visible[0].Role != provider.RoleSystem || !isCompactionSummary(visible[1]) { + t.Fatalf("visible = %+v, want the system prompt and one pointer", visible) + } + for _, word := range []string{"blue-whale", "friday", "owner lin"} { + if strings.Contains(visible[1].Content, word) { + t.Fatalf("the pointer carries %q; it must only point", word) + } + } + files, _ := filepath.Glob(filepath.Join(dir, "session.outputs", "fold-*.md")) + if len(files) != 1 { + t.Fatalf("archive files = %v", files) + } + body, _ := os.ReadFile(files[0]) + for _, word := range []string{"blue-whale", "friday", "owner lin"} { + if !strings.Contains(string(body), word) { + t.Fatalf("the archive lost %q", word) + } + } +} diff --git a/internal/runtime/agent/compact_commit_emit_test.go b/internal/runtime/agent/compact_commit_emit_test.go index ae3c1d6af6..ede467cd9f 100644 --- a/internal/runtime/agent/compact_commit_emit_test.go +++ b/internal/runtime/agent/compact_commit_emit_test.go @@ -52,8 +52,7 @@ func TestCommitSummaryEmitsOutsideCompactionLock(t *testing.T) { path := filepath.Join(testenv.TempDir(t), "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", + ContextWindow: 20_000, CompactRatio: 0.5, SessionPath: path, WorkspaceID: "ws", ModelRef: "p/m", }, sink) sink.agent = a if _, err := a.CompactNow(context.Background(), CompactRequest{}); err != nil { @@ -91,8 +90,7 @@ func TestCommitSurvivesPostPublishDirSyncFailure(t *testing.T) { }} path := filepath.Join(testenv.TempDir(t), "session.jsonl") a := New(prov, tool.NewRegistry(), sess, Options{ - ContextWindow: 20_000, CompactRatio: 0.5, RecentKeep: 2, - SessionPath: path, WorkspaceID: "ws", ModelRef: "p/m", + ContextWindow: 20_000, CompactRatio: 0.5, SessionPath: path, WorkspaceID: "ws", ModelRef: "p/m", }, event.Discard) if _, err := a.CompactNow(context.Background(), CompactRequest{}); err != nil { t.Fatalf("CompactNow with post-publish dir sync fault: %v", err) diff --git a/internal/runtime/agent/compact_coverage.go b/internal/runtime/agent/compact_coverage.go deleted file mode 100644 index 8124eef088..0000000000 --- a/internal/runtime/agent/compact_coverage.go +++ /dev/null @@ -1,160 +0,0 @@ -package agent - -import ( - "encoding/json" - "path" - "path/filepath" - "slices" - "strings" - - "reasonix/internal/base/shellparse" - "reasonix/internal/contract/provider" - "reasonix/internal/safety/evidence" -) - -// Acceptance used to ask a digest one question: is it smaller? A digest that -// summarized nothing answers yes. So a fold is also measured on the two losses -// that hurt — a change forgotten, and a failure forgotten and so repeated. - -// foldCoverage is what one digest kept of the facts a fold had to carry. -type foldCoverage struct { - Mutations []string // files the fold changed - Failures []string // commands the fold saw fail - MissingMut []string - MissingFai []string - // Unseen is the changes the summarizer was never shown: input shortened to - // fit its window leaves them out, so the digest cannot name them. - Unseen []string -} - -// Required is how many facts the digest had to carry. -func (c foldCoverage) Required() int { return len(c.Mutations) + len(c.Failures) } - -// Missing is how many it dropped. -func (c foldCoverage) Missing() int { return len(c.MissingMut) + len(c.MissingFai) } - -// LostAChange reports a forgotten change — the severity line, and one the -// mechanism owns rather than a ratio someone picked: a forgotten change makes -// the agent wrong, a forgotten failure only makes it slow. -func (c foldCoverage) LostAChange() bool { return len(c.MissingMut) > 0 } - -// LostEveryChange reports a digest that carried none of the fold's changes. -// That is not a quality judgment needing a threshold — it is a broken digest. -func (c foldCoverage) LostEveryChange() bool { - shown, lost := 0, 0 - for _, p := range c.Mutations { - if slices.Contains(c.Unseen, p) { - continue - } - shown++ - if slices.Contains(c.MissingMut, p) { - lost++ - } - } - return shown > 0 && lost == shown -} - -// Reason names what went missing, for the receipt and the retry instruction. -func (c foldCoverage) Reason() string { - var parts []string - if len(c.MissingMut) > 0 { - parts = append(parts, "changed files not mentioned: "+strings.Join(c.MissingMut, ", ")) - } - if len(c.MissingFai) > 0 { - parts = append(parts, "failures not mentioned: "+strings.Join(c.MissingFai, ", ")) - } - return strings.Join(parts, "; ") -} - -// foldFacts derives what a fold must carry forward, through the same pure -// classifier the ledger uses — the live ledger is per-task and never spans a -// fold. readOnly decides whether a call counts as a change. -func foldFacts(region []provider.Message, facts func(string) evidence.ToolFacts) foldCoverage { - var cov foldCoverage - seenMut, seenFail := map[string]bool{}, map[string]bool{} - calls := map[string]provider.ToolCall{} - for _, m := range region { - for _, tc := range m.ToolCalls { - calls[tc.ID] = tc - } - if m.Role != provider.RoleTool { - continue - } - call, ok := calls[m.ToolCallID] - if !ok { - continue - } - failed := isErrorMessage(m) - rec := evidence.ReceiptFromToolCall(call.Name, json.RawMessage(call.ExecutionArguments()), !failed, facts(call.Name)) - if !failed && rec.Mutation && len(rec.Paths) > 0 { - for _, p := range rec.Paths { - if p = strings.TrimSpace(p); p != "" && !seenMut[p] { - seenMut[p] = true - cov.Mutations = append(cov.Mutations, p) - } - } - } - if failed { - if cmd := commandSignature(rec.Command); cmd != "" && !seenFail[cmd] { - seenFail[cmd] = true - cov.Failures = append(cov.Failures, cmd) - } - } - } - slices.Sort(cov.Mutations) - slices.Sort(cov.Failures) - return cov -} - -// measureFoldCoverage checks a digest against the facts its fold produced. -func measureFoldCoverage(region []provider.Message, facts func(string) evidence.ToolFacts, digest string) foldCoverage { - cov := foldFacts(region, facts) - haystack := strings.ToLower(digest) - for _, p := range cov.Mutations { - if !mentionsPath(haystack, p) { - cov.MissingMut = append(cov.MissingMut, p) - } - } - for _, c := range cov.Failures { - if !strings.Contains(haystack, strings.ToLower(c)) { - cov.MissingFai = append(cov.MissingFai, c) - } - } - return cov -} - -// mentionsPath accepts the full path or the bare file name: insisting on the -// full path would fail a digest that did its job. -func mentionsPath(haystack, p string) bool { - lower := strings.ToLower(p) - if strings.Contains(haystack, lower) { - return true - } - base := path.Base(filepath.ToSlash(lower)) - return base != "" && base != "." && base != "/" && strings.Contains(haystack, base) -} - -// commandSignature reduces a command to what a digest would repeat: the program -// and its first argument. -func commandSignature(command string) string { - fields, malformed := shellparse.StaticFields(strings.TrimSpace(command)) - if malformed != "" || len(fields) == 0 { - if trimmed := strings.TrimSpace(command); trimmed != "" { - return firstLine(trimmed) - } - return "" - } - if len(fields) > 2 { - fields = fields[:2] - } - return strings.Join(fields, " ") -} - -// coverageDemands reports whether a call is one the digest must carry, so the -// index can take everything else and nothing falls between the two. -func coverageDemands(rec evidence.Receipt, failed bool) bool { - if failed { - return commandSignature(rec.Command) != "" - } - return rec.Mutation && len(rec.Paths) > 0 -} diff --git a/internal/runtime/agent/compact_coverage_test.go b/internal/runtime/agent/compact_coverage_test.go deleted file mode 100644 index bb385c94c7..0000000000 --- a/internal/runtime/agent/compact_coverage_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package agent - -import ( - "strings" - "sync" - "testing" - - "reasonix/internal/contract/provider" - "reasonix/internal/safety/evidence" -) - -// coverageTools answers from the shipped registry rather than a copy of it, so -// this test reads the contracts the agent reads. -var coverageRegistry = sync.OnceValue(builtinToolRegistry) - -func coverageTools(name string) evidence.ToolFacts { - t, ok := coverageRegistry().Get(name) - if !ok { - return readOnlyFacts - } - return toolFacts(t) -} - -func coverageRegion() []provider.Message { - return []provider.Message{ - {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{ - {ID: "w1", Name: "write_file", Arguments: `{"path":"internal/parser/lexer.go"}`}, - {ID: "w2", Name: "edit_file", Arguments: `{"path":"internal/parser/reader.go","old_string":"a","new_string":"b"}`}, - {ID: "r1", Name: "read_file", Arguments: `{"path":"internal/unrelated/notes.md"}`}, - {ID: "b1", Name: "bash", Arguments: `{"command":"go test ./internal/parser/ -run TestLexer"}`}, - }}, - {Role: provider.RoleTool, ToolCallID: "w1", Name: "write_file", Content: "wrote"}, - {Role: provider.RoleTool, ToolCallID: "w2", Name: "edit_file", Content: "applied"}, - {Role: provider.RoleTool, ToolCallID: "r1", Name: "read_file", Content: "contents"}, - {Role: provider.RoleTool, ToolCallID: "b1", Name: "bash", Content: "error: FAIL TestLexer"}, - } -} - -// The facts a fold must carry are derived from what it did, not from its text: -// changes made and commands that failed. Reads stay out — the workspace still -// holds what they found, and demanding them back would push the summarizer -// toward listing paths to pass a check. -func TestFoldFactsAreChangesAndFailures(t *testing.T) { - cov := foldFacts(coverageRegion(), coverageTools) - if len(cov.Mutations) != 2 { - t.Fatalf("mutations = %v, want the two written files", cov.Mutations) - } - for _, unwanted := range cov.Mutations { - if strings.Contains(unwanted, "notes.md") { - t.Errorf("a read was counted as a change: %v", cov.Mutations) - } - } - if len(cov.Failures) != 1 || cov.Failures[0] != "go test" { - t.Fatalf("failures = %v, want the failed command reduced to its signature", cov.Failures) - } -} - -// A digest that names the file it changed has carried the change, whether it -// wrote the full path or the bare name. -func TestCoverageAcceptsFullPathOrBareName(t *testing.T) { - region := coverageRegion() - full := measureFoldCoverage(region, coverageTools, "changed internal/parser/lexer.go and internal/parser/reader.go; go test failed") - if full.Missing() != 0 { - t.Fatalf("full paths not credited: %+v", full) - } - bare := measureFoldCoverage(region, coverageTools, "split lexer.go, then reader.go picked it up; go test still red") - if bare.Missing() != 0 { - t.Fatalf("bare file names not credited: %+v", bare) - } -} - -// The severity line is the mechanism's own: a forgotten change makes the agent -// wrong, a forgotten failure only makes it slow. -func TestCoverageSeparatesChangesFromFailures(t *testing.T) { - region := coverageRegion() - lostFailure := measureFoldCoverage(region, coverageTools, "changed lexer.go and reader.go") - if lostFailure.LostAChange() { - t.Errorf("a digest carrying every change must not read as having lost one: %+v", lostFailure) - } - if lostFailure.Missing() != 1 { - t.Errorf("the dropped failure should still be counted: %+v", lostFailure) - } - lostChange := measureFoldCoverage(region, coverageTools, "reader.go was touched; go test failed") - if !lostChange.LostAChange() || lostChange.LostEveryChange() { - t.Errorf("one missing change should be partial, not total: %+v", lostChange) - } - empty := measureFoldCoverage(region, coverageTools, "The user asked for a refactor. Work proceeded.") - if !empty.LostEveryChange() { - t.Errorf("a digest naming no change at all must read as broken: %+v", empty) - } -} - -// A fold with nothing to carry cannot fail the check. -func TestCoverageIsSatisfiedByAnEmptyFold(t *testing.T) { - cov := measureFoldCoverage([]provider.Message{ - {Role: provider.RoleUser, Content: "what does this project do?"}, - {Role: provider.RoleAssistant, Content: "It is a coding agent."}, - }, coverageTools, "the user asked what the project does") - if cov.Required() != 0 || cov.LostAChange() || cov.LostEveryChange() { - t.Fatalf("a fold that changed nothing owes nothing: %+v", cov) - } -} diff --git a/internal/runtime/agent/compact_failure_code_test.go b/internal/runtime/agent/compact_failure_code_test.go index d5d2bdaecb..f1113d7dbf 100644 --- a/internal/runtime/agent/compact_failure_code_test.go +++ b/internal/runtime/agent/compact_failure_code_test.go @@ -18,7 +18,7 @@ func failureFixture(t *testing.T, prov provider.Provider) (*Agent, *recordSink) t.Helper() sink := &recordSink{} a := New(prov, tool.NewRegistry(), foldableSessionOverForce(6), Options{ - ContextWindow: 5000, CompactRatio: 0.5, RecentKeep: 2, ArchiveDir: testenv.TempDir(t), + ContextWindow: 5000, CompactRatio: 0.5, ArchiveDir: testenv.TempDir(t), }, sink) return a, sink } diff --git a/internal/runtime/agent/compact_fold_input.go b/internal/runtime/agent/compact_fold_input.go index 235933c38c..1206feccc4 100644 --- a/internal/runtime/agent/compact_fold_input.go +++ b/internal/runtime/agent/compact_fold_input.go @@ -2,14 +2,8 @@ package agent import ( "context" - "errors" - "fmt" - "strings" - "unicode/utf8" - "reasonix/internal/contract/event" "reasonix/internal/contract/provider" - "reasonix/internal/state/sessionstore" ) // foldSummary is what compaction reports about turning a fold into a digest. @@ -22,12 +16,6 @@ type foldSummary struct { Usage *provider.Usage FoldTokens int Spans int - // Coverage is what this digest kept of the facts its fold produced. - Coverage foldCoverage - // Input is what the summarizer was shown; nil means the whole fold. - Input []provider.Message - // CoverageBackstopped marks a digest the host had to complete itself. - CoverageBackstopped bool } // summaryInputTokens sizes messages as summarizer input in the real tokens @@ -44,104 +32,14 @@ func (a *contextWindow) summaryInputTokens(msgs []provider.Message) int { }}) } -// foldOrDegrade archives a fold and, when the archive cannot be written and the -// fold is the only way out, converts that failure into a mechanical digest. The -// telemetry it returns always reports the original failure, even when the fold -// recovered from it. -func (a *contextWindow) foldOrDegrade(ctx context.Context, trigger string, mustFree bool, fold []provider.Message, instructions string, sourceTokens int) (foldSummary, CompactionTelemetry, error) { +// foldArchived writes the fold to its archive file. A failure to write it is +// the fold's failure: nothing else stands in for the archive. +func (a *contextWindow) foldArchived(ctx context.Context, trigger string, fold []provider.Message, instructions string, sourceTokens int) (foldSummary, CompactionTelemetry, error) { spend := compactionSpendFrom(ctx) res, err := a.foldToArchive(fold, instructions) tele := compactionTelemetryFromSummary(trigger, a.cacheState(), sourceTokens, res, spend.read()) - if err == nil { - return res, tele, nil + if err != nil { + tele.Error = err.Error() } - cause := err.Error() - if res, err = a.degradeFoldSummary(res, mustFree, fold, err); err != nil { - tele.Error = cause - return res, tele, err - } - tele = compactionTelemetryFromSummary(trigger, a.cacheState(), sourceTokens, res, spend.read()) - tele.Error = cause - return res, tele, nil -} - -const ( - // What the placeholder digest holds of the user's own words: each turn is - // shortened to at most mechanicalUserTurnChars, and the whole list to about - // mechanicalUserTotalChars, so a long session cannot make the stand-in as - // large as the thing it replaces. - mechanicalUserTurnChars = 240 - mechanicalUserTotalChars = 16000 - mechanicalUserMinChars = 60 -) - -// mechanicalFoldDigest stands in for a digest the summarizer could not produce. -// Saying the summary is missing stops the model reading the gap as "nothing was -// there". The user's words are the part nothing else holds, so they ride along, -// shortened and in order; assistant and tool content stays in the transcript. -func mechanicalFoldDigest(fold []provider.Message) string { - return fmt.Sprintf("%d earlier message(s) were folded here to free context, but the automatic summary was unavailable. Ask the user if you need details from before this point.", len(fold)) + foldedUserWords(fold) -} - -// foldedUserWords lists the user's own words from a fold, shortened and oldest -// first, as a block to append to a digest; empty when the user said nothing. -func foldedUserWords(fold []provider.Message) string { - var b strings.Builder - var said []string - for _, m := range fold { - if m.Role != provider.RoleUser || m.LocalOnly || isCompactionSummary(m) { - continue - } - if text := sessionstore.UserMessageText(m); text != "" { - said = append(said, text) - } - } - if len(said) == 0 { - return "" - } - each := min(mechanicalUserTurnChars, max(mechanicalUserMinChars, mechanicalUserTotalChars/len(said))) - b.WriteString("\n\nWhat the user said in the folded part, shortened, oldest first:") - used := 0 - for i, text := range said { - line := shortenForDigest(text, each) - if used+len(line) > mechanicalUserTotalChars { - fmt.Fprintf(&b, "\n- [%d more earlier messages of the user are not listed]", len(said)-i) - break - } - used += len(line) - b.WriteString("\n- " + line) - } - return b.String() -} - -// shortenForDigest collapses whitespace and cuts at a rune boundary. -func shortenForDigest(s string, limit int) string { - s = strings.Join(strings.Fields(s), " ") - if len(s) <= limit { - return s - } - cut := limit - for cut > 0 && !utf8.RuneStart(s[cut]) { - cut-- - } - return s[:cut] + "…" -} - -// degradeFoldSummary turns a summarizer failure into a mechanical fold only -// where that fold is the only way out; below the ceiling the turn still goes -// out, so the error is kept and a recoverable view is not folded blind. -// Cancellation is the caller's decision, never a summarizer failure. -func (a *contextWindow) degradeFoldSummary(res foldSummary, mustFree bool, fold []provider.Message, cause error) (foldSummary, error) { - if !mustFree || errors.Is(cause, context.Canceled) { - return res, cause - } - a.svc.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, - Text: "Context was compacted without a generated summary.", - Detail: fmt.Sprintf("compaction summary unavailable (%v); folded mechanically", cause)}) - res.Text = mechanicalFoldDigest(fold) - res.Mode = CompactionModeDegraded - // Measured here too: without it a fold that lost everything reports the - // same coverage as one that lost nothing. - res.Coverage = measureFoldCoverage(fold, a.toolFactsFor, res.Text) - return res, nil + return res, tele, err } diff --git a/internal/runtime/agent/compact_helpers_test.go b/internal/runtime/agent/compact_helpers_test.go index 6b60d77975..ae631bb9a9 100644 --- a/internal/runtime/agent/compact_helpers_test.go +++ b/internal/runtime/agent/compact_helpers_test.go @@ -47,7 +47,6 @@ func agentOverForceWindow(t *testing.T, prov provider.Provider, sess *sessionsto return New(prov, tool.NewRegistry(), sess, Options{ ContextWindow: window, CompactRatio: 0.5, - RecentKeep: 2, ArchiveDir: testenv.TempDir(t), }, event.Discard) } diff --git a/internal/runtime/agent/compact_index.go b/internal/runtime/agent/compact_index.go deleted file mode 100644 index 55f3757c86..0000000000 --- a/internal/runtime/agent/compact_index.go +++ /dev/null @@ -1,314 +0,0 @@ -package agent - -import ( - "encoding/json" - "fmt" - "reasonix/internal/state/sessionstore" - "slices" - "strconv" - "strings" - - "reasonix/internal/contract/ablation" - "reasonix/internal/contract/provider" - "reasonix/internal/safety/evidence" -) - -// A digest carries what the fold changed and what failed; everything else -// becomes prose or nothing, and the model cannot tell which. An index line -// says what prose cannot: this happened, and here is where it still is. - -// The heading carries its own legend; a separate explanatory line would be -// fixed overhead on every digest for the rest of the session. -const indexSectionHeading = "## Folded work index (#n = transcript position)" - -// foldIndexEntry is one folded item reduced to a line. -type foldIndexEntry struct { - Canonical int // position in the canonical transcript, or -1 when unknown - Kind string // "user" | tool name - Subject string // path, command, or a user turn's opening words - Note string // "dropped" for a user turn past the budget, else "" - rank int // lower survives the budget first -} - -func (e foldIndexEntry) line() string { - var b strings.Builder - if e.Canonical >= 0 { - fmt.Fprintf(&b, "#%d ", e.Canonical) - } - b.WriteString(e.Kind) - if e.Subject != "" { - b.WriteString(" " + e.Subject) - } - if e.Note != "" { - b.WriteString(" (" + e.Note + ")") - } - return "- " + b.String() -} - -// Ranks order what survives a bounded index. A user turn the budget could not -// hold comes first: its original is words nobody can re-derive. Failed -// exploration outranks a plain read — a path already tried is worth more. -const ( - rankDroppedUserTurn = iota - rankFailedCall - rankCommand - rankRead -) - -// buildFoldIndex reduces the fold region to index lines. origin maps a region -// position to its canonical index, or -1 for a message already folded once. -func buildFoldIndex(region []provider.Message, kept []bool, facts func(string) evidence.ToolFacts, origin func(int) int) []foldIndexEntry { - var out []foldIndexEntry - calls := map[string]provider.ToolCall{} - callAt := map[string]int{} - for i, m := range region { - for _, tc := range m.ToolCalls { - calls[tc.ID] = tc - callAt[tc.ID] = i - } - switch { - case m.Role == provider.RoleUser && !m.LocalOnly && !isCompactionSummary(m): - if i < len(kept) && kept[i] { - continue // held verbatim; the projection still shows it - } - out = append(out, foldIndexEntry{ - Canonical: origin(i), Kind: "you", Subject: quotedOpening(m.Content), - Note: "summary only", rank: rankDroppedUserTurn, - }) - case m.Role == provider.RoleTool: - call, ok := calls[m.ToolCallID] - if !ok { - continue - } - failed := isErrorMessage(m) - rec := evidence.ReceiptFromToolCall(call.Name, json.RawMessage(call.ExecutionArguments()), !failed, facts(call.Name)) - // Skip exactly what the digest is already on the hook for (see - // compact_coverage.go): a change with a path, or a failed command. - // Anything else falls to the index, so nothing lands between them. - if coverageDemands(rec, failed) { - continue - } - entry := foldIndexEntry{Canonical: origin(callAt[m.ToolCallID]), Kind: call.Name, rank: rankRead} - switch { - case rec.Command != "": - entry.Subject, entry.rank = firstLine(rec.Command), rankCommand - case len(rec.Paths) > 0: - entry.Subject = strings.Join(rec.Paths, " ") - default: - entry.Subject = summarizeToolArgs(call.Arguments) - } - if failed { - entry.Note, entry.rank = "failed", rankFailedCall - } - out = append(out, entry) - } - } - return out -} - -// renderFoldIndex writes the section, dropping the lowest-ranked entries when -// the budget binds. Ties keep transcript order so the section reads forward. -func (a *contextWindow) renderFoldIndex(entries []foldIndexEntry, budgetTokens int) string { - if len(entries) == 0 || budgetTokens <= 0 { - return "" - } - order := make([]int, len(entries)) - for i := range order { - order[i] = i - } - // Stable by rank: sort.SliceStable would do, but the slice is small and the - // selection has to stay in transcript order for rendering anyway. - var chosen []int - spent := a.textTokens(indexSectionHeading) - for rank := rankDroppedUserTurn; rank <= rankRead; rank++ { - for _, i := range order { - if entries[i].rank != rank { - continue - } - cost := a.textTokens(entries[i].line()) - if spent+cost > budgetTokens { - continue - } - spent += cost - chosen = append(chosen, i) - } - } - if len(chosen) == 0 { - return "" - } - keep := make([]bool, len(entries)) - for _, i := range chosen { - keep[i] = true - } - var b strings.Builder - b.WriteString(indexSectionHeading + "\n") - for i, e := range entries { - if keep[i] { - b.WriteString(e.line() + "\n") - } - } - return strings.TrimRight(b.String(), "\n") -} - -// splitFoldIndex separates a digest's prose from the index section this package -// appended to it. Keeping them apart is what lets a later fold re-summarize the -// prose without asking a model to rewrite lines it never wrote. -func splitFoldIndex(digest string) (prose, index string) { - i := strings.Index(digest, indexSectionHeading) - if i < 0 { - return digest, "" - } - return strings.TrimRight(digest[:i], "\n "), strings.TrimSpace(digest[i:]) -} - -// mergeFoldIndex carries the previous index forward ahead of the new lines and -// trims from the oldest when the budget binds — an entry that has survived more -// folds is the one whose original is furthest out of reach. -func (a *contextWindow) mergeFoldIndex(previous, fresh string, budgetTokens int) string { - previous, fresh = strings.TrimSpace(previous), strings.TrimSpace(fresh) - if previous == "" { - return fresh - } - lines := append(indexBodyLines(previous), indexBodyLines(fresh)...) - if len(lines) == 0 { - return "" - } - spent := a.textTokens(indexSectionHeading) - first := 0 - for i, line := range slices.Backward(lines) { - cost := a.textTokens(line) - if spent+cost > budgetTokens { - first = i + 1 - break - } - spent += cost - } - if first >= len(lines) { - return "" - } - var b strings.Builder - b.WriteString(indexSectionHeading + "\n") - if first > 0 { - fmt.Fprintf(&b, "- (%d older entries dropped; %s)\n", first, a.droppedIndexHint()) - } - for _, l := range lines[first:] { - b.WriteString(l + "\n") - } - return strings.TrimRight(b.String(), "\n") -} - -func indexBodyLines(section string) []string { - var out []string - for line := range strings.SplitSeq(section, "\n") { - trimmed := strings.TrimRight(line, " ") - if strings.HasPrefix(trimmed, "- ") && !strings.HasPrefix(trimmed, "- (") { - out = append(out, trimmed) - } - } - return out -} - -// quotedOpening is a user turn reduced to enough words to recognise it. -func quotedOpening(content string) string { - const maxRunes = 60 - flat := strings.Join(strings.Fields(strings.TrimSpace(content)), " ") - if flat == "" { - return "" - } - runes := []rune(flat) - if len(runes) > maxRunes { - flat = string(runes[:maxRunes]) + "…" - } - return strconv.Quote(flat) -} - -// foldIndexBudget is the index's share of a checkpoint. A line costs about a -// dozen tokens, so one percent of the window addresses roughly a hundred items -// — shared by every generation, since the index is cumulative. It is still the -// cheapest thing a fold keeps: prose needs hundreds of tokens to say what a -// line says, and only a line can be recalled. -func (a *contextWindow) foldIndexBudget() int { - const floor = 256 - // The bench axis scales the whole allowance, floor included: a floor that - // survived would make the off arm a small index rather than none. - scale := a.ablation.FoldIndex().Ratio() - if scale <= 0 { - return 0 - } - window := a.effectiveContextWindow() - if window <= 0 { - return int(floor * scale) - } - return int(float64(max(floor, window/100)) * scale) -} - -// stripFoldIndexFromDigests pulls the host-written index out of any prior -// digest in the fold input and returns it separately. The summarizer never -// sees it: rewriting lines it did not write only spends output budget and -// risks garbling addresses. -func stripFoldIndexFromDigests(fold []provider.Message) ([]provider.Message, string) { - var carried []string - out := make([]provider.Message, 0, len(fold)) - for _, m := range fold { - if !isCompactionSummary(m) { - out = append(out, m) - continue - } - prose, index := splitFoldIndex(m.Content) - if index != "" { - carried = append(carried, index) - } - m.Content = prose - out = append(out, m) - } - return out, strings.Join(carried, "\n") -} - -// attachFoldIndex appends the merged index to a digest. -func (a *contextWindow) attachFoldIndex(digest, priorIndex string, entries []foldIndexEntry) string { - budget := a.foldIndexBudget() - merged := a.mergeFoldIndex(priorIndex, a.renderFoldIndex(entries, budget), budget) - if merged == "" { - return digest - } - return strings.TrimRight(digest, "\n") + "\n\n" + merged -} - -// canonicalOriginFor maps a fold-region position back to its place in the -// canonical transcript, which is what an index address has to name. Positions -// inside a previous projection have no canonical address of their own — that -// content was already folded once — so they answer -1. -func (a *contextWindow) canonicalOriginFor(state sessionstore.CompactionState, canonical, msgs []provider.Message, head int) func(int) int { - projected := len(state.Projection.Messages) - // visibleInputForFold either returned canonical itself, or the projection - // spliced with canonical[CoveredCount:]. The two cases differ only in where - // the canonical run begins. - if projected == 0 || len(msgs) == 0 || projected > len(msgs) { - return func(i int) int { - if pos := head + i; pos < len(canonical) { - return pos - } - return -1 - } - } - covered := state.Projection.CoveredCount - return func(i int) int { - pos := head + i - if pos < projected { - return -1 - } - if origin := covered + (pos - projected); origin < len(canonical) { - return origin - } - return -1 - } -} - -// droppedIndexHint names what can still reach a trimmed line. With search -// ablated the arm must not be told about a capability it does not have. -func (a *contextWindow) droppedIndexHint() string { - if a.ablation.Off(ablation.RecallSearch) { - return "the full transcript still holds them" - } - return "recall with a query still finds them" -} diff --git a/internal/runtime/agent/compact_index_test.go b/internal/runtime/agent/compact_index_test.go deleted file mode 100644 index 42c61120c5..0000000000 --- a/internal/runtime/agent/compact_index_test.go +++ /dev/null @@ -1,145 +0,0 @@ -package agent - -import ( - "strings" - "testing" - - "reasonix/internal/contract/provider" -) - -func indexRegion() []provider.Message { - return []provider.Message{ - {Role: provider.RoleUser, Content: "数据库迁移的约束是不能停机,而且必须可回滚"}, - {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{ - {ID: "r1", Name: "read_file", Arguments: `{"path":"internal/parser/lexer.go"}`}, - {ID: "g1", Name: "grep", Arguments: `{"pattern":"tokenize"}`}, - {ID: "b1", Name: "bash", Arguments: `{"command":"go build ./..."}`}, - {ID: "b2", Name: "bash", Arguments: `{"command":"go test ./internal/parser/"}`}, - {ID: "w1", Name: "write_file", Arguments: `{"path":"internal/parser/lexer.go"}`}, - }}, - {Role: provider.RoleTool, ToolCallID: "r1", Name: "read_file", Content: "contents"}, - {Role: provider.RoleTool, ToolCallID: "g1", Name: "grep", Content: "3 matches"}, - {Role: provider.RoleTool, ToolCallID: "b1", Name: "bash", Content: "ok"}, - {Role: provider.RoleTool, ToolCallID: "b2", Name: "bash", Content: "error: FAIL"}, - {Role: provider.RoleTool, ToolCallID: "w1", Name: "write_file", Content: "wrote"}, - } -} - -func identityOrigin(i int) int { return i } - -// Index budgets are token budgets, so the lines are costed the way the window -// is: an uncalibrated agent carries the ~4 chars per token fallback. -func indexAgent() *Agent { return &Agent{agentConfig: agentConfig{contextWindow: 128_000}} } - -// The budget is a token share of the window. Costing its lines in characters -// spent it four times over, leaving the address list a quarter of the size it -// was budgeted for — and the fold's only addresses along with it. -func TestFoldIndexBudgetIsSpentInTokens(t *testing.T) { - a := indexAgent() - entries := make([]foldIndexEntry, 40) - for i := range entries { - entries[i] = foldIndexEntry{Canonical: i, Kind: "read_file", Subject: "internal/runtime/agent/compact_index.go", rank: rankRead} - } - digest := a.window().attachFoldIndex("## Goal\nship it", "", entries) - if kept := strings.Count(digest, "read_file"); kept < len(entries) { - t.Fatalf("index kept %d of %d entries under a %d-token budget", kept, len(entries), a.window().foldIndexBudget()) - } -} - -// The index carries what the digest does not: reads, searches, and user turns -// the retention budget could not hold. Changes are the digest's contract, so -// indexing them again would pay for the same fact twice. -func TestFoldIndexCoversWhatTheDigestDoesNot(t *testing.T) { - entries := buildFoldIndex(indexRegion(), make([]bool, 7), coverageTools, identityOrigin) - rendered := indexAgent().window().renderFoldIndex(entries, 4000) - for _, want := range []string{"read_file", "grep", "go build", "#0"} { - if !strings.Contains(rendered, want) { - t.Errorf("index missing %q:\n%s", want, rendered) - } - } - // A change and a failed command are what the digest is on the hook for, so - // neither belongs here — the two halves must not both pay for one fact. - if strings.Contains(rendered, "write_file") { - t.Errorf("a change was indexed as well as digested:\n%s", rendered) - } - if strings.Contains(rendered, "go test") { - t.Errorf("a failed command was indexed as well as digested:\n%s", rendered) - } - if !strings.Contains(rendered, "不能停机") { - t.Errorf("the folded user turn was not indexed:\n%s", rendered) - } -} - -// A user turn held verbatim by the keep policy is still in the projection, so -// an index line for it would be a second copy of the same words. -func TestFoldIndexSkipsUserTurnsHeldVerbatim(t *testing.T) { - kept := make([]bool, 7) - kept[0] = true - rendered := indexAgent().window().renderFoldIndex(buildFoldIndex(indexRegion(), kept, coverageTools, identityOrigin), 4000) - if strings.Contains(rendered, "不能停机") { - t.Errorf("a verbatim-kept turn was indexed anyway:\n%s", rendered) - } -} - -// When the budget binds, a user turn nobody can re-derive outranks a read -// whose file is still on disk. -func TestFoldIndexBudgetKeepsTheIrreplaceableFirst(t *testing.T) { - entries := buildFoldIndex(indexRegion(), make([]bool, 7), coverageTools, identityOrigin) - rendered := indexAgent().window().renderFoldIndex(entries, 40) - if rendered == "" { - t.Fatal("a small budget should still hold the top-ranked entry") - } - if !strings.Contains(rendered, "不能停机") { - t.Errorf("the budget dropped the user turn before the reads:\n%s", rendered) - } - if strings.Contains(rendered, "read_file") { - t.Errorf("a read survived a budget that could not hold everything:\n%s", rendered) - } -} - -// The index round-trips out of a digest and back, so a later fold can -// re-summarize the prose without asking a model to rewrite host-written lines. -func TestFoldIndexSplitsAndMergesAcrossFolds(t *testing.T) { - first := indexAgent().window().renderFoldIndex(buildFoldIndex(indexRegion(), make([]bool, 7), coverageTools, identityOrigin), 4000) - digest := "## Goal\nship the parser\n\n" + first - - prose, index := splitFoldIndex(digest) - if strings.Contains(prose, indexSectionHeading) || !strings.Contains(prose, "ship the parser") { - t.Fatalf("prose and index did not separate:\n%s", prose) - } - if index != first { - t.Fatalf("index did not round-trip:\n%s", index) - } - - fresh := indexAgent().window().renderFoldIndex([]foldIndexEntry{ - {Canonical: 40, Kind: "read_file", Subject: "internal/other.go", rank: rankRead}, - }, 4000) - merged := indexAgent().window().mergeFoldIndex(index, fresh, 4000) - if !strings.Contains(merged, "internal/other.go") || !strings.Contains(merged, "不能停机") { - t.Fatalf("merge lost one side:\n%s", merged) - } - if strings.Count(merged, indexSectionHeading) != 1 { - t.Fatalf("merge produced more than one section:\n%s", merged) - } -} - -// A merged index that outgrows its budget drops from the oldest and says so, -// because an entry that survived more folds is the one furthest out of reach. -func TestFoldIndexMergeTrimsOldestAndSaysSo(t *testing.T) { - var old []foldIndexEntry - for i := range 40 { - old = append(old, foldIndexEntry{Canonical: i, Kind: "read_file", Subject: "internal/pkg/file.go", rank: rankRead}) - } - previous := indexAgent().window().renderFoldIndex(old, 8000) - fresh := indexAgent().window().renderFoldIndex([]foldIndexEntry{ - {Canonical: 99, Kind: "read_file", Subject: "internal/newest.go", rank: rankRead}, - }, 8000) - - merged := indexAgent().window().mergeFoldIndex(previous, fresh, 200) - if !strings.Contains(merged, "internal/newest.go") { - t.Fatalf("trimming dropped the newest entry:\n%s", merged) - } - if !strings.Contains(merged, "older entries dropped") { - t.Fatalf("the trim was silent:\n%s", merged) - } -} diff --git a/internal/runtime/agent/compact_kept_failures.go b/internal/runtime/agent/compact_kept_failures.go deleted file mode 100644 index c26adc87ef..0000000000 --- a/internal/runtime/agent/compact_kept_failures.go +++ /dev/null @@ -1,33 +0,0 @@ -package agent - -import ( - "slices" - - "reasonix/internal/contract/provider" -) - -const ( - keptFailuresWindowFrac = 0.05 - keptFailuresTriggerFrac = 0.10 -) - -// keptFailuresBudget caps what retained failures may spend of a checkpoint. A -// retained failure rides into the next fold's region, so without its own bound -// the retention grows by each round's failures until the acceptance ceiling. -func (a *contextWindow) keptFailuresBudget() int { - return a.retentionBudget(keptFailuresWindowFrac, keptFailuresTriggerFrac) -} - -// keepFailuresWithinBudget retains the newest failed tool results that fit the -// budget; the rest fold into the digest, whose coverage guard still names them. -func (a *contextWindow) keepFailuresWithinBudget(region []provider.Message, keep []bool, failures []int) { - budget := a.keptFailuresBudget() - for _, i := range slices.Backward(failures) { - cost := fixedTokenEstimate(region[i]) - if cost > budget { - continue - } - keep[i] = true - budget -= cost - } -} diff --git a/internal/runtime/agent/compact_kept_marked.go b/internal/runtime/agent/compact_kept_marked.go deleted file mode 100644 index af420a79d4..0000000000 --- a/internal/runtime/agent/compact_kept_marked.go +++ /dev/null @@ -1,37 +0,0 @@ -package agent - -import ( - "slices" - - "reasonix/internal/contract/provider" -) - -const ( - keptMarkedWindowFrac = 0.15 - keptMarkedTriggerFrac = 0.20 -) - -// keptMarkedBudget caps what [[keep]] turns may spend of a checkpoint. A turn the -// person marked is held verbatim, and a kept turn rides into the next fold's -// region, so without its own bound the retention grows by every marked turn -// until the candidate cannot land under the trigger and the fold is refused. -func (a *contextWindow) keptMarkedBudget() int { - return a.retentionBudget(keptMarkedWindowFrac, keptMarkedTriggerFrac) -} - -// keepMarkedWithinBudget retains the newest marked turns that fit the budget and -// returns the rest, which fold into the digest and are counted. -func (a *contextWindow) keepMarkedWithinBudget(region []provider.Message, keep []bool, marked []int) (dropped, droppedTokens int) { - budget := a.keptMarkedBudget() - for _, i := range slices.Backward(marked) { - cost := fixedTokenEstimate(region[i]) - if cost > budget { - dropped++ - droppedTokens += cost - continue - } - keep[i] = true - budget -= cost - } - return dropped, droppedTokens -} diff --git a/internal/runtime/agent/compact_longhorizon_test.go b/internal/runtime/agent/compact_longhorizon_test.go index f496ba8bbd..948005cacc 100644 --- a/internal/runtime/agent/compact_longhorizon_test.go +++ b/internal/runtime/agent/compact_longhorizon_test.go @@ -115,25 +115,6 @@ func growSession(a *Agent, gen int) { } } -// indexCarriesAddress reports whether any visible fold index still addresses -// this position. It is an observation, not a requirement: search must work -// whether or not the index happens to have kept the line. -func indexCarriesAddress(a *Agent, pos int) bool { - canonical, _ := a.sess.conversation.SnapshotMessagesVersion() - for _, m := range modelVisibleFromProjection(a.sess.win.compactionState.Projection, canonical) { - _, index := splitFoldIndex(m.Content) - if index == "" { - continue - } - for _, line := range indexBodyLines(index) { - if strings.HasPrefix(strings.TrimPrefix(line, "- "), fmt.Sprintf("#%d ", pos)) { - return true - } - } - } - return false -} - func hitRank(res tool.RecallResult, want int) int { for i, h := range res.Hits { if h.Position == want { @@ -143,67 +124,6 @@ func hitRank(res tool.RecallResult, want int) int { return 0 } -// The pin: whatever a projection lost, a search finds and a read returns -// verbatim, for as many generations as the session runs. -func TestLongHorizonRetrievability(t *testing.T) { - for _, c := range retrievalCarriers() { - t.Run(c.name, func(t *testing.T) { - sess, at := horizonSession(c, 6) - a := agentOverForce(t, &fakeProvider{reply: "Work continued."}, sess) - - const generations = 50 - indexHeldUntil, lostAt, proven := -1, -1, 0 - for gen := range generations { - if err := prepareContext(context.Background(), a, CompactionTriggerOverflow); err != nil { - t.Fatalf("generation %d prepare = %v", gen, err) - } - growSession(a, gen) - if indexCarriesAddress(a, at) { - indexHeldUntil = gen - } - // A tool call's index line quotes its command, so an indexed - // carrier stays legible until that line is trimmed. Only once - // the fact is genuinely out of sight does a hit prove anything. - if !foldedOutOfSight(a, c.needle) { - continue - } - if lostAt < 0 { - lostAt = gen - } - proven++ - res := searchRecall(t, a, c.query) - rank := hitRank(res, at) - if rank == 0 { - t.Fatalf("generation %d: search %q returned %v, want #%d\n%s", - gen, c.query, hitPositions(res), at, res.Text) - } - if rank > 5 { - t.Errorf("generation %d: #%d ranked %d, outside the first five hits", gen, at, rank) - } - } - if proven == 0 { - t.Fatalf("the fact never left the model's sight in %d generations; this run tested nothing", generations) - } - t.Logf("out of sight from generation %d; search verified %d generations", lostAt, proven) - // Exact recovery after every generation of loss. - read, err := a.RecallContext(context.Background(), tool.RecallRequest{Positions: []int{at}}) - if err != nil { - t.Fatalf("read #%d after %d generations = %v", at, generations, err) - } - if !strings.Contains(read.Text, c.needle) { - t.Errorf("read #%d returned %q, want the original wording", at, read.Text) - } - switch { - case !c.indexed && indexHeldUntil >= 0: - t.Errorf("an unindexed carrier held an index address through generation %d", indexHeldUntil) - case c.indexed: - t.Logf("index addressed #%d through generation %d of %d; search covered all %d", - at, indexHeldUntil, generations, generations) - } - }) - } -} - // Rank must not decay as the corpus grows: a long session is exactly where the // fact is hardest to find and most worth finding. func TestLongHorizonRankHoldsAcrossCorpusSizes(t *testing.T) { diff --git a/internal/runtime/agent/compact_loop_e2e_test.go b/internal/runtime/agent/compact_loop_e2e_test.go index c796670a30..0344cfbd4d 100644 --- a/internal/runtime/agent/compact_loop_e2e_test.go +++ b/internal/runtime/agent/compact_loop_e2e_test.go @@ -150,38 +150,34 @@ func consecutiveCompactingTurns(perTurn []int) int { return worst } -// 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) { +// TestTinyWindowFoldsEveryRoundAndStaysBounded: nothing is protected from a +// fold, so a tiny window whose every tool result nearly fills it is archived +// each round instead of blocking. The model, not the fold, decides when such a +// loop ends; what the fold owes is a request that stays under the window. +func TestTinyWindowFoldsEveryRoundAndStaysBounded(t *testing.T) { 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 + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := 0 a.svc.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++ + if e.Kind == event.CompactionDone { + done++ + if done >= 5 { + cancel() + } } }) - // 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) + _ = a.Run(ctx, "turn 0: keep going") + if done < 2 { + t.Fatalf("folds completed = %d, want a fold on every round", done) } - if blocked == 0 && a.window().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.window().currentProjectionVersion()) + if v := a.window().currentProjectionVersion(); v == 0 { + t.Fatal("no projection was installed") } } diff --git a/internal/runtime/agent/compact_manual_scope_test.go b/internal/runtime/agent/compact_manual_scope_test.go index 9d8b026def..009d74df49 100644 --- a/internal/runtime/agent/compact_manual_scope_test.go +++ b/internal/runtime/agent/compact_manual_scope_test.go @@ -20,8 +20,7 @@ func manualAgent(t *testing.T, sess *sessionstore.Session) (*Agent, *countingPro t.Helper() prov := &countingProvider{reply: "## Standing facts\n- never change the public API"} a := New(prov, tool.NewRegistry(), sess, Options{ - ContextWindow: 200_000, CompactRatio: 0.8, RecentKeep: 2, - SessionPath: filepath.Join(testenv.TempDir(t), "session.jsonl"), + ContextWindow: 200_000, CompactRatio: 0.8, SessionPath: filepath.Join(testenv.TempDir(t), "session.jsonl"), WorkspaceID: "ws", ModelRef: "p/m", ArchiveDir: testenv.TempDir(t), }, event.Discard) diff --git a/internal/runtime/agent/compact_manual_verdict_test.go b/internal/runtime/agent/compact_manual_verdict_test.go index 7200f1ae9e..ea3631e1f4 100644 --- a/internal/runtime/agent/compact_manual_verdict_test.go +++ b/internal/runtime/agent/compact_manual_verdict_test.go @@ -36,8 +36,7 @@ func TestManualCompactWithNothingToFoldIsAVerdictNotAnOverflow(t *testing.T) { for name, msgs := range shapes { t.Run(name, func(t *testing.T) { a := New(&fakeProvider{reply: "unused"}, tool.NewRegistry(), &sessionstore.Session{Messages: msgs}, Options{ - ContextWindow: 200_000, CompactRatio: 0.8, RecentKeep: 2, - SessionPath: filepath.Join(testenv.TempDir(t), "session.jsonl"), + ContextWindow: 200_000, CompactRatio: 0.8, SessionPath: filepath.Join(testenv.TempDir(t), "session.jsonl"), WorkspaceID: "ws", ModelRef: "p/m", }, event.Discard) diff --git a/internal/runtime/agent/compact_mechanical_users_test.go b/internal/runtime/agent/compact_mechanical_users_test.go deleted file mode 100644 index 102c1d1e23..0000000000 --- a/internal/runtime/agent/compact_mechanical_users_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package agent - -import ( - "strings" - "testing" - - "reasonix/internal/contract/provider" -) - -// The user's own words are what no later step can rebuild: the workspace still -// holds the code a constraint governs, nothing holds the constraint. A fold the -// summarizer could not digest has to carry them, shortened, or the model is told -// that earlier messages exist and given none of them. -func TestMechanicalFoldCarriesTheUsersWordsShortened(t *testing.T) { - long := "never touch the billing module " + strings.Repeat("because audit asked for it ", 40) - fold := []provider.Message{ - {Role: provider.RoleUser, Content: long}, - {Role: provider.RoleAssistant, Content: "assistant chatter that must not ride along"}, - {Role: provider.RoleTool, Content: "tool output that must not ride along"}, - {Role: provider.RoleUser, Content: "prefer table-driven tests"}, - {Role: provider.RoleUser, Content: "local-only note", LocalOnly: true}, - } - digest := mechanicalFoldDigest(fold) - for _, want := range []string{"never touch the billing module", "prefer table-driven tests", "summary was unavailable", "5 earlier message"} { - if !strings.Contains(digest, want) { - t.Errorf("digest lacks %q:\n%s", want, digest) - } - } - for _, banned := range []string{"assistant chatter", "tool output", "local-only note"} { - if strings.Contains(digest, banned) { - t.Errorf("digest carries %q:\n%s", banned, digest) - } - } - if strings.Contains(digest, long) { - t.Errorf("a long turn rode whole; it must be shortened") - } - if strings.Index(digest, "billing") > strings.Index(digest, "table-driven") { - t.Errorf("the user's turns are not in the order they were said:\n%s", digest) - } -} - -// A user turn reaches the fold as the provider saw it, with the host's date, -// instructions and policy blocks around the person's words. Those blocks are not -// what the user said, and the per-turn cut would spend the line on them. -func TestMechanicalFoldCarriesWhatThePersonSaidNotTheHostBlocks(t *testing.T) { - prefix := "Today's date is 2026-10-21 (Wednesday); the local time zone is UTC+08:00.\n" + - "" + strings.Repeat("keep the build green ", 30) + "\n" - fold := []provider.Message{ - {Role: provider.RoleUser, Content: prefix + "never touch the billing module", RawContent: "never touch the billing module"}, - {Role: provider.RoleUser, Content: prefix + "prefer table-driven tests"}, - {Role: provider.RoleUser, Content: prefix}, - } - digest := mechanicalFoldDigest(fold) - for _, want := range []string{"never touch the billing module", "prefer table-driven tests"} { - if !strings.Contains(digest, want) { - t.Errorf("digest lacks the user's %q:\n%s", want, digest) - } - } - for _, banned := range []string{"Today's date", "keep the build green", "project-instructions"} { - if strings.Contains(digest, banned) { - t.Errorf("digest reports the host's %q as the user's words:\n%s", banned, digest) - } - } - if got := strings.Count(digest, "\n- "); got != 2 { - t.Errorf("listed %d user turns, want 2: a turn holding only host blocks said nothing:\n%s", got, digest) - } -} - -func TestMechanicalFoldStaysBoundedForAHugeNumberOfTurns(t *testing.T) { - var fold []provider.Message - for range 3000 { - fold = append(fold, provider.Message{Role: provider.RoleUser, Content: strings.Repeat("x", 600)}) - } - if got := len(mechanicalFoldDigest(fold)); got > mechanicalUserTotalChars+1500 { - t.Fatalf("digest is %d chars for 3000 turns, want it near the %d cap", got, mechanicalUserTotalChars) - } -} diff --git a/internal/runtime/agent/compact_partition_test.go b/internal/runtime/agent/compact_partition_test.go deleted file mode 100644 index eb810eeb8f..0000000000 --- a/internal/runtime/agent/compact_partition_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package agent - -import ( - "fmt" - "strings" - "testing" - - "reasonix/internal/contract/provider" -) - -// 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) (kept, fold []provider.Message) { - t.Helper() - kept, fold, retention, _ := a.window().partitionFoldForProjection(region) - userTurns := 0 - for _, m := range region { - if m.Role == provider.RoleUser && !m.LocalOnly && !isCompactionSummary(m) { - userTurns++ - } - } - if got := retention.Kept + retention.Dropped; got != userTurns { - t.Errorf("retention accounts for %d user turns, region has %d", got, userTurns) - } - seen := map[string]int{} - for _, group := range [][]provider.Message{kept, fold} { - for _, m := range group { - seen[m.Content]++ - } - } - for _, m := range region { - if m.LocalOnly { - if seen[m.Content] != 0 { - t.Errorf("display-only message %q reached the projection", m.Content) - } - continue - } - switch seen[m.Content] { - case 1: - case 0: - t.Errorf("message %q is in no group — it would vanish from the projection", m.Content) - default: - t.Errorf("message %q is in %d groups — it would be duplicated", m.Content, seen[m.Content]) - } - } - return kept, fold -} - -func TestPartitionKeepsSmallUserTurnsVerbatim(t *testing.T) { - // A constraint stated mid-session cannot be re-derived once a digest drops - // it, so small user turns never reach the summarizer's judgement. - a := &Agent{} - region := []provider.Message{ - {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"}, - } - kept, fold := partitionCoversRegion(t, a, region) - if len(kept) != 3 || len(fold) != 1 { - t.Fatalf("kept=%d fold=%d, want 3/1", len(kept), len(fold)) - } - if got := renderTranscript(fold); strings.Contains(got, "small turn") { - t.Fatalf("no user turn should reach the summarizer: %s", got) - } -} - -func TestPartitionFoldsUserTurnsPastBudget(t *testing.T) { - // Unbounded hoisting is what padded an earlier revision's candidates past - // the acceptance ceiling, so a turn larger than the budget still folds. - a := &Agent{} - oversize := provider.Message{ - Role: provider.RoleUser, - Content: strings.Repeat("y", keptUserTurnsFloorTokens*40), - } - region := []provider.Message{ - {Role: provider.RoleUser, Content: "small and kept"}, - oversize, - } - kept, fold := partitionCoversRegion(t, a, region) - if len(kept) != 1 || len(fold) != 1 { - t.Fatalf("kept=%d fold=%d, want the oversize turn folded", len(kept), len(fold)) - } - if !strings.Contains(renderTranscript(kept), "small and kept") { - t.Fatal("the small turn should survive alongside a folded oversize one") - } -} - -func TestPartitionUserTurnBudgetIsBoundedInTotal(t *testing.T) { - a := &Agent{} - // The budget is now the only gate, so enough turns must exhaust it. - each := strings.Repeat("z", keptUserTurnsFloorTokens) - region := make([]provider.Message, 0, 32) - for i := range 32 { - // Distinct content: partitionCoversRegion keys its coverage check on it. - region = append(region, provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("%02d%s", i, each)}) - } - kept, fold := partitionCoversRegion(t, a, region) - if len(fold) == 0 { - t.Fatal("total budget never engaged: every turn was kept") - } - budget := a.window().keptUserTurnsBudget() - spent := 0 - for _, m := range kept { - spent += fixedTokenEstimate(m) - } - if spent > budget { - t.Fatalf("kept %d tokens of user turns, over the %d budget", spent, budget) - } -} - -func TestPartitionKeepsUserTurnsAcrossPriorDigest(t *testing.T) { - // The keep policy is scoped to messages after the latest digest so it cannot - // grow forever; user turns are bounded by budget instead, so a turn from - // before the digest must still survive the next fold. - a := &Agent{} - region := []provider.Message{ - {Role: provider.RoleUser, Content: "constraint from before the digest"}, - {Role: provider.RoleUser, Content: SummaryTagOpen + "\nprior digest\n" + SummaryTagClose}, - {Role: provider.RoleAssistant, Content: "work"}, - } - kept, fold := partitionCoversRegion(t, a, region) - if len(kept) != 1 || !strings.Contains(renderTranscript(kept), "constraint from before") { - t.Fatalf("pre-digest user turn must survive; kept=%v", renderTranscript(kept)) - } - if !strings.Contains(renderTranscript(fold), "prior digest") { - t.Fatal("the digest itself must still fold into the next one") - } -} - -func TestPartitionFoldsPriorDigests(t *testing.T) { - // 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"}, - } - 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)) - } -} - -func TestPartitionKeepPolicyOutranksFold(t *testing.T) { - a := &Agent{keepPolicy: KeepErrors} - region := []provider.Message{ - {Role: provider.RoleAssistant, Content: "unrelated prose"}, - {Role: provider.RoleAssistant, Content: "call", ToolCalls: []provider.ToolCall{{ID: "t1", Name: "bash"}}}, - {Role: provider.RoleTool, ToolCallID: "t1", Name: "bash", Content: "error: boom"}, - } - kept, fold := partitionCoversRegion(t, a, region) - if len(kept) != 2 || len(fold) != 1 { - t.Fatalf("kept=%d fold=%d, want the error tool-call group kept and the prose folded", len(kept), len(fold)) - } - if got := renderTranscript(kept); !strings.Contains(got, "error: boom") || !strings.Contains(got, "call") { - t.Fatalf("the failing call and its result must be kept together: %s", got) - } -} diff --git a/internal/runtime/agent/compact_plan_bench_test.go b/internal/runtime/agent/compact_plan_bench_test.go index c45b637c74..6bc163dff9 100644 --- a/internal/runtime/agent/compact_plan_bench_test.go +++ b/internal/runtime/agent/compact_plan_bench_test.go @@ -1,33 +1,3 @@ package agent -import ( - "fmt" - "testing" -) - -// planCompaction runs on the auto-fold path, where the user is already waiting. -// Measuring each candidate tail whole made its cost the transcript's size times -// the number of messages the boundary had to move — and a copy of the suffix -// per step once any message carried a decision receipt. If either returns, this -// benchmark grows superlinearly in turns and allocates again. -func BenchmarkPlanCompaction(b *testing.B) { - for _, tc := range []struct { - turns int - cjk bool - receipts bool - }{ - {200, false, false}, - {200, false, true}, - {200, true, false}, - {600, false, true}, - } { - msgs := foldFixture(tc.turns, tc.cjk, tc.receipts) - a := calibratedFoldAgent(msgs) - b.Run(fmt.Sprintf("turns=%d/cjk=%v/receipts=%v", tc.turns, tc.cjk, tc.receipts), func(b *testing.B) { - b.ReportAllocs() - for b.Loop() { - a.window().planCompaction(msgs, minCompactMessages, false) - } - }) - } -} +import () diff --git a/internal/runtime/agent/compact_plan_test.go b/internal/runtime/agent/compact_plan_test.go index dfd2b30aa0..c1ef6ac00d 100644 --- a/internal/runtime/agent/compact_plan_test.go +++ b/internal/runtime/agent/compact_plan_test.go @@ -41,12 +41,6 @@ func foldFixture(turns int, cjk, receipts bool) []provider.Message { return msgs } -func calibratedFoldAgent(msgs []provider.Message) *Agent { - a := &Agent{agentConfig: agentConfig{contextWindow: 128_000, recentKeep: 2}} - a.window().setPromptTokenCalibration(64_000, a.window().requestCalibrationShape(provider.Request{Messages: msgs})) - return a -} - // remeasuredTailStart carries one measurement forward instead of re-measuring // each candidate tail whole. That is only sound while a request's shape is the // sum of its messages' shapes — for every suffix, on a transcript carrying the @@ -75,27 +69,3 @@ func TestProjectedMessageShapesSumToTheWholeSliceMeasurement(t *testing.T) { } } } - -// The same equivalence one level up, where it decides what a fold keeps: the -// incremental walk must stop on the message the rescanning walk stopped on. -func TestRemeasuredTailStartMatchesTheRescanningWalk(t *testing.T) { - for _, cjk := range []bool{false, true} { - msgs := foldFixture(200, cjk, true) - a := calibratedFoldAgent(msgs) - head := a.window().pinnedPrefixLen(msgs) - budget := a.window().recentTailBudget() - from := tailStart(msgs, head, budget, a.window().tokPerChar(), a.window().tailFloor()) - floor := max(head, len(msgs)-a.window().tailFloor()) - - want := from - for want < floor && a.window().estimatedPromptTokens(provider.ModelMessages(msgs[want:])) > budget { - want++ - for want < floor && want < len(msgs) && msgs[want].Role == provider.RoleTool { - want++ - } - } - if got := a.window().remeasuredTailStart(msgs, from, floor, budget); got != want { - t.Fatalf("cjk=%v: incremental walk stopped at %d, rescanning walk at %d", cjk, got, want) - } - } -} diff --git a/internal/runtime/agent/compact_projection.go b/internal/runtime/agent/compact_projection.go index 9dd462179c..fab9ab9930 100644 --- a/internal/runtime/agent/compact_projection.go +++ b/internal/runtime/agent/compact_projection.go @@ -364,20 +364,17 @@ func buildVisibleCompressionProjection(visible []provider.Message, plan visibleC func compactionTelemetryFromSummary(trigger, cacheState string, sourceTokens int, res foldSummary, spend sessionstore.CompactionUsage) CompactionTelemetry { return CompactionTelemetry{ Trigger: trigger, CacheState: cacheState, Mode: res.Mode, - SourceTokens: sourceTokens, - ProviderRequestID: res.RequestID, - FoldTokens: res.FoldTokens, - Spans: spend.Calls, - CoverageRequired: res.Coverage.Required(), - CoverageMissing: res.Coverage.Missing(), - CoverageBackstopped: res.CoverageBackstopped, - SummaryUsage: spend, - InputTokens: spend.InputTokens, - OutputTokens: spend.OutputTokens, - CacheHitTokens: spend.CacheHitTokens, - CacheMissTokens: spend.CacheMissTokens, - CacheWriteTokens: spend.CacheWriteTokens, - RequestCount: spend.RequestAttempts, + SourceTokens: sourceTokens, + ProviderRequestID: res.RequestID, + FoldTokens: res.FoldTokens, + Spans: spend.Calls, + SummaryUsage: spend, + InputTokens: spend.InputTokens, + OutputTokens: spend.OutputTokens, + CacheHitTokens: spend.CacheHitTokens, + CacheMissTokens: spend.CacheMissTokens, + CacheWriteTokens: spend.CacheWriteTokens, + RequestCount: spend.RequestAttempts, } } @@ -426,27 +423,23 @@ func (a *contextWindow) compactToProjection(ctx context.Context, trigger, instru // Every checkpoint costs the whole prefix cache, so a second one waits // for a tail's worth of new closed history — otherwise a small window // folds every few rounds and spends more than the fold frees. - if a.estimatedPromptTokens(msgs[held:start]) < a.recentTailBudget() { + if a.estimatedPromptTokens(msgs[held:start]) < a.refoldMinTokens() { return CompactionNoop, NoopFoldBelowEconomics, nil } } // The annotation rides the projection, not the canonical transcript: the // original stays whole for resume and rewind. region := msgs[head:start] - policy, fold, retention, policyKeep := a.partitionUnderCeiling(scope, stateSnapshot, canonical, fromProjection, msgs, head, start) + kept, fold := a.partitionFold(region, msgs[start:]) if len(fold) == 0 || (!scope.ignoreEconomics && !a.foldEconomics(fold)) { return CompactionNoop, NoopFoldBelowEconomics, nil } - fold, priorIndex := stripFoldIndexFromDigests(fold) - foldIndex := buildFoldIndex(msgs[head:start], policyKeep, a.toolFactsFor, - a.canonicalOriginFor(stateSnapshot, canonical, msgs, head)) - reason, err := a.foldCannotFit(trigger, msgs, head, start, policy) + reason, err := a.foldCannotFit(trigger, msgs, head, start) if err != nil { return CompactionNoop, reason, err } sourceTokens := a.announceCompaction(trigger, len(fold), msgs) - kept, _, _, _ := a.partitionFoldWith(region, msgs[start:], policy) if a.svc.hooks != nil { if hookInstr := a.svc.hooks.PreCompact(ctx, trigger); hookInstr != "" { if instructions != "" { @@ -465,13 +458,12 @@ func (a *contextWindow) compactToProjection(ctx context.Context, trigger, instru return CompactionNoop, NoopFoldEmptyAfterHooks, nil } - res, tele, err := a.foldOrDegrade(ctx, trigger, mustFree, fold, instructions, sourceTokens) + res, tele, err := a.foldArchived(ctx, trigger, fold, instructions, sourceTokens) if err != nil { a.emitCompactionTelemetry(tele) a.emitCompactionAborted(trigger, compactionFailureCode(err)) return CompactionNoop, "", err } - res.Text = a.attachFoldIndex(res.Text, priorIndex, foldIndex) summary, err := a.interceptCompactionComplete(ctx, res.Text) if err != nil { tele.Error = err.Error() @@ -485,7 +477,6 @@ func (a *contextWindow) compactToProjection(ctx context.Context, trigger, instru projTokens := a.estimatedPromptTokens(a.withTodoIdentityTail(candidate)) fixedPrefixTokens := a.estimatedPromptTokens(msgs[:head]) tele.ProjectionTokens = projTokens - tele.UserTurnsKept, tele.UserTurnsDropped = retention.Kept, retention.Dropped a.emitCompactionTelemetry(tele) if err := a.acceptAttributed(trigger, scope, sourceTokens, projTokens, fixedPrefixTokens, kept, summary); err != nil { a.emitCompactionAborted(trigger, compactionFailureCode(err)) @@ -510,8 +501,6 @@ func (a *contextWindow) compactToProjection(ctx context.Context, trigger, instru CoverageBackstopped: tele.CoverageBackstopped, Remains: a.remainsAfterFold(), })}) // Only once the checkpoint is committed: a rejected candidate folded nothing. - a.sess.win.compaction.lastUserTurns = retention - a.noticeDroppedUserTurns(retention) return CompactionInstalled, "", nil } @@ -556,28 +545,6 @@ func (a *contextWindow) foldedProjection(state sessionstore.CompactionState, pro return checkpointProjectionMessages(msgs, head, kept, suffix, summary), boundary } -// partitionUnderCeiling partitions the fold region and names the retention it -// ran under. Retained failures are the one protection that can grow past what a -// checkpoint holds, and a summary billed to fit beside them is discarded whole; -// when they alone would exceed the ceiling they fold into the digest, whose -// coverage guard still names each one. -func (a *contextWindow) partitionUnderCeiling(scope compactionScope, state sessionstore.CompactionState, canonical []provider.Message, projected bool, msgs []provider.Message, head, start int) (KeepPolicy, []provider.Message, userTurnRetention, []bool) { - region := msgs[head:start] - kept, fold, retention, policyKeep := a.partitionFoldWith(region, msgs[start:], a.keepPolicy) - ceiling := a.checkpointCeiling() - if a.keepPolicy&KeepErrors == 0 || ceiling <= 0 || scope.ignoreEconomics { - return a.keepPolicy, fold, retention, policyKeep - } - projMsgs, boundary := a.foldedProjection(state, projected, msgs, kept, head, start, "") - candidate := modelVisibleFromProjection(sessionstore.ContextProjection{Messages: projMsgs, CoveredCount: boundary.Covered}, canonical) - if a.estimatedPromptTokens(a.withTodoIdentityTail(candidate)) <= ceiling { - return a.keepPolicy, fold, retention, policyKeep - } - degraded := a.keepPolicy &^ KeepErrors - _, fold, retention, policyKeep = a.partitionFoldWith(region, msgs[start:], degraded) - return degraded, fold, retention, policyKeep -} - // acceptCheckpointCandidate: ≤50% + smaller for auto; waiving economics may // exceed 50% only if still below trigger; manual below trigger accepts any // savings, since below the trigger the ceiling has nothing to protect. @@ -650,30 +617,27 @@ func (a *contextWindow) planFoldRegion(msgs []provider.Message, force bool) (hea return head, start, true, "" } -// partitionFoldWith partitions under policy rather than the configured one, so -// a fold can retain less than the session asks for when retaining it would -// leave nothing for the digest to fit in. -func (a *contextWindow) partitionFoldWith(region, tail []provider.Message, policy KeepPolicy) (kept, fold []provider.Message, retention userTurnRetention, policyKeep []bool) { - policyKeep, retention = a.keepIndexes(region, policy) +// partitionFold splits region into what stays and what is archived. +func (a *contextWindow) partitionFold(region, tail []provider.Message) (kept, fold []provider.Message) { + keep := a.keepIndexes(region) for i, m := range region { switch { case m.LocalOnly: // display-only output never reaches a provider case isCompactionSummary(m): - // Always merge prior digests into the single next summary. fold = append(fold, m) - case policyKeep[i] && m.Role == provider.RoleAssistant: - stay, rest := splitKeptTurn(region, policyKeep, i) + case keep[i] && m.Role == provider.RoleAssistant: + stay, rest := splitKeptTurn(region, keep, i) kept = append(kept, stay) if rest != nil { fold = append(fold, *rest) } - case policyKeep[i]: + case keep[i]: kept = append(kept, m) default: fold = append(fold, m) } } - return supersedeStandingState(kept, tail), fold, retention, policyKeep + return supersedeStandingState(kept, tail), fold } // announceCompaction puts the card up for a fold of n messages out of msgs and @@ -685,12 +649,3 @@ func (a *contextWindow) announceCompaction(trigger string, n int, msgs []provide })}) return sourceTokens } - -func (a *contextWindow) compact(ctx context.Context, trigger, instructions string, scope compactionScope) error { - _, _, err := a.compactToProjection(ctx, trigger, instructions, scope, false) - return err -} - -func (a *contextWindow) partitionFoldForProjection(region []provider.Message) (kept, fold []provider.Message, retention userTurnRetention, policyKeep []bool) { - return a.partitionFoldWith(region, nil, a.keepPolicy) -} diff --git a/internal/runtime/agent/compact_remains_test.go b/internal/runtime/agent/compact_remains_test.go index befb32c233..a3433ac159 100644 --- a/internal/runtime/agent/compact_remains_test.go +++ b/internal/runtime/agent/compact_remains_test.go @@ -40,7 +40,7 @@ func TestCompactionDoneCarriesWhatIsLeftWhenItIsStillLarge(t *testing.T) { run := func(window int) event.Compaction { done = nil a := New(&fakeProvider{reply: "## Summary\n- work"}, nil, foldableSessionOverForce(40), - Options{ContextWindow: window, CompactRatio: 0.5, RecentKeep: 2, ArchiveDir: testenv.TempDir(t)}, sink) + Options{ContextWindow: window, CompactRatio: 0.5, ArchiveDir: testenv.TempDir(t)}, sink) if _, _, err := a.window().compactToProjection(context.Background(), CompactionTriggerManual, "", compactionScope{ignoreThreshold: true, ignoreEconomics: true}, false); err != nil { t.Fatalf("compactToProjection: %v", err) } @@ -49,7 +49,7 @@ func TestCompactionDoneCarriesWhatIsLeftWhenItIsStillLarge(t *testing.T) { } return done[0] } - tight := run(2_000) + tight := run(150) if tight.Remains == nil || tight.Remains.Total <= 0 || len(tight.Remains.Parts) == 0 { t.Fatalf("a fold that left most of a small window said nothing about what is left: %+v", tight.Remains) } diff --git a/internal/runtime/agent/compact_retention.go b/internal/runtime/agent/compact_retention.go deleted file mode 100644 index 321e406de3..0000000000 --- a/internal/runtime/agent/compact_retention.go +++ /dev/null @@ -1,20 +0,0 @@ -package agent - -const keptUserTurnsTriggerFrac = 0.10 // user turns: share of the compaction trigger - -// retentionBudget is what one kind of verbatim retention may spend: a share of -// the window, held to a share of the trigger. The tail already stops at half -// the trigger; window shares alone let retention and tail together pass it on a -// window much larger than the trigger, and every fold is then refused as still -// at or above it. -func (a *contextWindow) retentionBudget(windowFrac, triggerFrac float64) int { - window := a.effectiveContextWindow() - if window <= 0 { - return keptUserTurnsFloorTokens - } - budget := int(float64(window) * windowFrac) - if trigger := a.compactTrigger(); trigger > 0 { - budget = min(budget, int(float64(trigger)*triggerFrac)) - } - return max(1, budget) -} diff --git a/internal/runtime/agent/compact_retention_trigger_test.go b/internal/runtime/agent/compact_retention_trigger_test.go deleted file mode 100644 index c3c4cef1ff..0000000000 --- a/internal/runtime/agent/compact_retention_trigger_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package agent - -import ( - "testing" -) - -func TestRetentionBudgetsKeepTheirWindowShareWhenTheTriggerIsLoose(t *testing.T) { - a := &Agent{agentConfig: agentConfig{contextWindow: 128_000, compactRatio: defaultCompactRatio}} - w := a.window() - if got, want := w.keptUserTurnsBudget(), 6_400; got != want { - t.Fatalf("keptUserTurnsBudget = %d, want %d", got, want) - } - if got, want := w.keptFailuresBudget(), 6_400; got != want { - t.Fatalf("keptFailuresBudget = %d, want %d", got, want) - } - if got, want := w.keptMarkedBudget(), 19_200; got != want { - t.Fatalf("keptMarkedBudget = %d, want %d", got, want) - } -} diff --git a/internal/runtime/agent/compact_tail_trigger_test.go b/internal/runtime/agent/compact_tail_trigger_test.go deleted file mode 100644 index d19721504a..0000000000 --- a/internal/runtime/agent/compact_tail_trigger_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package agent - -import ( - "context" - "io" - "net/http" - "net/http/httptest" - "reasonix/internal/state/sessionstore" - "strings" - "testing" - - "reasonix/internal/contract/event" - "reasonix/internal/contract/provider" - "reasonix/internal/contract/tool" - "reasonix/internal/model/openai" -) - -// oneTurnMock keeps a single turn going for rounds tool calls before answering, -// so the context passes the trigger without a second user message arriving. -type oneTurnMock struct { - t *testing.T - rounds int - seen int - peak int -} - -func (m *oneTurnMock) handler(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - if isSummarizeRequest(body) { - writeSSE(w, m.t, streamChunk(deltaText("## Digest\n- digest")), finishChunk("stop"), usageChunk(80, 30, 0, 80)) - return - } - promptTok := charsOf(decodeMessages(body)) / 4 - m.peak = max(m.peak, promptTok) - if m.seen >= m.rounds { - writeSSE(w, m.t, streamChunk(deltaText("done")), finishChunk("stop"), usageChunk(promptTok, 20, 0, promptTok)) - return - } - m.seen++ - writeSSE(w, m.t, streamChunk(deltaToolCall(m.seen, "fat_read", "{}")), finishChunk("tool_calls"), usageChunk(promptTok, 20, 0, promptTok)) -} - -// A verbatim tail wider than the trigger folds nothing, forever: the session -// crosses the threshold with every message still held, so maintenance runs each -// round and finds an empty fold region. It reached production as "a low -// compact_ratio disables compaction outright" — on a 1M window every ratio -// under ~0.19 put the trigger below the 96K tail. -func TestTailBudgetYieldsToLowTrigger(t *testing.T) { - for _, tc := range []struct { - name string - window int - ratio float64 - }{ - {"tail below trigger", 20000, 0.25}, - {"wide window, low ratio", 1000000, 0.005}, - } { - t.Run(tc.name, func(t *testing.T) { - mock := &oneTurnMock{t: t, rounds: 12} - srv := httptest.NewServer(http.HandlerFunc(mock.handler)) - defer srv.Close() - reg := tool.NewRegistry() - reg.Add(fatTool{blob: strings.Repeat("FILE CONTENTS LINE. ", 1000)}) - prov, err := openai.New(provider.Config{ - Name: "deepseek", BaseURL: srv.URL, Model: "deepseek-reasoner", APIKey: "test", - Extra: map[string]any{"api_key_env": "DEEPSEEK_API_KEY"}, - }) - if err != nil { - t.Fatalf("provider: %v", err) - } - a := New(prov, reg, sessionstore.NewSession(systemPrompt), Options{ - Temperature: 0, ContextWindow: tc.window, CompactRatio: tc.ratio, - RecentKeep: 4, MaxSteps: 40, - }, &collectSink{}) - started := 0 - a.svc.sink = event.FuncSink(func(e event.Event) { - if e.Kind == event.CompactionStarted { - started++ - } - }) - if err := a.Run(context.Background(), "one turn: keep reading until done"); err != nil { - t.Fatalf("run: %v", err) - } - if trigger, tail := a.window().compactTrigger(), a.window().recentTailBudget(); tail > trigger/2 { - t.Fatalf("tail %d leaves no fold region under trigger %d", tail, trigger) - } - if mock.peak <= a.window().compactTrigger() { - t.Fatalf("peak prompt %d never reached trigger %d; the case proves nothing", mock.peak, a.window().compactTrigger()) - } - // A fold that cannot get under the trigger is refused before its summary - // is paid for, so acting on the crossing is a card or a recorded refusal. - a.sess.win.compactionMu.Lock() - receipt := a.sess.win.compactionState.LastReceipt - a.sess.win.compactionMu.Unlock() - if started == 0 && receipt == nil { - t.Fatalf("context passed the trigger (peak %d > %d) but maintenance did nothing", mock.peak, a.window().compactTrigger()) - } - }) - } -} diff --git a/internal/runtime/agent/compact_test.go b/internal/runtime/agent/compact_test.go index 3af31a41f9..5d74a84100 100644 --- a/internal/runtime/agent/compact_test.go +++ b/internal/runtime/agent/compact_test.go @@ -4,11 +4,9 @@ import ( "context" "reasonix/internal/runtime/agent/testutil" "reasonix/internal/state/sessionstore" - "slices" "strings" "testing" - "reasonix/internal/base/testenv" "reasonix/internal/contract/event" "reasonix/internal/contract/provider" "reasonix/internal/contract/tool" @@ -72,75 +70,6 @@ func (f *fakeProvider) Stream(_ context.Context, req provider.Request) (<-chan p // visibleContext returns the model-visible projection when present, else the // canonical transcript. Compaction tests assert against this view. -func TestTailStart(t *testing.T) { - // 10-char content → with tokPerChar 1.0, each non-empty message costs 10 - // "tokens"; tool-call messages carry name+args instead. - msg := func(role provider.Role, n int) provider.Message { - return provider.Message{Role: role, Content: strings.Repeat("x", n)} - } - u := func(n int) provider.Message { return msg(provider.RoleUser, n) } - as := func(n int) provider.Message { return msg(provider.RoleAssistant, n) } - ac := provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "f", Arguments: "{}"}}} - to := func(n int) provider.Message { - return provider.Message{Role: provider.RoleTool, ToolCallID: "1", Name: "f", Content: strings.Repeat("x", n)} - } - - sys := provider.Message{Role: provider.RoleSystem} - cases := []struct { - name string - msgs []provider.Message - head int - budget int - minKeep int - wantStr int - }{ - // Budget 25 fits the two newest 10-char messages (20) but not a third (30); - // the tail stops at the third-from-last. - {"budget-bounds-tail", []provider.Message{u(10), as(10), u(10), as(10), u(10)}, 0, 25, 2, 3}, - // A single huge recent message can't blow the budget below minKeep: the last - // two are kept regardless. - {"min-keep-floor", []provider.Message{u(10), as(10), u(10), as(10), to(9999)}, 0, 25, 2, 3}, - // The boundary lands on an orphan tool result and must move back onto its - // assistant so the tail begins with the tool_calls. - {"align-off-tool", []provider.Message{sys, u(10), ac, to(10), ac, to(10)}, 1, 0, 1, 4}, - // A generous budget keeps everything down to the first compactable message - // after the head. - {"budget-keeps-all", []provider.Message{sys, u(10), as(10), u(10)}, 1, 100000, 2, 2}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - start := tailStart(tc.msgs, tc.head, tc.budget, 1.0, tc.minKeep) - if start != tc.wantStr { - t.Errorf("start = %d, want %d", start, tc.wantStr) - } - if tc.msgs[start].Role == provider.RoleTool { - t.Errorf("recent tail begins with orphan tool message at %d", start) - } - }) - } -} - -func TestTailStartSmallSession(t *testing.T) { - sys := provider.Message{Role: provider.RoleSystem} - usr := provider.Message{Role: provider.RoleUser, Content: "hi"} - for i, msgs := range [][]provider.Message{ - {sys, usr}, // system + one message: nothing fits the tail; must not index msgs[len] - {sys}, - {usr}, - {}, - } { - head := 0 - if len(msgs) > 0 && msgs[0].Role == provider.RoleSystem { - head = 1 - } - start := tailStart(msgs, head, 16384, 0.25, 2) - if start < head || start > len(msgs) { - t.Errorf("case %d: start=%d out of bounds [%d,%d]", i, start, head, len(msgs)) - } - } -} - func TestPinnedPrefixLen(t *testing.T) { sys := provider.Message{Role: provider.RoleSystem} small := provider.Message{Role: provider.RoleUser, Content: "do X with token T"} @@ -157,11 +86,11 @@ func TestPinnedPrefixLen(t *testing.T) { msgs []provider.Message want int }{ - {"pins-system-and-small-task", 0, []provider.Message{sys, small, as, as}, 2}, - {"summaries-are-not-pinned-A1-merge", 0, []provider.Message{sys, small, sum, sum, as}, 2}, + {"pins-only-the-system-prompt", 0, []provider.Message{sys, small, as, as}, 1}, + {"summaries-are-not-pinned-A1-merge", 0, []provider.Message{sys, small, sum, sum, as}, 1}, {"large-first-turn-stays-foldable", 0, []provider.Message{sys, big, as, as}, 1}, - {"tiny-window-wont-pin", 10, []provider.Message{sys, small, as, as}, 1}, - {"summary-is-not-the-task-turn", 0, []provider.Message{sys, sum, as}, 1}, + {"tiny-window-pins-the-same", 10, []provider.Message{sys, small, as, as}, 1}, + {"no-system-prompt-pins-nothing", 0, []provider.Message{small, as}, 0}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -172,250 +101,6 @@ func TestPinnedPrefixLen(t *testing.T) { } } -func TestKeepIndexesKeepsOnlyTheFailedCallsResult(t *testing.T) { - region := []provider.Message{ - {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{ - {ID: "err", Name: "bash", Arguments: `{"cmd":"bad"}`}, - {ID: "ok", Name: "read_file", Arguments: `{"path":"main.go"}`}, - }}, - {Role: provider.RoleTool, ToolCallID: "err", Name: "bash", Content: "error: command failed"}, - {Role: provider.RoleTool, ToolCallID: "ok", Name: "read_file", Content: "package main"}, - } - - keep, _ := (&Agent{keepPolicy: KeepErrors}).window().keepIndexes(region, KeepErrors) - if want := []bool{true, true, false}; !slices.Equal(keep, want) { - t.Fatalf("keep = %v, want %v: the failed pair stays, its sibling folds", keep, want) - } -} - -func TestKeepIndexesScopesPolicyAfterLatestSummary(t *testing.T) { - priorSummary := provider.Message{Role: provider.RoleUser, Content: SummaryTagOpen + "\nprior digest\n" + SummaryTagClose} - region := []provider.Message{ - {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "old", Name: "bash", Arguments: `{}`}}}, - {Role: provider.RoleTool, ToolCallID: "old", Name: "bash", Content: "error: old failure"}, - priorSummary, - {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "new", Name: "bash", Arguments: `{}`}}}, - {Role: provider.RoleTool, ToolCallID: "new", Name: "bash", Content: "error: new failure"}, - } - - keep, _ := (&Agent{keepPolicy: KeepErrors}).window().keepIndexes(region, KeepErrors) - want := []bool{false, false, false, true, true} - for i := range want { - if keep[i] != want[i] { - t.Fatalf("keep = %v, want %v", keep, want) - } - } -} - -// The marker is what lets a user turn exceed the size budget keepUserTurns -// applies, so it is asserted directly: at keepIndexes level every small user -// turn is kept regardless, which would hide a broken marker match. -func TestKeepUserMarkedRequiresUserPrefixMarker(t *testing.T) { - cases := []struct { - name string - msg provider.Message - want bool - }{ - {"assistant marker ignored", provider.Message{Role: provider.RoleAssistant, Content: "[keep] assistant output"}, false}, - {"marker must lead", provider.Message{Role: provider.RoleUser, Content: "ordinary prose mentioning [keep] later"}, false}, - {"leading marker after space", provider.Message{Role: provider.RoleUser, Content: " exact requirement"}, true}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if got := isUserMarked(tc.msg); got != tc.want { - t.Fatalf("isUserMarked = %v, want %v", got, tc.want) - } - }) - } -} - -// TestCompactEmitsEvents covers the card-driving signals: a CompactionStarted -// (before the summarizer runs) then a CompactionDone carrying the trigger, -// message count, and summary — in that order. -func TestCompactEmitsEvents(t *testing.T) { - prov := &fakeProvider{reply: "- goal: do X"} - sess := &sessionstore.Session{Messages: []provider.Message{ - {Role: provider.RoleSystem, Content: "sys"}, - {Role: provider.RoleUser, Content: "task"}, - {Role: provider.RoleAssistant, Content: strings.Repeat("step one work ", 200)}, - {Role: provider.RoleUser, Content: "more"}, - {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{ContextWindow: 50_000, RecentKeep: 2}, sink) - - if err := a.window().compact(context.Background(), "auto", "", compactionScope{ignoreThreshold: true, ignoreEconomics: true}); err != nil { - t.Fatalf("compact: %v", err) - } - - startedAt, doneAt := -1, -1 - for i, e := range got { - switch e.Kind { - case event.CompactionStarted: - startedAt = i - if e.Compaction.Trigger != "auto" { - t.Errorf("started trigger = %q, want auto", e.Compaction.Trigger) - } - case event.CompactionDone: - doneAt = i - c := e.Compaction - if c.Trigger != "auto" || c.Messages == 0 || !strings.Contains(c.Summary, "archived, not summarized") { - t.Errorf("done event = %+v", c) - } - } - } - if startedAt < 0 { - t.Fatal("no CompactionStarted event emitted") - } - if doneAt < 0 { - t.Fatal("no CompactionDone event emitted") - } - if startedAt > doneAt { - t.Errorf("CompactionStarted (%d) must precede CompactionDone (%d)", startedAt, doneAt) - } -} - -func TestCompactSkipsSingleSmallMessage(t *testing.T) { - prov := &fakeProvider{reply: "- should not be called"} - sess := &sessionstore.Session{Messages: []provider.Message{ - {Role: provider.RoleSystem, Content: "sys"}, - {Role: provider.RoleUser, Content: "tiny"}, - {Role: provider.RoleUser, Content: "next"}, - {Role: provider.RoleAssistant, Content: "ok"}, - }} - a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: testenv.TempDir(t)}, event.Discard) - - if err := a.window().compact(context.Background(), "auto", "", compactionScope{}); err != nil { - t.Fatalf("compact: %v", err) - } - if got := len(sess.Messages); got != 4 { - t.Fatalf("small single message should not compact, len = %d", got) - } - if len(prov.got) != 0 { - t.Fatalf("summarizer was called for tiny region: %+v", prov.got) - } -} - -func TestMaybeCompactThreshold(t *testing.T) { - // 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() *sessionstore.Session { - return &sessionstore.Session{Messages: []provider.Message{ - {Role: provider.RoleSystem, Content: "sys"}, - {Role: provider.RoleUser, Content: "task"}, - {Role: provider.RoleAssistant, Content: strings.Repeat("a ", 5000)}, - {Role: provider.RoleUser, Content: "c"}, - {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 compact_ratio: untouched, no summarizer call. - sess := newSess() - 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)) - } - if len(prov.got) != 0 { - t.Fatalf("below threshold called summarizer: %+v", prov.got) - } - - // 60% is below the sole 80% trigger: still no maintenance. - sess = newSess() - prov = &fakeProvider{reply: "s"} - a = New(prov, tool.NewRegistry(), sess, opts, event.Discard) - prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 6000}) - if a.window().currentProjectionVersion() != 0 || len(prov.got) != 0 { - t.Fatalf("60%% should not maintain: version=%d calls=%d", a.window().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 install a summary projection, got: %+v", visibleContext(a)) - } - 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}, 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)) - } -} - -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 := &sessionstore.Session{Messages: []provider.Message{ - {Role: provider.RoleSystem, Content: "sys"}, - {Role: provider.RoleUser, Content: "task"}, - {Role: provider.RoleAssistant, Content: big}, - {Role: provider.RoleUser, Content: "next"}, - {Role: provider.RoleAssistant, Content: "ok"}, - }} - prov := &fakeProvider{reply: "archived, not summarized"} - a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2}, event.Discard) - - // 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 != "task" { - t.Fatalf("first user turn not pinned verbatim in canonical: %+v", sess.Messages[1]) - } - proj := visibleContext(a) - if !hasCompactionSummary(proj) || !strings.Contains(joinContents(proj), "archived, not summarized") { - t.Fatalf("forced compact did not install summary projection: %+v", proj) - } -} - -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 := &sessionstore.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: "next"}, - {Role: provider.RoleAssistant, Content: "ok"}, - }} - prov := &fakeProvider{reply: "should not summarize"} - a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2}, event.Discard) - - // 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.window().currentProjectionVersion() != 0 { - t.Fatalf("low-value region installed projection version %d", a.window().currentProjectionVersion()) - } -} - func TestMaybeCompactFoldsSingleLargeMessageAtThreshold(t *testing.T) { const window = 10_000 // Large assistant work (not the first user turn) so it is foldable, not pinned. @@ -427,8 +112,7 @@ func TestMaybeCompactFoldsSingleLargeMessageAtThreshold(t *testing.T) { {Role: provider.RoleAssistant, Content: "ok"}, }} a := New(&fakeProvider{reply: "archived, not summarized"}, tool.NewRegistry(), sess, Options{ - ContextWindow: window, CompactRatio: 0.8, RecentKeep: 2, - }, event.Discard) + ContextWindow: window, CompactRatio: 0.8}, event.Discard) prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 8500}) if got := len(sess.Messages); got != 5 { @@ -466,62 +150,6 @@ func TestRenderTranscriptRedactsToolCallArgs(t *testing.T) { } } -// Display-only output stays verbatim in the canonical transcript by construction -// (compaction only writes a projection); this pins the other half: it must never -// reach the summarizer or the model-visible projection. -func TestInterruptedDisplayStaysOutOfCompactionPromptAndProjection(t *testing.T) { - local := provider.Message{ - Role: provider.RoleTool, ToolCallID: provider.LocalOnlyToolID, Name: provider.LocalOnlyToolName, - LocalOnly: true, Content: "partial visible answer", ReasoningContent: "private partial reasoning", - InterruptedTurn: &provider.InterruptedTurnRecovery{Pending: true}, - } - a := &Agent{} - kept, fold, retention, _ := a.window().partitionFoldForProjection([]provider.Message{local}) - if len(kept) != 0 || len(fold) != 0 { - t.Fatalf("compaction partition kept=%+v fold=%+v, want display-only output in neither", kept, fold) - } - if retention.Kept != 0 || retention.Dropped != 0 { - t.Fatalf("retention = %+v, want display-only output counted as neither kept nor dropped", retention) - } - if transcript := renderTranscript([]provider.Message{local}); transcript != "" { - t.Fatalf("local interrupted output leaked into compaction prompt: %q", transcript) - } -} - -func TestCompactKeepsActiveTurnVerbatim(t *testing.T) { - const currentCreatedAt int64 = 123456 - call := provider.Message{ - Role: provider.RoleAssistant, - ToolCalls: []provider.ToolCall{{ - ID: "write-1", Name: "write_file", Arguments: `{"path":"a.txt","content":"ok"}`, - }}, - } - result := provider.Message{Role: provider.RoleTool, ToolCallID: "write-1", Name: "write_file", Content: "wrote a.txt"} - sess := &sessionstore.Session{Messages: []provider.Message{ - {Role: provider.RoleSystem, Content: "sys"}, - {Role: provider.RoleUser, Content: strings.Repeat("old request ", 200)}, - {Role: provider.RoleAssistant, Content: strings.Repeat("old answer ", 200)}, - {Role: provider.RoleUser, Content: "update a.txt", CreatedAt: currentCreatedAt}, - call, - result, - }} - a := New(&fakeProvider{reply: "old work summary"}, tool.NewRegistry(), sess, Options{ - ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 1, - }, event.Discard) - a.activeTurnCreatedAt.Store(currentCreatedAt) - - if err := a.window().compact(context.Background(), "auto", "", compactionScope{ignoreThreshold: true, ignoreEconomics: true}); err != nil { - t.Fatalf("compact: %v", err) - } - start := a.window().activeTurnStart(sess.Messages) - if start < 0 || len(sess.Messages)-start != 3 { - t.Fatalf("active turn boundary = %d in %+v, want three-message verbatim tail", start, sess.Messages) - } - if sess.Messages[start].Content != "update a.txt" || sess.Messages[start+1].ToolCalls[0].Arguments != call.ToolCalls[0].Arguments || sess.Messages[start+2].Content != result.Content { - t.Fatalf("active turn changed during compaction: %+v", sess.Messages[start:]) - } -} - func TestSummarizeToolArgs(t *testing.T) { tests := []struct { name string diff --git a/internal/runtime/agent/compact_threshold_test.go b/internal/runtime/agent/compact_threshold_test.go index 848c26ea84..7cf8aa1cf7 100644 --- a/internal/runtime/agent/compact_threshold_test.go +++ b/internal/runtime/agent/compact_threshold_test.go @@ -27,31 +27,6 @@ func TestCompactTriggerIndependentOfOutputBudget(t *testing.T) { } } -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 - } - for _, tc := range cases { - a := &Agent{agentConfig: agentConfig{contextWindow: tc.window, compactRatio: defaultCompactRatio}} - if got := a.window().recentTailBudget(); got != tc.want { - t.Fatalf("window %d: recentTailBudget = %d, want %d", tc.window, got, tc.want) - } - } -} - -func TestRecentTailUsesWindowClampUnderCapacityDefault(t *testing.T) { - a := &Agent{agentConfig: agentConfig{contextWindow: 1_000_000, compactRatio: defaultCompactRatio}} - if got, want := a.window().recentTailBudget(), maxRecentTailTokens; got != want { - t.Fatalf("recentTailBudget = %d, want %d", got, want) - } -} - func TestCheckpointCeilingAndExceptionalSavings(t *testing.T) { a := &Agent{agentConfig: agentConfig{contextWindow: 1_000_000, compactRatio: 0.85}} if got := a.window().checkpointCeiling(); got != 500_000 { diff --git a/internal/runtime/agent/compact_user_turns.go b/internal/runtime/agent/compact_user_turns.go deleted file mode 100644 index 0be75c0978..0000000000 --- a/internal/runtime/agent/compact_user_turns.go +++ /dev/null @@ -1,104 +0,0 @@ -package agent - -import ( - "fmt" - - "reasonix/internal/contract/event" - "reasonix/internal/contract/provider" -) - -// userTurnRetention is what one fold could and could not hold verbatim. A -// dropped turn is the loss compaction cannot undo — the workspace still holds -// the code a constraint governs, nothing holds the constraint — so it is -// counted and surfaced rather than absorbed. -type userTurnRetention struct { - Kept int - Dropped int - DroppedTokens int - // MarkedDropped counts [[keep]] turns the marked-turn budget could not hold. - MarkedDropped, MarkedDroppedTokens int -} - -// keepUserTurns protects the user's own words from summarizer judgement: a -// constraint stated mid-session is unrecoverable once a digest drops it, while -// the work it governs stays re-derivable from the workspace. Unlike the keep -// policy it ignores policyStart — a bounded budget, not a fold horizon, is what -// stops it growing. -func (a *contextWindow) keepUserTurns(region []provider.Message, keep []bool) userTurnRetention { - budget := a.keptUserTurnsBudget() - var ret userTurnRetention - // Oldest-first: the recent tail already covers the newest turns verbatim, - // and an old turn has survived more folds than a new one. - for i, m := range region { - if m.Role != provider.RoleUser || m.LocalOnly || isCompactionSummary(m) { - continue - } - if keep[i] { - // Already held by the keep policy: a [[keep]] marker, which has a - // budget of its own. - ret.Kept++ - continue - } - // The remaining budget is the only gate. A separate per-turn ceiling - // used to drop a long turn even when the budget could hold it, and - // [[keep]] is the documented way to insist on a specific one. - cost := fixedTokenEstimate(projectedUserTurnFloor(m)) - if cost > budget { - ret.Dropped++ - ret.DroppedTokens += cost - continue - } - keep[i] = true - budget -= cost - ret.Kept++ - } - return ret -} - -// keptUserTurnsBudget caps what user turns may spend of the checkpoint. -// Hoisting them unbounded is what made an earlier revision pad candidates past -// the acceptance ceiling, which fails compaction outright rather than degrading -// it — so a bound stays, but it scales with the window instead of stopping at a -// fixed token count, and the user can name their own. -func (a *contextWindow) keptUserTurnsBudget() int { - if a.budgets.UserTurnKeepTokens > 0 { - return a.budgets.UserTurnKeepTokens - } - // A fraction and nothing else: an absolute floor on top of it would let a - // small window spend a large share of itself on retention, which pads the - // candidate past the acceptance ceiling and fails the fold outright. - return a.retentionBudget(keptUserTurnsWindowFrac, keptUserTurnsTriggerFrac) -} - -// noticeDroppedUserTurns reports the turns the budget could not hold. Without -// it the drop is invisible: the projection reads as complete, and the escape -// hatch is only useful to someone told it exists at the moment it is needed. -func (a *contextWindow) noticeDroppedUserTurns(ret userTurnRetention) { - if ret.MarkedDropped > 0 { - a.svc.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Code: event.NoticeCodeKeptTurnsFolded, - Text: fmt.Sprintf("%s you marked [[keep]] %s past the room [[keep]] has, so the oldest now survive only through the summary.", - pluralTurns(ret.MarkedDropped), wereOrWas(ret.MarkedDropped)), - Detail: event.TurnsDropped{Count: ret.MarkedDropped, Tokens: ret.MarkedDroppedTokens, Budget: a.keptMarkedBudget()}.Encode()}) - } - if ret.Dropped == 0 { - return - } - a.svc.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Code: event.NoticeCodeUserTurnsDropped, - Text: fmt.Sprintf("%s of yours %s too large to keep whole and now survive only through the summary. Prefix a turn with [[keep]] to hold it verbatim.", - pluralTurns(ret.Dropped), wereOrWas(ret.Dropped)), - Detail: event.TurnsDropped{Count: ret.Dropped, Tokens: ret.DroppedTokens, Budget: a.keptUserTurnsBudget()}.Encode()}) -} - -func pluralTurns(n int) string { - if n == 1 { - return "1 earlier message" - } - return fmt.Sprintf("%d earlier messages", n) -} - -func wereOrWas(n int) string { - if n == 1 { - return "was" - } - return "were" -} diff --git a/internal/runtime/agent/compact_user_turns_test.go b/internal/runtime/agent/compact_user_turns_test.go deleted file mode 100644 index f536bd43ce..0000000000 --- a/internal/runtime/agent/compact_user_turns_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package agent - -import ( - "context" - "encoding/json" - "reasonix/internal/state/sessionstore" - "strings" - "testing" - - "reasonix/internal/contract/event" - "reasonix/internal/contract/provider" - "reasonix/internal/contract/tool" -) - -// retentionSession puts one user turn of the given size in the fold region, -// behind enough assistant work that the recent tail cannot reach it. -func retentionSession(midTurn string) *sessionstore.Session { - big := strings.Repeat("work output line with detail. ", 250) - return &sessionstore.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: midTurn}, - {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"}, - }} -} - -func compactWithSink(t *testing.T, sess *sessionstore.Session) []event.Event { - t.Helper() - var got []event.Event - sink := event.FuncSink(func(e event.Event) { got = append(got, e) }) - a := New(&fakeProvider{reply: "digest"}, tool.NewRegistry(), sess, - Options{ContextWindow: 8_000, CompactRatio: 0.85, RecentKeep: 2}, sink) - if err := a.window().compact(context.Background(), "manual", "", compactionScope{ignoreThreshold: true, ignoreEconomics: true}); err != nil { - t.Fatalf("compact: %v", err) - } - return got -} - -func noticeMentioning(events []event.Event, substr string) (event.Event, bool) { - for _, e := range events { - if e.Kind == event.Notice && strings.Contains(e.Text+e.Detail, substr) { - return e, true - } - } - return event.Event{}, false -} - -// A turn past the budget is the one case where compaction still hands a user's -// own words to the summarizer. That has to be visible: the projection reads as -// complete either way, so silence here is indistinguishable from success. -func TestCompactionReportsDroppedUserTurns(t *testing.T) { - oversize := strings.Repeat("constraint detail. ", 500) // ~2375 tokens, past the per-turn ceiling - events := compactWithSink(t, retentionSession(oversize)) - - notice, ok := noticeMentioning(events, "[[keep]]") - if !ok { - t.Fatalf("a dropped user turn was not reported; events=%+v", noticeTexts(events)) - } - if notice.Level != event.LevelWarn { - t.Errorf("dropped-turn notice level = %v, want warn", notice.Level) - } - if notice.Code != event.NoticeCodeUserTurnsDropped || !event.DetailIsPayload(notice.Code) { - t.Errorf("dropped-turn notice code = %q, want %q whose Detail is its payload", notice.Code, event.NoticeCodeUserTurnsDropped) - } - var p event.TurnsDropped - if err := json.Unmarshal([]byte(notice.Detail), &p); err != nil || p.Count != 1 || p.Tokens == 0 || p.Budget == 0 { - t.Errorf("payload = %+v err=%v from %q, want one dropped turn with its tokens and budget", p, err, notice.Detail) - } - tele, ok := telemetryNotice(events) - if !ok { - t.Fatal("compaction telemetry carries no user-turn retention counts") - } - if tele.UserTurnsDropped != 1 { - t.Errorf("telemetry = %+v, want user_turns_dropped=1", tele) - } -} - -// telemetryNotice is the finished-fold notice's typed payload: a coded notice -// whose Detail a frontend words in its own language, not a line of key=value text. -func telemetryNotice(events []event.Event) (event.CompactionNotice, bool) { - for _, e := range events { - if e.Kind == event.Notice && e.Code == event.NoticeCodeCompactionTelemetry { - return event.DecodeCompactionNotice(e.Detail) - } - } - return event.CompactionNotice{}, false -} - -func TestCompactionTelemetryIsACodedNoticeWithATypedPayload(t *testing.T) { - events := compactWithSink(t, retentionSession("always use pnpm not npm")) - var notice event.Event - for _, e := range events { - if e.Kind == event.Notice && e.Code == event.NoticeCodeCompactionTelemetry { - notice = e - } - } - if notice.Code == "" { - t.Fatalf("no %s notice in %+v", event.NoticeCodeCompactionTelemetry, noticeTexts(events)) - } - p, ok := event.DecodeCompactionNotice(notice.Detail) - if !ok || p.Mode == "" || p.Trigger == "" || p.SourceTokens <= 0 || p.ProjectionTokens <= 0 { - t.Fatalf("payload = %+v ok=%v from %q", p, ok, notice.Detail) - } - if !strings.Contains(notice.Text, "Context compacted") || strings.Contains(notice.Text, "=") { - t.Fatalf("English fallback = %q, want a sentence and not key=value text", notice.Text) - } - if !event.DetailIsPayload(event.NoticeCodeCompactionTelemetry) { - t.Fatal("the notice's Detail is its payload, so a sink that prints Text must not append it") - } -} - -// The notice must stay rare enough to mean something: a fold that kept every -// user turn has nothing to warn about. -func TestCompactionSilentWhenEveryUserTurnKept(t *testing.T) { - events := compactWithSink(t, retentionSession("by the way, always use pnpm not npm")) - - if _, ok := noticeMentioning(events, "[[keep]]"); ok { - t.Errorf("warned about dropped turns when none were dropped; events=%+v", noticeTexts(events)) - } - tele, ok := telemetryNotice(events) - if !ok { - t.Fatal("compaction telemetry carries no user-turn retention counts") - } - if tele.UserTurnsDropped != 0 || tele.UserTurnsKept == 0 { - t.Errorf("telemetry = %+v, want user_turns_dropped=0 and some kept", tele) - } -} - -func noticeTexts(events []event.Event) []string { - var out []string - for _, e := range events { - if e.Kind == event.Notice { - out = append(out, e.Text) - } - } - return out -} - -// A child is built from the options its parent's task tool derives; with those -// options it holds the parent's instructions within a budget scaled to its own -// window. The inheritance itself is pinned on the delegation side. -func TestChildRetentionScalesToItsOwnWindow(t *testing.T) { - opts := Options{KeepPolicy: KeepErrors | KeepUserMarked, RecentKeep: 2, CompactRatio: 0.85, ContextWindow: 32_000, SubagentDepth: 1} - child := New(&fakeProvider{reply: "ok"}, tool.NewRegistry(), &sessionstore.Session{}, opts, event.Discard) - if got, want := child.window().keptUserTurnsBudget(), int(32_000*keptUserTurnsWindowFrac); got != want { - t.Fatalf("child retention budget = %d, want %d scaled to its own window", got, want) - } - kept, _, retention, _ := child.window().partitionFoldForProjection([]provider.Message{ - {Role: provider.RoleUser, Content: "parent instruction: do not touch the public API"}, - {Role: provider.RoleAssistant, Content: "child work"}, - }) - if retention.Kept != 1 || len(kept) != 1 { - t.Fatalf("kept=%d retention=%+v, want the parent's instruction held verbatim", len(kept), retention) - } -} diff --git a/internal/runtime/agent/compaction_budgets_test.go b/internal/runtime/agent/compaction_budgets_test.go index 13284a03df..b2a8ffb5a9 100644 --- a/internal/runtime/agent/compaction_budgets_test.go +++ b/internal/runtime/agent/compaction_budgets_test.go @@ -12,45 +12,6 @@ import ( "reasonix/internal/contract/tool" ) -// The keep budget is a share of the window, not a fixed token count. A user on -// a large window used to get the same 8192 as one on a small one, so most of -// their own words fell into the digest no matter how much room there was. -func TestUserTurnKeepBudgetScalesWithTheWindow(t *testing.T) { - for _, tc := range []struct{ window, want int }{ - {20_000, 1_000}, - {128_000, 6_400}, - {1_000_000, 50_000}, - } { - a := &Agent{} - a.contextWindow = tc.window - if got := a.window().keptUserTurnsBudget(); got != tc.want { - t.Errorf("window %d: budget = %d, want %d", tc.window, got, tc.want) - } - } -} - -// The verbatim tail is a token budget. Measuring the candidate tail in -// characters against it spent the budget four times over, so the tail that -// survived a fold was a quarter of the recent context the window had room for. -func TestRecentTailIsMeasuredInTheUnitItsBudgetUses(t *testing.T) { - a := &Agent{agentConfig: agentConfig{contextWindow: 128_000, recentKeep: 2}} - msgs := []provider.Message{{Role: provider.RoleSystem, Content: "sys"}} - for range 400 { - msgs = append(msgs, - provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("a plain english sentence about the work. ", 40)}, - provider.Message{Role: provider.RoleUser, Content: "continue"}, - ) - } - _, start, ok := a.window().planCompaction(msgs, 1, false) - if !ok { - t.Fatal("fixture produced no fold region") - } - budget := a.window().recentTailBudget() - if tail := a.window().estimatedPromptTokens(msgs[start:]); tail*2 < budget { - t.Fatalf("verbatim tail = %d tokens against a %d budget; the tail was measured in the wrong unit", tail, budget) - } -} - // The fixed prefix is compared against the compaction trigger, which is a // fraction of the window in real tokens. Sizing the prefix in characters // refused compaction outright on a session whose prefix was well within it — @@ -66,7 +27,7 @@ func TestLargeFixedPrefixDoesNotRefuseCompaction(t *testing.T) { provider.Message{Role: provider.RoleUser, Content: "continue"}) } a := New(&fakeProvider{reply: "digest"}, tool.NewRegistry(), sess, - Options{ContextWindow: 128_000, CompactRatio: 0.85, RecentKeep: 2, ArchiveDir: testenv.TempDir(t)}, event.Discard) + Options{ContextWindow: 128_000, CompactRatio: 0.85, ArchiveDir: testenv.TempDir(t)}, event.Discard) if len(prefix) < a.window().compactTrigger() { t.Fatalf("fixture prefix is %d characters, under the %d trigger; it cannot show the bug", len(prefix), a.window().compactTrigger()) } @@ -76,45 +37,6 @@ func TestLargeFixedPrefixDoesNotRefuseCompaction(t *testing.T) { } } -// Every fold bound is the user's to name, and a set value is honoured exactly -// rather than clamped back to what the host would have picked. -func TestConfiguredCompactionBudgetsAreHonoured(t *testing.T) { - a := &Agent{} - a.contextWindow = 200_000 - a.budgets = CompactionBudgets{ - UserTurnKeepTokens: 40_000, FirstTurnPinTokens: 9_000, CheckpointCeilingRatio: 0.8, - } - if got := a.window().keptUserTurnsBudget(); got != 40_000 { - t.Errorf("keep budget = %d, want the configured value", got) - } - if got := a.window().checkpointCeiling(); got != 160_000 { - t.Errorf("checkpoint ceiling = %d, want 80%% of the window", got) - } - // The first-turn pin still answers to the window guard: it rides the fixed - // prefix and is paid for on every request of the session. - if !a.window().fixedPinnableUserTurn(provider.Message{Role: provider.RoleUser, Content: shortText(8_000)}) { - t.Error("a turn inside the configured pin budget was refused") - } - huge := &Agent{} - huge.contextWindow = 20_000 - huge.budgets = CompactionBudgets{FirstTurnPinTokens: 1_000_000} - if huge.window().fixedPinnableUserTurn(provider.Message{Role: provider.RoleUser, Content: shortText(40_000)}) { - t.Error("the window guard must still bound the pinned first turn") - } -} - -// Unset means the built-in default, so an untouched config behaves as before. -func TestUnsetCompactionBudgetsKeepTheDefaults(t *testing.T) { - a := &Agent{} - a.contextWindow = 200_000 - if got, want := a.window().checkpointCeiling(), 100_000; got != want { - t.Errorf("checkpoint ceiling = %d, want the default half-window %d", got, want) - } - if got, want := a.window().keptUserTurnsBudget(), 10_000; got != want { - t.Errorf("keep budget = %d, want the default window share %d", got, want) - } -} - func shortText(chars int) string { b := make([]byte, chars) for i := range b { diff --git a/internal/runtime/agent/compaction_latch_audit_test.go b/internal/runtime/agent/compaction_latch_audit_test.go index 3f716c3efc..dcf0b82d4f 100644 --- a/internal/runtime/agent/compaction_latch_audit_test.go +++ b/internal/runtime/agent/compaction_latch_audit_test.go @@ -54,8 +54,7 @@ func TestStaleContextBlockFailsTheNextOverflowTurn(t *testing.T) { sess := &sessionstore.Session{Messages: latchTranscript()} prov := &appendDuringSummary{sess: sess} a := New(prov, tool.NewRegistry(), sess, Options{ - ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2, - WorkspaceID: "workspace", ModelRef: "model", + ContextWindow: window, CompactRatio: 0.85, WorkspaceID: "workspace", ModelRef: "model", }, event.Discard) a.BindSessionPath(path, true) if _, err := a.window().contextManager().Prepare(context.Background(), ContextPreparePolicy{ diff --git a/internal/runtime/agent/compaction_sidecar_authority_test.go b/internal/runtime/agent/compaction_sidecar_authority_test.go index 85635fec94..0763c2e1c2 100644 --- a/internal/runtime/agent/compaction_sidecar_authority_test.go +++ b/internal/runtime/agent/compaction_sidecar_authority_test.go @@ -28,8 +28,7 @@ func sidecarMessages() []provider.Message { func sidecarAgent(t *testing.T, prov provider.Provider, sess *sessionstore.Session, path string, load bool) *Agent { t.Helper() a := New(prov, tool.NewRegistry(), sess, Options{ - ContextWindow: 20_000, CompactRatio: 0.5, RecentKeep: 2, - WorkspaceID: "ws", ModelRef: "p/m", + ContextWindow: 20_000, CompactRatio: 0.5, WorkspaceID: "ws", ModelRef: "p/m", }, event.Discard) a.BindSessionPath(path, load) return a diff --git a/internal/runtime/agent/context_block_release_test.go b/internal/runtime/agent/context_block_release_test.go index d5a3668d36..215b5331c2 100644 --- a/internal/runtime/agent/context_block_release_test.go +++ b/internal/runtime/agent/context_block_release_test.go @@ -24,8 +24,7 @@ func newPressureAgent(t *testing.T, p provider.Provider) *Agent { {Role: provider.RoleAssistant, Content: "tail"}, } a := New(p, tool.NewRegistry(), &sessionstore.Session{Messages: msgs}, Options{ - ContextWindow: 20_000, CompactRatio: 0.5, RecentKeep: 2, - WorkspaceID: "workspace", ModelRef: "model", + ContextWindow: 20_000, CompactRatio: 0.5, WorkspaceID: "workspace", ModelRef: "model", }, event.Discard) a.BindSessionPath(testenv.TempDir(t)+"/session.jsonl", true) w := a.window() diff --git a/internal/runtime/agent/context_maintenance_issue7935_test.go b/internal/runtime/agent/context_maintenance_issue7935_test.go index a8fd9b4171..8d9f3af9cd 100644 --- a/internal/runtime/agent/context_maintenance_issue7935_test.go +++ b/internal/runtime/agent/context_maintenance_issue7935_test.go @@ -40,7 +40,6 @@ func TestIssue7935Maintains206ToolResultsOnceAndOnlyProcessesNewTail(t *testing. a := New(&fakeProvider{reply: "structured history digest"}, tool.NewRegistry(), sess, Options{ ContextWindow: 8_000, CompactRatio: 0.80, - RecentKeep: 2, ArchiveDir: testenv.TempDir(t), }, sink) diff --git a/internal/runtime/agent/context_maintenance_restart_test.go b/internal/runtime/agent/context_maintenance_restart_test.go index cbdefa3618..a926584ae2 100644 --- a/internal/runtime/agent/context_maintenance_restart_test.go +++ b/internal/runtime/agent/context_maintenance_restart_test.go @@ -45,7 +45,6 @@ func newRestartMaintenanceAgent(path string, messages []provider.Message, sink e return New(nil, tool.NewRegistry(), &sessionstore.Session{Messages: append([]provider.Message(nil), messages...)}, Options{ ContextWindow: restartMaintenanceWindow, CompactRatio: 0.80, - RecentKeep: 2, SessionPath: path, WorkspaceID: "workspace", ModelRef: "provider/model", @@ -110,7 +109,6 @@ func TestContextMaintenanceRestoresAppliedProjectionWithoutReapplying(t *testing &sessionstore.Session{Messages: append([]provider.Message(nil), messages...)}, Options{ ContextWindow: restartMaintenanceWindow, CompactRatio: 0.80, - RecentKeep: 2, SessionPath: path, WorkspaceID: "workspace", ModelRef: "provider/model", @@ -139,7 +137,6 @@ func TestContextMaintenanceRestoresAppliedProjectionWithoutReapplying(t *testing &sessionstore.Session{Messages: append([]provider.Message(nil), messages...)}, Options{ ContextWindow: restartMaintenanceWindow, CompactRatio: 0.80, - RecentKeep: 2, SessionPath: path, WorkspaceID: "workspace", ModelRef: "provider/model", diff --git a/internal/runtime/agent/context_manager.go b/internal/runtime/agent/context_manager.go index abaf1f0b71..6800c201a1 100644 --- a/internal/runtime/agent/context_manager.go +++ b/internal/runtime/agent/context_manager.go @@ -19,10 +19,6 @@ type compactionProgress struct { // threshold stays crossed, so without it every later round of the same turn // would report the same "folded nothing" again. lastReported maintenanceReport - // lastUserTurns is what the most recent fold could and could not hold of - // the user's own words. The notice fires once, at the moment of the fold; - // this is what /context can still answer with afterwards. - lastUserTurns userTurnRetention } // maintenanceReport is one reported no-fold verdict, scoped to the turn it was @@ -179,7 +175,7 @@ func (m ContextManager) foldContext(ctx context.Context, prepared PreparedContex return prepared, nil } status := "failed" - if errors.Is(err, errSummaryOutputTruncated) || errors.Is(err, errCheckpointRejected) { + if errors.Is(err, errCheckpointRejected) { status = "blocked" } reason := fmt.Sprintf("context summary failed: %v", err) diff --git a/internal/runtime/agent/context_manager_test.go b/internal/runtime/agent/context_manager_test.go index a8afc71e21..667795050e 100644 --- a/internal/runtime/agent/context_manager_test.go +++ b/internal/runtime/agent/context_manager_test.go @@ -50,8 +50,7 @@ func TestPrepareThresholdSkipsExtensionInterceptors(t *testing.T) { {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", + ContextWindow: 50_000, CompactRatio: 0.85, Extensions: d, WorkspaceID: "ws", ModelRef: "m", }, event.Discard) // Below fold: Prepare sizes the view and must not touch interceptors. diff --git a/internal/runtime/agent/context_overflow_test.go b/internal/runtime/agent/context_overflow_test.go index 3b915d4b9f..b6f6bec8eb 100644 --- a/internal/runtime/agent/context_overflow_test.go +++ b/internal/runtime/agent/context_overflow_test.go @@ -76,7 +76,6 @@ func TestContextOverflowRejectionFoldsAndReplays(t *testing.T) { session := foldableSessionOverForce(20) a := New(prov, tool.NewRegistry(), session, Options{ ContextWindow: 40_000, - RecentKeep: 2, ArchiveDir: testenv.TempDir(t), }, event.Discard) @@ -106,7 +105,6 @@ func TestContextOverflowProbesAnUndeclaredWindow(t *testing.T) { ) session := foldableSessionOverForce(60) a := New(prov, tool.NewRegistry(), session, Options{ - RecentKeep: 2, ArchiveDir: testenv.TempDir(t), }, event.Discard) if got := a.window().effectiveContextWindow(); got != 0 { @@ -157,7 +155,6 @@ func TestContextOverflowRecoveryIsOneShotPerRound(t *testing.T) { session := foldableSessionOverForce(20) a := New(prov, tool.NewRegistry(), session, Options{ ContextWindow: 40_000, - RecentKeep: 2, ArchiveDir: testenv.TempDir(t), }, event.Discard) @@ -193,7 +190,6 @@ func TestCodelessRejectionPastTheWindowWithItsCompletionFoldsAndReplays(t *testi ContextWindow: 12_000, MaxOutputTokens: 4_000, CompactRatio: 0.99, - RecentKeep: 2, ArchiveDir: testenv.TempDir(t), }, event.Discard) if est := a.window().estimatedPromptTokens(session.Snapshot()); est >= 12_000 || est+4_000 <= 12_000 { @@ -227,7 +223,6 @@ func TestCodelessRejectionOnASharedWindowFoldsAndReplays(t *testing.T) { ContextWindow: 20_000, MaxOutputTokens: 4_000, CompactRatio: 0.99, - RecentKeep: 2, ArchiveDir: testenv.TempDir(t), }, event.Discard) if est := a.window().estimatedPromptTokens(session.Snapshot()); est >= 20_000 || est+4_000 >= 20_000 || est+4_000+outputBudgetReserveFor(20_000) <= 20_000 { diff --git a/internal/runtime/agent/context_report.go b/internal/runtime/agent/context_report.go index 829764090f..d298c7d8d8 100644 --- a/internal/runtime/agent/context_report.go +++ b/internal/runtime/agent/context_report.go @@ -27,14 +27,6 @@ type ContextReport struct { LastResult int CacheState string BlockedReason string - - // What the last fold did to the user's own turns. A dropped turn is the - // loss compaction cannot undo, and the budget that decided it is the - // user's to set, so both the outcome and the budget are reportable. - UserTurnsKept int - UserTurnsDropped int - UserTurnsDroppedToks int - UserTurnKeepBudget int } // ContextReport samples the current context state. Compaction is disabled when @@ -48,15 +40,11 @@ func (a *Agent) ContextReport() ContextReport { func (a *contextWindow) contextReport() ContextReport { rep := ContextReport{ - Window: a.effectiveContextWindow(), - HardCeiling: a.hardInputCeiling(), - OutputBudget: a.maxOutputTokens, - CacheState: a.cacheState(), - UserTurnKeepBudget: a.keptUserTurnsBudget(), + Window: a.effectiveContextWindow(), + HardCeiling: a.hardInputCeiling(), + OutputBudget: a.maxOutputTokens, + CacheState: a.cacheState(), } - retention := a.sess.win.compaction.lastUserTurns - rep.UserTurnsKept, rep.UserTurnsDropped = retention.Kept, retention.Dropped - rep.UserTurnsDroppedToks = retention.DroppedTokens if u := a.sess.output.lastUsage.Load(); u != nil { rep.LatestPrompt = u.LatestPromptTokens() } diff --git a/internal/runtime/agent/context_report_test.go b/internal/runtime/agent/context_report_test.go index 67698304d8..0f42dd3302 100644 --- a/internal/runtime/agent/context_report_test.go +++ b/internal/runtime/agent/context_report_test.go @@ -16,8 +16,7 @@ func TestContextReportUsesLatestPromptNotBillableAggregate(t *testing.T) { sess := sessionstore.NewSession("sys") sess.Add(provider.Message{Role: provider.RoleUser, Content: strings.Repeat("x", 400)}) a := New(&fakeProvider{reply: "unused"}, tool.NewRegistry(), sess, Options{ - ContextWindow: 100_000, RecentKeep: 2, - }, event.Discard) + ContextWindow: 100_000}, event.Discard) a.sess.output.lastUsage.Store(&provider.Usage{ PromptTokens: 363_000, // three recovery attempts, billed @@ -33,8 +32,7 @@ func TestContextReportUsesLatestPromptNotBillableAggregate(t *testing.T) { // The sole trigger must come from the same helper the decision uses. func TestContextReportThresholdsMatchTheDecision(t *testing.T) { a := New(&fakeProvider{reply: "unused"}, tool.NewRegistry(), sessionstore.NewSession("sys"), Options{ - ContextWindow: 200_000, CompactRatio: 0.85, RecentKeep: 2, - }, event.Discard) + ContextWindow: 200_000, CompactRatio: 0.85}, event.Discard) rep := a.ContextReport() fold := a.window().compactTrigger() @@ -47,8 +45,7 @@ func TestContextReportThresholdsMatchTheDecision(t *testing.T) { // not be presented as if they did. func TestContextReportLeavesThresholdsZeroWhenDisabled(t *testing.T) { a := New(&fakeProvider{reply: "unused"}, tool.NewRegistry(), sessionstore.NewSession("sys"), Options{ - ContextWindow: 0, RecentKeep: 2, - }, event.Discard) + ContextWindow: 0}, event.Discard) rep := a.ContextReport() if rep.Window != 0 || rep.FoldThreshold != 0 { diff --git a/internal/runtime/agent/context_summary_failure_gap_test.go b/internal/runtime/agent/context_summary_failure_gap_test.go index 4f34d364b3..fa50b36c19 100644 --- a/internal/runtime/agent/context_summary_failure_gap_test.go +++ b/internal/runtime/agent/context_summary_failure_gap_test.go @@ -129,7 +129,7 @@ func TestRefusedOverWindowReadsStatusAndSizeNotWords(t *testing.T) { func TestProseOnlyOverflowRefusalFoldsAndReplays(t *testing.T) { prov := testutil.NewMock("mock") a := New(prov, tool.NewRegistry(), foldableSessionOverForce(20), Options{ - ContextWindow: 40_000, RecentKeep: 2, ArchiveDir: testenv.TempDir(t), + ContextWindow: 40_000, ArchiveDir: testenv.TempDir(t), }, event.Discard) w := a.window() refused := &samplingRequest{req: provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: strings.Repeat("x", 4*45_000)}}}} diff --git a/internal/runtime/agent/context_usage_test.go b/internal/runtime/agent/context_usage_test.go index cd4d3455ad..6d1e3c131a 100644 --- a/internal/runtime/agent/context_usage_test.go +++ b/internal/runtime/agent/context_usage_test.go @@ -45,7 +45,6 @@ func usageFixture(t *testing.T, toolResults int) *Agent { } return New(nil, tool.NewRegistry(), &sessionstore.Session{Messages: msgs}, Options{ ContextWindow: 1_000_000, - RecentKeep: 2, ArchiveDir: testenv.TempDir(t), }, event.Discard) } @@ -83,7 +82,6 @@ func TestContextUsedTokensIncludesToolSchemasLikeTheTrigger(t *testing.T) { } a := New(nil, reg, &sessionstore.Session{Messages: msgs}, Options{ ContextWindow: 1_000_000, - RecentKeep: 2, ArchiveDir: testenv.TempDir(t), }, event.Discard) @@ -104,7 +102,7 @@ func TestContextUsedTokensFollowsLiveToolRegistry(t *testing.T) { a := New(nil, reg, &sessionstore.Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "system"}, {Role: provider.RoleUser, Content: "task"}, - }}, Options{ContextWindow: 1_000_000, RecentKeep: 2, ArchiveDir: testenv.TempDir(t)}, event.Discard) + }}, Options{ContextWindow: 1_000_000, ArchiveDir: testenv.TempDir(t)}, event.Discard) withoutTools := a.ContextUsedTokens() reg.Add(bigSchemaTool{}) diff --git a/internal/runtime/agent/context_window_host.go b/internal/runtime/agent/context_window_host.go index 65c97bfcc8..8828b1292f 100644 --- a/internal/runtime/agent/context_window_host.go +++ b/internal/runtime/agent/context_window_host.go @@ -32,7 +32,6 @@ type contextWindow struct { sess *sessionRuntime svc *agentServices ablation ablation.Set - keepPolicy KeepPolicy strictAlternatingRoles bool activeTurnCreatedAt *atomic.Int64 windowProbe *contextWindowProbe @@ -43,7 +42,7 @@ type contextWindow struct { func (a *Agent) window() *contextWindow { return &contextWindow{ agentConfig: &a.agentConfig, windowHost: a, sess: &a.sess, svc: &a.svc, - ablation: a.ablation, keepPolicy: a.keepPolicy, strictAlternatingRoles: a.strictAlternatingRoles, + ablation: a.ablation, strictAlternatingRoles: a.strictAlternatingRoles, activeTurnCreatedAt: &a.activeTurnCreatedAt, windowProbe: &a.windowProbe, } } diff --git a/internal/runtime/agent/extensions_test.go b/internal/runtime/agent/extensions_test.go index 0cc57ca66a..c03ed102e3 100644 --- a/internal/runtime/agent/extensions_test.go +++ b/internal/runtime/agent/extensions_test.go @@ -1124,7 +1124,7 @@ func newCompactionAgent(t *testing.T, d *dispatch.Dispatcher) (*mockProvider, *A 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, + ContextWindow: 50_000, CompactRatio: 0.85, Extensions: d, }, event.Discard) } diff --git a/internal/runtime/agent/failure_survives_folds_test.go b/internal/runtime/agent/failure_survives_folds_test.go index 4d12f09398..b4f0da9a6a 100644 --- a/internal/runtime/agent/failure_survives_folds_test.go +++ b/internal/runtime/agent/failure_survives_folds_test.go @@ -1,16 +1,8 @@ package agent import ( - "context" "fmt" - "reasonix/internal/state/sessionstore" "strings" - "testing" - - "reasonix/internal/base/testenv" - "reasonix/internal/contract/event" - "reasonix/internal/contract/provider" - "reasonix/internal/contract/tool" ) // goTestFailure is shaped like the real thing on purpose: a failing `go test` @@ -28,53 +20,3 @@ func goTestFailure() string { } return strings.Join(lines, "\n") } - -// KeepErrors reads ToolExecution because text matching was proven to miss real -// failures. That record has to outlive the fold that first protects it: a -// projection is the next compaction's input, so a failure stripped at write time -// is invisible to the pass after next and the model silently stops seeing it. -func TestRecordedFailureSurvivesRepeatedCompaction(t *testing.T) { - bulk := strings.Repeat("work output line with detail. ", 250) - sess := &sessionstore.Session{Messages: []provider.Message{ - {Role: provider.RoleSystem, Content: "sys"}, - {Role: provider.RoleUser, Content: "task"}, - }} - a := New(&fakeProvider{reply: "digest"}, tool.NewRegistry(), sess, - Options{ContextWindow: 8000, CompactRatio: 0.85, RecentKeep: 2, - KeepPolicy: KeepErrors, ArchiveDir: testenv.TempDir(t)}, event.Discard) - - exit := 1 - sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "running tests", - ToolCalls: []provider.ToolCall{{ID: "canary", Name: "bash", Arguments: "{}"}}}) - sess.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "canary", Name: "bash", - Content: goTestFailure(), ToolExecution: &provider.ToolExecution{ExitCode: &exit}}) - - // Three folds: the first protects the failure, and the ones after it are - // where the record used to be gone. - for round := 1; round <= 3; round++ { - sess.Add(provider.Message{Role: provider.RoleAssistant, Content: bulk}) - sess.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("continue %d", round)}) - if err := a.window().compact(context.Background(), "manual", "", compactionScope{ignoreThreshold: true, ignoreEconomics: true}); err != nil { - t.Fatalf("round %d compact: %v", round, err) - } - - proj := visibleContext(a) - var carrier *provider.Message - for i, m := range proj { - if strings.Contains(m.Content, "TestCanary") { - carrier = &proj[i] - } - } - if carrier == nil { - t.Fatalf("round %d: the failure left the projection; the model is no longer told about it", round) - } - if carrier.ToolExecution == nil { - t.Fatalf("round %d: the failure record was stripped, so the next fold cannot classify it", round) - } - for _, m := range provider.ModelMessages(proj) { - if m.ToolExecution != nil { - t.Fatalf("round %d: local shell metadata reached the provider request", round) - } - } - } -} diff --git a/internal/runtime/agent/keep_failure_budget_test.go b/internal/runtime/agent/keep_failure_budget_test.go deleted file mode 100644 index 74a192716e..0000000000 --- a/internal/runtime/agent/keep_failure_budget_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package agent - -import ( - "fmt" - "strings" - "testing" - - "reasonix/internal/contract/provider" -) - -func failureGroup(n int, chars int) []provider.Message { - id := fmt.Sprintf("f%d", n) - return []provider.Message{ - {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "bash", Arguments: "{}"}}}, - {Role: provider.RoleTool, ToolCallID: id, Name: "bash", Content: "error: " + fmt.Sprintf("failure %d ", n) + strings.Repeat("x", chars)}, - } -} - -func keptFailureTokens(kept []provider.Message) (tokens int, ids map[string]bool) { - ids = map[string]bool{} - for _, m := range kept { - if m.Role == provider.RoleTool && isErrorMessage(m) { - tokens += fixedTokenEstimate(m) - ids[m.ToolCallID] = true - } - } - return tokens, ids -} - -// Retained failures are carried into the next fold as part of its region, so a -// retention with no budget of its own grows by every round's failures for as -// long as the session lasts. -func TestKeptFailuresStayWithinTheirBudgetAcrossFolds(t *testing.T) { - a := &Agent{agentConfig: agentConfig{contextWindow: 200_000}, keepPolicy: KeepErrors} - budget := a.window().keptFailuresBudget() - if budget <= 0 { - t.Fatalf("budget = %d, want a positive bound", budget) - } - - var carried []provider.Message - next := 0 - for round := 1; round <= 8; round++ { - region := []provider.Message{formatSummaryMessage("earlier work")} - region = append(region, carried...) - for range 6 { - region = append(region, failureGroup(next, 8000)...) - next++ - } - kept, _, _, _ := a.window().partitionFoldWith(region, nil, a.keepPolicy) - tokens, ids := keptFailureTokens(kept) - if tokens > budget { - t.Fatalf("round %d: %d tokens of failures kept, budget %d", round, tokens, budget) - } - if newest := fmt.Sprintf("f%d", next-1); !ids[newest] { - t.Fatalf("round %d: the newest failure %s was folded while older ones stayed: %v", round, newest, ids) - } - carried = kept - } - if _, ids := keptFailureTokens(carried); ids["f0"] { - t.Fatalf("the oldest failure is still carried after 8 rounds") - } -} - -func TestKeptFailuresUnderBudgetAllStay(t *testing.T) { - a := &Agent{agentConfig: agentConfig{contextWindow: 200_000}, keepPolicy: KeepErrors} - var region []provider.Message - for i := range 3 { - region = append(region, failureGroup(i, 400)...) - } - kept, fold, _, _ := a.window().partitionFoldWith(region, nil, a.keepPolicy) - if _, ids := keptFailureTokens(kept); len(ids) != 3 || len(fold) != 0 { - t.Fatalf("kept failures = %v, fold = %d messages, want all three kept and nothing folded", ids, len(fold)) - } -} - -// [[keep]] is the documented way past a retention budget, so a failure budget -// must not reach it. -func TestKeptFailuresBudgetLeavesUserMarkedTurnsAlone(t *testing.T) { - a := &Agent{agentConfig: agentConfig{contextWindow: 200_000}, keepPolicy: KeepErrors | KeepUserMarked} - marked := provider.Message{Role: provider.RoleUser, Content: "[[keep]] " + strings.Repeat("constraint ", 5000)} - region := []provider.Message{marked} - for i := range 20 { - region = append(region, failureGroup(i, 8000)...) - } - kept, _, _, _ := a.window().partitionFoldWith(region, nil, a.keepPolicy) - if len(kept) == 0 || kept[0].Content != marked.Content { - t.Fatalf("the [[keep]] turn did not survive intact among %d kept messages", len(kept)) - } -} diff --git a/internal/runtime/agent/keep_group_split_test.go b/internal/runtime/agent/keep_group_split_test.go index 733f921c53..a2a4919fa4 100644 --- a/internal/runtime/agent/keep_group_split_test.go +++ b/internal/runtime/agent/keep_group_split_test.go @@ -30,54 +30,6 @@ func batch(round, reads int, readBody string) []provider.Message { Content: goTestFailure(), ToolExecution: &provider.ToolExecution{ExitCode: &exit}}) } -// A failure is evidence about its own call. Keeping it must not pin the reads -// that ran beside it, and what stays has to be a valid transaction on its own. -func TestFailurePinsOnlyItsOwnCall(t *testing.T) { - a := &Agent{} - a.keepPolicy = KeepErrors - region := append([]provider.Message{{Role: provider.RoleUser, Content: "go"}}, batch(0, 6, strings.Repeat("source line\n", 400))...) - kept, fold, _, _ := a.window().partitionFoldForProjection(region) - - var keptResults, keptCalls int - for _, m := range kept { - if m.Role == provider.RoleTool { - keptResults++ - } - keptCalls += len(m.ToolCalls) - } - if keptResults != 1 || keptCalls != 1 { - t.Fatalf("kept %d calls and %d results, want only the failed pair", keptCalls, keptResults) - } - for _, m := range provider.ModelMessages(kept) { - if strings.Contains(m.Content, "interrupted") { - t.Fatalf("the kept assistant turn still issues a call nothing answers: %q", m.Content) - } - } - rendered := renderTranscript(fold) - if strings.Count(rendered, "[assistant calls read_file]") != 6 || strings.Count(rendered, "[tool read_file result]") != 6 { - t.Fatalf("the reads did not reach the summarizer with their calls:\n%s", rendered) - } -} - -// The folded siblings keep their calls, because the digest's coverage guard -// reads changed files and failed commands from the calls the fold holds. -func TestFoldedSiblingsKeepTheirCalls(t *testing.T) { - a := &Agent{} - a.keepPolicy = KeepErrors - region := []provider.Message{{Role: provider.RoleUser, Content: "go"}} - region = append(region, batch(0, 2, "body")...) - _, fold, _, _ := a.window().partitionFoldForProjection(region) - var calls []string - for _, m := range fold { - for _, tc := range m.ToolCalls { - calls = append(calls, tc.ID) - } - } - if len(calls) != 2 || calls[0] != "r0-0" || calls[1] != "r0-1" { - t.Fatalf("fold holds calls %v, want the two reads", calls) - } -} - // One failing call batched with many reads used to pin the whole batch, so the // candidate exceeded the ceiling and a billed summary was thrown away. func TestFailingBatchDoesNotBlockTheFold(t *testing.T) { @@ -92,8 +44,7 @@ func TestFailingBatchDoesNotBlockTheFold(t *testing.T) { } }) a := New(&fakeProvider{reply: "## Goal\ndigest"}, tool.NewRegistry(), sess, - Options{ContextWindow: 20000, CompactRatio: 0.8, RecentKeep: 2, - KeepPolicy: KeepErrors, ArchiveDir: testenv.TempDir(t)}, sink) + Options{ContextWindow: 20000, CompactRatio: 0.8, ArchiveDir: testenv.TempDir(t)}, sink) for round := range 3 { for _, m := range batch(round, 8, strings.Repeat("source line with detail\n", 85)) { sess.Add(m) @@ -124,8 +75,7 @@ func TestRetainedFailuresYieldWhenTheyAloneExceedTheCeiling(t *testing.T) { }} prov := &fakeProvider{reply: "## Goal\ndigest"} a := New(prov, tool.NewRegistry(), sess, - Options{ContextWindow: 20000, CompactRatio: 0.8, RecentKeep: 2, KeepPolicy: KeepErrors, - CompactionBudgets: CompactionBudgets{CheckpointCeilingRatio: 0.12}, ArchiveDir: testenv.TempDir(t)}, event.Discard) + Options{ContextWindow: 20000, CompactRatio: 0.8, CompactionBudgets: CompactionBudgets{CheckpointCeilingRatio: 0.12}, ArchiveDir: testenv.TempDir(t)}, event.Discard) for round := range 40 { for _, m := range batch(round, 0, "") { sess.Add(m) diff --git a/internal/runtime/agent/keep_marked_budget_test.go b/internal/runtime/agent/keep_marked_budget_test.go deleted file mode 100644 index 1dea245326..0000000000 --- a/internal/runtime/agent/keep_marked_budget_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package agent - -import ( - "fmt" - "strings" - "testing" - - "reasonix/internal/contract/provider" -) - -func markedTurn(n, chars int) provider.Message { - return provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("[[keep]] turn %d ", n) + strings.Repeat("c", chars)} -} - -func keptMarkedTokens(kept []provider.Message) (tokens int, turns map[string]bool) { - turns = map[string]bool{} - for _, m := range kept { - if m.Role == provider.RoleUser && isUserMarked(m) { - tokens += fixedTokenEstimate(m) - turns[strings.Fields(m.Content)[2]] = true - } - } - return tokens, turns -} - -// [[keep]] holds a turn verbatim inside a budget of its own. With no bound a -// session that marks turn after turn grows what every fold must carry until the -// candidate cannot land under the trigger, and a fold that cannot land is -// refused again every round. -func TestMarkedTurnsStayWithinTheirBudgetAndTheNewestSurvive(t *testing.T) { - a := &Agent{agentConfig: agentConfig{contextWindow: 200_000}, keepPolicy: KeepUserMarked} - budget := a.window().keptMarkedBudget() - if budget <= 0 { - t.Fatalf("budget = %d, want a positive bound", budget) - } - var region []provider.Message - for i := range 20 { - region = append(region, markedTurn(i, 40_000)) - } - kept, fold, retention, _ := a.window().partitionFoldWith(region, nil, a.keepPolicy) - tokens, turns := keptMarkedTokens(kept) - if tokens > budget { - t.Fatalf("%d tokens of [[keep]] turns kept, budget %d", tokens, budget) - } - if !turns["19"] || turns["0"] { - t.Fatalf("kept %v, want the newest turn kept and the oldest folded", turns) - } - if retention.MarkedDropped == 0 || retention.MarkedDropped != 20-len(turns) || len(fold) != retention.MarkedDropped { - t.Fatalf("retention reports %d dropped marked turns, fold has %d messages, %d kept", retention.MarkedDropped, len(fold), len(turns)) - } -} - -func TestMarkedTurnsUnderTheirBudgetAllStay(t *testing.T) { - a := &Agent{agentConfig: agentConfig{contextWindow: 200_000}, keepPolicy: KeepUserMarked} - var region []provider.Message - for i := range 4 { - region = append(region, markedTurn(i, 2_000)) - } - kept, fold, retention, _ := a.window().partitionFoldWith(region, nil, a.keepPolicy) - if _, turns := keptMarkedTokens(kept); len(turns) != 4 || len(fold) != 0 || retention.MarkedDropped != 0 { - t.Fatalf("kept %v, fold %d, dropped %d; want all four kept", turns, len(fold), retention.MarkedDropped) - } -} - -// Marked turns carried through folds must not accumulate: a turn kept at one -// fold rides into the next fold's region. -func TestMarkedTurnsCarriedThroughFoldsDoNotAccumulate(t *testing.T) { - a := &Agent{agentConfig: agentConfig{contextWindow: 200_000}, keepPolicy: KeepUserMarked} - budget := a.window().keptMarkedBudget() - var carried []provider.Message - next := 0 - for round := 1; round <= 8; round++ { - region := []provider.Message{formatSummaryMessage("earlier work")} - region = append(region, carried...) - for range 5 { - region = append(region, markedTurn(next, 40_000)) - next++ - } - kept, _, _, _ := a.window().partitionFoldWith(region, nil, a.keepPolicy) - if tokens, _ := keptMarkedTokens(kept); tokens > budget { - t.Fatalf("round %d: %d tokens kept, budget %d", round, tokens, budget) - } - carried = kept - } -} diff --git a/internal/runtime/agent/kept_projection.go b/internal/runtime/agent/kept_projection.go index c7fa8d209e..2ea327ed34 100644 --- a/internal/runtime/agent/kept_projection.go +++ b/internal/runtime/agent/kept_projection.go @@ -35,29 +35,10 @@ func supersedeStandingState(kept, tail []provider.Message) []provider.Message { return kept } -// StripSupersededUserBlocks is the floor a retained turn can shrink to, with -// every standing block gone. keepUserTurns costs turns against this floor: at -// most one copy of each variant survives the whole fold, so the error is those -// few blocks rather than one per turn. -func StripSupersededUserBlocks(content string) string { - return filterLeadingBlocks(content, func(tag, _ string) bool { - return sessionstore.SupersededUserBlock[tag] - }) -} - func carriesStandingState(m provider.Message) bool { return m.Role == provider.RoleUser && !m.LocalOnly && !isCompactionSummary(m) } -// projectedUserTurnFloor is what keepUserTurns measures a retained turn as. -func projectedUserTurnFloor(m provider.Message) provider.Message { - if !carriesStandingState(m) { - return m - } - m.Content = StripSupersededUserBlocks(m.Content) - return m -} - // filterLeadingBlocks walks the host-injected block chain that opens content and // drops the ones drop reports. It walks the chain rather than matching anywhere // in the text: a block the host did not inject is the user's own prose, and a diff --git a/internal/runtime/agent/kept_projection_test.go b/internal/runtime/agent/kept_projection_test.go index 4eab36c2a9..fd227eeebb 100644 --- a/internal/runtime/agent/kept_projection_test.go +++ b/internal/runtime/agent/kept_projection_test.go @@ -10,103 +10,6 @@ import ( const testGoalBlock = "\nship the parser\n\nGoal mode: pursue this goal autonomously.\n" -// Standing state is superseded by the live turn, so retained copies go. -func TestStripSupersededDropsStandingState(t *testing.T) { - turn := "\nthink in zh\n\n\n" + - "\nreply in zh\n\n\n" + - testGoalBlock + "\n\n" + - "\nCurrent workspace: \"/repo\".\n\n\n" + - "keep the public API stable" - got := StripSupersededUserBlocks(turn) - if got != "keep the public API stable" { - t.Fatalf("standing state survived the strip:\n%q", got) - } -} - -// A one-time fact is the only record the model has, so it must survive - and -// must not hide a standing block sitting behind it. -func TestStripSupersededKeepsOneTimeFactsAndSeesPastThem(t *testing.T) { - turn := "\n- prefers tabs\n\n\n" + - "\njob 3 finished\n\n\n" + - testGoalBlock + "\n\n" + - "carry on" - got := StripSupersededUserBlocks(turn) - for _, want := range []string{"prefers tabs", "job 3 finished", "carry on"} { - if !strings.Contains(got, want) { - t.Fatalf("one-time fact %q was dropped:\n%q", want, got) - } - } - if strings.Contains(got, "\nverify=full\n" - got := StripSupersededUserBlocks(turn) - if !strings.Contains(got, "" - const words = "a real constraint the model must not lose" - region := make([]provider.Message, 0, 12) - for range 12 { - region = append(region, provider.Message{Role: provider.RoleUser, Content: contract + "\n\n" + words}) - } - - stripped := fixedTokenEstimate(projectedUserTurnFloor(region[0])) - unstripped := fixedTokenEstimate(region[0]) - a := &Agent{agentConfig: agentConfig{contextWindow: 128_000, compactRatio: defaultCompactRatio}} - a.budgets.UserTurnKeepTokens = stripped * len(region) - if unstripped*len(region) <= a.budgets.UserTurnKeepTokens { - t.Fatalf("fixture is not discriminating: unstripped %d x %d fits the %d budget", - unstripped, len(region), a.budgets.UserTurnKeepTokens) - } - - keep := make([]bool, len(region)) - ret := a.window().keepUserTurns(region, keep) - if ret.Dropped != 0 || ret.Kept != len(region) { - t.Fatalf("kept %d dropped %d of %d; boilerplate is still charged to the budget", - ret.Kept, ret.Dropped, len(region)) - } -} - // The newest block of each variant survives; older ones of that same variant go. // A short reminder and the full statement it points at are different variants, // so the reminder must never displace the contract. diff --git a/internal/runtime/agent/projection_test.go b/internal/runtime/agent/projection_test.go index 9b707cffac..5486f61119 100644 --- a/internal/runtime/agent/projection_test.go +++ b/internal/runtime/agent/projection_test.go @@ -13,7 +13,6 @@ import ( "reasonix/internal/base/testenv" "reasonix/internal/contract/event" "reasonix/internal/contract/provider" - "reasonix/internal/contract/tool" ) func TestCompactionStateAtomicSaveLoad(t *testing.T) { @@ -147,7 +146,6 @@ func TestCompactToProjectionLeavesCanonicalIntact(t *testing.T) { a := New(fp, nil, sess, Options{ ContextWindow: 50_000, CompactRatio: 0.85, - RecentKeep: 2, SessionPath: sessionPath, ModelRef: "test/model", }, event.Discard) @@ -195,59 +193,6 @@ func TestCompactToProjectionLeavesCanonicalIntact(t *testing.T) { } } -func TestFixedEarlyUserTurnsStableAcrossCompactions(t *testing.T) { - fp := &fakeProvider{reply: "digest-1"} - sess := sessionstore.NewSession("sys") - // 30 distinct user turns so a "latest N" strategy would reshuffle. The - // first four are large enough that usage-calibrated eligibility would reject - // them at 1 token/char, but the fixed fallback estimate accepts them. - for i := range 30 { - user := "unique-user-fact-" + strings.Repeat(string(rune('a'+i%26)), 20) + "-" + strings.Repeat("0", i%10+1) - if i < 4 { - user = "fixed-early-" + string(rune('a'+i)) + strings.Repeat("x", 1200) - } - sess.Add(provider.Message{Role: provider.RoleUser, Content: user}) - sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("work-", 50) + string(rune('A'+i%26))}) - } - dir := testenv.TempDir(t) - a := New(fp, nil, sess, Options{ContextWindow: 4000, RecentKeep: 2, ArchiveDir: dir, SessionPath: filepath.Join(dir, "s.jsonl")}, event.Discard) - if _, err := a.CompactNow(context.Background(), CompactRequest{}); err != nil { - t.Fatalf("compact1: %v", err) - } - firstPrefix := earlyUserPrefix(a.sess.win.compactionState.Projection.Messages) - // Grow the session and compact again. - for i := range 8 { - sess.Add(provider.Message{Role: provider.RoleUser, Content: "later-fact-" + strings.Repeat("z", 30) + string(rune('0'+i))}) - sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("more-", 60)}) - } - // Simulate a projected request reporting a very different calibration from - // the pre-projection canonical estimate. This remains useful for tail sizing, - // but must not change which early turns define the stable prefix. - a.sess.output.lastUsage.Store(&provider.Usage{PromptTokens: charsOfMessages(sess.Messages)}) - a.window().setPromptTokenCalibration(charsOfMessages(sess.Messages), requestCalibrationShapeOf(provider.Request{Messages: sess.Messages})) - if got := a.window().tokPerChar(); got < 0.9 || got > 1.1 { - t.Fatalf("test did not install the intended dynamic calibration: %f", got) - } - fp.reply = "digest-2" - if _, err := a.CompactNow(context.Background(), CompactRequest{}); err != nil { - t.Fatalf("compact2: %v", err) - } - secondPrefix := earlyUserPrefix(a.sess.win.compactionState.Projection.Messages) - if firstPrefix != secondPrefix { - t.Fatalf("early user prefix drifted across compactions:\n1: %q\n2: %q", firstPrefix, secondPrefix) - } - // Exactly one summary in the projection (A1 rolling merge). - summaries := 0 - for _, m := range a.sess.win.compactionState.Projection.Messages { - if isCompactionSummary(m) { - summaries++ - } - } - if summaries != 1 { - t.Fatalf("summaries in projection = %d, want 1", summaries) - } -} - func earlyUserPrefix(msgs []provider.Message) string { var b strings.Builder for _, m := range msgs { @@ -284,7 +229,7 @@ func TestArchiveDirIgnoredOnCheckpointInstall(t *testing.T) { t.Fatal(err) } a := New(fp, nil, sess, Options{ - ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 2, ArchiveDir: badArchive, + ContextWindow: 50_000, CompactRatio: 0.85, ArchiveDir: badArchive, }, event.Discard) if _, err := a.CompactNow(context.Background(), CompactRequest{}); err != nil { t.Fatalf("CompactNow with unusable ArchiveDir: %v", err) @@ -320,242 +265,3 @@ func joinContents(msgs []provider.Message) string { } return b.String() } - -func TestCompactReplacesHistory(t *testing.T) { - prov := &fakeProvider{reply: "- goal: do X\n- changed file Y"} - bigStep := strings.Repeat("important implementation detail ", 200) - sess := &sessionstore.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: bigStep}, - {Role: provider.RoleAssistant, Content: bigStep}, - {Role: provider.RoleUser, Content: "next"}, - {Role: provider.RoleAssistant, Content: "ok"}, - }} - dir := testenv.TempDir(t) - 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.window().compact(context.Background(), "manual", "", compactionScope{ignoreThreshold: true, ignoreEconomics: true}); err != nil { - t.Fatalf("compact: %v", err) - } - // Canonical transcript is never rewritten by projection compaction. - if got := sess.RewriteVersion(); got != 0 { - t.Fatalf("rewrite version = %d, want 0 (canonical intact)", got) - } - if len(sess.Messages) != beforeLen { - t.Fatalf("canonical len changed: %d -> %d", beforeLen, len(sess.Messages)) - } - - proj := visibleContext(a) - if !hasCompactionSummary(proj) { - t.Fatalf("projection missing summary: %+v", proj) - } - if proj[0].Role != provider.RoleSystem { - t.Errorf("message 0 = %s, want system", proj[0].Role) - } - // Tail preserved in projection. - if proj[len(proj)-2].Content != "next" || proj[len(proj)-1].Content != "ok" { - t.Errorf("recent tail not preserved: %+v", proj[len(proj)-2:]) - } - var foundSummary bool - for _, m := range proj { - if strings.Contains(m.Content, "archived, not summarized") { - foundSummary = true - } - } - if !foundSummary { - t.Errorf("archive pointer missing: %+v", proj) - } -} - -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 := &sessionstore.Session{Messages: []provider.Message{ - {Role: provider.RoleSystem, Content: "sys"}, - {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{ - ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 2, - }, event.Discard) - beforeVersion := sess.RewriteVersion() - - if err := a.window().compact(context.Background(), "auto", "", compactionScope{ignoreThreshold: true, ignoreEconomics: true}); err != nil { - t.Fatalf("compact: %v", err) - } - if sess.RewriteVersion() != beforeVersion { - t.Fatalf("canonical rewrite version changed: %d -> %d", beforeVersion, sess.RewriteVersion()) - } - if !hasCompactionSummary(visibleContext(a)) { - t.Fatal("expected projection summary") - } - if got := a.window().currentProjectionVersion(); got != 1 { - t.Fatalf("projection version = %d, want 1", got) - } - if reasons := sess.DrainContentRewriteReasons(); len(reasons) != 0 { - t.Fatalf("projection compact queued canonical rewrite reasons %v; want none", reasons) - } -} - -func TestCompactKeepsMidSessionUserTurns(t *testing.T) { - // 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 := &sessionstore.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: 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"}, - }} - // The summarizer is given a reply that drops the fact entirely: a mid-session - // user turn must survive on its own, never on the digest having captured it. - a := New(&fakeProvider{reply: "Standing facts: none"}, tool.NewRegistry(), sess, - Options{ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2}, event.Discard) - - if err := a.window().compact(context.Background(), "manual", "", compactionScope{ignoreThreshold: true, ignoreEconomics: true}); err != nil { - t.Fatalf("compact: %v", err) - } - - // 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, midFact) { - keptMidCanonical = true - } - } - if !pinnedFirst || !keptMidCanonical { - t.Fatalf("canonical lost user turns (first=%v mid=%v)", pinnedFirst, keptMidCanonical) - } - proj := visibleContext(a) - var projFirst, projMidVerbatim bool - for _, m := range proj { - if isCompactionSummary(m) { - continue - } - if m.Role == provider.RoleUser && m.Content == "first task" { - projFirst = true - } - if m.Role == provider.RoleUser && m.Content == midFact { - projMidVerbatim = true - } - } - if !projFirst { - t.Fatalf("fixed early user turn missing from projection: %+v", proj) - } - if !projMidVerbatim { - t.Fatalf("mid-session user turn must stay verbatim, not depend on the digest: %+v", proj) - } - if !strings.Contains(joinContents(proj), "pnpm") { - t.Fatalf("mid-session fact lost from projection: %+v", proj) - } - if strings.Contains(joinContents(proj), big) { - t.Errorf("assistant/tool work was not folded out of projection") - } -} - -func TestCompactKeepsUserMarkedMessages(t *testing.T) { - prov := &fakeProvider{reply: "- unmarked work summarized"} - // 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 := &sessionstore.Session{Messages: []provider.Message{ - {Role: provider.RoleSystem, Content: "sys"}, - {Role: provider.RoleUser, Content: "task"}, - {Role: provider.RoleUser, Content: marked}, - {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{ - ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 2, KeepPolicy: KeepUserMarked, - }, event.Discard) - - if err := a.window().compact(context.Background(), "manual", "", compactionScope{ignoreThreshold: true, ignoreEconomics: true}); err != nil { - t.Fatalf("compact: %v", err) - } - var keptCanonical, keptProj bool - for _, m := range sess.Messages { - if m.Content == marked { - keptCanonical = true - break - } - } - for _, m := range visibleContext(a) { - if m.Content == marked { - keptProj = true - break - } - } - if !keptCanonical { - t.Fatalf("marked message missing from canonical: %+v", sess.Messages) - } - if !keptProj { - t.Fatalf("marked message not kept in projection: %+v", visibleContext(a)) - } - files, _ := filepath.Glob(filepath.Join(a.window().outputsDir(), "fold-*.md")) - for _, f := range files { - if body, err := os.ReadFile(f); err == nil && strings.Contains(string(body), "exact requirement") { - t.Fatalf("marked message was still folded into the archive %s", f) - } - } -} - -func TestRunCompactsAfterFinalAnswer(t *testing.T) { - // 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 := &sessionstore.Session{Messages: []provider.Message{ - {Role: provider.RoleSystem, Content: "sys"}, - {Role: provider.RoleUser, Content: "task"}, - {Role: provider.RoleAssistant, Content: big}, - {Role: provider.RoleAssistant, Content: big}, - }} - // 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.window().estimatedPromptTokens(a.window().modelVisibleMessages()); before < a.window().compactTrigger() { - t.Fatalf("fixture est=%d below fold trigger %d", before, a.window().compactTrigger()) - } - if err := a.Run(context.Background(), "what's the status?"); err != nil { - t.Fatalf("run: %v", err) - } - if !hasCompactionSummary(visibleContext(a)) { - 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 { - t.Fatalf("canonical rewrite version = %d, want 0", got) - } -} diff --git a/internal/runtime/agent/projection_valid_test.go b/internal/runtime/agent/projection_valid_test.go index 751a1ee91c..ecf7fcb724 100644 --- a/internal/runtime/agent/projection_valid_test.go +++ b/internal/runtime/agent/projection_valid_test.go @@ -228,7 +228,6 @@ func TestForceThresholdNoopReturnsCompactionRequired(t *testing.T) { a := New(&fakeProvider{reply: "unused"}, tool.NewRegistry(), sess, Options{ ContextWindow: 200, CompactRatio: 0.5, - RecentKeep: 2, }, event.Discard) _, err := a.window().contextManager().Prepare(context.Background(), ContextPreparePolicy{Trigger: CompactionTriggerPressure}) @@ -251,7 +250,6 @@ func TestCompactInstallsCoveredPrefixHash(t *testing.T) { path := filepath.Join(dir, "s.jsonl") a := New(fp, tool.NewRegistry(), sess, Options{ ContextWindow: 2000, - RecentKeep: 2, ArchiveDir: dir, SessionPath: path, WorkspaceID: "ws", diff --git a/internal/runtime/agent/revalidate_projection_test.go b/internal/runtime/agent/revalidate_projection_test.go index 925ae10930..3b5614ff14 100644 --- a/internal/runtime/agent/revalidate_projection_test.go +++ b/internal/runtime/agent/revalidate_projection_test.go @@ -24,7 +24,7 @@ func projectionWithLiveTail(t *testing.T) (*Agent, *sessionstore.Session, int) { } dir := testenv.TempDir(t) a := New(&fakeProvider{reply: "digest"}, tool.NewRegistry(), sess, Options{ - ContextWindow: 2000, RecentKeep: 2, ArchiveDir: dir, + ContextWindow: 2000, ArchiveDir: dir, SessionPath: filepath.Join(dir, "s.jsonl"), WorkspaceID: "ws", ModelRef: "m", }, event.Discard) if _, err := a.CompactNow(context.Background(), CompactRequest{}); err != nil { diff --git a/internal/runtime/agent/touched_paths_test.go b/internal/runtime/agent/touched_paths_test.go index 8c556291f4..ea194e4848 100644 --- a/internal/runtime/agent/touched_paths_test.go +++ b/internal/runtime/agent/touched_paths_test.go @@ -6,7 +6,6 @@ import ( "errors" "reflect" "slices" - "strings" "sync" "testing" @@ -273,34 +272,6 @@ func TestRewrittenCallFeedsExecutedArgumentsLiveAndOriginalOnesOnReplay(t *testi } } -// Compaction folds what the model sees and leaves the canonical transcript -// whole, so a conversation reopened after one still replays every call. -func TestCompactionDoesNotCostTheReplayAnyTouchedPath(t *testing.T) { - reg := tool.NewRegistry() - reg.Add(readingTool{pathTool{name: "read_file", readOnly: true, read: "/ws/early.go"}}) - sess := &sessionstore.Session{Messages: []provider.Message{ - {Role: provider.RoleSystem, Content: "sys"}, - {Role: provider.RoleUser, Content: "task"}, - callMsg("1", "read_file"), resultMsg("1", "read_file", nil), - {Role: provider.RoleAssistant, Content: strings.Repeat("a ", 5000)}, - {Role: provider.RoleUser, Content: "c"}, - {Role: provider.RoleAssistant, Content: strings.Repeat("b ", 5000)}, - {Role: provider.RoleUser, Content: "e"}, - {Role: provider.RoleAssistant, Content: "f"}, - }} - opts := Options{ContextWindow: 10_000, CompactRatio: 0.8, RecentKeep: 2} - a := New(&fakeProvider{reply: "s"}, reg, sess, opts, event.Discard) - prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 8500}) - if !hasCompactionSummary(visibleContext(a)) { - t.Fatal("the fixture did not compact") - } - obs := &recordedPaths{} - a.SetPathObserver(obs) - if got, want := obs.seen(), []string{"/ws/early.go"}; !reflect.DeepEqual(got, want) { - t.Fatalf("after compaction the replay saw %q, want %q", got, want) - } -} - type proxyTool struct { pathTool target tool.Tool diff --git a/internal/runtime/agent/usage_anchor_test.go b/internal/runtime/agent/usage_anchor_test.go index 5cd2f44904..5fe77769b9 100644 --- a/internal/runtime/agent/usage_anchor_test.go +++ b/internal/runtime/agent/usage_anchor_test.go @@ -63,7 +63,7 @@ func anchorSession(t *testing.T) *sessionstore.Session { func newAnchorAgent(t *testing.T) (*Agent, *summaryCounter) { prov := &summaryCounter{} a := New(prov, tool.NewRegistry(), anchorSession(t), Options{ - ContextWindow: anchorWindow, CompactRatio: 0.85, RecentKeep: 2, WorkspaceID: "ws", ModelRef: "m", ArchiveDir: testenv.TempDir(t), + ContextWindow: anchorWindow, CompactRatio: 0.85, WorkspaceID: "ws", ModelRef: "m", ArchiveDir: testenv.TempDir(t), }, event.Discard) return a, prov } @@ -215,7 +215,7 @@ func TestReportedUsageNeverRaisesTheHardCeilingVerdict(t *testing.T) { a := New(prov, tool.NewRegistry(), &sessionstore.Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "system"}, {Role: provider.RoleUser, Content: "only turn"}, - }}, Options{ContextWindow: anchorWindow, CompactRatio: 0.85, RecentKeep: 2, WorkspaceID: "ws", ModelRef: "m"}, event.Discard) + }}, Options{ContextWindow: anchorWindow, CompactRatio: 0.85, WorkspaceID: "ws", ModelRef: "m"}, event.Discard) sendWithUsage(t, a, &provider.Usage{PromptTokens: 99_990}) _, err := a.window().contextManager().Prepare(context.Background(), ContextPreparePolicy{Trigger: CompactionTriggerPressure}) if errors.Is(err, ErrCompactionRequired) { @@ -231,7 +231,7 @@ func TestUnfoldableReportedUsageDoesNotRepeatTheAttempt(t *testing.T) { a := New(prov, tool.NewRegistry(), &sessionstore.Session{Messages: []provider.Message{ {Role: provider.RoleSystem, Content: "system"}, {Role: provider.RoleUser, Content: "only turn"}, - }}, Options{ContextWindow: anchorWindow, CompactRatio: 0.85, RecentKeep: 2, WorkspaceID: "ws", ModelRef: "m", ArchiveDir: testenv.TempDir(t)}, event.Discard) + }}, Options{ContextWindow: anchorWindow, CompactRatio: 0.85, WorkspaceID: "ws", ModelRef: "m", ArchiveDir: testenv.TempDir(t)}, event.Discard) for range 5 { sendWithUsage(t, a, &provider.Usage{PromptTokens: 90_000}) foldPass(t, a) @@ -316,7 +316,7 @@ func newCalibratedAgent(t *testing.T) *Agent { } msgs = append(msgs, provider.Message{Role: provider.RoleUser, Content: "current"}) a := New(&summaryCounter{}, tool.NewRegistry(), &sessionstore.Session{Messages: msgs}, Options{ - ContextWindow: anchorWindow, CompactRatio: 0.85, RecentKeep: 2, WorkspaceID: "ws", ModelRef: "m", ArchiveDir: testenv.TempDir(t), + ContextWindow: anchorWindow, CompactRatio: 0.85, WorkspaceID: "ws", ModelRef: "m", ArchiveDir: testenv.TempDir(t), }, event.Discard) sendWithUsage(t, a, &provider.Usage{PromptTokens: 9_000}) return a diff --git a/internal/session/control/adopt_notice_test.go b/internal/session/control/adopt_notice_test.go index 2e118058e5..229ac474c5 100644 --- a/internal/session/control/adopt_notice_test.go +++ b/internal/session/control/adopt_notice_test.go @@ -24,7 +24,7 @@ func coldNoticeController(t *testing.T, sink event.Sink) (*Controller, string) { if _, err := sessionstore.EnsureBranchMeta(path); err != nil { t.Fatalf("meta: %v", err) } - exec := agent.New(nil, nil, sessionstore.NewSession("sys"), agent.Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: dir}, event.Discard) + exec := agent.New(nil, nil, sessionstore.NewSession("sys"), agent.Options{ContextWindow: 1000, ArchiveDir: dir}, event.Discard) c := New(Options{Executor: exec, SessionDir: dir, Label: "test", Sink: sink}) c.testCacheColdAfter = -1 // force cold return c, path diff --git a/internal/session/control/context_command.go b/internal/session/control/context_command.go index d415da964a..ce29b983e2 100644 --- a/internal/session/control/context_command.go +++ b/internal/session/control/context_command.go @@ -85,14 +85,6 @@ func renderContextReport(rep agent.ContextReport) (summary, detail string) { } else { line("last maintenance", "none this session") } - if rep.UserTurnKeepBudget > 0 { - yours := fmt.Sprintf("budget %s per fold", thousands(rep.UserTurnKeepBudget)) - if rep.UserTurnsKept > 0 || rep.UserTurnsDropped > 0 { - yours = fmt.Sprintf("%d kept · %d dropped (~%s tokens) · %s", - rep.UserTurnsKept, rep.UserTurnsDropped, thousands(rep.UserTurnsDroppedToks), yours) - } - line("your turns", yours) - } if rep.CacheState != "" { line("cache", rep.CacheState) } diff --git a/internal/session/control/fold_not_reused_test.go b/internal/session/control/fold_not_reused_test.go index 424fd6c501..86dc3afac0 100644 --- a/internal/session/control/fold_not_reused_test.go +++ b/internal/session/control/fold_not_reused_test.go @@ -42,7 +42,7 @@ func foldController(t *testing.T, sink event.Sink, coversHash string) (*Controll }); err != nil { t.Fatalf("sidecar: %v", err) } - exec := agent.New(nil, nil, sessionstore.NewSession("sys"), agent.Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: dir}, event.Discard) + exec := agent.New(nil, nil, sessionstore.NewSession("sys"), agent.Options{ContextWindow: 1000, ArchiveDir: dir}, event.Discard) c := New(Options{Executor: exec, SessionDir: dir, Label: "test", Sink: sink}) loaded, err := sessionstore.LoadSession(path) if err != nil { diff --git a/internal/session/control/resume_prune_test.go b/internal/session/control/resume_prune_test.go index 89bca20c37..38d1b1cc41 100644 --- a/internal/session/control/resume_prune_test.go +++ b/internal/session/control/resume_prune_test.go @@ -34,7 +34,7 @@ func coldResumeFixture(t *testing.T, threshold time.Duration) (*sessionstore.Ses t.Fatalf("meta: %v", err) } - exec := agent.New(nil, nil, sessionstore.NewSession("sys"), agent.Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: dir}, event.Discard) + exec := agent.New(nil, nil, sessionstore.NewSession("sys"), agent.Options{ContextWindow: 1000, ArchiveDir: dir}, event.Discard) c := New(Options{Executor: exec, SessionDir: dir, Label: "test"}) if threshold == 0 { c.testCacheColdAfter = -1 // force cold @@ -90,7 +90,7 @@ func TestColdResumeAfterClonedHistoryStaysInPlace(t *testing.T) { msgs[0].Content = "new sys" resumed := loaded.CloneWithMessages(msgs) - exec := agent.New(nil, nil, sessionstore.NewSession("new sys"), agent.Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: dir}, event.Discard) + exec := agent.New(nil, nil, sessionstore.NewSession("new sys"), agent.Options{ContextWindow: 1000, ArchiveDir: dir}, event.Discard) c := New(Options{Executor: exec, SessionDir: dir, Label: "test"}) c.testCacheColdAfter = -1 // force cold c.Resume(resumed, path)