mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
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:
1 parent
e808b48946
commit
76ed2566ed
88 files changed
+209
-3738
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) != ""
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)}}}}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user