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

This commit is contained in:
yhh committed 2026-11-06 10:02:32 -08:00
1 parent e808b48946
commit 76ed2566ed
88 files changed
+209 -3738

No files matched your search

-4
View File
@@ -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}
}
@@ -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,
+1 -2
View File
@@ -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)
+1 -2
View File
@@ -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 {
+1 -2
View File
@@ -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.
+1 -3
View File
@@ -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 {
+13 -41
View File
@@ -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-<hash>.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
-17
View File
@@ -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) != ""
}
-13
View File
@@ -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")
+1 -5
View File
@@ -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,
@@ -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,
}
}
@@ -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
}
@@ -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)
}
}
-2
View File
@@ -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,
@@ -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)
}
}
-3
View File
@@ -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,
@@ -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
+6 -12
View File
@@ -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"`
-20
View File
@@ -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 {
-8
View File
@@ -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
-6
View File
@@ -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)
}
-15
View File
@@ -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)
+3 -6
View File
@@ -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
}
+2 -4
View File
@@ -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
@@ -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
}
+2 -2
View File
@@ -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
@@ -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)
}
+30 -192
View File
@@ -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, "<keep>") ||
strings.HasPrefix(content, "<!-- keep -->")
}
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
}
@@ -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))
}
}
+2 -3
View File
@@ -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()
}
+40 -2
View File
@@ -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)
}
}
}
@@ -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)
-160
View File
@@ -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
}
@@ -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)
}
}
@@ -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
}
+6 -108
View File
@@ -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
}
@@ -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)
}
-314
View File
@@ -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"
}
@@ -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)
}
}
@@ -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
}
}
@@ -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
}
@@ -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) {
+18 -22
View File
@@ -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")
}
}
@@ -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)
@@ -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)
@@ -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 := "<current-date>Today's date is 2026-10-21 (Wednesday); the local time zone is UTC+08:00.</current-date>\n" +
"<project-instructions>" + strings.Repeat("keep the build green ", 30) + "</project-instructions>\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)
}
}
@@ -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)
}
}
@@ -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 ()
@@ -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)
}
}
}
+22 -67
View File
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
}
@@ -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())
}
})
}
}
+5 -377
View File
@@ -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: " <keep> 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
@@ -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 {
@@ -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"
}
@@ -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)
}
}
@@ -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 {
@@ -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{
@@ -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
@@ -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()
@@ -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)
@@ -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",
+1 -5
View File
@@ -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)
@@ -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.
@@ -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 {
+4 -16
View File
@@ -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()
}
@@ -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 {
@@ -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)}}}}
+1 -3
View File
@@ -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{})
@@ -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,
}
}
+1 -1
View File
@@ -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)
}
@@ -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)
}
}
}
}
@@ -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))
}
}
@@ -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)
@@ -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
}
}
-19
View File
@@ -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
@@ -10,103 +10,6 @@ import (
const testGoalBlock = "<active-goal>\nship the parser\n\nGoal mode: pursue this goal autonomously.\n</active-goal>"
// Standing state is superseded by the live turn, so retained copies go.
func TestStripSupersededDropsStandingState(t *testing.T) {
turn := "<reasoning-language>\nthink in zh\n</reasoning-language>\n\n" +
"<response-language>\nreply in zh\n</response-language>\n\n" +
testGoalBlock + "\n\n" +
"<workspace>\nCurrent workspace: \"/repo\".\n</workspace>\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 := "<memory-update>\n- prefers tabs\n</memory-update>\n\n" +
"<background-jobs>\njob 3 finished\n</background-jobs>\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, "<active-goal") {
t.Fatalf("a one-time block ahead of the goal block hid it from the strip:\n%q", got)
}
}
// The per-turn execution policy records which policy governed that turn, so it
// is provenance rather than standing state.
func TestStripSupersededKeepsPerTurnProvenance(t *testing.T) {
turn := testGoalBlock + "\n\ndo the thing\n\n<execution-policy preset=\"delivery\" version=\"3\">\nverify=full\n</execution-policy>"
got := StripSupersededUserBlocks(turn)
if !strings.Contains(got, "<execution-policy") {
t.Fatalf("execution-policy provenance was dropped:\n%q", got)
}
if strings.Contains(got, "<active-goal") {
t.Fatalf("goal block survived:\n%q", got)
}
}
// A synthetic turn that was nothing but standing state must stay a turn.
func TestStripSupersededNeverEmptiesATurn(t *testing.T) {
if got := StripSupersededUserBlocks(testGoalBlock); got != testGoalBlock {
t.Fatalf("a turn made only of standing state was emptied:\n%q", got)
}
}
// Stripping runs on every fold, so it has to reach a fixed point.
func TestStripSupersededIsIdempotent(t *testing.T) {
turn := testGoalBlock + "\n\nreal user words"
once := StripSupersededUserBlocks(turn)
if twice := StripSupersededUserBlocks(once); twice != once {
t.Fatalf("not a fixed point:\nonce=%q\ntwice=%q", once, twice)
}
}
func TestStripSupersededLeavesPlainUserTextAlone(t *testing.T) {
const plain = "look at <active-goal> in input.go and tell me what it does"
if got := StripSupersededUserBlocks(plain); got != plain {
t.Fatalf("a tag the user typed was treated as an injection:\n%q", got)
}
}
// The retention budget must be spent on what the projection carries. Measuring
// the unstripped turn is what let host boilerplate crowd out the user's words:
// the budget here holds every turn once stripped and cannot hold them before.
func TestUserTurnRetentionBudgetHoldsMoreRealTurns(t *testing.T) {
contract := "<active-goal>\nship the parser\n\n" +
strings.Repeat("Goal mode: pursue this goal autonomously and honor the task contract. ", 18) +
"\n</active-goal>"
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.
+1 -295
View File
@@ -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)
}
}
@@ -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",
@@ -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 {
@@ -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
+4 -4
View File
@@ -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
@@ -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
@@ -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)
}
@@ -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 {
@@ -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)