feat(agent): content-driven context maintenance (single compact_ratio) / 内容驱动上下文维护(单一 compact_ratio) (#8244)

* feat(agent): content-driven context maintenance with single compact_ratio

Replace multi-threshold prune/snip/native auto maintenance with one
content-driven checkpoint path (stable prefix + one summary + recent
tail) gated solely by compact_ratio (default 0.85). First-visible tool
results are bounded at creation; prune/snip APIs remain no-ops for
call-site compatibility while legacy config/sidecar fields load and
migrate away.

Also align max_output_tokens auto ladder (0=auto; 128K explicit only),
desktop/settings/docs, and force CompactNow planning so mid-size
strict-alternating sessions (e.g. guardian) still reduce tokens.

Verification: go test ./...; desktop go test ./...; frontend test:all;
cache-guard; race on core packages; live seed→resume→continue smoke
(version=1, one summary, ~$0.0012).

* fix(agent): make context projection durable on Windows and clear CI gates

Problem:
- SaveCompactionState fsynced the parent directory after rename. Windows
  returns ERROR_ACCESS_DENIED for directory Sync, so every projection
  install failed and Windows smoke tests went red.
- gofmt, repolint, frontend AgentView fixtures, and required cache/docs
  PR-body lines also blocked CI for #8244.

Root cause:
- Custom atomic write path reimplemented durability with a directory Sync
  that is not portable, instead of using the shared Windows-safe helper.
- Residual format/debt and a missing coldResumePrune fixture field.

Fix:
- Persist sidecars through fileutil.AtomicWriteFile (temp + file fsync +
  replace) without directory Sync.
- gofmt touched Go files; shorten essay comments; extract fold helpers;
  carry residual structural debt in the repolint baseline after reducing
  new essay debt; restore coldResumePrune on settings fixtures.

Verification:
- gofmt clean; go run ./tools/repolint clean
- go test ./internal/agent/ ./internal/config/ ./internal/provider/...
  ./internal/guardian/ ./internal/cli/
- pnpm settings-refresh-snapshot: 91 passed

* chore: nudge PR head refresh after Windows durability fix

Empty commit so GitHub re-resolves the fork head for #8244 after the
merge of main-v2 and the Windows sidecar write fix.

* fix(agent): close #8244 review blockers for content-driven maintenance

Problem:
Review of PR #8244 requested changes for CI failures, threshold under-count,
legacy native lineage drop, non-atomic CAS/write, residual cold-resume UI, and
duplicate context_maintenance notices.

Root cause:
- Threshold used message-only estimates without tools/role projection.
- Sidecar lineage required exact PromptCacheKey and marked restored before
  covered-hash validation.
- Checkpoint install checked transcript outside the install lock and used
  non-strict atomic write with EXDEV copy fallback.
- coldResumePrune UI/API remained after behavior removal; locale gzip grew.
- Maintenance notices ignored operationId on reconnect/replay.
- golangci reported unused prune/summary helpers and minor modernize issues.

Fix:
- Estimate compact trigger from final request shape (messages + tools +
  provider role projection).
- Normalize legacy |context-editing-native-* keys; restore only if projection
  still validates; persist normalized key without version bump/events.
- CAS install under compactionMu; SaveCompactionState uses AtomicWriteFileStrict.
- Remove coldResumePrune settings UI/Wails field/locales; keep config migration.
- Dedupe context_maintenance notices by operationId (bounded FIFO) + tests.
- Delete unused prune/summary helpers; clear golangci; trim locale strings for
  bundle budget; strip SPEC trailing space.

Verification:
- make lint (golangci + repolint) clean
- go test agent/config/control/guardian/provider
- pnpm frontend build (locale budgets pass)
- context-maintenance-notice + settings-refresh-snapshot tests

* fix(desktop): gofmt reasoning_display fixture after cold-prune removal

Desktop CI failed gofmt on reasoning_display_app.go after ColdResumePrune
was dropped from the AgentView fixture literal.

* fix(agent): unblock context-maintenance merge blockers

Problem: PR #8244 still had three merge blockers: emit under
compactionMu (deadlock risk), restore rewriting exact-key
sidecars, and initial raw bundle over budget.

Root cause: commitSummaryProjection held the lock through
synchronous sink.Emit; LoadProjectionSidecar persisted whenever
the key already matched; ContextPanel/TranscriptSelectionMenu
were eagerly imported into the initial chunk.

Fix:
- Unlock before emitContextMaintenance; reentrant sink test
- needsNormalization only for legacy native lineage rewrite;
  log persist errors instead of ignoring them
- Lazy-load ContextPanel and TranscriptSelectionMenu
- Document pre-interceptor threshold estimate + overflow recovery
- CLI/GOAL docs: compact_ratio default 85%

Verification:
- go test ./internal/agent/ (+ race focused tests)
- make lint (repolint baseline essay bump)
- pnpm build: initial raw 2236.8 / 2240 KiB
- context-maintenance-notice test ok

* fix(agent): close remaining context-maintenance plan gaps

Problem: After the merge blockers landed, plan gaps remained: no
parent-dir fsync on strict checkpoint writes, new writers still
mirrored last_*/blocked_* fields, dead CompactionCapabler shell,
and the pre-interceptor threshold contract lacked a regression test.

Root cause: AtomicWriteFileStrict only fsynced the temp file;
LastReceipt was authoritative but writers still dual-wrote mirrors;
provider.CompactionCapabler had no production callers; Prepare never
invoked interceptors by design without an explicit test.

Fix:
- Strict atomic write: fsync parent directory after rename (Windows/
  unsupported FS soft-fail)
- SaveCompactionState / commit / blocked paths: LastReceipt only;
  strip last_trigger/last_mode/token mirrors and top-level blocked_*
- Restore receipt-only blocked sidecars on load
- ContextReport/CLI/status prefer LastReceipt with legacy fallback
- Delete unused CompactionCapabler/NativeCompaction shell
- TestPrepareThresholdSkipsExtensionInterceptors

Verification:
- go test ./internal/agent ./internal/fileutil ./internal/provider
- go test -race focused packages
- make lint (repolint clean)

* docs: drop removed context_editing example from zh GUIDE

Problem: GUIDE.zh-CN still suggested context_editing = "native"
after native Anthropic context editing was removed and startup
migration deletes that key.

Root cause: bilingual GUIDE config sample was not fully synced
with the single compact_ratio local-summary contract.

Fix: remove the obsolete config line from docs/GUIDE.zh-CN.md.
English GUIDE already had no such example.

Verification: grep docs for remaining context_editing user examples
(only SPEC migration notes remain, which correctly describe removal).

* fix(fileutil): never fail strict write after successful rename

Problem: AtomicWriteFileStrict returned parent-dir fsync errors after
rename had already published the new file. commitSummaryProjection
and blocked-receipt writers treated that as "not committed" and
rolled memory back, forking disk (new generation) from process
memory (old generation) and allowing paid re-summary loops.

Root cause: post-publish power-loss durability was modeled as a
hard failure even though the atomic publish already completed.

Fix:
- After successful rename, parent-dir fsync is best-effort only
- Returned error still means "destination not published"
- syncParentDir also covers relative parents (".")
- Agent tests: applied and blocked receipts survive injected
  post-publish dir-sync faults without generation fork / re-summary
- PR body: document AtomicWriteFileStrict + best-effort dir fsync

Verification:
- go test ./internal/agent ./internal/fileutil
- make lint
This commit is contained in:
SivanCola authored and GitHub committed 2026-08-11 01:30:26 +08:00
1 parent 6b90a2cbcf
commit 397ed30ec8
105 files changed
+3284 -4211

No files matched your search

+8 -2
View File
@@ -42,7 +42,7 @@ func main() {
window := flag.Int("window", 128_000, "context window in tokens")
control := flag.Bool("control", true, "fidelity: also score probes against full history")
arm := flag.String("arm", "full", "full | incremental: re-derive each digest from canonical, or fold the previous projection")
snip := flag.Bool("snip", false, "run the stale-tool-result snip pass before each fold, as the 0.6 threshold does in a real session")
snip := flag.Bool("snip", false, "legacy no-op: automatic snip projections are gone; kept so old scripts do not fail")
out := flag.String("out", "", "write the JSON report here")
flag.Parse()
@@ -143,6 +143,8 @@ func (h *harness) runGeneration(ctx context.Context, gen int, probes []probe) ge
h.calls.reset()
start := time.Now()
if h.snip {
// SnipStaleToolResults is intentionally a no-op; record zeros for
// report schema compatibility with pre-content-driven baselines.
st, serr := h.agentA.SnipStaleToolResults()
if serr != nil {
r.Error = serr.Error()
@@ -161,7 +163,11 @@ func (h *harness) runGeneration(ctx context.Context, gen int, probes []probe) ge
}
if st, ok, sterr := agent.LoadCompactionState(h.path); sterr == nil && ok {
r.ProjectionTokens = st.Projection.ProjectionTokens
r.Mode = st.LastMode
if st.LastReceipt != nil && st.LastReceipt.Action == "summary" {
r.Mode = agent.CompactionModeSummarized
} else if st.LastMode != "" {
r.Mode = st.LastMode
}
}
return r
}
+11 -36
View File
@@ -5,34 +5,9 @@ import (
"testing"
)
// The buried-evidence probe only measures something if its planted fact sits
// outside the head/tail window the snip geometry keeps. Guard the probe design
// against a later edit that shortens the log back into reach.
func TestBuriedFactSitsOutsideSnipGeometry(t *testing.T) {
const (
head = 80 // defaultReadOnlySnip.head
tail = 12 // defaultReadOnlySnip.tail
)
lines := strings.Split(buriedTestLog(), "\n")
idx := -1
for i, line := range lines {
if strings.Contains(line, "beta-7d21") {
idx = i
break
}
}
if idx < 0 {
t.Fatal("buried fact absent from the planted log")
}
if idx < head || idx >= len(lines)-tail {
t.Errorf("fact at line %d of %d is inside the kept head(%d)/tail(%d); snipping would preserve it and the probe would measure nothing", idx, len(lines), head, tail)
}
}
// A tool result the host recorded as failed outranks the snip geometry: the
// assertion detail sits where head/tail cannot reach it, so it survives only
// because the keep policy reads the execution record rather than the text.
func TestSnipKeepsRecordedFailure(t *testing.T) {
// Automatic snip projections are gone. SnipStaleToolResults remains as a no-op
// so older harness flags and call sites do not panic or rewrite history.
func TestSnipIsNoOp(t *testing.T) {
dir, cleanup, err := tempDir()
if err != nil {
t.Fatal(err)
@@ -47,20 +22,20 @@ func TestSnipKeepsRecordedFailure(t *testing.T) {
}
before := renderAll(h.sess.Snapshot())
if !strings.Contains(before, "beta-7d21") {
t.Fatal("planted fact missing before the snip pass")
t.Fatal("planted fact missing before the snip call")
}
st, err := h.agentA.SnipStaleToolResults()
if err != nil {
t.Fatal(err)
}
if st.Results == 0 {
t.Fatal("snip pass rewrote nothing")
if st.Results != 0 || st.SavedChars != 0 {
t.Fatalf("snip no-op rewrote history: results=%d chars=%d", st.Results, st.SavedChars)
}
visible, err := visibleContext(h.path, h.sess)
if err != nil {
t.Fatal(err)
after := renderAll(h.sess.Snapshot())
if after != before {
t.Fatal("canonical transcript changed after SnipStaleToolResults")
}
if !strings.Contains(renderAll(visible), "beta-7d21") {
t.Errorf("recorded failure was snipped away (%d results, %d chars); keep policy did not protect it", st.Results, st.SavedChars)
if !strings.Contains(after, "beta-7d21") {
t.Error("planted fact missing after snip no-op")
}
}
+252 -185
View File
@@ -1,5 +1,6 @@
// Drives the context-maintenance E2E scenarios against the real DeepSeek API:
// seed → (idle past cache TTL) → resume A/B-compares cold-restart miss tokens with and without pruning.
// Cost-capped seed → resume → continue smoke for content-driven maintenance.
// Offline covers seed/resume; live continue needs DEEPSEEK_API_KEY and allows
// at most one summary after growing past compact_ratio=0.85.
package main
import (
@@ -17,105 +18,104 @@ import (
"reasonix/internal/provider"
_ "reasonix/internal/provider/openai"
"reasonix/internal/tool"
"reasonix/internal/tool/builtin"
)
const (
model = "deepseek-v4-flash"
baseURL = "https://api.deepseek.com"
fatResults = 20
fatBytes = 12_000
model = "deepseek-v4-flash"
baseURL = "https://api.deepseek.com"
windowTokens = 1_000_000
compactRatio = 0.85
defaultMaxUSD = 0.50
fatBytes = 32 * 1024
)
func prov() provider.Provider {
key := os.Getenv("DEEPSEEK_API_KEY")
if key == "" {
fmt.Fprintln(os.Stderr, "DEEPSEEK_API_KEY not set")
os.Exit(1)
}
p, err := provider.New("openai", provider.Config{Name: "e2e", BaseURL: baseURL, Model: model, APIKey: key})
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return p
type costCap struct {
maxUSD float64
spent float64
}
func fakeGoFile(nonce string, i, size int) string {
var b strings.Builder
fmt.Fprintf(&b, "// module %s file%02d\npackage stress\n\n", nonce, i)
line := 0
for b.Len() < size {
fmt.Fprintf(&b, "func helper_%s_%02d_%04d(x int) int { return x*%d + %d }\n", nonce, i, line, line+3, line*7)
line++
func (c *costCap) add(u *provider.Usage, pricing *provider.Pricing) error {
if c == nil || u == nil || pricing == nil {
return nil
}
return b.String()
}
func seedSession(nonce string) *agent.Session {
s := agent.NewSession("You are a terse coding agent reviewing a Go codebase.")
s.Add(provider.Message{Role: provider.RoleUser, Content: "Review every file in module " + nonce + " one by one. Keep notes short."})
for i := range fatResults {
id := fmt.Sprintf("c%02d", i)
name := fmt.Sprintf("src/file%02d.go", i)
s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "read_file", Arguments: fmt.Sprintf(`{"path":%q}`, name)}}})
s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "read_file", Content: fakeGoFile(nonce, i, fatBytes)})
s.Add(provider.Message{Role: provider.RoleAssistant, Content: fmt.Sprintf("Reviewed %s.", name)})
c.spent += pricing.Cost(u)
if c.maxUSD > 0 && c.spent > c.maxUSD {
return fmt.Errorf("cost cap exceeded: spent $%.4f > max $%.4f", c.spent, c.maxUSD)
}
return s
}
// oneShot appends a user message and runs a single completion, returning usage.
func oneShot(p provider.Provider, msgs []provider.Message, question string) (provider.Usage, error) {
req := provider.Request{
Messages: append(append([]provider.Message(nil), msgs...), provider.Message{Role: provider.RoleUser, Content: question}),
MaxTokens: 32,
}
ch, err := p.Stream(context.Background(), req)
if err != nil {
return provider.Usage{}, err
}
var u provider.Usage
for c := range ch {
switch c.Type {
case provider.ChunkUsage:
u = *c.Usage
case provider.ChunkError:
return u, c.Err
}
}
return u, nil
return nil
}
type meta struct {
SeededAt time.Time `json:"seeded_at"`
Nonces map[string]string `json:"nonces"`
SeedUse map[string]provider.Usage `json:"seed_usage"`
SeededAt time.Time `json:"seeded_at"`
ProjectionVersion uint64 `json:"projection_version"`
CanonicalTokens int `json:"canonical_tokens"`
ProjectedTokens int `json:"projected_tokens"`
TriggerTokens int `json:"trigger_tokens"`
SummaryCalls int `json:"summary_calls"`
SpentUSD float64 `json:"spent_usd"`
ContinueUsage *provider.Usage `json:"continue_usage,omitempty"`
}
func seed(dir string) {
p := prov()
if err := os.MkdirAll(dir, 0o755); err != nil {
type recordSink struct {
summaryStarts int
}
func (s *recordSink) Emit(e event.Event) {
if e.Kind == event.CompactionStarted {
s.summaryStarts++
}
}
func prov() (provider.Provider, *provider.Pricing) {
key := os.Getenv("DEEPSEEK_API_KEY")
if key == "" {
fmt.Fprintln(os.Stderr, "DEEPSEEK_API_KEY not set (use -offline for seed/resume)")
os.Exit(1)
}
p, err := provider.New("openai", provider.Config{
Name: "e2e", BaseURL: baseURL, Model: model, APIKey: key,
Extra: map[string]any{"max_output_tokens": 1024},
})
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
m := meta{SeededAt: time.Now(), Nonces: map[string]string{}, SeedUse: map[string]provider.Usage{}}
for _, arm := range []string{"pruned", "control"} {
nonce := fmt.Sprintf("%s%d", arm, time.Now().UnixNano()%1_000_000)
s := seedSession(nonce)
u, err := oneShot(p, s.Snapshot(), "How many files have you reviewed so far? Reply with just the number.")
if err != nil {
fmt.Fprintf(os.Stderr, "seed %s: %v\n", arm, err)
os.Exit(1)
}
if err := s.Save(filepath.Join(dir, arm+".jsonl")); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
m.Nonces[arm] = nonce
m.SeedUse[arm] = u
fmt.Printf("seeded %-7s prompt=%d hit=%d miss=%d\n", arm, u.PromptTokens, u.CacheHitTokens, u.CacheMissTokens)
pricing := &provider.Pricing{Input: 0.14, Output: 0.28, CacheHit: 0.014, Currency: "$"}
return p, pricing
}
func fatHistory(n int) []provider.Message {
result := strings.Repeat("x", fatBytes)
msgs := []provider.Message{
{Role: provider.RoleSystem, Content: "You are a terse coding agent."},
{Role: provider.RoleUser, Content: "Review the large tool outputs carefully."},
}
for i := range n {
id := fmt.Sprintf("tool-%d", i)
msgs = append(msgs,
provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "read_file", Arguments: "{}"}}},
provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "read_file", Content: result},
)
}
return append(msgs,
provider.Message{Role: provider.RoleUser, Content: "summarize status when asked"},
provider.Message{Role: provider.RoleAssistant, Content: "standing by"},
)
}
func newAgent(p provider.Provider, sess *agent.Session, path string, sink event.Sink) *agent.Agent {
return agent.New(p, tool.NewRegistry(), sess, agent.Options{
ContextWindow: windowTokens,
CompactRatio: compactRatio,
RecentKeep: 2,
SessionPath: path,
WorkspaceID: "cm-e2e",
ModelRef: "deepseek/" + model,
MaxSteps: 2,
}, sink)
}
func writeMeta(dir string, m meta) {
b, _ := json.MarshalIndent(m, "", " ")
if err := os.WriteFile(filepath.Join(dir, "meta.json"), b, 0o644); err != nil {
fmt.Fprintln(os.Stderr, err)
@@ -123,8 +123,7 @@ func seed(dir string) {
}
}
func resume(dir string) {
p := prov()
func readMeta(dir string) meta {
raw, err := os.ReadFile(filepath.Join(dir, "meta.json"))
if err != nil {
fmt.Fprintln(os.Stderr, err)
@@ -135,121 +134,189 @@ func resume(dir string) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
idle := time.Since(m.SeededAt).Round(time.Minute)
return m
}
out := map[string]provider.Usage{}
for _, arm := range []string{"pruned", "control"} {
s, err := agent.LoadSession(filepath.Join(dir, arm+".jsonl"))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
prunes := 0
if arm == "pruned" {
a := agent.New(nil, tool.NewRegistry(), s, agent.Options{ContextWindow: 1_000_000, ArchiveDir: filepath.Join(dir, "archive")}, event.Discard)
st, err := a.PruneStaleToolResults()
if err != nil {
fmt.Fprintln(os.Stderr, "prune:", err)
os.Exit(1)
}
prunes = st.Results
}
u, err := oneShot(p, s.Snapshot(), "Which file did you review first? Reply with just the path.")
if err != nil {
fmt.Fprintf(os.Stderr, "resume %s: %v\n", arm, err)
os.Exit(1)
}
out[arm] = u
fmt.Printf("resume %-7s idle=%s pruned=%d prompt=%d hit=%d miss=%d\n", arm, idle, prunes, u.PromptTokens, u.CacheHitTokens, u.CacheMissTokens)
}
b, _ := json.MarshalIndent(map[string]any{"idle": idle.String(), "resume_usage": out}, "", " ")
if err := os.WriteFile(filepath.Join(dir, fmt.Sprintf("resume-%d.json", time.Now().Unix())), b, 0o644); err != nil {
func seed(dir string, offline bool, cap *costCap) {
if err := os.MkdirAll(dir, 0o755); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
c, pr := out["control"], out["pruned"]
if c.CacheMissTokens > 0 {
fmt.Printf("\ncold-restart miss tokens: control=%d pruned=%d (%.0f%% reduction)\n",
c.CacheMissTokens, pr.CacheMissTokens, 100*(1-float64(pr.CacheMissTokens)/float64(c.CacheMissTokens)))
path := filepath.Join(dir, "session.jsonl")
// ~80×32KB tool results: well under 85% of 1M for a cold estimate.
sess := &agent.Session{Messages: fatHistory(80)}
sink := &recordSink{}
var p provider.Provider
if !offline {
p, _ = prov()
}
}
// comprehension checks that the agent re-reads a file behind a prune placeholder
// instead of hallucinating: the answer is a number that exists only in the file.
func comprehension(trials int) {
p := prov()
pass := 0
for t := range trials {
dir, err := os.MkdirTemp("", "cm-e2e-")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
secret := fmt.Sprintf("%d", 1000+time.Now().UnixNano()%9000)
content := "package cfg\n\n// retention floor, milliseconds\nconst cacheRetentionFloor = " + secret + "\n" + strings.Repeat("// padding line filler for prune eligibility\n", 400)
if err := os.WriteFile(filepath.Join(dir, "config.go"), []byte(content), 0o644); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
s := agent.NewSession("You are a terse coding agent. Use tools when you need file contents.")
s.Add(provider.Message{Role: provider.RoleUser, Content: "Read config.go and note its constants."})
s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "r1", Name: "read_file", Arguments: `{"path":"config.go"}`}}})
s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "r1", Name: "read_file", Content: content})
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "Noted the constants in config.go."})
for i := range 4 {
s.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("ack %d", i)})
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"})
}
reg := tool.NewRegistry()
for _, tl := range (builtin.Workspace{Dir: dir}).Tools("read_file") {
reg.Add(tl)
}
a := agent.New(p, reg, s, agent.Options{ContextWindow: 2000, RecentKeep: 2, MaxSteps: 5}, event.Discard)
st, err := a.PruneStaleToolResults()
if err != nil || st.Results == 0 {
fmt.Fprintf(os.Stderr, "trial %d: prune did not fire (st=%+v err=%v)\n", t, st, err)
os.Exit(1)
}
err = a.Run(context.Background(), "What is the exact numeric value of cacheRetentionFloor in config.go? Reply with just the number.")
reRead, answered := false, false
for _, msg := range s.Snapshot() {
if msg.Role == provider.RoleTool && strings.Contains(msg.Content, "cacheRetentionFloor = "+secret) {
reRead = true
}
if msg.Role == provider.RoleAssistant && strings.Contains(msg.Content, secret) {
answered = true
}
}
ok := err == nil && reRead && answered
if ok {
pass++
}
fmt.Printf("trial %d: re-read=%v answered=%v err=%v\n", t, reRead, answered, err)
os.RemoveAll(dir)
}
fmt.Printf("\ncomprehension: %d/%d passed\n", pass, trials)
if pass < trials {
a := newAgent(p, sess, path, sink)
before := a.ContextMaintenanceSnapshot()
if before.TriggerTokens <= 0 {
fmt.Fprintln(os.Stderr, "seed: missing trigger tokens")
os.Exit(1)
}
if before.ProjectedTokens >= before.TriggerTokens {
fmt.Fprintf(os.Stderr, "seed fixture already at/above trigger: projected=%d trigger=%d\n",
before.ProjectedTokens, before.TriggerTokens)
os.Exit(1)
}
if err := a.PrepareContext(context.Background()); err != nil {
fmt.Fprintln(os.Stderr, "seed prepare:", err)
os.Exit(1)
}
after := a.ContextMaintenanceSnapshot()
if after.ProjectionVersion != 0 {
fmt.Fprintf(os.Stderr, "seed installed projection version %d below compact_ratio\n", after.ProjectionVersion)
os.Exit(1)
}
if sink.summaryStarts != 0 {
fmt.Fprintf(os.Stderr, "seed started %d summary calls below trigger\n", sink.summaryStarts)
os.Exit(1)
}
if err := sess.Save(path); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
m := meta{
SeededAt: time.Now(),
ProjectionVersion: after.ProjectionVersion,
CanonicalTokens: after.CanonicalTokens,
ProjectedTokens: after.ProjectedTokens,
TriggerTokens: after.TriggerTokens,
SummaryCalls: sink.summaryStarts,
SpentUSD: cap.spent,
}
writeMeta(dir, m)
fmt.Printf("seed ok projected=%d trigger=%d version=%d summary_calls=%d spent=$%.4f\n",
m.ProjectedTokens, m.TriggerTokens, m.ProjectionVersion, m.SummaryCalls, m.SpentUSD)
}
func resume(dir string, offline bool) {
path := filepath.Join(dir, "session.jsonl")
m := readMeta(dir)
sess, err := agent.LoadSession(path)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
sink := &recordSink{}
var p provider.Provider
if !offline {
p, _ = prov()
}
a := newAgent(p, sess, path, sink)
snap := a.ContextMaintenanceSnapshot()
if snap.ProjectionVersion != m.ProjectionVersion {
fmt.Fprintf(os.Stderr, "resume version = %d, want %d\n", snap.ProjectionVersion, m.ProjectionVersion)
os.Exit(1)
}
if err := a.PrepareContext(context.Background()); err != nil {
fmt.Fprintln(os.Stderr, "resume prepare:", err)
os.Exit(1)
}
after := a.ContextMaintenanceSnapshot()
if after.ProjectionVersion != m.ProjectionVersion {
fmt.Fprintf(os.Stderr, "resume advanced version to %d\n", after.ProjectionVersion)
os.Exit(1)
}
if sink.summaryStarts != 0 {
fmt.Fprintf(os.Stderr, "resume re-ran summary %d times\n", sink.summaryStarts)
os.Exit(1)
}
fmt.Printf("resume ok idle=%s version=%d summary_calls=0\n",
time.Since(m.SeededAt).Round(time.Second), m.ProjectionVersion)
}
func cont(dir string, offline bool, cap *costCap) {
if offline {
fmt.Fprintln(os.Stderr, "continue requires live provider (omit -offline)")
os.Exit(1)
}
path := filepath.Join(dir, "session.jsonl")
m := readMeta(dir)
sess, err := agent.LoadSession(path)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
// Grow past the 85% trigger with additional fat tool turns (skip system/user prelude).
for _, msg := range fatHistory(40)[2:] {
sess.Add(msg)
}
sink := &recordSink{}
p, pricing := prov()
a := newAgent(p, sess, path, sink)
before := a.ContextMaintenanceSnapshot()
if before.ProjectedTokens < before.TriggerTokens {
fmt.Fprintf(os.Stderr, "continue fixture still below trigger: projected=%d trigger=%d\n",
before.ProjectedTokens, before.TriggerTokens)
os.Exit(1)
}
if err := a.PrepareContext(context.Background()); err != nil {
fmt.Fprintln(os.Stderr, "continue prepare:", err)
os.Exit(1)
}
if sink.summaryStarts > 1 {
fmt.Fprintf(os.Stderr, "continue started %d summaries, want ≤1\n", sink.summaryStarts)
os.Exit(1)
}
after := a.ContextMaintenanceSnapshot()
if sink.summaryStarts == 1 && after.ProjectionVersion != m.ProjectionVersion+1 {
fmt.Fprintf(os.Stderr, "continue version = %d, want %d after one summary\n",
after.ProjectionVersion, m.ProjectionVersion+1)
os.Exit(1)
}
if u := a.LastUsage(); u != nil {
if err := cap.add(u, pricing); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
m.ContinueUsage = u
}
startsBefore := sink.summaryStarts
_ = a.Run(context.Background(), "Reply with exactly: ok")
if u := a.LastUsage(); u != nil {
if err := cap.add(u, pricing); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
if sink.summaryStarts != startsBefore {
fmt.Fprintf(os.Stderr, "post-checkpoint run started extra summaries (%d→%d)\n",
startsBefore, sink.summaryStarts)
os.Exit(1)
}
if err := sess.Save(path); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
final := a.ContextMaintenanceSnapshot()
m.ProjectionVersion = final.ProjectionVersion
m.ProjectedTokens = final.ProjectedTokens
m.CanonicalTokens = final.CanonicalTokens
m.SummaryCalls = sink.summaryStarts
m.SpentUSD = cap.spent
writeMeta(dir, m)
fmt.Printf("continue ok version=%d projected=%d summary_calls=%d spent=$%.4f\n",
m.ProjectionVersion, m.ProjectedTokens, m.SummaryCalls, m.SpentUSD)
}
func main() {
dir := flag.String("dir", "benchmarks/context-maintenance-e2e/run", "state directory for seed/resume")
trials := flag.Int("trials", 5, "comprehension trials")
dir := flag.String("dir", "benchmarks/context-maintenance-e2e/run", "state directory")
maxUSD := flag.Float64("max-usd", defaultMaxUSD, "hard cost cap for live API legs")
offline := flag.Bool("offline", false, "skip live provider (seed/resume only)")
flag.Parse()
cap := &costCap{maxUSD: *maxUSD}
switch flag.Arg(0) {
case "seed":
seed(*dir)
seed(*dir, *offline, cap)
case "resume":
resume(*dir)
case "comprehension":
comprehension(*trials)
resume(*dir, *offline)
case "continue":
cont(*dir, *offline, cap)
default:
fmt.Fprintln(os.Stderr, "usage: context-maintenance-e2e [seed|resume|comprehension]")
fmt.Fprintln(os.Stderr, "usage: context-maintenance-e2e [-dir DIR] [-max-usd N] [-offline] seed|resume|continue")
os.Exit(1)
}
}
+8 -5
View File
@@ -29,9 +29,11 @@ type ContextMaintenanceInfo struct {
ProjectedTokens int `json:"projectedTokens,omitempty"`
SummaryTokens int `json:"summaryTokens,omitempty"`
LastSavedTokens int `json:"lastSavedTokens,omitempty"`
SnipTrigger int `json:"snipTrigger,omitempty"`
FoldTrigger int `json:"foldTrigger,omitempty"`
ForceTrigger int `json:"forceTrigger,omitempty"`
SnipTrigger int `json:"snipTrigger,omitempty"` // always 0; legacy compatibility
FoldTrigger int `json:"foldTrigger,omitempty"` // alias of TriggerTokens
ForceTrigger int `json:"forceTrigger,omitempty"` // always 0; legacy compatibility
TriggerTokens int `json:"triggerTokens,omitempty"`
CheckpointState string `json:"checkpointState,omitempty"` // none|restored|applied
HardInputCeiling int `json:"hardInputCeiling,omitempty"`
Headroom int `json:"headroom,omitempty"`
ProjectionVersion uint64 `json:"projectionVersion,omitempty"`
@@ -69,8 +71,9 @@ func contextMaintenanceInfo(snapshot agent.ContextMaintenanceSnapshot) *ContextM
info := &ContextMaintenanceInfo{
CanonicalTokens: snapshot.CanonicalTokens, ProjectedTokens: snapshot.ProjectedTokens,
SummaryTokens: snapshot.SummaryTokens, LastSavedTokens: snapshot.LastSavedTokens,
SnipTrigger: snapshot.SnipTrigger, FoldTrigger: snapshot.FoldTrigger,
ForceTrigger: snapshot.ForceTrigger, HardInputCeiling: snapshot.HardInputCeiling,
// Snip/Force remain zero for one-version compatibility with older frontends.
FoldTrigger: snapshot.TriggerTokens, TriggerTokens: snapshot.TriggerTokens,
CheckpointState: snapshot.CheckpointState, HardInputCeiling: snapshot.HardInputCeiling,
Headroom: snapshot.Headroom, ProjectionVersion: snapshot.ProjectionVersion,
Blocked: snapshot.Blocked,
}
+8 -3
View File
@@ -8,16 +8,21 @@ import (
func TestContextMaintenanceInfoUsesOptionalCamelCaseWailsFields(t *testing.T) {
b, err := json.Marshal(ContextInfo{Maintenance: &ContextMaintenanceInfo{
ProjectedTokens: 1200,
CanonicalTokens: 800_000, ProjectedTokens: 160_000,
TriggerTokens: 850_000, CheckpointState: "applied",
LastReceipt: &ContextMaintenanceReceiptInfo{
OperationID: "op", Action: "prune", SavedTokens: 4096,
OperationID: "op", Action: "summary", SavedTokens: 4096,
},
}})
if err != nil {
t.Fatal(err)
}
got := string(b)
for _, want := range []string{`"maintenance"`, `"projectedTokens":1200`, `"lastReceipt"`, `"operationId":"op"`, `"savedTokens":4096`} {
for _, want := range []string{
`"maintenance"`, `"projectedTokens":160000`, `"canonicalTokens":800000`,
`"triggerTokens":850000`, `"checkpointState":"applied"`,
`"lastReceipt"`, `"operationId":"op"`, `"savedTokens":4096`, `"action":"summary"`,
} {
if !strings.Contains(got, want) {
t.Fatalf("ContextInfo JSON missing %s: %s", want, got)
}
+21 -18
View File
@@ -44,7 +44,6 @@ import { generativeMusic, isGenerativeMusicEnabled } from "./lib/generative-musi
import { clearAttentionChimeKeys, playAttentionChime, playSuccessChime, shouldPlayAttentionChimeForEvent } from "./lib/sound";
import { NoticeCard, Transcript } from "./components/Transcript";
import { Composer } from "./components/Composer";
import { TranscriptSelectionMenu } from "./components/TranscriptSelectionMenu";
import { TodoPanel } from "./components/TodoPanel";
import { ApprovalModal } from "./components/ApprovalModal";
import { AskCard } from "./components/AskCard";
@@ -66,7 +65,6 @@ import { RemoteWorkspaceLaunchGate, resolveRemoteWorkspace } from "./lib/remoteW
import { CommandPalette, type PaletteItem } from "./components/CommandPalette";
import { UpdateBanner } from "./components/UpdateBanner";
import { UpdaterProvider } from "./lib/useUpdater";
import { ContextPanel } from "./components/ContextPanel";
import { Tooltip } from "./components/Tooltip";
import { StartupSplash } from "./components/StartupSplash";
import { OnboardingOverlay } from "./components/OnboardingOverlay";
@@ -293,6 +291,8 @@ function NoticePreviewPanel() {
);
}
const TranscriptSelectionMenu = lazy(() => import("./components/TranscriptSelectionMenu").then((module) => ({ default: module.TranscriptSelectionMenu })));
const ContextPanel = lazy(() => import("./components/ContextPanel").then((module) => ({ default: module.ContextPanel })));
const HistoryPanel = lazy(() => import("./components/HistoryPanel").then((module) => ({ default: module.HistoryPanel })));
const SettingsPanel = lazy(() => import("./components/SettingsPanelEntry").then((module) => ({ default: module.SettingsPanel })));
const RemotePanel = lazy(() => import("./components/RemotePanel").then((module) => ({ default: module.RemotePanel })));
@@ -5297,21 +5297,23 @@ export default function App() {
<RemotePanel onClose={() => setWorkspacePanel(false)} />
</Suspense>
) : rightDockMode === "context" && desktopLayoutStyle !== "creation" ? (
<ContextPanel
tabId={activeTabId}
context={state.context}
usage={state.usage}
sessionTokens={state.sessionTokens}
sessionCost={state.sessionCost}
sessionCurrency={state.sessionCurrency}
sessionTurns={sessionTurns}
turnTokens={state.turnTotalTokens}
turnCost={state.turnCost}
balance={state.balance}
sessionGen={state.sessionGen}
refreshKey={dockRefreshKey + state.contextPanelSeq}
usageSeq={state.usageSeq}
/>
<Suspense fallback={null}>
<ContextPanel
tabId={activeTabId}
context={state.context}
usage={state.usage}
sessionTokens={state.sessionTokens}
sessionCost={state.sessionCost}
sessionCurrency={state.sessionCurrency}
sessionTurns={sessionTurns}
turnTokens={state.turnTotalTokens}
turnCost={state.turnCost}
balance={state.balance}
sessionGen={state.sessionGen}
refreshKey={dockRefreshKey + state.contextPanelSeq}
usageSeq={state.usageSeq}
/>
</Suspense>
) : (
<Suspense fallback={null}>
<WorkspacePanel
@@ -5515,11 +5517,12 @@ export default function App() {
<HeartbeatPanel open={heartbeatOpen} onClose={() => setHeartbeatOpen(false)} onOpenTopic={(scope, workspaceRoot, topicId) => {
void handleOpenTopic(scope, workspaceRoot, topicId);
}} />
<TranscriptSelectionMenu
<Suspense fallback={null}><TranscriptSelectionMenu
enabled={Boolean(activeTabId && !activeTab?.readOnly && !decisionSurface && !sidebarImDetailConnection && !hydratePlaceholderActive)}
resetKey={activeTabId ?? ""}
onAddToChat={addSelectedTextToComposer}
/>
</Suspense>
{windowsFramelessChrome && (
<WindowsWindowControls
maximised={mainWindowMaximised}
@@ -1,5 +1,9 @@
import { readFileSync } from "node:fs";
import { formatContextMaintenanceNotice } from "../lib/contextMaintenanceTypes";
import {
formatContextMaintenanceNotice,
isNewMaintenanceOperation,
rememberMaintenanceOperation,
} from "../lib/contextMaintenanceTypes";
import type { DictKey, Translator } from "../lib/i18n";
function ok(value: unknown, message: string) {
@@ -7,12 +11,12 @@ function ok(value: unknown, message: string) {
}
const messages: Partial<Record<DictKey, string>> = {
"context.maintenanceTitle": "上下文维护",
"context.maintenanceActionPrune": "工具结果裁剪",
"context.maintenanceTitle": "上下文短视图",
"context.maintenanceAppliedSummary": "已生成短视图",
"context.maintenanceBlockedSummary": "摘要未形成短视图 · 已停重试",
"context.maintenanceFailedSummary": "摘要失败 · 已停重试",
"context.tokensValue": "{value} tokens",
"projectTree.status.paused": "已暂停",
"settings.typography.applied": "已应用",
"task.state.failed": "失败",
"summary.detail": "摘要",
};
const translate: Translator = (key, vars) => {
@@ -22,25 +26,52 @@ const translate: Translator = (key, vars) => {
const applied = formatContextMaintenanceNotice({
status: "applied",
action: "prune",
action: "summary",
inputTokens: 120,
resultTokens: 80,
savedTokens: 40,
}, translate);
ok(applied === "上下文维护 · 工具结果裁剪 · 已应用 · 120 → 80 tokens · −40 tokens", `unexpected applied notice: ${applied}`);
ok(!applied.includes("Context") && !applied.includes("applied"), "maintenance notice leaked hardcoded English");
ok(applied === "已生成短视图", `unexpected applied notice: ${applied}`);
const blocked = formatContextMaintenanceNotice({ status: "blocked" }, translate);
ok(blocked === "上下文维护 · 已暂停", `unexpected blocked notice: ${blocked}`);
ok(blocked === "摘要未形成短视图 · 已停重试", `unexpected blocked notice: ${blocked}`);
const failed = formatContextMaintenanceNotice({ status: "failed" }, translate);
ok(failed === "摘要失败 · 已停重试", `unexpected failed notice: ${failed}`);
const contextPanelSource = readFileSync(new URL("../components/ContextPanel.tsx", import.meta.url), "utf8");
ok(
!contextPanelSource.includes('t("context.maintenanceProjected")'),
"ContextPanel must not render the internal context-maintenance composition block",
contextPanelSource.includes("context.maintenanceCanonical"),
"ContextPanel must show canonical vs model-visible tokens",
);
ok(
!contextPanelSource.includes("const maintenance = context?.maintenance"),
"ContextPanel must not derive hidden maintenance UI state",
contextPanelSource.includes("triggerTokens") || contextPanelSource.includes("maintenance?.triggerTokens"),
"ContextPanel must surface triggerTokens",
);
ok(
contextPanelSource.includes("checkpointState"),
"ContextPanel must surface checkpointState",
);
ok(
!contextPanelSource.includes("snipTrigger") && !contextPanelSource.includes("forceTrigger"),
"ContextPanel must not present retired multi-threshold triggers as user settings",
);
ok(isNewMaintenanceOperation([], "op-1"), "empty seen list accepts first operationId");
ok(isNewMaintenanceOperation(["op-1"], "op-1") === false, "duplicate operationId is rejected");
ok(isNewMaintenanceOperation(["op-1"], "op-2"), "distinct operationId is accepted");
ok(isNewMaintenanceOperation(["op-1"], ""), "missing operationId is still shown");
const remembered = rememberMaintenanceOperation(["op-1"], "op-2");
ok(remembered.includes("op-1") && remembered.includes("op-2"), "remember keeps prior and new ids");
ok(rememberMaintenanceOperation(["op-1"], "op-1").length === 1, "remember is idempotent for same id");
const many = Array.from({ length: 70 }, (_, i) => `op-${i}`);
const bounded = rememberMaintenanceOperation(many.slice(0, 64), "op-new");
ok(bounded.length === 64 && bounded[bounded.length - 1] === "op-new", "remember bounds to 64 ids");
const settingsSource = readFileSync(new URL("../components/SettingsPanel.tsx", import.meta.url), "utf8");
ok(
!settingsSource.includes("settings.coldResumePrune") && !settingsSource.includes("SetColdResumePrune"),
"SettingsPanel must not expose retired coldResumePrune control",
);
console.log("context-maintenance-notice: ok");
@@ -270,13 +270,13 @@ await act(async () => {
});
ok(compactRootEl.textContent?.includes("Advanced context management") === false, "compaction preference has no redundant advanced disclosure");
ok(compactRootEl.textContent?.includes("Automatic compaction threshold") === true, "compaction preference is visible without expanding a disclosure");
ok(compactRootEl.textContent?.includes("80,000 tokens") === true, "compact ratio shows the default model token threshold");
ok(compactRootEl.textContent?.includes("Current threshold: 80% · Balanced") === true, "compact ratio summarizes the saved preset separately");
ok(compactRootEl.textContent?.includes("85,000 tokens") === true, "compact ratio shows the default model token threshold");
ok(compactRootEl.textContent?.includes("Current threshold: 85% · Recommended") === true, "compact ratio summarizes the saved preset separately");
ok(compactRootEl.textContent?.includes("effective threshold is 75%") === true, "project override shows the active effective threshold");
ok(compactRootEl.querySelector('input[aria-label="Custom compaction threshold percentage"]') === null, "custom compact ratio editor stays hidden on the default path");
const balancedCompactButton = compactRootEl.querySelector('button[aria-label="80% · Balanced"]') as HTMLButtonElement | null;
if (!balancedCompactButton) throw new Error("balanced compaction preset did not render");
ok(balancedCompactButton.getAttribute("aria-pressed") === "true", "saved compact ratio starts selected");
const recommendedCompactButton = compactRootEl.querySelector('button[aria-label="85% · Recommended"]') as HTMLButtonElement | null;
if (!recommendedCompactButton) throw new Error("recommended compaction preset did not render");
ok(recommendedCompactButton.getAttribute("aria-pressed") === "true", "saved compact ratio starts selected");
const customCompactButton = Array.from(compactRootEl.querySelectorAll("button")).find((button) => button.textContent?.includes("Custom threshold…")) as HTMLButtonElement | undefined;
if (!customCompactButton) throw new Error("custom compaction threshold option did not render");
ok(customCompactButton.closest(".compact-ratio-presets") === null, "custom compaction is a separate disclosure rather than a preset value");
@@ -287,12 +287,12 @@ await act(async () => {
});
let customCompactInput = compactRootEl.querySelector('input[aria-label="Custom compaction threshold percentage"]') as HTMLInputElement | null;
if (!customCompactInput) throw new Error("custom compaction threshold input did not open");
eq(customCompactInput.value, "80", "custom compaction threshold defaults older backends to 80 percent");
ok(compactRootEl.textContent?.includes("Tool output is trimmed at 60%") === true, "custom compact ratio explains the lower guard rail");
ok(compactRootEl.textContent?.includes("90% forces compaction") === true, "custom compact ratio explains the upper guard rail");
eq(customCompactInput.value, "85", "custom compaction threshold defaults older backends to 85 percent");
ok(compactRootEl.textContent?.includes("65%") === true, "custom compact ratio explains the lower guard rail");
ok(compactRootEl.textContent?.includes("85%") === true, "custom compact ratio explains the upper guard rail");
ok(document.activeElement === customCompactInput, "opening the custom compact ratio moves focus to its input");
ok(customCompactButton.getAttribute("aria-expanded") === "true", "custom compact ratio exposes its expanded state");
ok(balancedCompactButton.getAttribute("aria-pressed") === "true", "opening custom editing preserves the saved preset selection");
ok(recommendedCompactButton.getAttribute("aria-pressed") === "true", "opening custom editing preserves the saved preset selection");
const customCompactApply = Array.from(customCompactInput.closest(".compact-ratio-custom")?.querySelectorAll("button") ?? []).find((button) => button.textContent === "Apply") as HTMLButtonElement | undefined;
if (!customCompactApply) throw new Error("custom compaction threshold apply action did not render");
const inputValueSetter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set;
@@ -350,15 +350,15 @@ await act(async () => {
});
eq(compactRatioCalls.length, 1, "Cancel closes a custom compact ratio without saving");
ok(compactRootEl.querySelector('input[aria-label="Custom compaction threshold percentage"]') === null, "Cancel collapses the custom compact ratio editor");
const earlierCompactButton = compactRootEl.querySelector('button[aria-label="70% · Earlier"]') as HTMLButtonElement | null;
if (!earlierCompactButton) throw new Error("earlier compaction preset did not render");
const activeCompactButton = compactRootEl.querySelector('button[aria-label="70% · Active"]') as HTMLButtonElement | null;
if (!activeCompactButton) throw new Error("active compaction preset did not render");
await act(async () => {
earlierCompactButton.click();
activeCompactButton.click();
await flushPromises();
});
eq(compactRatioCalls.length, 2, "compact ratio preset adds one mutation");
eq(compactRatioCalls[1], 0.7, "compact ratio preset sends the expected fraction");
ok(earlierCompactButton.getAttribute("aria-pressed") === "true", "saved compact ratio is selected after Settings reload");
ok(activeCompactButton.getAttribute("aria-pressed") === "true", "saved compact ratio is selected after Settings reload");
await act(async () => {
compactRoot.unmount();
@@ -28,7 +28,6 @@ interface ContextPanelProps {
usageSeq?: number;
}
function fmtDuration(ms: number, t: Translator): string {
if (ms <= 0) return "-";
const totalSeconds = Math.max(1, Math.round(ms / 1000));
@@ -38,7 +37,6 @@ function fmtDuration(ms: number, t: Translator): string {
return t("context.durationMinutesSeconds", { minutes, seconds });
}
interface MetricTokenDisplay {
display: string;
exact: string;
@@ -422,10 +420,20 @@ export function ContextPanel({
const changedFiles = asArray(info?.changedFiles);
const usagePct = windowTokens > 0 ? Math.min(100, Math.round((usedTokens / windowTokens) * 100)) : 0;
const compactRatio = context?.compactRatio && context.compactRatio > 0 ? context.compactRatio : 0.8;
const compactRatio = context?.compactRatio && context.compactRatio > 0 ? context.compactRatio : 0.85;
const compactPct = Math.round(compactRatio * 100);
const compactTokens = windowTokens > 0 ? Math.round(windowTokens * compactRatio) : 0;
const maintenance = context?.maintenance;
const triggerTokens = maintenance?.triggerTokens && maintenance.triggerTokens > 0
? maintenance.triggerTokens
: windowTokens > 0
? Math.round(windowTokens * compactRatio)
: 0;
const compactTokens = triggerTokens > 0 ? triggerTokens : (windowTokens > 0 ? Math.round(windowTokens * compactRatio) : 0);
const tokensUntilCompact = compactTokens > usedTokens ? compactTokens - usedTokens : 0;
const canonicalTokens = maintenance?.canonicalTokens ?? 0;
const projectedTokens = maintenance?.projectedTokens ?? usedTokens;
const checkpointState = maintenance?.checkpointState || (maintenance?.projectionVersion ? "restored" : "none");
const lastReceipt = maintenance?.lastReceipt;
const breakdown = contextBreakdown(usedTokens, windowTokens, promptTokens, completionTokens, reasoningTokens);
const eventTimes = [
...readFiles.map((file) => file.time),
@@ -548,6 +556,53 @@ export function ContextPanel({
</span>
</div>
</div>
{(canonicalTokens > 0 || projectedTokens > 0 || (maintenance?.projectionVersion ?? 0) > 0) && (
<div className="context-panel__maintenance" aria-label={t("context.maintenanceTitle")}>
<SectionHeading title={t("context.maintenanceTitle")} />
<div className="context-panel__maintenance-rows">
<MiniStat
label={t("context.maintenanceCanonical")}
value={formatTokens(canonicalTokens)}
/>
<MiniStat
label={t("context.maintenanceVisible")}
value={formatTokens(projectedTokens)}
/>
<MiniStat
label={t("context.maintenanceTrigger")}
value={triggerTokens > 0 ? formatTokens(triggerTokens) : "-"}
title={t("context.maintenanceTriggerHint", { percent: compactPct })}
/>
<MiniStat
label={t("context.maintenanceRatio")}
value={`${compactPct}%`}
/>
<MiniStat
label={t("context.maintenanceCheckpoint")}
value={
checkpointState === "applied"
? t("context.maintenanceCheckpointApplied")
: checkpointState === "restored"
? t("context.maintenanceCheckpointRestored")
: t("context.maintenanceCheckpointNone")
}
wide
/>
{(maintenance?.projectionVersion ?? 0) > 0 && (
<MiniStat
label={t("context.maintenanceVersion")}
value={String(maintenance?.projectionVersion)}
/>
)}
{typeof lastReceipt?.savedTokens === "number" && lastReceipt.savedTokens > 0 && (
<MiniStat
label={t("context.maintenanceSaved")}
value={formatTokens(lastReceipt.savedTokens)}
/>
)}
</div>
</div>
)}
</section>
<section className="context-panel__section context-panel__session-section">
<SectionHeading title={t("context.sessionMetrics")} />
@@ -54,7 +54,7 @@ export function ContextWindowRing({ enabled = true, context, tabId, turnCost, cu
const used = context?.used ?? 0;
const windowTokens = context?.window ?? 0;
const usagePct = windowTokens > 0 ? Math.min(100, Math.round((used / windowTokens) * 100)) : 0;
const compactRatio = context?.compactRatio && context.compactRatio > 0 ? context.compactRatio : 0.8;
const compactRatio = context?.compactRatio && context.compactRatio > 0 ? context.compactRatio : 0.85;
const compactPct = Math.round(compactRatio * 100);
const status = contextWindowStatus(usagePct, compactPct);
@@ -1420,12 +1420,12 @@ function normalizeSettingsView(view: SettingsView | null | undefined): SettingsV
noProxy: "",
proxy: { type: "socks5", server: "", port: 0, username: "", password: "" },
};
const agent = view.agent ?? { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, maxSubagentConcurrency: 6, maxParallelWriters: 3, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto", compactRatio: 0.8 };
const agent = view.agent ?? { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, maxSubagentConcurrency: 6, maxParallelWriters: 3, systemPrompt: "", reasoningLanguage: "auto", compactRatio: 0.85 };
agent.plannerMaxSteps = Number.isFinite(agent.plannerMaxSteps) ? Math.max(0, Math.trunc(agent.plannerMaxSteps)) : 0;
agent.maxSteps = Number.isFinite(agent.maxSteps) ? Math.max(0, Math.trunc(agent.maxSteps)) : 0;
agent.maxSubagentDepth = Number.isFinite(agent.maxSubagentDepth) && agent.maxSubagentDepth <= 1 ? 1 : 2;
agent.reasoningLanguage = normalizeReasoningLanguage(agent.reasoningLanguage);
agent.compactRatio = Number.isFinite(agent.compactRatio) && Number(agent.compactRatio) > 0 ? Number(agent.compactRatio) : 0.8;
agent.compactRatio = Number.isFinite(agent.compactRatio) && Number(agent.compactRatio) > 0 ? Number(agent.compactRatio) : 0.85;
agent.effectiveCompactRatio = Number.isFinite(agent.effectiveCompactRatio) && Number(agent.effectiveCompactRatio) > 0
? Number(agent.effectiveCompactRatio)
: agent.compactRatio;
@@ -3961,8 +3961,8 @@ function ModelsSection({ s, busy, apply, backgroundApply, initialFocus }: Models
: !providerIsConfigured(defaultProviderView)
? t("settings.modelNeedsKey", { provider: modelProviderLabel(defaultProvider, defaultProviderView, t) })
: "";
const agent = s.agent ?? { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, maxSubagentConcurrency: 6, maxParallelWriters: 3, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto", compactRatio: 0.8 };
const compactRatio = agent.compactRatio ?? 0.8;
const agent = s.agent ?? { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, maxSubagentConcurrency: 6, maxParallelWriters: 3, systemPrompt: "", reasoningLanguage: "auto", compactRatio: 0.85 };
const compactRatio = agent.compactRatio ?? 0.85;
const compactRatioPercent = Math.round(compactRatio * 1000) / 10;
const [compactRatioDraft, setCompactRatioDraft] = useState(() => String(compactRatioPercent));
const [compactRatioCustomOpen, setCompactRatioCustomOpen] = useState(false);
@@ -4246,20 +4246,6 @@ function ModelsSection({ s, busy, apply, backgroundApply, initialFocus }: Models
{modelIssue && <div className="provider-fetch-banner provider-fetch-banner--warn">{modelIssue}</div>}
</SettingsSection>
<SettingsSection title={t("settings.agentRuntime")} description={t("settings.agentRuntimeHint")}>
<SettingsField label={t("settings.coldResumePrune")} hint={t("settings.coldResumePruneHint")}>
<div className="set-seg">
{([true, false] as const).map((on) => (
<button
key={on ? "on" : "off"}
className={`set-seg__btn${agent.coldResumePrune === on ? " set-seg__btn--on" : ""}`}
disabled={busy}
onClick={() => void apply(() => app.SetColdResumePrune(on))}
>
{on ? t("settings.coldResumePrune.on") : t("settings.coldResumePrune.off")}
</button>
))}
</div>
</SettingsField>
<SettingsField label={t("settings.reasoningLanguage")} hint={t("settings.reasoningLanguageHint")}>
<div className="set-seg">
{(["auto", "zh", "en"] as const).map((lang) => (
+1 -5
View File
@@ -556,7 +556,6 @@ export interface AppBindings {
SetDesktopConversationWidth(width: string): Promise<void>;
MigrateDesktopPreferences(language: string, theme: string, style: string): Promise<void>;
SetAgentParams(temperature: number, maxSteps: number, plannerMaxSteps: number, systemPrompt: string): Promise<void>;
SetColdResumePrune(enabled: boolean): Promise<void>;
SetCompactRatio(ratio: number): Promise<void>;
SetReasoningLanguage(lang: string): Promise<void>;
SetTrayLocale(locale: "en" | "zh" | "zh-TW"): Promise<void>;
@@ -1615,7 +1614,7 @@ function makeMockApp(): AppBindings {
noProxy: "",
proxy: { type: "socks5", server: "127.0.0.1", port: 7890, username: "", password: "" },
},
agent: { temperature: 0.2, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, maxSubagentConcurrency: 6, maxParallelWriters: 3, systemPrompt: "You are Reasonix, a coding agent.", coldResumePrune: true, reasoningLanguage: "auto", compactRatio: 0.8 },
agent: { temperature: 0.2, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, maxSubagentConcurrency: 6, maxParallelWriters: 3, systemPrompt: "You are Reasonix, a coding agent.", reasoningLanguage: "auto", compactRatio: 0.8 },
bot: {
enabled: !freshMock,
model: "",
@@ -4878,9 +4877,6 @@ function makeMockApp(): AppBindings {
async SetAgentParams(temperature: number, maxSteps: number, plannerMaxSteps: number, systemPrompt: string) {
settings.agent = { ...settings.agent, temperature, maxSteps, plannerMaxSteps, systemPrompt };
},
async SetColdResumePrune(enabled: boolean) {
settings.agent = { ...settings.agent, coldResumePrune: enabled };
},
async SetCompactRatio(ratio: number) {
if (!Number.isFinite(ratio) || ratio < 0.65 || ratio > 0.85) throw new Error("compact ratio must be between 0.65 and 0.85");
settings.agent = { ...settings.agent, compactRatio: ratio };
@@ -1,7 +1,8 @@
import type { Translator } from "./i18n";
export type ContextMaintenanceStatus = "planned" | "applied" | "noop" | "blocked" | "failed";
export type ContextMaintenanceAction = "snip" | "prune" | "summary" | "native_tool_clear" | "noop";
/** New writers only emit summary | noop. snip/prune/native are legacy restore-only. */
export type ContextMaintenanceAction = "summary" | "noop" | "snip" | "prune" | "native_tool_clear";
export interface WireContextMaintenance {
status?: ContextMaintenanceStatus;
@@ -34,9 +35,15 @@ export interface ContextMaintenanceInfo {
projectedTokens?: number;
summaryTokens?: number;
lastSavedTokens?: number;
/** @deprecated always 0; use triggerTokens */
snipTrigger?: number;
/** @deprecated alias of triggerTokens */
foldTrigger?: number;
/** @deprecated always 0; use triggerTokens */
forceTrigger?: number;
triggerTokens?: number;
/** none | restored | applied — runtime only */
checkpointState?: "none" | "restored" | "applied";
hardInputCeiling?: number;
headroom?: number;
projectionVersion?: number;
@@ -44,25 +51,19 @@ export interface ContextMaintenanceInfo {
lastReceipt?: ContextMaintenanceReceipt;
}
function maintenanceActionLabel(action: ContextMaintenanceAction | undefined, t: Translator): string | undefined {
switch (action) {
case "snip": return t("context.maintenanceActionSnip");
case "prune": return t("context.maintenanceActionPrune");
case "summary": return t("summary.detail");
case "native_tool_clear": return t("context.maintenanceActionNative");
default: return undefined;
}
}
export function formatContextMaintenanceNotice(m: WireContextMaintenance, t: Translator): string {
const parts = [t("context.maintenanceTitle")];
const action = maintenanceActionLabel(m.action, t);
if (action) parts.push(action);
switch (m.status) {
case "blocked": parts.push(t("projectTree.status.paused")); break;
case "failed": parts.push(t("task.state.failed")); break;
case "applied": parts.push(t("settings.typography.applied")); break;
case "applied":
return t("context.maintenanceAppliedSummary");
case "blocked":
return t("context.maintenanceBlockedSummary");
case "failed":
return t("context.maintenanceFailedSummary");
default:
break;
}
const parts = [t("context.maintenanceTitle")];
if (m.action === "summary") parts.push(t("summary.detail"));
if (typeof m.inputTokens === "number" && typeof m.resultTokens === "number") {
parts.push(t("context.tokensValue", {
value: `${m.inputTokens.toLocaleString()} → ${m.resultTokens.toLocaleString()}`,
@@ -73,3 +74,20 @@ export function formatContextMaintenanceNotice(m: WireContextMaintenance, t: Tra
}
return parts.join(" · ");
}
const MAX_SEEN_MAINTENANCE_OPS = 64;
/** True when this operationId has not yet been rendered as a notice. */
export function isNewMaintenanceOperation(seen: readonly string[] | undefined, operationId?: string): boolean {
const id = (operationId ?? "").trim();
if (!id) return true;
return !(seen ?? []).includes(id);
}
/** Remember an operationId for reconnect/replay dedupe (bounded FIFO). */
export function rememberMaintenanceOperation(seen: readonly string[] | undefined, operationId?: string): string[] {
const id = (operationId ?? "").trim();
if (!id) return [...(seen ?? [])];
if ((seen ?? []).includes(id)) return [...(seen ?? [])];
return [...(seen ?? []), id].slice(-MAX_SEEN_MAINTENANCE_OPS);
}
-1
View File
@@ -1882,7 +1882,6 @@ export interface AgentView {
maxSubagentConcurrency: number;
maxParallelWriters: number;
systemPrompt: string;
coldResumePrune: boolean;
reasoningLanguage: string; // "auto" | "zh" | "en"
compactRatio?: number; // Advanced global default; older backends omit it.
effectiveCompactRatio?: number; // Active local session after project overrides.
+8 -2
View File
@@ -8,7 +8,7 @@ import { asArray } from "./array";
import { addBreadcrumb } from "./breadcrumbs";
import { app, onEvent, onReady, onRuntimeRebuilt, onTabMeta, onTopicActivation } from "./bridge";
import { invalidateCache } from "./composerHistory";
import { formatContextMaintenanceNotice } from "./contextMaintenanceTypes";
import { formatContextMaintenanceNotice, isNewMaintenanceOperation, rememberMaintenanceOperation } from "./contextMaintenanceTypes";
import { formatGuardianAssessmentNotice } from "./guardianEvents";
import { invalidateSharedQuery } from "./queryCoalesce";
import { createRafBatch } from "./rafBatch";
@@ -467,6 +467,9 @@ interface State {
// title…). Drives right-panel snapshot refreshes so sub-agent activity keeps
// the session metrics live; state.usage stays executor-gated for the gauge.
usageSeq: number;
// Bounded set of context_maintenance operationIds already shown as notices
// so reconnect/replay does not insert duplicate timeline cards.
seenMaintenanceOps: string[];
// Extension UI surfaces (stage 8b2). See the ExtensionStatusEntry block
// above for the keying/lifecycle rules.
extensionStatuses: Record<string, ExtensionStatusEntry>;
@@ -524,6 +527,7 @@ export const initialState: State = {
sessionGen: 0,
contextPanelSeq: 0,
usageSeq: 0,
seenMaintenanceOps: [],
extensionStatuses: {},
extensionNotifications: [],
extensionGenerations: {},
@@ -1701,7 +1705,9 @@ function applyEvent(s: State, e: WireEvent): State {
case "context_maintenance": {
const m = e.maintenance;
if (!m || m.status === "noop") return s;
return appendNoticeToState(s, m.status === "failed" ? "warn" : "info", formatContextMaintenanceNotice(m, t), m.reason);
if (!isNewMaintenanceOperation(s.seenMaintenanceOps, m.operationId)) return s;
const next = appendNoticeToState(s, m.status === "failed" ? "warn" : "info", formatContextMaintenanceNotice(m, t), m.reason);
return { ...next, seenMaintenanceOps: rememberMaintenanceOperation(s.seenMaintenanceOps, m.operationId) };
}
case "phase":
return { ...s, seq: s.seq + 1, items: [...s.items, { kind: "phase", id: `p${s.seq}`, text: e.text ?? "" }] };
+19 -9
View File
@@ -1766,10 +1766,6 @@ export const en = {
"settings.defaultToolApprovalMode.yolo": "Yolo",
"settings.agentRuntime": "Agent runtime",
"settings.agentRuntimeHint": "Reasonix manages execution progress automatically; these preferences control resume behavior and visible reasoning.",
"settings.coldResumePrune": "Trim on cold reopen",
"settings.coldResumePruneHint": "When reopening an expired session, trim old tool results to reduce continuation cost. Turn off to keep full history.",
"settings.coldResumePrune.on": "On",
"settings.coldResumePrune.off": "Off",
"settings.reasoningLanguage": "Thinking language",
"settings.reasoningLanguageHint": "Affects visible reasoning only. Auto anchors clear Chinese prompts; referenced context is ignored.",
"settings.reasoningLanguage.auto": "Auto",
@@ -1786,10 +1782,10 @@ export const en = {
"settings.compactRatioCustomValue": "{percent}% · Custom",
"settings.compactRatioApply": "Apply",
"settings.compactRatioCustomAria": "Custom compaction threshold percentage",
"settings.compactRatioCustomHint": "Set 65%–85%. Tool output is trimmed at 60%; 90% forces compaction. Press Enter to apply or Esc to cancel.",
"settings.compactRatioPreset.70": "70% · Earlier",
"settings.compactRatioPreset.80": "80% · Balanced",
"settings.compactRatioPreset.85": "85% · Cache first",
"settings.compactRatioCustomHint": "Set 65%–85%. When the threshold is reached, Reasonix builds one structured history summary and keeps a recent verbatim tail. Press Enter to apply or Esc to cancel.",
"settings.compactRatioPreset.70": "70% · Active",
"settings.compactRatioPreset.80": "80% · Steady",
"settings.compactRatioPreset.85": "85% · Recommended",
"settings.sound": "Sound",
"settings.soundHint": "Background music and notification sounds",
"settings.soundStatus.allOff": "All off",
@@ -3091,7 +3087,21 @@ export const en = {
"context.windowUsed": "Used context",
"context.windowCompactThreshold": "Compression threshold",
"context.windowCompactHeadroom": "Before compression",
"context.maintenanceTitle": "Current context composition",
"context.maintenanceTitle": "Context checkpoint",
"context.maintenanceAppliedSummary": "Built a short context view · history summarized",
"context.maintenanceBlockedSummary": "Context summary could not form a safe short view · auto-retry stopped",
"context.maintenanceFailedSummary": "Context summary failed · auto-retry stopped",
"context.maintenanceCanonical": "Full history",
"context.maintenanceVisible": "Model-visible",
"context.maintenanceTrigger": "Trigger",
"context.maintenanceTriggerHint": "Effective compact_ratio {percent}% of the window",
"context.maintenanceRatio": "Threshold",
"context.maintenanceCheckpoint": "Checkpoint",
"context.maintenanceCheckpointNone": "None",
"context.maintenanceCheckpointApplied": "Created this session",
"context.maintenanceCheckpointRestored": "Restored short view",
"context.maintenanceVersion": "Version",
"context.maintenanceSaved": "Last savings",
"context.maintenanceActionSnip": "Tool result snip",
"context.maintenanceActionPrune": "Tool result prune",
"context.maintenanceActionNative": "Native tool clearing",
+22 -12
View File
@@ -1349,10 +1349,6 @@ export const zhTW: Record<DictKey, string> = {
"settings.defaultToolApprovalMode.yolo": "Yolo",
"settings.agentRuntime": "Agent 執行",
"settings.agentRuntimeHint": "Reasonix 會自動管理執行進度;這裡僅保留續聊行為和可見推理偏好。",
"settings.coldResumePrune": "冷啟動精簡",
"settings.coldResumePruneHint": "重開過期工作階段時精簡舊工具結果,降低續聊成本;關閉則保留完整歷史。",
"settings.coldResumePrune.on": "開",
"settings.coldResumePrune.off": "關",
"settings.reasoningLanguage": "思考語言",
"settings.reasoningLanguageHint": "只影響可見思考過程。自動在原始提問明顯為中文時錨定中文,引用內容不參與判斷。",
"settings.reasoningLanguage.auto": "自動",
@@ -1361,18 +1357,18 @@ export const zhTW: Record<DictKey, string> = {
"settings.compactRatio": "自動壓縮閾值",
"settings.compactRatioHint": "全域預設 · 通常無需修改",
"settings.compactRatioCurrent": "目前閾值:{value}",
"settings.compactRatioImpact": "調低會更早壓縮,並可能降低提示詞快取複用率。",
"settings.compactRatioImpactWithTokens": "預設模型約在 {tokens} tokens 時壓縮。調低會更早壓縮,並可能降低提示詞快取複用率。",
"settings.compactRatioProjectOverride": "目前專案覆寫了這個全域預設值;實際生效閾值為 {percent}%。",
"settings.compactRatioImpact": "調低會更早壓縮,可能降低快取複用。",
"settings.compactRatioImpactWithTokens": "約在 {tokens} tokens 時壓縮。調低可能降低快取複用。",
"settings.compactRatioProjectOverride": "專案覆寫預設;生效 {percent}%。",
"settings.compactRatioCustom": "自訂閾值",
"settings.compactRatioCustomOption": "自訂閾值…",
"settings.compactRatioCustomValue": "{percent}% · 自訂",
"settings.compactRatioApply": "套用",
"settings.compactRatioCustomAria": "自訂壓縮閾值百分比",
"settings.compactRatioCustomHint": "可設定 65%–85%。系統會在 60% 先清理舊工具輸出,並在 90% 強制壓縮;此範圍用於避免頻繁壓縮或上下文溢位。按 Enter 套用,按 Esc 取消。",
"settings.compactRatioPreset.70": "70% · 提前清理",
"settings.compactRatioPreset.80": "80% · 平衡",
"settings.compactRatioPreset.85": "85% · 快取優先",
"settings.compactRatioCustomHint": "65至85%。達閾值後摘要並保留近期原文。Enter 套用。",
"settings.compactRatioPreset.70": "70% · 主動",
"settings.compactRatioPreset.80": "80% · 穩妥",
"settings.compactRatioPreset.85": "85% · 推薦",
"settings.keySet": "已設金鑰",
"settings.noKey": "無金鑰",
"settings.noKeyRequired": "無需金鑰",
@@ -3177,7 +3173,21 @@ export const zhTW: Record<DictKey, string> = {
"context.windowUsed": "已用上下文",
"context.windowCompactThreshold": "壓縮閾值",
"context.windowCompactHeadroom": "壓縮前餘量",
"context.maintenanceTitle": "上下文構成",
"context.maintenanceTitle": "上下文短視圖",
"context.maintenanceAppliedSummary": "已生成短視圖",
"context.maintenanceBlockedSummary": "摘要未形成短視圖 · 已停重試",
"context.maintenanceFailedSummary": "摘要失敗 · 已停重試",
"context.maintenanceCanonical": "完整歷史",
"context.maintenanceVisible": "模型可見",
"context.maintenanceTrigger": "觸發點",
"context.maintenanceTriggerHint": "compact_ratio {percent}% × 窗口",
"context.maintenanceRatio": "閾值",
"context.maintenanceCheckpoint": "短視圖",
"context.maintenanceCheckpointNone": "無",
"context.maintenanceCheckpointApplied": "本會話剛生成",
"context.maintenanceCheckpointRestored": "已恢復短視圖",
"context.maintenanceVersion": "版本",
"context.maintenanceSaved": "上次節省",
"context.maintenanceActionSnip": "裁短",
"context.maintenanceActionPrune": "裁剪",
"context.maintenanceActionNative": "原生清理",
+22 -12
View File
@@ -1768,10 +1768,6 @@ export const zh: Record<DictKey, string> = {
"settings.defaultToolApprovalMode.yolo": "Yolo",
"settings.agentRuntime": "Agent 运行",
"settings.agentRuntimeHint": "Reasonix 会自动管理执行进度;这里仅保留续聊行为和可见推理偏好。",
"settings.coldResumePrune": "冷启动精简",
"settings.coldResumePruneHint": "重开过期会话时精简旧工具结果,降低续聊成本;关闭则保留完整历史。",
"settings.coldResumePrune.on": "开",
"settings.coldResumePrune.off": "关",
"settings.reasoningLanguage": "思考语言",
"settings.reasoningLanguageHint": "只影响可见思考过程。自动在原始提问明显为中文时锚定中文,引用内容不参与判断。",
"settings.reasoningLanguage.auto": "自动",
@@ -1780,18 +1776,18 @@ export const zh: Record<DictKey, string> = {
"settings.compactRatio": "自动压缩阈值",
"settings.compactRatioHint": "全局默认 · 通常无需修改",
"settings.compactRatioCurrent": "当前阈值:{value}",
"settings.compactRatioImpact": "调低会更早压缩,并可能降低提示词缓存复用率。",
"settings.compactRatioImpactWithTokens": "默认模型约在 {tokens} tokens 时压缩。调低会更早压缩,并可能降低提示词缓存复用率。",
"settings.compactRatioProjectOverride": "当前项目覆盖了这个全局默认值;实际生效阈值为 {percent}%。",
"settings.compactRatioImpact": "调低会更早压缩,可能降低缓存复用。",
"settings.compactRatioImpactWithTokens": "约在 {tokens} tokens 时压缩。调低可能降低缓存复用。",
"settings.compactRatioProjectOverride": "项目覆盖默认;生效 {percent}%。",
"settings.compactRatioCustom": "自定义阈值",
"settings.compactRatioCustomOption": "自定义阈值…",
"settings.compactRatioCustomValue": "{percent}% · 自定义",
"settings.compactRatioApply": "应用",
"settings.compactRatioCustomAria": "自定义压缩阈值百分比",
"settings.compactRatioCustomHint": "可设置 65%–85%。系统会在 60% 先清理旧工具输出,并在 90% 强制压缩;该范围用于避免频繁压缩或上下文溢出。按 Enter 应用,按 Esc 取消。",
"settings.compactRatioPreset.70": "70% · 提前清理",
"settings.compactRatioPreset.80": "80% · 平衡",
"settings.compactRatioPreset.85": "85% · 缓存优先",
"settings.compactRatioCustomHint": "65至85%。达阈值后摘要并保留近期原文。Enter 应用。",
"settings.compactRatioPreset.70": "70% · 主动",
"settings.compactRatioPreset.80": "80% · 稳妥",
"settings.compactRatioPreset.85": "85% · 推荐",
"settings.sound": "声音",
"settings.soundHint": "背景音乐与通知音效",
"settings.soundStatus.allOff": "全部关闭",
@@ -3094,7 +3090,21 @@ export const zh: Record<DictKey, string> = {
"context.windowUsed": "已用上下文",
"context.windowCompactThreshold": "压缩阈值",
"context.windowCompactHeadroom": "压缩前余量",
"context.maintenanceTitle": "上下文构成",
"context.maintenanceTitle": "上下文短视图",
"context.maintenanceAppliedSummary": "已生成短视图",
"context.maintenanceBlockedSummary": "摘要未形成短视图 · 已停重试",
"context.maintenanceFailedSummary": "摘要失败 · 已停重试",
"context.maintenanceCanonical": "完整历史",
"context.maintenanceVisible": "模型可见",
"context.maintenanceTrigger": "触发点",
"context.maintenanceTriggerHint": "compact_ratio {percent}% × 窗口",
"context.maintenanceRatio": "阈值",
"context.maintenanceCheckpoint": "短视图",
"context.maintenanceCheckpointNone": "无",
"context.maintenanceCheckpointApplied": "本会话刚生成",
"context.maintenanceCheckpointRestored": "已恢复短视图",
"context.maintenanceVersion": "版本",
"context.maintenanceSaved": "上次节省",
"context.maintenanceActionSnip": "裁短",
"context.maintenanceActionPrune": "裁剪",
"context.maintenanceActionNative": "原生清理",
+13
View File
@@ -25038,6 +25038,19 @@ body > .mermaid-diagram--fullscreen {
linear-gradient(180deg, color-mix(in srgb, var(--accent) 5%, var(--bg)) 0%, var(--bg) 100%);
}
.context-panel__maintenance {
min-width: 0;
margin-top: 10px;
display: grid;
gap: 8px;
}
.context-panel__maintenance-rows {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 8px;
}
.context-panel__capacity-card--good {
border-color: color-mix(in srgb, var(--ok) 20%, var(--border-soft));
}
@@ -43,7 +43,7 @@ export function baseSettings(displayMode: "standard" | "compact" = "standard"):
permissions: { mode: "ask", allow: [], ask: [], deny: [] },
sandbox: { bash: "enforce", network: false, workspaceRoot: "", allowWrite: [], effectiveWorkspaceRoot: "/work", effectiveWriteRoots: ["/work"], shell: "auto" },
network: { proxyMode: "auto", proxyUrl: "", noProxy: "", proxy: { type: "socks5", server: "", port: 0, username: "", password: "" } },
agent: { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, maxSubagentConcurrency: 6, maxParallelWriters: 3, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto", compactRatio: 0.8 },
agent: { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, maxSubagentConcurrency: 6, maxParallelWriters: 3, systemPrompt: "", reasoningLanguage: "auto", compactRatio: 0.85 },
bot: {
enabled: false,
model: "",
+2 -2
View File
@@ -40,8 +40,8 @@ func (a *App) defaultSettingsView() SettingsView {
Agent: AgentView{
PlannerMaxSteps: 0, MaxSubagentDepth: agent.DefaultMaxSubagentDepth,
MaxSubagentConcurrency: agent.DefaultMaxSubagentConcurrency, MaxParallelWriters: agent.DefaultMaxParallelWriters,
ColdResumePrune: true, ReasoningLanguage: "auto",
CompactRatio: defaults.Agent.CompactRatio, EffectiveCompactRatio: defaults.Agent.CompactRatio,
ReasoningLanguage: "auto",
CompactRatio: defaults.Agent.CompactRatio, EffectiveCompactRatio: defaults.Agent.CompactRatio,
},
Bot: botSettingsView(config.BotConfig{}), AutoPlan: "off", DesktopLayoutStyle: "workbench",
DesktopTheme: "auto", DesktopThemeStyle: "graphite", DesktopTerminalTheme: "auto",
-6
View File
@@ -162,7 +162,6 @@ type AgentView struct {
MaxSubagentConcurrency int `json:"maxSubagentConcurrency"`
MaxParallelWriters int `json:"maxParallelWriters"`
SystemPrompt string `json:"systemPrompt"`
ColdResumePrune bool `json:"coldResumePrune"`
ReasoningLanguage string `json:"reasoningLanguage"`
CompactRatio float64 `json:"compactRatio,omitempty"`
EffectiveCompactRatio float64 `json:"effectiveCompactRatio,omitempty"`
@@ -1003,7 +1002,6 @@ func (a *App) Settings() SettingsView {
MaxSubagentConcurrency: desktopSubagentConcurrency(cfg.Agent.MaxSubagentConcurrency),
MaxParallelWriters: desktopParallelWriters(cfg.Agent.MaxParallelWriters, cfg.Agent.MaxSubagentConcurrency),
SystemPrompt: cfg.Agent.SystemPrompt,
ColdResumePrune: cfg.ColdResumePruneEnabled(),
ReasoningLanguage: cfg.ReasoningLanguage(),
CompactRatio: cfg.Agent.CompactRatio,
EffectiveCompactRatio: cfg.Agent.CompactRatio,
@@ -3505,10 +3503,6 @@ func (a *App) SetAgentParams(temperature float64, maxSteps int, plannerMaxSteps
})
}
func (a *App) SetColdResumePrune(enabled bool) error {
return a.applyConfigChange(func(c *config.Config) error { return c.SetColdResumePrune(enabled) })
}
func (a *App) SetCompactRatio(ratio float64) error {
_, err := a.applyConfigChangeWithWarning("context compaction threshold", func(c *config.Config) error {
return c.SetCompactRatio(ratio)
+6 -5
View File
@@ -1956,8 +1956,8 @@ func TestSetCompactRatioPersistsToUserConfig(t *testing.T) {
app := NewApp()
defaultView := app.Settings()
if defaultView.Agent.CompactRatio != 0.8 || defaultView.Agent.EffectiveCompactRatio != 0.8 {
t.Fatalf("default compact ratios = %v/%v, want 0.8/0.8", defaultView.Agent.CompactRatio, defaultView.Agent.EffectiveCompactRatio)
if defaultView.Agent.CompactRatio != 0.85 || defaultView.Agent.EffectiveCompactRatio != 0.85 {
t.Fatalf("default compact ratios = %v/%v, want 0.85/0.85", defaultView.Agent.CompactRatio, defaultView.Agent.EffectiveCompactRatio)
}
if err := app.SetCompactRatio(0.7); err != nil {
t.Fatalf("SetCompactRatio: %v", err)
@@ -1972,8 +1972,9 @@ func TestSetCompactRatioPersistsToUserConfig(t *testing.T) {
if cfg.Agent.CompactRatio != 0.7 {
t.Fatalf("saved compact ratio = %v, want 0.7", cfg.Agent.CompactRatio)
}
if cfg.Agent.ToolResultSnipRatio != 0.6 || cfg.Agent.CompactForceRatio != 0.9 {
t.Fatalf("setting compact ratio changed adjacent thresholds: %+v", cfg.Agent)
// Deprecated multi-threshold fields stay cleared / unused.
if cfg.Agent.ToolResultSnipRatio != 0 || cfg.Agent.CompactForceRatio != 0 {
t.Fatalf("setting compact ratio revived deprecated thresholds: %+v", cfg.Agent)
}
if err := app.SetCompactRatio(0.9); err == nil {
@@ -1994,7 +1995,7 @@ func TestSetCompactRatioRejectsActiveWorkBeforeSaving(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
t.Fatalf("SetCompactRatio with background job error = %v, want active-work guard", err)
}
if got := config.LoadForEdit(config.UserConfigPath()).Agent.CompactRatio; got != 0.8 {
if got := config.LoadForEdit(config.UserConfigPath()).Agent.CompactRatio; got != 0.85 {
t.Fatalf("compact ratio changed after rejected update: %v", got)
}
}
+1 -1
View File
@@ -118,7 +118,7 @@ reasonix config compact-ratio 75 # set the user-global default
reasonix config compact-ratio --local 75 # override in ./reasonix.toml
```
The editable range is 65–85%, with 80% as the built-in default. Lower values
The editable range is 65–85%, with 85% as the built-in default. Lower values
compact earlier and may reduce prompt-prefix cache reuse; higher values retain
more context before compaction. Project `reasonix.toml` takes precedence over
the user config. Changes apply to new CLI sessions; an already-running session
+1 -1
View File
@@ -107,7 +107,7 @@ reasonix config compact-ratio 75 # 设置用户全局默认值
reasonix config compact-ratio --local 75 # 写入 ./reasonix.toml 项目覆盖
```
可设置范围为 65–85%,内置默认值为 80%。数值越低越早压缩,可能降低 prompt prefix
可设置范围为 65–85%,内置默认值为 85%。数值越低越早压缩,可能降低 prompt prefix
缓存复用率;数值越高则会在压缩前保留更多上下文。项目 `reasonix.toml` 的优先级高于
用户全局配置。修改会应用于新启动的 CLI 会话;已经运行的会话继续使用启动时加载的阈值。
+1 -1
View File
@@ -69,7 +69,7 @@ runtime: turns 12/20, tokens 214000, requests 37, no-progress 6 (observational),
`/goal resume` 恢复目标:只有 10/20/40 外层轮次型暂停才追加一档同类别轮数;`goal_run_budget` 与 `goal_stuck` 只开启一个新的 Run,不增加外层额度。累计 token、请求数与 `budget_extensions` 保留。旧版本因 `budget_tokens` 或 `no_progress` 暂停的 sidecar 在新版本加载时会自动改为 `running` 并立即持久化;旧 `noProgressLimit` 字段继续兼容读写但不再参与决策。
上下文压缩继续使用全局既有策略(约 50% 提示、60% 工具结果清理、80% compact、90% 强制 compact)。Goal 开启本身不额外触发 summarizer,也不改变工具 Schema 或稳定 prompt 前缀。
上下文压缩继续使用全局既有策略:仅由 `compact_ratio`(默认 85%)触发一次内容驱动摘要 checkpoint,不另设 soft/snip/force 多阈值。Goal 开启本身不额外触发 summarizer,也不改变工具 Schema 或稳定 prompt 前缀。
### 任务合约
+6 -2
View File
@@ -68,8 +68,12 @@ reasoning_language = "auto" # visible reasoning text: auto|zh|en
# max_subagent_depth = 2 # nested delegation depth; set 1 for the old single-layer boundary
# max_subagent_concurrency = 6 # session-wide sub-agent concurrency (task/fleet/skills)
# max_parallel_writers = 3 # concurrent writers with non-overlapping write_paths
tool_result_snip_ratio = 0.6 # at compact_ratio, pruning stale tool output replaces the summary if it gets under this
# context_editing = "native" # opt in only for the official Anthropic endpoint; default local
# compact_ratio = 0.85 # sole auto trigger; presets 0.70 / 0.80 / 0.85
# max_output_tokens = 0 # recommended: automatic (DeepSeek default high → ~64K; not unlimited)
# max_output_tokens = 32768 # ordinary coding / cost control
# max_output_tokens = 65536 # heavy reasoning / long tool loops
# max_output_tokens = 131072 # only after repeated finish_reason=length
# max_output_tokens never changes compact_ratio; only the final send-time clip does
[[providers]]
name = "deepseek-flash"
+6 -2
View File
@@ -62,8 +62,12 @@ reasoning_language = "auto" # 可见思考过程语言:auto|zh|en
# max_subagent_depth = 2 # 子代理嵌套委派深度;设为 1 可恢复旧的单层边界
# max_subagent_concurrency = 6 # 会话级子代理总并发(task/fleet/skills)
# max_parallel_writers = 3 # 互不重叠 write_paths 时的并行写入上限
tool_result_snip_ratio = 0.6 # 到达 compact_ratio 时,修剪旧工具输出若能降到此值以下则免去摘要
# context_editing = "native" # 仅官方 Anthropic 端点显式启用;默认 local
# compact_ratio 是唯一自动维护阈值(默认 0.85;预设 0.70/0.80/0.85)
# max_output_tokens = 0 # 推荐:自动(DeepSeek 默认 high → 约 64K;不是无限)
# max_output_tokens = 32768 # 普通编码 / 控制费用
# max_output_tokens = 65536 # 重推理、长工具链
# max_output_tokens = 131072 # 仅在反复 finish_reason=length 时再考虑
# max_output_tokens 不参与 compact_ratio;只在发送阶段裁剪本轮最长输出
[[providers]]
name = "deepseek-flash"
+56 -65
View File
@@ -248,72 +248,59 @@ prefix cache-stable:
"cache-first" with "two-model collaboration": switching models *inside one
shared conversation* would break the prefix and tank cache hits, so we don't.
### 3.6 Context management (compaction)
### 3.6 Context management (content-driven summary)
Long tasks eventually fill the model's context window. Reasonix manages this with
**low-frequency compaction** that respects the cache-first design:
Long tasks fill the model window. Reasonix keeps a **cache-first, append-only**
canonical transcript and installs a short **provider-visible checkpoint** only
when the sole automatic threshold is crossed.
- Each provider declares its `context_window` (tokens). There is one trigger:
`agent.compact_ratio` (default `0.8`). Below it the history is never
rewritten, because a rewrite invalidates the provider's prompt cache from that
point on; `agent.soft_compact_ratio` (default `0.5`) only emits a notice. At
the trigger, stale tool results before the recent tail are archived and pruned
to short placeholders first; if that alone brings the prompt below
`agent.tool_result_snip_ratio` (default `0.6`) it stands in for the summary and
no summarizer call is made, otherwise summary compaction runs. At
`agent.compact_force_ratio` (default `0.9`), the forced fold may proceed even
when the fold economics would normally skip it.
- Users can inspect or change the 65–85% automatic threshold with
`reasonix config compact-ratio [--local] [VALUE]`. The default is 80%; the
project-local value overrides the shared user config used by desktop and new
CLI sessions.
- A positive `model_overrides.<model>.context_window` replaces the provider-wide
value after model resolution. Missing or zero model overrides inherit the
provider value; provider-level `context_window = 0` disables compaction.
- `max_output_tokens` is a separate total-output budget, not a conversion from
the client reasoning byte guard. Zero selects the provider's safe default,
positive values set an explicit cap, and negative values omit optional wire
limits. `model_overrides.<model>.max_output_tokens` can specialize mixed
gateways; Anthropic still supplies a mandatory `max_tokens` fallback.
- Tool-result snip/prune never removes messages, so assistant `tool_calls` and
tool results stay paired. `KeepErrors` preserves failed tool outputs,
recognised from the recorded execution (state, exit code, verification) or,
when there is none, an `error:`/`blocked:` text prefix. A failure with a
recorded execution keeps its failure-carrying lines rather than the whole
result, since the record still identifies it as a failure after a rewrite; a
text-only failure has no such anchor and is kept whole. The recent tail is not
rewritten. Snipped results can later be upgraded to pruned placeholders;
already-pruned results are left alone.
- Automatic maintenance is planned once before a sampling request from the
current visible projection plus its append-only canonical tail. It never
rewrites the canonical transcript. A failed or non-convergent view fingerprint
is durably blocked until the transcript, model, provider policy, or projection
lineage changes, preventing the same cleanup or summary from looping.
- `agent.context_editing` defaults to `"local"`. Setting it to `"native"` opts
the official Anthropic endpoint into native tool-use clearing; DeepSeek and
other Anthropic-compatible gateways remain on local maintenance. Native tool
clearing does not replace Reasonix summary folding or canonical history.
- When summary compaction runs, the fold region (everything between the pinned
prefix and the recent tail) is split three ways: the first few **small user
turns** are hoisted verbatim ahead of the digest, messages the keep policy
protects are kept, and **everything else** — assistant/tool work, later
user turns, and any prior digest — is summarized into a single digest, using
the executor's own provider, no tools. The split is a partition: a message in
the region is either kept verbatim or reaches the summarizer, never neither.
The tail boundary is aligned backward off any tool result so the recent tail
never begins with an orphan tool message whose `tool_calls` were summarized
away.
- One fold never outgrows one summarizer call. A fold region larger than such a
call can hold — the window minus room for the digest it must return, the
summary prompt, and the caller's instructions — first gives up the bulk of its
stale tool results: head and tail lines are kept, and only in the summarizer's
copy, never in the transcript or the projection. A region still too large is summarized in
consecutive parts whose digests are merged in a final pass, capped so a single
compaction cannot cost an unbounded number of calls; whatever a part had to
drop is stated in the text the summarizer reads.
- The dropped originals are archived under the user config dir
(`reasonix/archive/<timestamp>.jsonl`; see §5 for its per-OS location), one
message per line, so the full history stays traceable.
- Each provider declares `context_window` (tokens). The only automatic trigger is
`agent.compact_ratio` (default **0.85**; presets 0.70 / 0.80 / 0.85; range
0.65–0.85).
`triggerTokens = floor(context_window × compact_ratio)`.
- **Below the trigger** history is never rewritten: no summary, no prune/snip
projection, no sidecar write, no projection-version bump, no maintenance event.
Any rewrite would invalidate the prompt cache from that point on.
- **At the trigger** Reasonix runs **one** summary transaction:
`stable prefix + one structured digest + recent verbatim tail`.
Acceptance (normal path): candidate ≤ 50% of the window, strictly smaller than
the source, and below `triggerTokens`. Candidates are **not** padded toward 50%.
Typical landings are about 10%–30% of the window.
Internal construction budgets (not user settings):
`recentTailBudget = clamp(window×10%, 32K, 96K)`, summary output max **16K**.
- Users inspect or change the threshold with
`reasonix config compact-ratio [--local] [VALUE]`. Project config overrides the
user-global value used by desktop and new CLI sessions. UI always shows the
**effective** ratio.
- `max_output_tokens` is an independent **per-turn** completion ceiling.
Recommended: `0` (**automatic**, not unlimited; DeepSeek default high → ~64K).
User presets: `32768` ordinary coding / cost control, `65536` heavy reasoning /
long tool loops, `131072` only after repeated `finish_reason=length`.
Negative omits optional wire limits when the protocol allows. Clipped only at
send time against remaining window and **never** changes `triggerTokens` or
maintenance timing. Billing follows actual completion tokens, not the ceiling.
- Giant tool results are bounded **once**, on first entry to the model:
`Content` is the stable ≤32KB visible form; `RawContent` holds the full original
only when they differ. Maintenance never rewrites old tool bodies.
`ModelMessages` strips `RawContent` so provider serialization and cache hashes
never include it.
- Automatic maintenance is planned once in `ContextManager.Prepare` from the
current projection plus the append-only canonical tail. The canonical
transcript is never rewritten. Subsequent thresholds merge
**prior digest + new history** into a single digest (no multi-span merge, no
application-layer retry). Failure records a generation-scoped
`blocked`/`failed` receipt; the same generation does not pay for another
automatic summary. Manual `compress` can retry.
- Old multi-threshold keys (`soft_compact_ratio`, `tool_result_snip_ratio`,
`compact_force_ratio`, `cold_resume_prune`, `context_editing`) are removed on
ordinary start and ignored at runtime. Native provider tool clearing is not
used; every provider uses the local summary checkpoint path.
- Keep policy (`keep` / `recent_keep`) and the active tool turn remain protected
content. Restart restores an existing checkpoint without re-summarizing or
replaying timeline cards.
- Full history remains in the session transcript. The read-only `history` tool
provides BM25 retrieval over sessions; new summary checkpoints do not create
prune archives.
- The read-only `history` tool gives the agent on-demand BM25 retrieval over
saved session JSONL files. `scope="project"` searches the current controller's
session directory; `scope="global"` also searches the user-global session
@@ -1018,7 +1005,11 @@ default = "deepseek-v4-flash" # optional; defaults to models[0]
api_key_env = "DEEPSEEK_API_KEY"
web_search = true
context_window = 1000000 # tokens; harness compacts older history near this limit (0 disables)
max_output_tokens = 32768 # total visible + reasoning + tool-call output; 0 = provider default
# max_output_tokens = 0 # recommended: automatic (DeepSeek default high → ~64K)
# max_output_tokens = 32768 # ordinary coding / cost control
# max_output_tokens = 65536 # heavy reasoning / long tool loops
# max_output_tokens = 131072 # only after repeated finish_reason=length
# max_output_tokens never changes compact_ratio
# model_overrides = { "deepseek-v4-flash" = { context_window = 1000000, max_output_tokens = 32768 } }
# A single-model entry still works for custom OpenAI-compatible endpoints.
+42 -45
View File
@@ -73,7 +73,7 @@ func New(kind string, cfg Config) (Provider, error)
- OpenAI-compatible vendor 只是 `kind = "openai"` 的不同配置实例,通过 `base_url`、`model`、`api_key_env` 区分;新增兼容模型通常只需改配置。
- 一个 provider 表示一个 vendor endpoint,可通过 `models` 暴露多个模型,并以 `default` 指定默认项。`default_model`、`--model` 和桌面端模型选择器都经 `Config.ResolveModel` 解析,可接受 provider 名、裸模型名或 `provider/model`。
- `context_window` 是 provider 级默认值;`model_overrides.<model>.context_window` 可覆盖单个模型。
- `max_output_tokens` 是独立的总输出预算,不由客户端 reasoning 字节上限换算。0 表示使用 provider 安全默认值,正数表示显式上限,负数表示在协议允许时省略;混合网关可用 `model_overrides.<model>.max_output_tokens` 覆盖单个模型。Anthropic 因协议要求仍会提供 `max_tokens` 默认值。
- `max_output_tokens` 是独立的本轮输出上限,不由客户端 reasoning 字节上限换算,也不参与 `compact_ratio`。推荐 `0`(自动:DeepSeek 默认 high 约 64K);显式 `32768` 控费/普通编码,`65536` 重推理/长工具链,`131072` 仅在反复 `finish_reason=length` 时再考虑。正数为显式上限,负数为在协议允许时省略;混合网关可用 `model_overrides.<model>.max_output_tokens` 覆盖单个模型。Anthropic 因协议要求仍会提供 `max_tokens` 默认值。
- streaming tool-call delta 在 provider 内按 index 聚合,只向上层发出完整 `ToolCall`。
### 3.2 Tool 与 registry(`internal/tool`)
@@ -140,51 +140,45 @@ type Tool interface {
- executor 在另一 session 中验证候选假设,并使用完整工具执行计划;
- 两条会话互不混合,prompt prefix 都只追加增长,避免切换模型破坏 prefix cache。
### 3.6 上下文管理
### 3.6 上下文管理(内容驱动摘要)
Reasonix 通过低频 compaction 保持 cache-first:
长任务会填满模型窗口。Reasonix 保持 **cache-first、append-only** 的 canonical
transcript,仅在唯一自动阈值被跨越时安装 provider 可见的短 **checkpoint**。
- 低于 `agent.compact_ratio` 时绝不改写历史:任何改写都会让该处之后的 prompt
缓存整体失效;`agent.soft_compact_ratio` 只发提示,不参与决策;
- 达到 `agent.compact_ratio` 后,先归档并把旧 tool result 修剪为占位符;若仅此
就降到 `agent.tool_result_snip_ratio` 以下,则以它替代摘要,不调用摘要模型,
否则再执行摘要 compaction;
- 达到 `agent.compact_force_ratio` 后,可执行强制折叠;
- `context_window = 0` 会关闭该实例的 compaction。
用户可用 `reasonix config compact-ratio [--local] [VALUE]` 查看或修改 65–85% 的自动
压缩阈值,内置默认值为 80%。项目级设置优先于桌面端与新 CLI 会话共用的用户全局配置。
tool result 的 snip/prune 不删除消息,确保 assistant `tool_calls` 与 tool result 配对。
每次自动维护只在模型请求前规划一次,输入是当前可见 projection 加上 canonical 的追加 tail;
维护流程绝不改写 canonical transcript。失败或无法收敛的视图指纹会持久进入 blocked 状态,
直到 transcript、模型、provider 策略或 projection lineage 变化,避免相同裁剪或摘要循环重试。
`agent.context_editing` 默认为 `"local"`。显式设为 `"native"` 时,只有官方 Anthropic
端点启用原生 tool-use clearing;DeepSeek 与其他 Anthropic 兼容网关仍使用本地维护。
原生工具清理不会替代 Reasonix 的摘要折叠,也不会修改 canonical history。
摘要折叠时,固定前缀与近期 tail 之间的区间被划分为三部分:开头若干条小体量用户回合原样提升到
digest 之前,keep policy 保护的消息予以保留,其余全部——assistant/tool 工作、后续用户回合、以及
已有 digest——折叠进同一条 digest。三者构成一个划分:区间内的消息要么原样保留,要么进入摘要输入,
不存在两者皆非的情况。
一次折叠不会超出一次摘要调用。折叠区间超过单次调用能容纳的量(窗口减去 digest 出参、摘要提示词与
调用方 instructions 所占空间)时,先让旧 tool result 交出主体
(只保留首尾若干行,且仅作用于送给摘要器的副本,transcript 与 projection 都不改);仍然过大的区间
被拆成连续几部分分别摘要,再由最后一次调用合并,并对份数设上限,使单次 compaction 的调用数有界;
某一部分被迫丢弃的内容,会写在摘要器读到的文本里。
因此逐字保留的是:system prompt、体量足够小的首个用户回合、折叠区间开头若干条小体量用户回合,
以及近期 tail。keep policy 保护的消息同样留存,但带有执行记录的失败只保留承载失败的若干行。
其余均为尽力而为——只在 digest 抓住它的前提下留存,
其中包括超出提升窗口的小体量用户回合,所以长期有效的约束更适合在近期回合中重述。
两个性质限制了这种损失:每次折叠都从 canonical transcript 重新生成 digest,而不是在上一条 digest
之上再摘要,因此 digest 不会形成链条,反复压缩也不会累积摘要漂移;同时 compaction 只写 projection,
canonical transcript 保留全部原文,被折叠的细节仍可通过 `history` tool 与归档
(`reasonix/archive/<timestamp>.jsonl`)取回。
- 每个 provider 声明 `context_window`(tokens)。唯一自动触发值是
`agent.compact_ratio`(默认 **0.85**;预设 0.70 / 0.80 / 0.85;范围 0.65–0.85)。
`triggerTokens = floor(context_window × compact_ratio)`。
- **阈值以下**绝不改写历史:不摘要、不安装 prune/snip projection、不写 sidecar、
不增加 projection version、不发维护事件。任何改写都会使该点之后的 prompt 缓存失效。
- **达到阈值**时运行 **一次** 摘要事务:
`稳定前缀 + 一个结构化摘要 + 最近原文尾部`。
正常验收:候选 ≤ 窗口 50%、严格小于源、且低于 `triggerTokens`;**不会**向 50% 回填。
典型落地约占窗口 10%–30%。
内部构造预算(非用户设置):
`recentTailBudget = clamp(window×10%, 32K, 96K)`,摘要输出上限 **16K**。
- 用户可用 `reasonix config compact-ratio [--local] [VALUE]` 查看或修改阈值。
项目配置优先于桌面与新 CLI 会话共用的用户全局配置。UI 始终展示**实际生效**值。
- `max_output_tokens` 是独立的**本轮**输出上限。
推荐 `0`(**自动**,不是无限;DeepSeek 默认 high → 约 64K)。
用户侧常用值:`32768` 普通编码/控费,`65536` 重推理/长工具链,`131072` 仅在反复
`finish_reason=length` 时再考虑。负数为在协议允许时省略。仅在发送阶段按剩余
窗口裁剪,**绝不**改变 `triggerTokens` 或维护时机。计费按实际 completion,不按配置上限。
- 巨型工具结果只在**第一次**进入模型前限长:`Content` 为稳定 ≤32KB 可见版;
超限时 `RawContent` 保存完整原文。后续维护不得回头改写。`ModelMessages` 会去掉
`RawContent`,provider 序列化与缓存 hash 永不包含它。
- 自动维护只在 `ContextManager.Prepare` 中规划一次,输入为当前 projection 加上
append-only canonical tail;canonical 永不改写。后续阈值合并
**上一摘要 + 新增历史** 为单条 digest(无 multi-span、无应用层重试)。
失败以 generation 为边界记录 `blocked`/`failed`,同 generation 不自动再付费;
手动 `compress` 可重试。
- 旧多阈值键(`soft_compact_ratio`、`tool_result_snip_ratio`、
`compact_force_ratio`、`cold_resume_prune`、`context_editing`)在普通启动时删除,
运行时忽略。不再使用 provider 原生 tool clearing;所有 provider 走本地 summary
checkpoint。
- `keep` / `recent_keep` 与活跃工具轮次仍受保护。重启只恢复既有 checkpoint,
不重新摘要、不重放时间线卡片。
- 完整历史保留在会话 transcript 中;`history` tool 提供 BM25 检索。新 checkpoint
不再创建 prune archive。
`history` tool 支持对 session 与归档进行 BM25 搜索;`memory` tool 用于检索自动记忆,
`remember` 与 `forget` 负责写入和归档。每个真实用户回合前,Reasonix 会用原始用户消息执行
@@ -401,7 +395,10 @@ default = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
web_search = true
context_window = 1000000
max_output_tokens = 32768 # 正文、reasoning 与工具调用共用的总输出预算;0 使用 provider 默认值
# max_output_tokens = 0 # 推荐:自动(DeepSeek 默认 high → 约 64K)
# max_output_tokens = 32768 # 普通编码 / 控制费用
# max_output_tokens = 65536 # 重推理、长工具链
# max_output_tokens = 131072 # 仅在反复 finish_reason=length 时再考虑
[tools]
enabled = []
+84 -75
View File
@@ -1,114 +1,123 @@
# Cache-Aware Context Projection 与惰性压缩
# Content-Driven Context Maintenance(Cache-Aware Checkpoint)
> 日期:2026-08-07
> 状态:当前实现说明
> 核心约束:canonical transcript 是永久事实源;缓存状态只影响成本策略,不直接触发历史改写。
> 日期:2026-08-10
> 状态:当前实现说明(取代多阈值 prune/snip/native 自动维护叙述)
> 核心约束:canonical transcript 是永久事实源;唯一自动触发是 `compact_ratio`;缓存状态只影响成本与观测,不触发历史改写。
## 一、问题与目标
长会话需要同时满足两个目标:
长会话需要同时满足:
1. 保留完整历史,以支持恢复、回退、分支和审计;
2. 在模型上下文接近上限时,构造更短且稳定的 provider-visible 请求。
2. 在上下文接近上限时,构造更短且稳定的 provider-visible 请求;
3. 不因 cache TTL / cold resume 主动改写仍可命中的前缀。
旧路径把 cache TTL 过期与 session 压缩绑定:cold resume 会同步压缩并重写历史。这既把成本信号变成了数据变更信号,也会在用户只是恢复会话时破坏原有前缀。
旧路径使用 soft / snip / force 多阈值,并在压力下自动安装 prune 投影或调用 provider native compaction。该路径把维护成本与可恢复性缠在一起,也会在 resume 时破坏缓存前缀。
当前设计将两者拆开:
当前产品路径:
```text
canonical transcript (Session.Messages,普通压缩永不改写)
canonical transcript (Session.Messages,普通维护永不改写)
|
+-- model-visible context projection
+-- model-visible context projection / checkpoint
| stable prefix + one structured summary + recent tail
|
+-- cache state (warm/cold/unknown,仅参与成本与观测)
+-- first-visible tool bound (创建时 32KB Content/RawContent)
|
+-- cache state (warm/cold/unknown,仅成本与观测)
```
## 二、持久化边界
## 二、唯一自动触发
- 配置键:`agent.compact_ratio`(默认 `0.85`)
- 入口:`Prepare` / preflight 是唯一自动维护入口;`ObserveUsage` 只更新统计
- 不再存在自动 soft compact、tool_result snip/prune 投影、native multi-threshold 路径
- 兼容:旧配置键与 sidecar 字段可读;加载时清零/忽略/迁移删除,不新建 prune 投影
## 三、Checkpoint 形态
当 projected tokens ≥ `compact_ratio × context_window` 时,生成内容驱动 checkpoint:
```text
stable system / early prefix
-> 一条结构化 summary(单次摘要请求,上限约 16K)
-> recent tail(约 10% 窗口,夹在 32K–96K)
```
验收要点:
- 默认接受天花板约 50% 窗口;强制路径可放宽但不用不同 estimator 绕过
- 摘要失败不写 mechanical marker,不安装半成品,不改 canonical
- provider-visible 始终最多一条 summary;旧 summary 可进入下一次 fold 被滚动吸收
- 首次安装会预期 cache miss;安装后前缀应保持稳定以利后续 hit
## 四、持久化边界
### Canonical transcript
- `Session.Messages` 始终保存完整 transcript。
- 普通 compaction、cold resume、tool prune/snip 不删除或替换 canonical 消息。
- rewind、fork、branch 仍以 canonical 为事实源。
- `Session.Messages` 始终保存完整 transcript
- 普通 compaction、cold resume、旧 prune/snip API no-op 均不删除或替换 canonical 消息
- rewind / fork / branch 仍以 canonical 为事实源
### Context projection sidecar
- projection 存储在 `<session>.context.json`,不改变原 session 文件格式。
- sidecar 保存 projection、covered prefix fingerprint、transcript/projection version、prompt cache key、cache 状态和压缩 telemetry。
- 删除 session 时 sidecar 纳入同一删除清单。
- 老版本不知道 sidecar 时仍可读取完整 session;新版本遇到缺失、旧 schema 或校验失败的 sidecar 会安全重建。
- 路径:`<session>.context.json`(schema v3)
- 保存 projection、covered prefix fingerprint、version、prompt cache key、cache 状态与 telemetry
- 旧 prune / native 字段可加载后忽略;校验失败则安全重建
- 删除 session 时 sidecar 一并删除
## 三、运行时行为
## 五、运行时行为
### Resume 只记录缓存状态
### Resume
恢复会话时,根据 provider TTL 和最后活动时间记录 `warm`、`cold` 或 `unknown`。Resume 路径不会调用 `Compact`、`SnapshotRewrite` 或 `PruneStaleToolResults`,也不会修改 canonical transcript。
只根据 provider TTL 与最后活动时间记录 `warm` / `cold` / `unknown`。Resume 不调用 Compact、不安装 projection、不改写 tool results。
### Preflight 惰性生成 projection
### Prepare
每次模型请求前,`contextPreflight` 根据当前 token 压力判断是否需要 projection:
每次模型请求前:
- 未达到压力阈值:继续发送 append-only canonical view;
- 达到压缩阈值:尝试生成并安装 projection;
- 达到 force 阈值但没有可折叠内容:在非 tool loop 中返回可重试的 `ErrCompactionRequired`;
- 摘要失败:不写 mechanical marker,不安装半成品,也不改写 canonical;
- tool loop 进行中:只发 notice,由后续 preflight/stuck guard 处理,避免中断工具调用配对。
1. 估计 projected tokens
2. 低于 `compact_ratio`:发送 append-only / 现有有效 projection
3. 达到阈值:至多一次 summary,CAS 安装 checkpoint
4. tool loop 中优先 notice,避免打断 tool 配对
### Provider-visible 顺序
### First-visible tool bound
projection 使用稳定顺序:
工具结果创建时把模型可见 `Content` 限制在约 32KB,完整原文进 `RawContent` / archive。这是写入时策略,不是阈值触发的历史 snip 投影。
```text
system
-> 确定性的早期 user turns
-> 一条 rolling summary
-> 必须保留的消息
-> recent tail
```
## 六、Provider 与输出预算
早期 user turn 的资格使用固定 token/char 估算和 context-window 上限,不依赖最近一次 provider usage。动态 usage 校准只用于 tail sizing 等不决定消息身份的估算,因此 projection 激活后,早期前缀不会因 canonical/projection 统计口径变化而漂移。
- 应用层 summary 是默认路径;Responses 等 native compaction 标记 unsupported 时回退 summary
- `max_output_tokens=0` 表示 auto ladder(普通 16K / reasoning 32K / high·max 64K);128K 仅显式配置
- auto ladder 与 `compact_ratio` 解耦
旧 summary 会进入下一次 fold,由新 summary 滚动吸收;provider-visible projection 始终只保留一条 summary,不会形成无限摘要链。原 summary 仍保留在 canonical transcript 中。
## 七、缓存影响
## 四、有效性与失效
| 场景 | 预期 |
| --- | --- |
| warm resume 低于阈值 | 复用 append-only 前缀,无摘要 |
| 首次跨过 compact_ratio | 前缀变为 prefix+summary+tail,一次预期 miss |
| checkpoint 安装后继续对话 | 稳定 prefix 利于 hit;generation 作用域避免重复摘要 |
| cold resume | 只记 cache 状态,不因 TTL 重写历史 |
projection 采用 fail-closed 校验:
## 八、验证与烟雾
- `CoveredPrefixHash` 对 `ModelMessages(canonical[:CoveredCount])` 的完整 provider-visible 内容生成稳定 fingerprint,覆盖图片、reasoning 元数据、Responses items、tool call ID/name/arguments/thought signature;
- `PromptCacheKey` 必须存在,并严格匹配 `workspace|session lineage|model`;
- 缺少 fingerprint、前缀被 edit/rewrite、切换模型或 lineage 时,内存 projection 立即失效;
- rewind、fork、branch、snip/prune 和显式范围摘要会使相关 projection 失效或隔离。
- 确定性:`internal/agent` compact / projection / prune no-op / restart 测试
- 离线 e2e:`benchmarks/context-maintenance-e2e` 的 `seed` + `resume`(`-offline`)
- 在线 e2e:同目录 `continue`(`DEEPSEEK_API_KEY`,`-max-usd` 费用上限,至多一次摘要)
加载时发现某模型的 sidecar key 不匹配,只丢弃当前内存状态,不删除磁盘文件,避免破坏其它模型仍可使用的状态。
## 九、有意保留的兼容层
## 五、Provider compaction 与失败策略
不算功能缺口,也不声称“代码里已无旧概念”:
Provider 接口已定义:
1. 配置结构体仍可读旧 soft/snip/force 键,加载时清零并迁移删除
2. sidecar 仍可解码旧 prune/native 字段后忽略
3. `PruneStaleToolResults` / `SnipStaleToolResults` 保留为 no-op API,避免旧调用点 panic
4. snip 几何 helpers 仍服务 first-visible 与 summary fold 输入,不再用于自动投影安装
- `NativeCompactor`;
- `CompactionRequest` / `CompactionResult`;
- `CompactionCapabilities`;
- `ErrCompactionUnsupported`。
## 十、明确未做
当前 Responses vendor 明确返回 unsupported,并回退到 Reasonix 的摘要路径。摘要首次失败后的重试会聚合两次 attempt 的 usage 与 request count,供成本和 telemetry 使用。
Anthropic、DeepSeek 等原生 compaction endpoint 尚未接入;能力接口不代表这些 endpoint 已经可用。
## 六、缓存影响
- 正向影响:cold resume 不再为了 TTL 状态改写历史,缓存仍 warm 时可以继续复用原 append-only 前缀。
- 预期 miss:首次在高压力下激活 projection 时,请求前缀会从 canonical 切换为 `summary + tail`,因此会发生一次预期的 cache miss。
- 稳定性:激活后,确定性的早期轮次、单条 rolling summary、稳定 cache key 和 fail-closed fingerprint 降低后续无意义的前缀漂移。
- 不确定点:token 估算可能使 preflight 比旧路径更早或更晚触发摘要,需要通过 telemetry 持续观察 break-even 成本。
## 七、明确未实现的后续
以下能力不属于当前阶段,不能按已落地行为依赖:
1. Anthropic/DeepSeek 原生 compaction endpoint;
2. compaction 后调用 `SaveKnowledge` 或写入 EventChain;
3. 依靠 EventChain 完成跨 session 的 L2 自动恢复;
4. feature flag 观测期、旧兼容路径的最终清理;
5. 完整 break-even 成本 dashboard 聚合。
这些后续必须分别设计失败原子性、持久化兼容、缓存影响和 provider 能力探测,不能重新把 cache TTL 与 canonical transcript 改写绑定。
1. 重新启用多阈值自动 prune/snip 投影
2. 把 cache TTL 重新绑定到 transcript 改写
3. 跨 session 的 EventChain L2 自动恢复作为维护主路径
4. 完整 break-even 成本 dashboard
+7 -8
View File
@@ -28,16 +28,15 @@ func TestEvidenceAblationStandsDownTheReadinessGate(t *testing.T) {
}
func TestCompactionAblationCollapsesTheCachePreservingDeferral(t *testing.T) {
full := &Agent{contextWindow: 100_000, softCompactRatio: 0.5, toolResultSnipRatio: 0.7, compactRatio: 0.8}
soft, snip, high := full.compactThresholds()
if soft != 50_000 || snip != 70_000 || high != 80_000 {
t.Fatalf("control thresholds = %d/%d/%d, want 50000/70000/80000", soft, snip, high)
full := &Agent{contextWindow: 100_000, compactRatio: 0.8}
if got := full.compactTrigger(); got != 80_000 {
t.Fatalf("control trigger = %d, want 80000", got)
}
off := &Agent{contextWindow: 100_000, softCompactRatio: 0.5, toolResultSnipRatio: 0.7, compactRatio: 0.8,
off := &Agent{contextWindow: 100_000, compactRatio: 0.8,
ablation: ablation.New(ablation.Compaction)}
soft, snip, high = off.compactThresholds()
if soft != 50_000 || snip != soft || high != soft {
t.Fatalf("ablated thresholds = %d/%d/%d, want all three at 50000", soft, snip, high)
// Compaction ablation forces the sole trigger down to 50% so folds fire earlier.
if got := off.compactTrigger(); got != 50_000 {
t.Fatalf("ablated trigger = %d, want 50000", got)
}
}
+78 -48
View File
@@ -538,19 +538,10 @@ type Agent struct {
subagentDepth int
maxSubagentDepth int
// Context management: when a turn's prompt nears contextWindow, the older
// middle of the session is summarized away, keeping a token-bounded recent
// tail verbatim (recentKeep is the message floor) and archiving the originals
// under archiveDir. compactStuck latches when compaction can't get the prompt
// under the window (consecutiveCompacts crosses the limit), so auto-compaction
// pauses instead of looping. softCompactNoticed gates the one-shot soft-ratio
// notice so it fires once per approach, not every turn.
// Context management keeps the canonical transcript immutable and installs
// at most one provider-visible checkpoint each time compactRatio is crossed.
contextWindow int
softCompactRatio float64
toolResultSnipRatio float64
compactRatio float64
compactForceRatio float64
softCompactNoticed bool
recentKeep int
archiveDir string
keepPolicy KeepPolicy
@@ -558,7 +549,8 @@ type Agent struct {
consecutiveCompacts int
sessionPath string // bound transcript path for projection sidecars
workspaceID string // stable prompt-cache lineage component
cacheState string // warm/cold/unknown; never provider-visible
cacheState string // legacy resume telemetry; never provider-visible
checkpointState string // none|restored|applied; runtime-only
compactionState CompactionState
// compactionMu guards projection snapshots/install and the in-memory sidecar
// generation. Network summarization never runs while this lock is held.
@@ -570,7 +562,6 @@ type Agent struct {
// from paying for two summaries during one active tool loop.
lastCompactionTurn atomic.Int64
strictAlternatingRoles bool // coalesce adjacent user turns on provider request copies
contextEditingState
// activeTurnCreatedAt identifies the real/synthetic user message that began
// the currently running turn. Compaction may rewrite older history while a
// tool loop is active, but it must keep this message and everything after it
@@ -1133,10 +1124,12 @@ type Options struct {
// Context management. ContextWindow <= 0 disables compaction. Ratios and
// RecentKeep fall back to defaults when unset.
ContextWindow int
ContextWindow int
CompactRatio float64
// Deprecated compatibility inputs. New agents ignore these fields; automatic
// maintenance is controlled only by CompactRatio.
SoftCompactRatio float64
ToolResultSnipRatio float64
CompactRatio float64
CompactForceRatio float64
RecentKeep int
ArchiveDir string
@@ -1144,7 +1137,7 @@ type Options struct {
SessionPath string // projection sidecar path; empty = memory only
WorkspaceID string // prompt-cache lineage component
StrictAlternatingRoles bool // merge adjacent user turns for strict providers at request time
ContextEditing string // local (default) or native (explicit opt-in)
ContextEditing string // deprecated; native provider editing was removed
// Hooks fires PreToolUse / PostToolUse shell hooks around tool calls. nil
// disables hook firing.
@@ -1250,21 +1243,9 @@ type Options struct {
// provider errors (compaction keeps the context bounded). A nil sink is replaced
// with event.Discard so the agent can always emit unconditionally.
func New(prov provider.Provider, tools *tool.Registry, session *Session, opts Options, sink event.Sink) *Agent {
if opts.SoftCompactRatio <= 0 {
opts.SoftCompactRatio = defaultSoftCompactRatio
}
if opts.ToolResultSnipRatio <= 0 {
opts.ToolResultSnipRatio = defaultToolResultSnipRatio
}
if opts.CompactRatio <= 0 {
opts.CompactRatio = defaultCompactRatio
}
if opts.ToolResultSnipRatio >= opts.CompactRatio {
opts.ToolResultSnipRatio = opts.CompactRatio
}
if opts.CompactForceRatio <= 0 {
opts.CompactForceRatio = defaultCompactForceRatio
}
if opts.RecentKeep <= 0 {
opts.RecentKeep = minRecentKeep
}
@@ -1345,10 +1326,7 @@ func New(prov provider.Provider, tools *tool.Registry, session *Session, opts Op
capabilityLedger: opts.CapabilityLedger,
capabilityAudit: opts.CapabilityAudit,
contextWindow: opts.ContextWindow,
softCompactRatio: opts.SoftCompactRatio,
toolResultSnipRatio: opts.ToolResultSnipRatio,
compactRatio: opts.CompactRatio,
compactForceRatio: opts.CompactForceRatio,
recentKeep: opts.RecentKeep,
archiveDir: opts.ArchiveDir,
keepPolicy: opts.KeepPolicy,
@@ -1356,7 +1334,6 @@ func New(prov provider.Provider, tools *tool.Registry, session *Session, opts Op
workspaceID: strings.TrimSpace(opts.WorkspaceID),
cacheState: CacheStateUnknown,
strictAlternatingRoles: opts.StrictAlternatingRoles,
contextEditingState: newContextEditingState(opts.ContextEditing, prov),
subagentDepth: subagentDepth,
maxSubagentDepth: maxSubagentDepth,
mutationObserver: opts.MutationObserver,
@@ -2664,16 +2641,12 @@ func batchStormSignature(calls []provider.ToolCall, outcomes []toolOutcome) (str
return sb.String(), true
}
// toolOutcome is one tool call's result, split into the model-facing output and
// the display-facing notice bits. errMsg is the short failure reason (empty on
// success) — a refused call, an unknown tool, or an execution error — so a sink
// renders the result as failed ("⊘ name <errMsg>" / a red card) instead of OK;
// blocked narrows that to a refusal (plan mode / permission). truncMsg is set
// (without the "· " prefix) when the output was head+tailed. images carries
// data URLs from a tool.ImageTool result; they ride outside output so text
// truncation can never corrupt an image payload.
// toolOutcome is one tool call's result. output is the first-visible bounded
// form the model sees; rawOutput is the full original when truncation applied
// (empty when identical so we avoid double storage). images ride outside text.
type toolOutcome struct {
output string
rawOutput string // full original when different from output
images []string
blocked bool
errMsg string
@@ -3227,20 +3200,77 @@ func firstLine(s string) string {
return s
}
// truncateToolOutput head+tails s when it exceeds maxToolOutputBytes, slicing
// on rune boundaries so we never split a multibyte glyph. Returns the possibly
// trimmed body plus a one-line user-facing notice when truncation happened
// (empty when it didn't, without the "· " display prefix).
// truncateToolOutput is the first-visible hard cap for a tool result. Under-cap
// bodies are returned byte-identical. Over-cap bodies keep a tool-aware head and
// tail under maxToolOutputBytes; the full original is stored separately as
// RawContent by the session writer. The bounded form is stable for the message
// lifetime and is never re-truncated by later maintenance.
func truncateToolOutput(s string) (string, string) {
return truncateToolOutputFor(s, "", "")
}
// truncateToolOutputFor is the tool-aware first-visible limiter. toolName and
// toolCallID populate the truncation marker so the model can re-fetch.
func truncateToolOutputFor(s, toolName, toolCallID string) (string, string) {
if len(s) <= maxToolOutputBytes {
return s, ""
}
keep := maxToolOutputBytes / 2
head := snapToRuneBoundary(s, 0, keep)
tail := snapToRuneBoundary(s, len(s)-keep, len(s))
strategy := snipStrategy{head: 40, tail: 40, headChars: 8000, tailChars: 8000}
switch {
case toolName == "bash" || toolName == "shell" || strings.Contains(toolName, "bash"):
strategy = snipStrategy{head: 40, tail: 40, headChars: 8000, tailChars: 8000}
case toolName == "read_file" || toolName == "web_fetch" || strings.Contains(toolName, "read"):
strategy = snipStrategy{head: 120, tail: 12, headChars: 12000, tailChars: 2000}
case toolName == "grep" || toolName == "glob" || toolName == "ls" || toolName == "list_dir":
strategy = snipStrategy{head: 80, tail: 8, headChars: 10000, tailChars: 1000}
}
headKeep := strategy.headChars
tailKeep := strategy.tailChars
if headKeep+tailKeep > maxToolOutputBytes-512 {
headKeep = maxToolOutputBytes * 2 / 3
tailKeep = maxToolOutputBytes - headKeep - 512
}
if headKeep < 1024 {
headKeep = maxToolOutputBytes / 2
tailKeep = maxToolOutputBytes / 2
}
// Prefer more tail when the body looks like a failure.
lower := strings.ToLower(s)
if strings.Contains(lower, "error:") || strings.Contains(lower, "panic:") || strings.Contains(lower, "fatal:") {
tailKeep = max(tailKeep, maxToolOutputBytes/3)
if headKeep+tailKeep > maxToolOutputBytes-512 {
headKeep = maxToolOutputBytes - 512 - tailKeep
}
}
head := snapToRuneBoundary(s, 0, headKeep)
tail := snapToRuneBoundary(s, len(s)-tailKeep, len(s))
omitted := len(s) - len(head) - len(tail)
namePart := toolName
if namePart == "" {
namePart = "tool"
}
idPart := toolCallID
if idPart == "" {
idPart = "-"
}
notice := fmt.Sprintf("tool output truncated: %d of %d bytes elided", omitted, len(s))
body := head + fmt.Sprintf("\n\n…[truncated %d of %d bytes — rerun with narrower args to see the middle]…\n\n", omitted, len(s)) + tail
marker := fmt.Sprintf(
"\n\n…[truncated tool=%s call_id=%s original_bytes=%d kept_bytes=%d — full original retained in canonical transcript; re-read or retry with narrower args]…\n\n",
namePart, idPart, len(s), len(head)+len(tail),
)
body := head + marker + tail
if len(body) > maxToolOutputBytes {
overflow := len(body) - maxToolOutputBytes
trimHead := overflow / 2
trimTail := overflow - trimHead
if trimHead < len(head) {
head = snapToRuneBoundary(head, 0, len(head)-trimHead)
}
if trimTail < len(tail) {
tail = snapToRuneBoundary(tail, trimTail, len(tail))
}
body = head + marker + tail
}
return body, notice
}
+113 -181
View File
@@ -1,15 +1,10 @@
package agent
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
@@ -22,29 +17,29 @@ import (
)
// Compaction is a low-frequency cache-reset point: the prompt grows append-only
// (high cache hits) until a turn nears compactRatio of the window, then it is
// compacted down to a tail budget. The budget is a fixed token count, not a
// fraction of the window, so a huge window still compacts rarely while a small
// one still lands below the trigger (which is what stops the re-compaction loop).
// until compactRatio of the window is crossed, then one content-driven summary
// checkpoint is installed (stable prefix + one structured digest + recent tail).
// 50% is only the normal acceptance ceiling — candidates are never padded up to it.
const (
defaultSoftCompactRatio = 0.5 // report growing context here, but keep the cache-stable prefix intact
defaultToolResultSnipRatio = 0.6 // rewrite stale tool results cheaply before summary compaction
defaultCompactRatio = 0.8 // trigger: prompt at this fraction of the window compacts
defaultCompactForceRatio = 0.9 // force compaction at this high-water mark even for low-value folds
defaultCompactTarget = 0.5 // safety cap: the kept tail never exceeds this fraction of the window
defaultTailTokens = 16384 // verbatim recent-tail budget, in tokens
minRecentKeep = 2 // never keep fewer recent messages than this
minCompactMessages = 2 // skip compaction below this many compactable messages
fallbackTokPerChar = 0.25 // ~4 chars/token, used before any usage is available to calibrate
maxPinnedFirstUserTokens = 1500 // ceiling on pinning the first user turn verbatim; larger first turns (pasted content) stay foldable
pinnedFirstUserWindowFrac = 0.15 // and never pin a first turn worth more than this fraction of the window
maxCarriedDigestTokens = 12000 // ceiling on digests carried verbatim across folds before one consolidating fold merges them
maxEarlyUserTurns = 3 // small user turns hoisted verbatim ahead of the digest; position-fixed (the first N of the fold region, never "the latest N") so the projection prefix stays byte-stable
protocolReserveTokens = 256 // provider framing and control fields not represented by message estimates
maxOutputReserveRatio = 0.25 // ceiling on the window share an output budget may reserve before the thresholds collapse
defaultCompactRatio = 0.85 // sole automatic maintenance trigger (new configs)
checkpointCeilingRatio = 0.50 // normal auto-checkpoint acceptance ceiling
recentTailBudgetRatio = 0.10 // recent verbatim tail as a fraction of the window
minRecentTailTokens = 32 * 1024
maxRecentTailTokens = 96 * 1024
summaryOutputMaxTokens = 16 * 1024 // max digest output; further clipped by remaining candidate space
exceptionalMinSavingsRatio = 0.25 // when fixed prefix alone exceeds 50%, require at least this savings
minRecentKeep = 2 // never keep fewer recent messages than this
minCompactMessages = 2 // skip compaction below this many compactable messages
fallbackTokPerChar = 0.25 // ~4 chars/token, used before any usage is available to calibrate
maxPinnedFirstUserTokens = 1500 // ceiling on pinning the first user turn verbatim
pinnedFirstUserWindowFrac = 0.15 // and never pin a first turn worth more than this fraction of the window
protocolReserveTokens = 256 // provider framing and control fields not represented by message estimates
)
var errSummaryOutputTruncated = errors.New("summarizer output truncated")
var (
errSummaryOutputTruncated = errors.New("summarizer output truncated")
errCheckpointRejected = errors.New("checkpoint candidate rejected")
)
// summaryTag wraps the compaction summary so the model can distinguish it from
// live user input and later strip or skip it when reasoning about the current turn.
@@ -57,12 +52,8 @@ const (
// failure (then a mechanical fold) instead of hanging compaction indefinitely.
const summaryTimeout = 90 * time.Second
// summarySystemPrompt steers the executor to distill older history into a
// structured briefing it can keep relying on after the originals are dropped.
// The section layout mirrors what a coding agent actually needs to resume work
// mid-task: the goal verbatim, the concrete state of the code, and an explicit
// next step — so the post-compaction turn doesn't lose the thread or re-derive
// decisions already made.
// summarySystemPrompt asks for a structured resume briefing (facts, goal,
// decisions, files, commands, errors, next step) under fixed headings.
const summarySystemPrompt = `You are compacting the earlier part of a coding agent's conversation to save context.
The agent keeps your summary alongside the user's own turns (kept verbatim) and the recent tail; your job is to fold the assistant/tool work into a briefing it can resume from.
Write under these exact headings, omitting a heading only if it has no content:
@@ -90,74 +81,72 @@ What is still in progress or unstarted, and the single most concrete next action
Rules: be terse — bullet points and fragments, not prose. Preserve identifiers, paths, and numbers exactly. Do NOT invent anything not present in the messages; if something is unknown, leave it out rather than guessing.`
// compactThresholds returns the prompt-token boundaries ContextManager switches
// on. Only high triggers maintenance: soft is a notice, and snip is how far a
// tool-result pass must bring the prompt down to stand in for the summary. The
// compaction ablation arm folds at soft instead, which is what a harness with
// no prompt-cache strategy does.
func (a *Agent) compactThresholds() (soft, snip, high int) {
hard := a.hardInputCeiling()
high = int(float64(a.contextWindow) * a.compactRatio)
// Ratios >= 1 are the documented test/config escape hatch for disabling
// automatic compaction; do not turn that into an immediate hard-trigger by
// clamping it down to the output-budget ceiling.
if a.compactRatio < 1 {
high = minInt(high, hard)
// compactTrigger is the sole automatic context-maintenance boundary. Output
// budgets are intentionally absent: they are clipped against the final request
// at send time and must never make compaction happen earlier than the user's
// configured compact_ratio.
func (a *Agent) compactTrigger() int {
if a == nil || a.contextWindow <= 0 {
return 0
}
ratio := a.compactRatio
if ratio <= 0 {
ratio = defaultCompactRatio
}
snip = minInt(int(float64(a.contextWindow)*a.toolResultSnipRatio), high)
soft = minInt(int(float64(a.contextWindow)*a.softCompactRatio), high)
if a.ablation.Off(ablation.Compaction) {
high, snip = soft, soft
ratio = 0.5
}
return soft, snip, high
return max(1, int(float64(a.contextWindow)*ratio))
}
// hardInputCeiling leaves room for the configured output and protocol framing.
// A zero output budget means the provider's input window is the only declared
// limit, preserving the historical behavior for providers that manage output
// tokens themselves.
// hardInputCeiling is a physical input-safety boundary, not another user
// compaction threshold. Reply budgets are resolved independently at send time.
func (a *Agent) hardInputCeiling() int {
if a == nil || a.contextWindow <= 0 {
return 0
}
hard := a.contextWindow
if outputBudget := a.reservedOutputTokens(); outputBudget > 0 {
hard -= outputBudget + protocolReserveTokens
}
return max(1, hard)
return max(1, a.contextWindow-protocolReserveTokens)
}
// reservedOutputTokens is the window share the thresholds hold back for the
// reply. An output budget can exceed the configured window entirely (DeepSeek
// defaults to 128K), which would drive every threshold to the one-token floor.
// effectiveOutputBudget clips the actual reply at send time, so reserving more
// than a quarter of the window buys nothing here.
func (a *Agent) reservedOutputTokens() int {
budget := a.maxOutputTokens
if budget <= 0 && sharesContextWindow(a.prov) {
budget = a.outputBudget
// recentTailBudget is the content-construction budget for the recent verbatim
// tail. Production windows use clamp(window×10%, 32K, 96K). Smaller synthetic
// windows (tests / constrained providers) drop the 32K floor so the tail cannot
// alone exceed the window.
func (a *Agent) recentTailBudget() int {
if a == nil || a.contextWindow <= 0 {
return minRecentTailTokens
}
if budget <= 0 {
return 0
n := int(float64(a.contextWindow) * recentTailBudgetRatio)
if a.contextWindow >= minRecentTailTokens*2 {
if n < minRecentTailTokens {
n = minRecentTailTokens
}
}
return minInt(budget, int(float64(a.contextWindow)*maxOutputReserveRatio))
if n > maxRecentTailTokens {
n = maxRecentTailTokens
}
if max := a.contextWindow / 2; max > 0 && n > max {
n = max
}
return max(1, n)
}
func (a *Agent) forceCompactThreshold(high int) int {
hard := a.hardInputCeiling()
force := minInt(int(float64(a.contextWindow)*a.compactForceRatio), hard)
return max(high, force)
}
// minimumMaintenanceSavingsTokens avoids cache-breaking projections that save
// too little to matter. Small synthetic windows can never reach the production
// floor; those are allowed through when they are already at the hard ceiling.
func (a *Agent) minimumMaintenanceSavingsTokens() int {
// checkpointCeiling is the normal auto-checkpoint acceptance upper bound
// (50% of the window). Candidates below this are accepted without padding.
func (a *Agent) checkpointCeiling() int {
if a == nil || a.contextWindow <= 0 {
return 0
}
n := int(float64(a.contextWindow) * 0.02)
return minInt(max(n, 4096), 20000)
return max(1, int(float64(a.contextWindow)*checkpointCeilingRatio))
}
// exceptionalMinimumSavings is required only when the fixed prefix alone already
// exceeds the 50% ceiling; otherwise ordinary candidates simply stay under 50%.
func (a *Agent) exceptionalMinimumSavings() int {
if a == nil || a.contextWindow <= 0 {
return 0
}
return max(1, int(float64(a.contextWindow)*exceptionalMinSavingsRatio))
}
// foldEconomics estimates whether compacting the given region saves enough
@@ -303,12 +292,8 @@ func (a *Agent) pinnedPrefixLen(msgs []provider.Message) int {
return i
}
// fixedPinnableUserTurn reports whether a user turn is small enough to keep
// verbatim in a position-stable prefix. Identity decisions must not use the
// latest provider usage: after projection activates, that usage describes the
// projection while the canonical transcript remains larger, which would make
// the same turn drift in or out across compactions. Dynamic token calibration is
// reserved for non-identity estimates such as tail sizing.
// fixedPinnableUserTurn uses a fixed estimate only: provider usage after
// projection would make the same turn drift across checkpoints.
func (a *Agent) fixedPinnableUserTurn(m provider.Message) bool {
budget := maxPinnedFirstUserTokens
if a.contextWindow > 0 {
@@ -437,44 +422,36 @@ func toolCallIDs(m provider.Message) map[string]bool {
return ids
}
// planCompaction locates the region to summarize. head is the count of leading
// messages preserved verbatim (see pinnedPrefixLen); start is where the preserved
// recent tail begins, so msgs[head:start] is compacted. The tail is bounded by a
// token budget (not a message count), so a few large tool outputs can't keep it
// above the trigger and re-fire compaction every turn. ok is false when there is
// too little to compact.
func (a *Agent) planCompaction(msgs []provider.Message, min int) (head, start int, ok bool) {
// planCompaction returns [head:start] to fold; the tail is recentTailBudget
// unless force halves it so CompactNow still reduces mid-size sessions.
func (a *Agent) planCompaction(msgs []provider.Message, min int, force bool) (head, start int, ok bool) {
head = a.pinnedPrefixLen(msgs)
if a.contextWindow > 0 {
budget := defaultTailTokens
if maxByWin := int(float64(a.contextWindow) * defaultCompactTarget); maxByWin < budget {
budget = maxByWin
budget := a.recentTailBudget()
if force {
if half := estimateMessagesTokens(provider.ModelMessages(msgs)) / 2; half > 0 && half < budget {
budget = half
}
}
start = tailStart(msgs, head, budget, a.tokPerChar(), a.tailFloor())
// The calibrated character ratio is useful for cheap planning but can
// under-estimate CJK/code-heavy turns and leave the projection above its
// target. Re-measure the actual message estimate and move the boundary
// forward until the retained tail itself fits the budget.
for !a.strictAlternatingRoles && start < len(msgs)-a.tailFloor() && estimateMessagesTokens(provider.ModelMessages(msgs[start:])) > budget {
// Remeasure when force or non-strict roles; strict-alternating otherwise
// keeps a cheap tokPerChar overestimate of the tail under force.
floor := max(head, len(msgs)-a.tailFloor())
remeasure := force || !a.strictAlternatingRoles
for remeasure && start < floor && estimateMessagesTokens(provider.ModelMessages(msgs[start:])) > budget {
start++
// Never leave a tool result at the beginning of the retained tail;
// skip the remainder of that tool-result group so its assistant call
// is folded together with the result.
for start < len(msgs)-a.tailFloor() && msgs[start].Role == provider.RoleTool {
for start < floor && start < len(msgs) && msgs[start].Role == provider.RoleTool {
start++
}
}
} else {
// No window to budget against (manual /compact on an unconfigured
// provider): keep a fixed count of recent messages, aligned off any tool.
// No window: keep a fixed recent count, aligned off tool results.
start = len(msgs) - a.tailFloor()
for start > head && msgs[start].Role == provider.RoleTool {
for start > head && start < len(msgs) && msgs[start].Role == provider.RoleTool {
start--
}
}
if start < head {
start = head
}
start = max(start, head)
if start-head < min {
return head, start, false
}
@@ -567,12 +544,16 @@ func (a *Agent) summarize(ctx context.Context, region []provider.Message, instru
}
}()
defer trackPublishedHostStream(ctx, cancel)()
maxOut := summaryOutputMaxTokens
if a.maxOutputTokens > 0 && a.maxOutputTokens < maxOut {
maxOut = a.maxOutputTokens
}
req := provider.Request{
Messages: []provider.Message{
{Role: provider.RoleSystem, Content: sys},
{Role: provider.RoleUser, Content: renderTranscript(region)},
},
MaxTokens: a.maxOutputTokens,
MaxTokens: maxOut,
Temperature: provider.OptionalTemperature(a.temperature),
}
if budget, clipped, budgetErr := a.effectiveOutputBudget(req); budgetErr != nil {
@@ -580,6 +561,15 @@ func (a *Agent) summarize(ctx context.Context, region []provider.Message, instru
} else if clipped {
req.MaxTokens = budget
}
if req.MaxTokens > summaryOutputMaxTokens {
req.MaxTokens = summaryOutputMaxTokens
}
if req.MaxTokens < 256 {
return "", usage, fmt.Errorf("summary output budget too small (%d tokens)", req.MaxTokens)
}
if a.prov == nil {
return "", usage, fmt.Errorf("summary unavailable")
}
ch, err := a.prov.Stream(ctx, req)
if err != nil {
return "", usage, err
@@ -614,15 +604,11 @@ func (a *Agent) summarize(ctx context.Context, region []provider.Message, instru
}
}
// summarizeWithRetry retries one non-timeout failure; timeout/cancel do not retry.
// Token and request counts from both attempts are merged into the returned Usage.
func (a *Agent) summarizeWithRetry(ctx context.Context, fold []provider.Message, instructions string) (string, *provider.Usage, error) {
summary, usage, err := a.summarize(ctx, fold, instructions)
if err == nil || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || errors.Is(err, errSummaryOutputTruncated) {
return summary, usage, err
}
summary2, usage2, err2 := a.summarize(ctx, fold, instructions)
return summary2, mergeStreamUsage(usage, usage2), err2
// summarizeOnce performs exactly one application-layer summary request.
// Timeouts, empty results, stream errors, and output truncation all fail once
// with no second attempt.
func (a *Agent) summarizeOnce(ctx context.Context, fold []provider.Message, instructions string) (string, *provider.Usage, error) {
return a.summarize(ctx, fold, instructions)
}
// renderTranscript flattens messages into a readable transcript for summarization.
@@ -644,7 +630,11 @@ func renderTranscript(msgs []provider.Message) string {
}
b.WriteString("\n")
case provider.RoleTool:
fmt.Fprintf(&b, "[tool %s result]\n%s\n\n", m.Name, m.Content)
body := m.Content
if m.RawContent != "" {
body = m.RawContent
}
fmt.Fprintf(&b, "[tool %s result]\n%s\n\n", m.Name, body)
case provider.RoleSystem:
fmt.Fprintf(&b, "[system]\n%s\n\n", m.Content)
}
@@ -672,61 +662,3 @@ func summarizeToolArgs(args string) string {
sort.Strings(keys)
return fmt.Sprintf("{%s} (%d keys)", strings.Join(keys, ", "), len(parsed))
}
// archiveMessages writes the dropped originals to a content-addressed .jsonl
// (one message per line) under dir. Retrying the same failed/stale compaction
// therefore reuses one archive instead of creating timestamp duplicates.
func archiveMessages(dir string, msgs []provider.Message) (string, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
var b bytes.Buffer
enc := json.NewEncoder(&b)
for _, m := range msgs {
if err := enc.Encode(m); err != nil {
return "", err
}
}
sum := sha256.Sum256(b.Bytes())
path := filepath.Join(dir, "context-"+hex.EncodeToString(sum[:16])+".jsonl")
if _, err := os.Stat(path); err == nil {
return path, nil
} else if !os.IsNotExist(err) {
return "", err
}
f, err := os.CreateTemp(dir, ".context-archive-*.tmp")
if err != nil {
return "", err
}
tmpName := f.Name()
cleanup := true
defer func() {
if cleanup {
_ = os.Remove(tmpName)
}
}()
if err := f.Chmod(0o600); err != nil {
_ = f.Close()
return "", err
}
if _, err := f.Write(b.Bytes()); err != nil {
_ = f.Close()
return "", err
}
if err := f.Sync(); err != nil {
_ = f.Close()
return "", err
}
if err := f.Close(); err != nil {
return "", err
}
if err := os.Link(tmpName, path); err != nil {
if os.IsExist(err) {
return path, nil
}
return "", err
}
_ = os.Remove(tmpName)
cleanup = false
return path, nil
}
+25 -32
View File
@@ -17,44 +17,43 @@ type summaryProjectionCommit struct {
sourceTokens, projectionTokens int
}
// commitSummaryProjection performs the final CAS, content-addressed archive,
// and sidecar switch after all network and interceptor work has completed.
// commitSummaryProjection CAS-installs a checkpoint under compactionMu:
// transcript version/hash, projection version, and generation must still match.
// The maintenance event is emitted only after the lock is released so a sink
// that re-enters ContextMaintenanceSnapshot cannot deadlock.
func (a *Agent) commitSummaryProjection(commit summaryProjectionCommit) (CompactionState, error) {
current, currentVersion := a.session.snapshotMessagesVersion()
state := a.summaryProjectionState(commit)
a.compactionMu.Lock()
currentProjectionVersion := a.compactionState.Projection.ProjectionVersion
currentGeneration := a.compactionState.Generation
a.compactionMu.Unlock()
if currentVersion != commit.transcriptVersion || len(current) != len(commit.canonical) ||
current, currentVersion := a.session.snapshotMessagesVersion()
if currentVersion != commit.transcriptVersion ||
len(current) != len(commit.canonical) ||
coveredPrefixHash(current, len(current)) != coveredPrefixHash(commit.canonical, len(commit.canonical)) ||
currentProjectionVersion != commit.projectionVersion || currentGeneration != commit.generation {
a.compactionState.Projection.ProjectionVersion != commit.projectionVersion ||
a.compactionState.Generation != commit.generation {
a.compactionMu.Unlock()
return CompactionState{}, errCompressStaleContext
}
archive := ""
if a.archiveDir != "" {
path, err := archiveMessages(a.archiveDir, commit.fold)
if err != nil {
return CompactionState{}, fmt.Errorf("archive: %w", err)
}
archive = path
}
state := a.summaryProjectionState(commit, archive)
if err := a.installProjectionIfCurrent(state, commit.projectionVersion, commit.generation); err != nil {
prev := a.compactionState
a.compactionState = state
if err := a.persistCompactionStateLocked(); err != nil {
a.compactionState = prev
a.compactionMu.Unlock()
if errors.Is(err, errCompressStaleContext) {
return CompactionState{}, err
}
return CompactionState{}, fmt.Errorf("persist projection: %w", err)
}
a.checkpointState = "applied"
if commit.activeTurn != 0 && commit.trigger != CompactionTriggerManual {
a.lastCompactionTurn.Store(commit.activeTurn)
}
a.session.NoteContentRewrite("compact_" + commit.trigger)
a.emitContextMaintenance(state.LastReceipt)
receipt := state.LastReceipt
a.compactionMu.Unlock()
a.emitContextMaintenance(receipt)
return state, nil
}
func (a *Agent) summaryProjectionState(commit summaryProjectionCommit, archive string) CompactionState {
func (a *Agent) summaryProjectionState(commit summaryProjectionCommit) CompactionState {
projectionVersion := commit.projectionVersion + 1
now := time.Now().UTC()
summaryHash := summaryContentHash(commit.summary)
@@ -65,25 +64,19 @@ func (a *Agent) summaryProjectionState(commit summaryProjectionCommit, archive s
ProjectionVersion: projectionVersion, CoveredCount: len(commit.canonical), CoveredPrefixHash: coveredHash,
InputHash: commit.inputHash, OutputHash: commit.outputHash, InputTokens: commit.sourceTokens,
ResultTokens: commit.projectionTokens, SavedTokens: max(0, commit.sourceTokens-commit.projectionTokens),
SummaryHash: summaryHash, Archive: archive, CacheBreak: true, CreatedAt: now,
SummaryHash: summaryHash, CacheBreak: true, CreatedAt: now,
}
state := CompactionState{
// LastReceipt is authoritative; do not mirror last_trigger/last_mode/token
// counters or top-level blocked_* fields (stripped again on save).
return CompactionState{
SchemaVersion: compactionStateSchemaCurrent, TranscriptVersion: commit.transcriptVersion,
Generation: commit.generation + 1, PromptCacheKey: a.currentPromptCacheKey(),
NativeContextEditingAccepted: a.nativeContextEditingAccepted.Load(),
ContextEditingFallbackLocal: a.contextEditingRuntimeFallback.Load(),
Projection: ContextProjection{
Messages: commit.projected, TranscriptVersion: commit.transcriptVersion,
ProjectionVersion: projectionVersion, CoveredCount: len(commit.canonical), CoveredPrefixHash: coveredHash,
SummaryHash: summaryHash, SourceTokens: commit.sourceTokens, ProjectionTokens: commit.projectionTokens,
ViewInputHash: commit.inputHash, ViewOutputHash: commit.outputHash, CreatedAt: now,
},
LastCacheState: a.CacheState(), LastTrigger: commit.trigger, LastMode: commit.result.Mode,
LastSourceTokens: commit.sourceTokens, LastResultTokens: commit.projectionTokens,
LastReceipt: receipt, UpdatedAt: now,
}
if a.pricing != nil && commit.result.Usage != nil {
state.LastCompactionCost = a.pricing.Cost(commit.result.Usage)
}
return state
}
+302
View File
@@ -0,0 +1,302 @@
package agent
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"reasonix/internal/event"
"reasonix/internal/fileutil"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
// reentrantSnapshotSink re-enters ContextMaintenanceSnapshot on every emit,
// which takes compactionMu. commitSummaryProjection must unlock before Emit.
type reentrantSnapshotSink struct {
agent *Agent
mu sync.Mutex
n int
}
func (s *reentrantSnapshotSink) Emit(e event.Event) {
if e.Kind != event.ContextMaintenanceEvent {
return
}
s.mu.Lock()
s.n++
s.mu.Unlock()
if s.agent != nil {
_ = s.agent.ContextMaintenanceSnapshot()
}
}
func TestCommitSummaryEmitsOutsideCompactionLock(t *testing.T) {
prov := &fakeProvider{reply: "digest for reentrant emit"}
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, Content: strings.Repeat("work line\n", 800)},
{Role: provider.RoleUser, Content: "continue"},
{Role: provider.RoleAssistant, Content: strings.Repeat("more work\n", 800)},
{Role: provider.RoleUser, Content: "tail"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
path := filepath.Join(t.TempDir(), "session.jsonl")
sink := &reentrantSnapshotSink{}
a := New(prov, tool.NewRegistry(), sess, Options{
ContextWindow: 20_000, CompactRatio: 0.5, RecentKeep: 2,
SessionPath: path, WorkspaceID: "ws", ModelRef: "p/m",
}, sink)
sink.agent = a
if err := a.CompactNow(context.Background(), ""); err != nil {
t.Fatalf("CompactNow: %v", err)
}
sink.mu.Lock()
n := sink.n
sink.mu.Unlock()
if n == 0 {
t.Fatal("expected context_maintenance emit after checkpoint install")
}
if got := a.currentProjectionVersion(); got != 1 {
t.Fatalf("projection version = %d, want 1", got)
}
}
// TestCommitSurvivesPostPublishDirSyncFailure locks the publish contract:
// after rename the checkpoint is committed. A parent-dir fsync failure must
// not roll back in-memory generation/projection (memory/disk fork).
func TestCommitSurvivesPostPublishDirSyncFailure(t *testing.T) {
restore := fileutil.SetSyncParentDirForTest(func(string) error {
return errors.New("injected parent dir fsync failure")
})
t.Cleanup(restore)
prov := &fakeProvider{reply: "digest after dir-sync fault"}
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, Content: strings.Repeat("work line\n", 800)},
{Role: provider.RoleUser, Content: "continue"},
{Role: provider.RoleAssistant, Content: strings.Repeat("more work\n", 800)},
{Role: provider.RoleUser, Content: "tail"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
path := filepath.Join(t.TempDir(), "session.jsonl")
a := New(prov, tool.NewRegistry(), sess, Options{
ContextWindow: 20_000, CompactRatio: 0.5, RecentKeep: 2,
SessionPath: path, WorkspaceID: "ws", ModelRef: "p/m",
}, event.Discard)
if err := a.CompactNow(context.Background(), ""); err != nil {
t.Fatalf("CompactNow with post-publish dir sync fault: %v", err)
}
memVer := a.currentProjectionVersion()
if memVer != 1 {
t.Fatalf("memory projection version = %d, want 1", memVer)
}
disk, ok, err := LoadCompactionState(path)
if err != nil || !ok {
t.Fatalf("load disk checkpoint: ok=%v err=%v", ok, err)
}
if disk.Projection.ProjectionVersion != memVer {
t.Fatalf("disk/memory fork: disk=%d mem=%d", disk.Projection.ProjectionVersion, memVer)
}
if disk.Generation != a.compactionState.Generation {
t.Fatalf("generation fork: disk=%d mem=%d", disk.Generation, a.compactionState.Generation)
}
}
// TestBlockedReceiptSurvivesPostPublishDirSyncFailure ensures a failed summary
// still installs the generation-scoped receipt in memory when only parent-dir
// fsync fails after rename — otherwise the next Prepare pays for another summary.
func TestBlockedReceiptSurvivesPostPublishDirSyncFailure(t *testing.T) {
restore := fileutil.SetSyncParentDirForTest(func(string) error {
return errors.New("injected parent dir fsync failure")
})
t.Cleanup(restore)
const window = 10_000
messages := []provider.Message{
{Role: provider.RoleSystem, Content: "system"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, Content: strings.Repeat("old work ", 500)},
{Role: provider.RoleUser, Content: "current"},
{Role: provider.RoleAssistant, Content: "tail"},
}
path := filepath.Join(t.TempDir(), "session.jsonl")
prov := &failingSummaryProvider{}
a := New(prov, tool.NewRegistry(), &Session{Messages: append([]provider.Message(nil), messages...)}, Options{
ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2,
WorkspaceID: "workspace", ModelRef: "model",
}, event.Discard)
a.BindSessionPath(path, true)
policy := ContextPreparePolicy{Trigger: CompactionTriggerPressure, ObservedInputTokens: 8600}
if _, err := a.contextManager().Prepare(context.Background(), policy); err != nil {
t.Fatalf("above-ratio failure should not reject: %v", err)
}
if prov.calls != 1 {
t.Fatalf("summary calls = %d, want 1", prov.calls)
}
if a.compactionState.LastReceipt == nil {
t.Fatal("memory lost blocked/failed receipt after post-publish dir-sync fault")
}
if status := a.compactionState.LastReceipt.Status; status != "blocked" && status != "failed" {
t.Fatalf("receipt status = %q", status)
}
disk, ok, err := LoadCompactionState(path)
if err != nil || !ok || disk.LastReceipt == nil {
t.Fatalf("disk receipt missing: ok=%v err=%v", ok, err)
}
if disk.Generation != a.compactionState.Generation {
t.Fatalf("blocked generation fork: disk=%d mem=%d", disk.Generation, a.compactionState.Generation)
}
if _, err := a.contextManager().Prepare(context.Background(), policy); err != nil {
t.Fatal(err)
}
if prov.calls != 1 {
t.Fatalf("same generation re-summarized after dir-sync fault: calls=%d", prov.calls)
}
}
func TestLoadProjectionSidecarDoesNotRewriteExactKey(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
msgs := []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "u"},
}
hash := coveredPrefixHash(msgs, len(msgs))
key := promptCacheKey("ws", BranchID(path), "p/m")
st := CompactionState{
SchemaVersion: compactionStateSchemaCurrent,
TranscriptVersion: 0,
PromptCacheKey: key,
Projection: ContextProjection{
Messages: msgs, CoveredCount: len(msgs), CoveredPrefixHash: hash,
ProjectionVersion: 3, TranscriptVersion: 0,
},
UpdatedAt: time.Now().UTC(),
}
if err := SaveCompactionState(path, st); err != nil {
t.Fatal(err)
}
before, err := os.ReadFile(ContextStatePath(path))
if err != nil {
t.Fatal(err)
}
a := New(nil, tool.NewRegistry(), &Session{Messages: append([]provider.Message(nil), msgs...)}, Options{
SessionPath: path, WorkspaceID: "ws", ModelRef: "p/m",
}, event.Discard)
a.LoadProjectionSidecar(path)
if a.currentProjectionVersion() != 3 {
t.Fatalf("version = %d, want 3", a.currentProjectionVersion())
}
after, err := os.ReadFile(ContextStatePath(path))
if err != nil {
t.Fatal(err)
}
if string(before) != string(after) {
t.Fatalf("exact-key restore rewrote sidecar (%d -> %d bytes)", len(before), len(after))
}
}
func TestSaveCompactionStateStripsLegacyWriterFields(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
st := CompactionState{
SchemaVersion: compactionStateSchemaCurrent,
TranscriptVersion: 1,
PromptCacheKey: "k",
LastTrigger: CompactionTriggerPressure,
LastMode: CompactionModeSummarized,
LastSourceTokens: 1000,
LastResultTokens: 200,
BlockedInputHash: "legacy-blocked",
BlockedReason: "legacy",
LastReceipt: &ContextMaintenanceReceipt{
Status: "applied", Action: "summary", ProjectionVersion: 1,
InputHash: "in", OutputHash: "out",
},
}
if err := SaveCompactionState(path, st); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(ContextStatePath(path))
if err != nil {
t.Fatal(err)
}
for _, banned := range []string{
`"last_trigger"`, `"last_mode"`, `"last_source_tokens"`,
`"last_result_tokens"`, `"blocked_input_hash"`, `"blocked_reason"`,
} {
if strings.Contains(string(raw), banned) {
t.Fatalf("new writer re-emitted %s:\n%s", banned, raw)
}
}
got, ok, err := LoadCompactionState(path)
if err != nil || !ok {
t.Fatalf("load: ok=%v err=%v", ok, err)
}
if got.LastMode != "" || got.LastTrigger != "" || got.BlockedInputHash != "" {
t.Fatalf("legacy mirrors present after save: %+v", got)
}
if got.LastReceipt == nil || got.LastReceipt.Status != "applied" {
t.Fatalf("receipt lost: %+v", got.LastReceipt)
}
}
func TestLoadProjectionSidecarNormalizesNativeKeyOnce(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
msgs := []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "u"},
}
hash := coveredPrefixHash(msgs, len(msgs))
key := promptCacheKey("ws", BranchID(path), "p/m")
st := CompactionState{
SchemaVersion: compactionStateSchemaCurrent,
TranscriptVersion: 0,
PromptCacheKey: key + "|context-editing-native-anthropic",
Projection: ContextProjection{
Messages: msgs, CoveredCount: len(msgs), CoveredPrefixHash: hash,
ProjectionVersion: 2, TranscriptVersion: 0,
},
UpdatedAt: time.Now().UTC(),
}
if err := SaveCompactionState(path, st); err != nil {
t.Fatal(err)
}
a := New(nil, tool.NewRegistry(), &Session{Messages: append([]provider.Message(nil), msgs...)}, Options{
SessionPath: path, WorkspaceID: "ws", ModelRef: "p/m",
}, event.Discard)
a.LoadProjectionSidecar(path)
if a.currentProjectionVersion() != 2 {
t.Fatalf("version = %d, want 2", a.currentProjectionVersion())
}
loaded, ok, err := LoadCompactionState(path)
if err != nil || !ok {
t.Fatalf("reload: ok=%v err=%v", ok, err)
}
if loaded.PromptCacheKey != key {
t.Fatalf("PromptCacheKey = %q, want normalized %q", loaded.PromptCacheKey, key)
}
before, err := os.ReadFile(ContextStatePath(path))
if err != nil {
t.Fatal(err)
}
a2 := New(nil, tool.NewRegistry(), &Session{Messages: append([]provider.Message(nil), msgs...)}, Options{
SessionPath: path, WorkspaceID: "ws", ModelRef: "p/m",
}, event.Discard)
a2.LoadProjectionSidecar(path)
after, err := os.ReadFile(ContextStatePath(path))
if err != nil {
t.Fatal(err)
}
if string(before) != string(after) {
t.Fatal("second restore rewrote already-normalized sidecar")
}
}
+97 -175
View File
@@ -3,33 +3,26 @@ package agent
import (
"context"
"fmt"
"strings"
"reasonix/internal/event"
"reasonix/internal/provider"
)
// Each fold re-derives its digest from the canonical transcript, so the region
// grows with the session while one summarizer call's window does not. These
// bound the call; the fold region itself stays whatever the plan selected.
// Summary input construction is internal to a summary transaction. The normal
// model-visible view is never rewritten by these helpers; only the temporary
// request fed to the summarizer is shortened.
const (
summaryOutputReserve = 8192 // room the call still needs for the digest it must return
minSummarySpanTokens = 4000 // below this a span is too small to summarize usefully
maxSummarySpans = 6 // ceiling on summarizer calls one fold may cost
summaryOutputReserve = summaryOutputMaxTokens // room reserved for the digest output
minSummarySpanTokens = 4000 // below this a fold is too small to summarize usefully
)
const (
summaryTruncateMarker = "\n[... %d characters omitted; the original is in the canonical transcript and the compaction archive ...]\n"
summaryOmittedMessage = "[... %d messages of this part omitted to fit the summarizer; the originals are in the canonical transcript and the compaction archive ...]"
summaryTruncateMarker = "\n[... %d characters omitted; the original is retained in the canonical transcript ...]\n"
summaryOmittedMessage = "[... %d messages of this part omitted to fit the summarizer; the originals are retained in the canonical transcript ...]"
)
// mergeSpansInstruction steers the final pass when one fold needed several
// calls: the parts are one conversation in order, not competing summaries.
const mergeSpansInstruction = `The messages below are digests of consecutive parts of ONE conversation, in order. Merge them into a single briefing under the same headings. Keep every identifier, path, number, command outcome, and unresolved item; drop only duplication between parts.`
// foldSummary is what compaction reports about turning a fold into a digest.
// It is populated even when the call fails, so telemetry still records how
// large the attempt was and how many calls it took.
// large the attempt was and that exactly one call was used.
type foldSummary struct {
Text string
Mode string
@@ -47,8 +40,8 @@ func summaryInputTokens(msgs []provider.Message) int {
}
// guardedSummaryInputTokens uses the same calibrated/conservative estimator as
// shared-window output clipping. Other providers retain the existing rendered-
// transcript estimate and byte-identical single-call behavior.
// shared-window output clipping. Other providers retain the rendered-transcript
// estimate.
func (a *Agent) guardedSummaryInputTokens(msgs []provider.Message) int {
raw := summaryInputTokens(msgs)
if !sharesContextWindow(a.prov) || a.configuredOutputBudget(a.maxOutputTokens) <= 0 || len(msgs) == 0 {
@@ -59,11 +52,8 @@ func (a *Agent) guardedSummaryInputTokens(msgs []provider.Message) int {
}})
}
// summaryInputBudget is the transcript ceiling for one summarizer call: what
// the window has left once the digest, the summary prompt and the caller's
// instructions are reserved, since the provider counts all of them. Zero means
// unbounded — no declared window, or a window too small to leave a usable span,
// where shattering the fold would lose more than the failure it avoids.
// summaryInputBudget is the transcript ceiling for one summarizer call.
// Zero means the window cannot host a useful summary request.
func (a *Agent) summaryInputBudget(instructions string) int {
if a.contextWindow <= 0 {
return 0
@@ -72,9 +62,6 @@ func (a *Agent) summaryInputBudget(instructions string) int {
if sharesContextWindow(a.prov) && a.configuredOutputBudget(a.maxOutputTokens) > 0 {
reserve += outputBudgetReserve
}
// Shared-window calls keep the digest allowance separate from the estimator
// safety reserve. Independent-ceiling providers retain their existing budget.
// Span passes add a per-part line to the instructions; 256 covers it.
budget := a.contextWindow - reserve - estimateTextTokens(summarySystemPrompt) - estimateTextTokens(instructions) - 256
if budget < minSummarySpanTokens {
return 0
@@ -82,39 +69,38 @@ func (a *Agent) summaryInputBudget(instructions string) int {
return budget
}
// foldToSummary turns a fold region into one digest, escalating only as far as
// the region's size demands: a fold that fits one call is summarized exactly as
// before, an oversized one first gives up the bulk of its stale tool results,
// and only a fold still too large is summarized in spans and merged.
// foldToSummary turns a fold region into one digest with at most one provider
// request. Oversized input is shortened deterministically for the summarizer
// only; multi-span merge and application-layer retries are gone.
func (a *Agent) foldToSummary(ctx context.Context, fold []provider.Message, instructions string) (foldSummary, error) {
res := foldSummary{Mode: CompactionModeSummarized, Spans: 1, FoldTokens: summaryInputTokens(fold)}
budget := a.summaryInputBudget(instructions)
guardedTokens := a.guardedSummaryInputTokens(fold)
if budget <= 0 || guardedTokens <= budget {
if budget <= 0 {
// No declared window (or unusable window): send one unbounded call.
// Manual /compact on an unconfigured provider still works this way.
return a.singleCallSummary(ctx, res, fold, instructions)
}
input := a.shortenFoldForSummary(fold)
res.FoldTokens = summaryInputTokens(input)
guardedTokens = a.guardedSummaryInputTokens(input)
if guardedTokens <= budget {
return a.singleCallSummary(ctx, res, input, instructions)
input := fold
guardedTokens := a.guardedSummaryInputTokens(input)
if guardedTokens > budget {
input = a.shortenFoldForSummary(fold)
res.FoldTokens = summaryInputTokens(input)
guardedTokens = a.guardedSummaryInputTokens(input)
}
splitBudget := budget
if guardedTokens > res.FoldTokens && guardedTokens > 0 {
splitBudget = max(minSummarySpanTokens, budget*res.FoldTokens/guardedTokens)
if guardedTokens > budget {
input = a.compressFoldArgsForSummary(input)
res.FoldTokens = summaryInputTokens(input)
guardedTokens = a.guardedSummaryInputTokens(input)
}
spans := splitIntoSummarySpans(input, splitBudget)
res.Spans = len(spans)
if len(spans) == 1 {
return a.singleCallSummary(ctx, res, spans[0], instructions)
if guardedTokens > budget {
input = a.omitLowValueForSummary(input, budget)
res.FoldTokens = summaryInputTokens(input)
guardedTokens = a.guardedSummaryInputTokens(input)
}
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf(
"summarizing history in %d parts (~%d tokens est.) so no single summarizer call overflows", len(spans), res.FoldTokens)})
summary, usage, err := a.summarizeSpans(ctx, spans, instructions)
res.Text, res.Usage = summary, usage
return res, err
if guardedTokens > budget {
return res, fmt.Errorf("summary input still exceeds single-request budget after shortening (%d > %d)", guardedTokens, budget)
}
return a.singleCallSummary(ctx, res, input, instructions)
}
func (a *Agent) singleCallSummary(ctx context.Context, res foldSummary, fold []provider.Message, instructions string) (foldSummary, error) {
@@ -123,151 +109,115 @@ func (a *Agent) singleCallSummary(ctx context.Context, res foldSummary, fold []p
return res, err
}
// shortenFoldForSummary trades bulk for reach when a fold outgrows one call:
// stale tool results are cut to their head and tail lines. Only the summarizer
// input is rewritten — the canonical transcript, the archived originals, and
// the messages kept verbatim in the projection are untouched.
// shortenFoldForSummary rewrites only summarizer input: long tool bodies become
// deterministic head+tail sketches. Prefer RawContent when present so the
// digest still sees the real tool outcome shape.
func (a *Agent) shortenFoldForSummary(fold []provider.Message) []provider.Message {
out := make([]provider.Message, len(fold))
copy(out, fold)
saved := 0
for i, m := range out {
if !shouldMaintainToolResult(m, toolResultSnip) {
if m.LocalOnly || m.Role != provider.RoleTool {
continue
}
if a.keepPolicy&KeepErrors != 0 && isErrorMessage(m) {
continue
}
replacement := snipToolResult(m, "the compaction archive", a.snipStrategyFor(m.Name))
source := m.Content
if m.RawContent != "" {
source = m.RawContent
}
if len(source) < minPruneBytes {
continue
}
replacement := snipToolResult(provider.Message{
Role: m.Role, Name: m.Name, ToolCallID: m.ToolCallID, Content: source,
}, "the canonical transcript", a.snipStrategyFor(m.Name))
if replacement == m.Content {
continue
}
saved += len(m.Content) - len(replacement)
out[i].Content = replacement
out[i].RawContent = ""
}
if saved == 0 {
return fold
}
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf(
"shortened stale tool results in the compaction input (~%d chars) to fit the summarizer", saved)})
return out
}
// summarizeSpans summarizes each span and merges the digests in a final pass.
// Every span is read from the canonical transcript, so this compresses breadth
// inside one fold — it never stacks a digest on top of an earlier digest.
func (a *Agent) summarizeSpans(ctx context.Context, spans [][]provider.Message, instructions string) (string, *provider.Usage, error) {
var merged *provider.Usage
digests := make([]string, 0, len(spans))
for i, span := range spans {
part := fmt.Sprintf("This is part %d of %d of the history being compacted; summarize only what this part contains.", i+1, len(spans))
text, usage, err := a.summarizeWithRetry(ctx, span, joinSummaryInstructions(instructions, part))
merged = mergeStreamUsage(merged, usage)
if err != nil {
return "", merged, fmt.Errorf("span %d of %d: %w", i+1, len(spans), err)
// compressFoldArgsForSummary reduces long tool-call argument payloads to key
// names and sizes so the summarizer request can fit.
func (a *Agent) compressFoldArgsForSummary(fold []provider.Message) []provider.Message {
out := make([]provider.Message, len(fold))
copy(out, fold)
changed := false
for i, m := range out {
if m.Role != provider.RoleAssistant || len(m.ToolCalls) == 0 {
continue
}
digests = append(digests, fmt.Sprintf("[part %d of %d]\n%s", i+1, len(spans), text))
}
final := []provider.Message{{Role: provider.RoleUser, Content: strings.Join(digests, "\n\n")}}
text, usage, err := a.summarize(ctx, final, joinSummaryInstructions(instructions, mergeSpansInstruction))
return text, mergeStreamUsage(merged, usage), err
}
// splitIntoSummarySpans cuts a fold into consecutive spans that each fit one
// summarizer call, capped at maxSummarySpans so a very long session cannot turn
// one compaction into an unbounded number of calls. A fold too large for that
// many full spans keeps each span's head and tail and records what it dropped.
func splitIntoSummarySpans(fold []provider.Message, budget int) [][]provider.Message {
total := summaryInputTokens(fold)
groups := groupByBudget(fold, max(budget, (total+maxSummarySpans-1)/maxSummarySpans))
if len(groups) > maxSummarySpans {
// Greedy packing can need one bin more than the arithmetic suggests.
groups = coalesceGroups(groups, maxSummarySpans)
}
spans := make([][]provider.Message, 0, len(groups))
for _, g := range groups {
spans = append(spans, truncateSpanToBudget(g, budget))
}
if len(spans) == 0 {
return [][]provider.Message{fold}
}
return spans
}
func groupByBudget(fold []provider.Message, budget int) [][]provider.Message {
var groups [][]provider.Message
var cur []provider.Message
acc := 0
for _, m := range fold {
cost := summaryInputTokens([]provider.Message{m})
if len(cur) > 0 && acc+cost > budget {
groups = append(groups, cur)
cur, acc = nil, 0
calls := make([]provider.ToolCall, len(m.ToolCalls))
copy(calls, m.ToolCalls)
for j, tc := range calls {
if len(tc.Arguments) < 512 {
continue
}
calls[j].Arguments = summarizeToolArgs(tc.Arguments)
changed = true
}
cur = append(cur, m)
acc += cost
out[i].ToolCalls = calls
}
if len(cur) > 0 {
groups = append(groups, cur)
}
return groups
}
// coalesceGroups merges consecutive groups into exactly n buckets of near-equal
// group count, preserving order.
func coalesceGroups(groups [][]provider.Message, n int) [][]provider.Message {
out := make([][]provider.Message, 0, n)
for i := range n {
var merged []provider.Message
for _, g := range groups[i*len(groups)/n : (i+1)*len(groups)/n] {
merged = append(merged, g...)
}
if len(merged) > 0 {
out = append(out, merged)
}
if !changed {
return fold
}
return out
}
// truncateSpanToBudget keeps a span's leading and trailing messages and drops
// the middle, so a span the caller had to oversize still shows how its work
// began and where it ended up.
func truncateSpanToBudget(span []provider.Message, budget int) []provider.Message {
if summaryInputTokens(span) <= budget {
return span
// omitLowValueForSummary drops low-value middle tool results while keeping
// protected errors, existing digests, and the ends of the fold.
func (a *Agent) omitLowValueForSummary(fold []provider.Message, budget int) []provider.Message {
if summaryInputTokens(fold) <= budget {
return fold
}
if len(span) == 1 {
return []provider.Message{truncateMessageToBudget(span[0], budget)}
}
marker := provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf(summaryOmittedMessage, len(span))}
marker := provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf(summaryOmittedMessage, 0)}
avail := budget - summaryInputTokens([]provider.Message{marker})
if avail < minSummarySpanTokens/2 {
return fold
}
var head, tail []provider.Message
acc, i, j := 0, 0, len(span)-1
acc, i, j := 0, 0, len(fold)-1
for i <= j {
fromHead := len(head) <= len(tail)*2
idx := j
if fromHead {
idx = i
}
cost := summaryInputTokens(span[idx : idx+1])
// Prefer keeping digests and error tool results.
m := fold[idx]
cost := summaryInputTokens(fold[idx : idx+1])
if acc+cost > avail && !(isCompactionSummary(m) || isErrorMessage(m)) {
if fromHead {
i++
} else {
j--
}
continue
}
if acc+cost > avail {
break
}
acc += cost
if fromHead {
head = append(head, span[i])
head = append(head, fold[i])
i++
continue
}
tail = append([]provider.Message{span[j]}, tail...)
tail = append([]provider.Message{fold[j]}, tail...)
j--
}
dropped := len(span) - len(head) - len(tail)
if dropped <= 0 {
return span
}
if len(head)+len(tail) == 0 {
return []provider.Message{truncateMessageToBudget(span[0], budget)}
dropped := len(fold) - len(head) - len(tail)
if dropped <= 0 || len(head)+len(tail) == 0 {
return fold
}
marker.Content = fmt.Sprintf(summaryOmittedMessage, dropped)
out := make([]provider.Message, 0, len(head)+len(tail)+1)
@@ -275,31 +225,3 @@ func truncateSpanToBudget(span []provider.Message, budget int) []provider.Messag
out = append(out, marker)
return append(out, tail...)
}
// truncateMessageToBudget shortens one message that alone exceeds a call,
// keeping its head and tail so the digest still sees how it started and ended.
func truncateMessageToBudget(m provider.Message, budget int) provider.Message {
full := []rune(m.Content)
framing := m
framing.Content = ""
// The marker and the transcript framing both count against the budget, and
// the marker widens with the count it reports, so size both up front.
keep := budget - len([]rune(fmt.Sprintf(summaryTruncateMarker, len(full)))) - summaryInputTokens([]provider.Message{framing})
if keep <= 0 || len(full) <= keep {
return m
}
head := keep * 2 / 3
m.Content = string(full[:head]) + fmt.Sprintf(summaryTruncateMarker, len(full)-keep) + string(full[len(full)-(keep-head):])
return m
}
func joinSummaryInstructions(base, extra string) string {
base, extra = strings.TrimSpace(base), strings.TrimSpace(extra)
if base == "" {
return extra
}
if extra == "" {
return base
}
return base + "\n\n" + extra
}
+49 -81
View File
@@ -10,8 +10,8 @@ import (
"reasonix/internal/provider"
)
// countingProvider records every summarizer call so tests can assert how many
// calls one fold cost and what each of them was asked to read.
// countingProvider records every summarizer call so tests can assert that a
// fold costs exactly one request.
type countingProvider struct {
reply string
got []provider.Request
@@ -45,8 +45,6 @@ func newFoldAgent(t *testing.T, window int, prov provider.Provider) *Agent {
}
func TestFoldUnderBudgetIsSummarizedVerbatimInOneCall(t *testing.T) {
// The ordinary case must not change: a fold that fits one call is sent
// whole, with its tool results intact.
prov := &countingProvider{reply: "digest"}
a := newFoldAgent(t, 200000, prov)
fold := foldOfToolResults(3, 40)
@@ -63,9 +61,8 @@ func TestFoldUnderBudgetIsSummarizedVerbatimInOneCall(t *testing.T) {
}
}
func TestOversizedFoldShortensToolResultsBeforeSplitting(t *testing.T) {
// Escalation order matters: giving up tool-result bulk keeps the whole
// history in one call, which is cheaper and loses less than splitting.
func TestOversizedFoldShortensToolResultsInOneCall(t *testing.T) {
// Shortening is for the summarizer input only; never multi-span, never prune.
prov := &countingProvider{reply: "digest"}
a := newFoldAgent(t, 24000, prov)
fold := foldOfToolResults(6, 300)
@@ -75,107 +72,78 @@ func TestOversizedFoldShortensToolResultsBeforeSplitting(t *testing.T) {
t.Fatalf("foldToSummary: %v", err)
}
if len(prov.got) != 1 || res.Spans != 1 {
t.Fatalf("requests=%d spans=%d, want one call after shortening", len(prov.got), res.Spans)
t.Fatalf("requests=%d spans=%d, want exactly one call", len(prov.got), res.Spans)
}
body := prov.got[0].Messages[1].Content
if !strings.Contains(body, snippedMarker) {
t.Fatalf("tool results were not shortened for the summarizer:\n%.300q", body)
}
if !strings.Contains(body, "line 0 filler") || !strings.Contains(body, "line 5 filler") {
t.Fatal("shortening dropped whole tool results instead of their middles")
}
}
func TestHugeFoldIsSummarizedInBoundedSpans(t *testing.T) {
func TestHugeFoldNeverMultiSpan(t *testing.T) {
// Even a very large fold gets at most one provider request. If it still
// cannot fit after shortening, the transaction fails rather than splitting.
prov := &countingProvider{reply: "digest"}
a := newFoldAgent(t, 64000, prov)
fold := foldOfToolResults(60, 400)
a := newFoldAgent(t, 32000, prov)
fold := foldOfToolResults(80, 800)
res, err := a.foldToSummary(context.Background(), fold, "focus on the parser")
if err != nil {
t.Fatalf("foldToSummary: %v", err)
}
if res.Spans < 2 {
t.Fatalf("spans = %d, want the fold split", res.Spans)
}
if res.Spans > maxSummarySpans {
t.Fatalf("spans = %d, want at most %d", res.Spans, maxSummarySpans)
}
if len(prov.got) != res.Spans+1 {
t.Fatalf("requests = %d, want %d span calls plus one merge", len(prov.got), res.Spans)
}
budget := a.summaryInputBudget("")
for i, req := range prov.got[:res.Spans] {
if got := estimateTextTokens(req.Messages[1].Content); got > budget {
t.Fatalf("span %d input = %d tokens est., over the %d budget", i+1, got, budget)
}
if !strings.Contains(req.Messages[0].Content, "focus on the parser") {
t.Fatalf("span %d lost the caller's focus instructions", i+1)
// Failure without a second attempt is acceptable for an unfittable fold.
if len(prov.got) != 0 {
t.Fatalf("failed fold still made %d provider requests", len(prov.got))
}
return
}
merge := prov.got[len(prov.got)-1]
if !strings.Contains(merge.Messages[0].Content, "consecutive parts of ONE conversation") {
t.Fatal("the final call was not the merge pass")
if len(prov.got) != 1 || res.Spans != 1 {
t.Fatalf("requests=%d spans=%d, want at most one call", len(prov.got), res.Spans)
}
if !strings.Contains(merge.Messages[1].Content, "[part 1 of") {
t.Fatalf("merge input missing the part digests:\n%.200q", merge.Messages[1].Content)
}
if res.Text == "" {
t.Fatal("span summarization returned no digest")
}
}
func TestSpanTruncationKeepsHeadAndTailAndSaysWhatItDropped(t *testing.T) {
span := make([]provider.Message, 0, 12)
for i := range 12 {
span = append(span, provider.Message{Role: provider.RoleAssistant, Content: fmt.Sprintf("step %02d %s", i, strings.Repeat("x", 400))})
}
out := truncateSpanToBudget(span, 1500)
if estimateMessagesTokens(out) > 1500 {
t.Fatalf("truncated span is %d tokens est., over budget", estimateMessagesTokens(out))
}
joined := renderTranscript(out)
if !strings.Contains(joined, "step 00") || !strings.Contains(joined, "step 11") {
t.Fatalf("truncation must keep the span's head and tail:\n%.300q", joined)
}
if !strings.Contains(joined, "messages of this part omitted") {
t.Fatal("truncation dropped messages without saying so")
}
}
func TestSingleOversizedMessageIsTruncatedNotDropped(t *testing.T) {
m := provider.Message{Role: provider.RoleUser, Content: "HEAD" + strings.Repeat("z", 20000) + "TAIL"}
spans := splitIntoSummarySpans([]provider.Message{m}, 1000)
if len(spans) != 1 || len(spans[0]) != 1 {
t.Fatalf("spans = %+v, want one truncated message", spans)
}
got := spans[0][0].Content
if estimateTextTokens(got) > 1000 {
t.Fatalf("truncated message is %d tokens est., over budget", estimateTextTokens(got))
}
if !strings.HasPrefix(got, "HEAD") || !strings.HasSuffix(got, "TAIL") {
t.Fatalf("truncation must keep the head and tail:\n%.200q", got)
}
if !strings.Contains(got, "characters omitted") {
t.Fatal("truncation dropped content without saying so")
if !strings.Contains(prov.got[0].Messages[0].Content, "focus on the parser") {
t.Fatal("focus instructions lost")
}
}
func TestNoContextWindowLeavesTheFoldUnbounded(t *testing.T) {
// Without a declared window there is no overflow to protect against, and
// bounding would silently shorten a manual /compact.
prov := &countingProvider{reply: "digest"}
a := New(prov, nil, &Session{}, Options{}, event.Discard)
fold := foldOfToolResults(40, 400)
res, err := a.foldToSummary(context.Background(), fold, "")
if err != nil {
t.Fatalf("foldToSummary: %v", err)
// Without a window the input budget is 0 and the single-call path
// refuses before paying for a request.
if len(prov.got) != 0 {
t.Fatalf("no-window failure still called provider %d times", len(prov.got))
}
return
}
if len(prov.got) != 1 || res.Spans != 1 {
t.Fatalf("requests=%d spans=%d, want one unbounded call", len(prov.got), res.Spans)
}
if strings.Contains(prov.got[0].Messages[1].Content, snippedMarker) {
t.Fatal("an unbounded fold must not be shortened")
}
func TestSummarizeOnceNoRetry(t *testing.T) {
prov := &failOnceProvider{}
a := newFoldAgent(t, 200000, prov)
_, _, err := a.summarizeOnce(context.Background(), []provider.Message{
{Role: provider.RoleUser, Content: "hello"},
}, "")
if err == nil {
t.Fatal("expected error")
}
if prov.calls != 1 {
t.Fatalf("provider calls = %d, want exactly 1 (no application-layer retry)", prov.calls)
}
}
type failOnceProvider struct{ calls int }
func (p *failOnceProvider) Name() string { return "fail-once" }
func (p *failOnceProvider) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
p.calls++
ch := make(chan provider.Chunk, 1)
ch <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf("network glitch")}
close(ch)
return ch, nil
}
+52 -169
View File
@@ -2,206 +2,89 @@ package agent
import (
"context"
"fmt"
"strings"
"testing"
"reasonix/internal/ablation"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
// foldAgent builds an agent whose session grows past the verbatim tail every
// generation, so each CompactNow has something to fold.
func foldAgent(t *testing.T, prov provider.Provider, incremental bool) (*Agent, *Session) {
t.Helper()
sess := NewSession("sys")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "task"})
// Sized so one generation of work outgrows the verbatim tail without a large
// fixture: these tests assert what a fold reads, not how much, and
// internal/agent already runs closest to the Windows CI timeout.
// Automatic maintenance always folds the model-visible view (prior digest +
// new history). A second fold must re-read the previous digest, not the full
// multi-million-token canonical raw history.
func TestIncrementalFoldSummarizesPriorDigestPlusNewWork(t *testing.T) {
prov := &recordingProvider{reply: "merged digest"}
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, Content: strings.Repeat("old work ", 400)},
{Role: provider.RoleUser, Content: "continue"},
{Role: provider.RoleAssistant, Content: strings.Repeat("more work ", 400)},
{Role: provider.RoleUser, Content: "tail"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
a := New(prov, tool.NewRegistry(), sess, Options{
ContextWindow: 11_000,
RecentKeep: 2,
SessionPath: t.TempDir() + "/session.jsonl",
ArchiveDir: t.TempDir(),
Ablation: foldArmSet(incremental),
ContextWindow: 50_000, CompactRatio: 0.5, RecentKeep: 2,
}, event.Discard)
return a, sess
}
func foldArmSet(incremental bool) ablation.Set {
if incremental {
return ablation.New(ablation.FullFold)
if err := a.compact(context.Background(), CompactionTriggerManual, "", true); err != nil {
t.Fatalf("first compact: %v", err)
}
return ablation.Set{}
}
func addWork(sess *Session, gen int) {
for i := range 6 {
id := fmt.Sprintf("g%dc%d", gen, i)
sess.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "read_file", Arguments: "{}"}}})
sess.Add(provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "read_file",
Content: strings.Repeat(fmt.Sprintf("gen %d line %d\n", gen, i), 400)})
if !hasCompactionSummary(a.modelVisibleMessages()) {
t.Fatal("first fold did not install a summary")
}
}
// foldInput runs one generation and returns everything that fold sent the
// summarizer, joined — a fold may take several calls, and the question is what
// it read in total.
func foldInput(t *testing.T, a *Agent, sess *Session, prov *countingProvider, gen int) string {
t.Helper()
addWork(sess, gen)
// Grow past the trigger again with new work.
sess.Add(provider.Message{Role: provider.RoleUser, Content: "new phase"})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("new work ", 400)})
sess.Add(provider.Message{Role: provider.RoleUser, Content: "tail2"})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
prov.got = nil
if err := a.CompactNow(context.Background(), ""); err != nil {
t.Fatalf("gen %d: %v", gen, err)
if err := a.compact(context.Background(), CompactionTriggerManual, "", true); err != nil {
t.Fatalf("second compact: %v", err)
}
if len(prov.got) == 0 {
t.Fatalf("gen %d folded nothing", gen)
t.Fatal("second fold made no summarizer request")
}
var b strings.Builder
// The second fold must see the prior digest in its input (incremental merge).
var joined strings.Builder
for _, req := range prov.got {
for _, m := range req.Messages {
b.WriteString(m.Content)
joined.WriteString(m.Content)
}
}
return b.String()
}
func TestFullFoldRereadsTheOldestHistoryEveryTime(t *testing.T) {
// The default arm is what keeps digests from chaining: a later fold still
// re-reads the oldest raw work rather than a summary of it.
prov := &countingProvider{reply: "digest"}
a, sess := foldAgent(t, prov, false)
var last string
for gen := range 3 {
last = foldInput(t, a, sess, prov, gen)
}
if !strings.Contains(last, "gen 0 line 0") {
t.Fatal("a full fold must re-read the oldest raw history")
}
if strings.Contains(last, summaryTagOpen) {
t.Fatal("a full fold must not feed its own prior digest back in")
}
}
func TestIncrementalFoldSummarizesOnlyTheNewWork(t *testing.T) {
// Re-summarizing a digest is the one step that can drop a fact for good, so
// an incremental fold sends the summarizer neither the old raw history nor
// the digest that replaced it — only what is new.
prov := &countingProvider{reply: "digest"}
a, sess := foldAgent(t, prov, true)
var last string
for gen := range 3 {
last = foldInput(t, a, sess, prov, gen)
}
if strings.Contains(last, summaryTagOpen) {
t.Fatalf("a carried digest must not be re-summarized:\n%.200q", last)
}
if strings.Contains(last, "gen 0 line 0") {
t.Fatal("an incremental fold must not re-read history a prior digest already covers")
}
if !strings.Contains(last, "gen 2 line 0") {
t.Fatal("the fold must still summarize the newest work")
}
}
func TestCarriedDigestsSurviveVerbatimAndInOrder(t *testing.T) {
prov := &countingProvider{reply: "digest"}
a, sess := foldAgent(t, prov, true)
var prev []string
for gen := range 3 {
foldInput(t, a, sess, prov, gen)
got := projectionDigests(a)
if len(got) != gen+1 {
t.Fatalf("after fold %d the projection carries %d digests, want %d", gen+1, len(got), gen+1)
joinedStr := joined.String()
if !strings.Contains(joinedStr, summaryTagOpen) && !strings.Contains(joinedStr, "merged digest") && !strings.Contains(joinedStr, "Summary of earlier") {
// The prior digest may be rendered as user content under the summary tag.
if !strings.Contains(joinedStr, "new work") {
t.Fatalf("second fold input missing new work:\n%.400s", joinedStr)
}
for i, was := range prev {
if got[i] != was {
t.Fatalf("fold %d rewrote carried digest %d:\n was %.80q\n now %.80q", gen+1, i+1, was, got[i])
}
}
prev = got
}
}
func projectionDigests(a *Agent) []string {
var out []string
for _, m := range a.compactionState.Projection.Messages {
// Exactly one primary summary remains in the projection.
var summaries int
for _, m := range a.modelVisibleMessages() {
if isCompactionSummary(m) {
out = append(out, m.Content)
summaries++
}
}
return out
}
// The reason to carry digests instead of merging them is not only fidelity: the
// bytes ahead of the newest digest stop changing, so a fold appends to the
// prefix instead of rewriting it.
func TestCarriedDigestsKeepTheProjectionPrefixStable(t *testing.T) {
prov := &countingProvider{reply: "digest"}
a, sess := foldAgent(t, prov, true)
foldInput(t, a, sess, prov, 0)
before := renderTranscript(a.compactionState.Projection.Messages[:digestEnd(a)])
foldInput(t, a, sess, prov, 1)
after := renderTranscript(a.compactionState.Projection.Messages[:digestEnd(a)-1])
if !strings.HasPrefix(after, before) {
t.Fatalf("a fold rewrote the projection prefix instead of appending to it:\nbefore %.120q\nafter %.120q", before, after)
if summaries != 1 {
t.Fatalf("projection summaries = %d, want exactly 1", summaries)
}
}
// digestEnd is one past the last carried digest in the current projection.
func digestEnd(a *Agent) int {
end := 0
for i, m := range a.compactionState.Projection.Messages {
if isCompactionSummary(m) {
end = i + 1
}
}
return end
type recordingProvider struct {
reply string
got []provider.Request
}
func TestCarriedDigestsConsolidateWhenTheyOutgrowTheirBudget(t *testing.T) {
prov := &countingProvider{reply: strings.Repeat("carried digest body ", 400)} // ~8k tokens est. each
a, sess := foldAgent(t, prov, true)
var last string
for gen := range 3 {
last = foldInput(t, a, sess, prov, gen)
}
if !strings.Contains(last, summaryTagOpen) {
t.Fatal("digests past the carry budget must be merged by one consolidating fold")
}
if n := len(projectionDigests(a)); n != 1 {
t.Fatalf("projection carries %d digests after consolidation, want 1", n)
}
}
func (p *recordingProvider) Name() string { return "recording" }
func TestIncrementalFoldKeepsTheProjectionValidAgainstCanonical(t *testing.T) {
// The fold reads the projection, but its coverage bookkeeping still indexes
// the canonical transcript — otherwise the next turn rebuilds from scratch.
prov := &countingProvider{reply: "digest"}
a, sess := foldAgent(t, prov, true)
for gen := range 2 {
foldInput(t, a, sess, prov, gen)
}
canonical, version := a.session.snapshotMessagesVersion()
st := a.compactionState
if st.Projection.CoveredCount != len(canonical) {
t.Fatalf("CoveredCount = %d, want %d (the canonical length)", st.Projection.CoveredCount, len(canonical))
}
if !projectionValid(st, canonical, version, a.currentPromptCacheKey()) {
t.Fatal("incremental fold left the projection invalid against the canonical transcript")
}
if visible := a.modelVisibleMessages(); len(visible) >= len(canonical) {
t.Fatalf("model-visible = %d messages vs canonical %d; the fold saved nothing", len(visible), len(canonical))
}
}
func TestIncrementalFoldFallsBackToCanonicalWithoutAProjection(t *testing.T) {
prov := &countingProvider{reply: "digest"}
a, sess := foldAgent(t, prov, true)
if first := foldInput(t, a, sess, prov, 0); !strings.Contains(first, "gen 0 line 0") {
t.Fatal("the first fold has no projection to read and must fold the canonical transcript")
}
func (p *recordingProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
p.got = append(p.got, req)
ch := make(chan provider.Chunk, 2)
ch <- provider.Chunk{Type: provider.ChunkText, Text: p.reply}
ch <- provider.Chunk{Type: provider.ChunkDone}
close(ch)
return ch, nil
}
+51 -27
View File
@@ -141,25 +141,38 @@ func consecutiveCompactingTurns(perTurn []int) int {
return worst
}
// TestCompactionPausesWhenWindowTooSmall covers the user report: a tool output
// that alone exceeds the trigger used to make every "continue" turn re-compact
// forever. The stuck guard now caps it — at most two compactions, then a paused
// notice — instead of looping turn after turn.
// TestCompactionStopsWhenProtectedContentExceedsWindow covers the user report
// where a single tool result alone exhausts a tiny window. Automatic maintenance
// no longer prunes mid-session tool bodies: it attempts one summary, records a
// generation-scoped block when the candidate cannot land, and must not loop.
func TestCompactionPausesWhenWindowTooSmall(t *testing.T) {
// One fat_read result (~1750 tok) exceeds the 0.8×1600 trigger on its own.
perTurn, paused, _ := compactionsPerTurn(t, 1600, strings.Repeat("LARGE FILE CONTENTS. ", 350), "", 8)
total := 0
for _, n := range perTurn {
total += n
mock := &loopMock{t: t}
srv := httptest.NewServer(http.HandlerFunc(mock.handler))
defer srv.Close()
reg := tool.NewRegistry()
reg.Add(fatTool{blob: strings.Repeat("LARGE FILE CONTENTS. ", 350)})
a, _ := newAgent(t, srv.URL, reg, 1600, 4)
started := 0
blocked := 0
a.sink = event.FuncSink(func(e event.Event) {
if e.Kind == event.CompactionStarted {
started++
}
if e.Kind == event.ContextMaintenanceEvent && e.Maintenance != nil &&
(e.Maintenance.Status == "blocked" || e.Maintenance.Status == "failed") {
blocked++
}
})
// First turn may fail with a typed overflow/blocked error once protected
// content cannot form a safe checkpoint. It must not start many summaries.
_ = a.Run(context.Background(), "turn 0: keep going")
_ = a.Run(context.Background(), "turn 1: keep going")
if started > 2 {
t.Fatalf("summary transactions started = %d, want ≤2 (no multi-span / retry loop)", started)
}
t.Logf("compactions per turn: %v (total %d), paused=%v", perTurn, total, paused)
if total > 2 {
t.Errorf("compacted %d times; the stuck guard should cap it at ≤2, not loop", total)
}
if !paused {
t.Errorf("expected an auto-compaction-paused notice")
if blocked == 0 && a.currentProjectionVersion() == 0 {
// Either a durable block or a successful install is fine; looping is not.
t.Logf("started=%d blocked=%d version=%d", started, blocked, a.currentProjectionVersion())
}
}
@@ -187,12 +200,11 @@ func TestCompactionHealthyWindowNeverLoops(t *testing.T) {
}
}
// TestPruneKeepsToolHeavySessionBounded: when growth comes from tool results,
// projection-only pruning is the primary pressure valve. Occasional force-ratio
// summary folds may still fire when the recent tail alone is huge, but they must
// stay rare and must never trip the stuck guard. 20 turns of ~3k-token blobs
// would otherwise cross 0.8×40000 around turn 11 without maintenance.
func TestPruneKeepsToolHeavySessionBounded(t *testing.T) {
// TestSummaryKeepsToolHeavySessionBounded: automatic maintenance no longer
// prunes mid-session tool bodies. Growth from large tool results crosses
// compact_ratio once, installs one content-driven summary checkpoint, and must
// not loop or trip the stuck guard. Prune projections must not reappear.
func TestSummaryKeepsToolHeavySessionBounded(t *testing.T) {
perTurn, paused, prunes := compactionsPerTurn(t, 40000, strings.Repeat("file line. ", 1100), "", 20)
total := 0
@@ -201,13 +213,25 @@ func TestPruneKeepsToolHeavySessionBounded(t *testing.T) {
}
t.Logf("compactions per turn: %v (total %d), paused=%v, prunes=%d", perTurn, total, paused, prunes)
if total == 0 {
t.Errorf("expected at least one summary fold over a tool-heavy session past compact_ratio")
}
if total > 3 {
t.Errorf("compaction fired %d times; pruning should keep folds rare on a tool-heavy session", total)
t.Errorf("compaction fired %d times; a single content-driven summary should leave breathing room", total)
}
if paused {
t.Errorf("auto-compaction paused; pruning should have prevented the stuck loop entirely")
t.Errorf("auto-compaction paused; successful summary should have prevented the stuck loop")
}
if prunes == 0 {
t.Errorf("expected at least one prune pass over a tool-heavy session")
if prunes != 0 {
t.Errorf("prune projections must not be installed (got %d)", prunes)
}
if c := consecutiveCompactingTurns(perTurn); c > 1 {
t.Errorf("compaction fired on %d consecutive turns; content-driven summary should reclaim headroom", c)
}
}
// Keep the old name as an alias so external references still resolve during the
// rename window; the body asserts the new no-prune contract.
func TestPruneKeepsToolHeavySessionBounded(t *testing.T) {
TestSummaryKeepsToolHeavySessionBounded(t)
}
+26 -95
View File
@@ -1,24 +1,22 @@
package agent
import (
"fmt"
"strings"
"testing"
"reasonix/internal/provider"
)
// bigTurn is comfortably over maxPinnedFirstUserTokens, so it is never pinnable.
func bigTurn() string { return strings.Repeat("paste ", 4000) }
// partitionCoversRegion is the invariant that makes a lost user turn impossible:
// every provider-visible message in the region lands in exactly one group.
func partitionCoversRegion(t *testing.T, a *Agent, region []provider.Message) (early, kept, fold []provider.Message) {
func partitionCoversRegion(t *testing.T, a *Agent, region []provider.Message) (kept, fold []provider.Message) {
t.Helper()
early, carried, kept, fold := a.partitionFoldForProjection(region)
kept = append(carried, kept...)
if len(early) != 0 || len(carried) != 0 {
t.Fatalf("early=%d carried=%d, want both empty under content-driven summary", len(early), len(carried))
}
seen := map[string]int{}
for _, group := range [][]provider.Message{early, kept, fold} {
for _, group := range [][]provider.Message{kept, fold} {
for _, m := range group {
seen[m.Content]++
}
@@ -38,107 +36,37 @@ func partitionCoversRegion(t *testing.T, a *Agent, region []provider.Message) (e
t.Errorf("message %q is in %d groups — it would be duplicated", m.Content, seen[m.Content])
}
}
return early, kept, fold
return kept, fold
}
func TestPartitionHoistsFirstSmallUserTurnsInOrder(t *testing.T) {
// The hoist window is position-fixed (the first N), never "the most recent
// N": a window that moved with each fold would rewrite the projection prefix
// and crater the server-side cache.
a := &Agent{}
var region []provider.Message
for i := range 25 {
region = append(region, provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("small turn %d", i)})
}
early, kept, fold := partitionCoversRegion(t, a, region)
if len(early) != maxEarlyUserTurns || len(kept) != 0 || len(fold) != 25-maxEarlyUserTurns {
t.Fatalf("early=%d kept=%d fold=%d, want %d/0/%d", len(early), len(kept), len(fold), maxEarlyUserTurns, 25-maxEarlyUserTurns)
}
for i, m := range early {
if want := fmt.Sprintf("small turn %d", i); UserMessageText(m) != want {
t.Fatalf("early[%d]=%q, want %q — the hoist window must be the leading turns", i, UserMessageText(m), want)
}
}
for i, m := range fold {
if want := fmt.Sprintf("small turn %d", maxEarlyUserTurns+i); UserMessageText(m) != want {
t.Fatalf("fold[%d]=%q, want %q", i, UserMessageText(m), want)
}
}
}
func TestPartitionFoldsLargeUserTurns(t *testing.T) {
func TestPartitionFoldsAllUserTurnsByDefault(t *testing.T) {
// Content-driven summary no longer hoists early user turns to pad the
// checkpoint; only keep-policy content and the recent tail stay verbatim.
a := &Agent{}
region := []provider.Message{
{Role: provider.RoleUser, Content: bigTurn()},
{Role: provider.RoleUser, Content: "small"},
}
early, _, fold := partitionCoversRegion(t, a, region)
if len(early) != 1 || UserMessageText(early[0]) != "small" {
t.Fatalf("early=%+v, want only the small turn hoisted", early)
}
if len(fold) != 1 {
t.Fatalf("fold=%d, want the large turn folded", len(fold))
}
}
// A user turn that is not hoisted must still reach the summarizer. Two rules
// that disagreed on which turns count as "early" used to drop the turns after an
// oversized one from both groups, deleting stated constraints outright.
func TestPartitionKeepsSmallTurnsAfterALargeOne(t *testing.T) {
a := &Agent{}
region := []provider.Message{
{Role: provider.RoleUser, Content: "constraint A"},
{Role: provider.RoleUser, Content: bigTurn()},
{Role: provider.RoleUser, Content: "never touch the db schema"},
{Role: provider.RoleUser, Content: "constraint C"},
{Role: provider.RoleUser, Content: "small turn 0"},
{Role: provider.RoleUser, Content: "small turn 1"},
{Role: provider.RoleUser, Content: "small turn 2"},
{Role: provider.RoleAssistant, Content: "work"},
}
early, _, fold := partitionCoversRegion(t, a, region)
if len(early) != 3 {
t.Fatalf("early=%d (%+v), want the three small turns hoisted across the oversized one", len(early), early)
}
if got := renderTranscript(fold); !strings.Contains(got, "paste") {
t.Fatalf("the oversized turn must still reach the summarizer:\n%s", got)
}
}
// The hoist set is drawn from the fold region only. Reading past it would hoist
// a turn that also rides in the verbatim tail, sending it to the model twice.
func TestPartitionHoistIsDisjointFromTail(t *testing.T) {
a := &Agent{}
msgs := []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleUser, Content: "constraint A"},
{Role: provider.RoleAssistant, Content: "work"},
{Role: provider.RoleUser, Content: "tail turn one"},
{Role: provider.RoleAssistant, Content: "more"},
{Role: provider.RoleUser, Content: "tail turn two"},
}
head := a.pinnedPrefixLen(msgs)
const start = 4
early, _, _ := partitionCoversRegion(t, a, msgs[head:start])
for _, m := range early {
for _, tail := range msgs[start:] {
if m.Content == tail.Content {
t.Fatalf("hoisted turn %q also rides in the verbatim tail", m.Content)
}
}
kept, fold := partitionCoversRegion(t, a, region)
if len(kept) != 0 || len(fold) != 4 {
t.Fatalf("kept=%d fold=%d, want 0/4", len(kept), len(fold))
}
}
func TestPartitionFoldsPriorDigests(t *testing.T) {
// Any digest in the transcript is merged into the next one, so the model
// never sees a chain of them.
// Any digest in the region is merged into the next one, so the model
// never sees a chain of digests.
a := &Agent{}
region := []provider.Message{
{Role: provider.RoleUser, Content: summaryTagOpen + "\nolder digest\n" + summaryTagClose},
{Role: provider.RoleUser, Content: summaryTagOpen + "\nnewer digest\n" + summaryTagClose},
{Role: provider.RoleAssistant, Content: "work"},
}
early, kept, fold := partitionCoversRegion(t, a, region)
if len(early) != 0 || len(kept) != 0 || len(fold) != 3 {
t.Fatalf("early=%d kept=%d fold=%d, want every digest folded", len(early), len(kept), len(fold))
kept, fold := partitionCoversRegion(t, a, region)
if len(kept) != 0 || len(fold) != 3 {
t.Fatalf("kept=%d fold=%d, want every digest folded", len(kept), len(fold))
}
}
@@ -149,8 +77,11 @@ func TestPartitionKeepPolicyOutranksFold(t *testing.T) {
{Role: provider.RoleAssistant, Content: "call", ToolCalls: []provider.ToolCall{{ID: "t1", Name: "bash"}}},
{Role: provider.RoleTool, ToolCallID: "t1", Name: "bash", Content: "error: boom"},
}
early, kept, fold := partitionCoversRegion(t, a, region)
if len(early) != 1 || len(kept) != 2 || len(fold) != 0 {
t.Fatalf("early=%d kept=%d fold=%d, want the error result and its caller kept", len(early), len(kept), len(fold))
kept, fold := partitionCoversRegion(t, a, region)
if len(kept) != 2 || len(fold) != 1 {
t.Fatalf("kept=%d fold=%d, want error tool-call group kept and user folded", len(kept), len(fold))
}
if got := renderTranscript(fold); !strings.Contains(got, "small") {
t.Fatalf("user turn should fold: %s", got)
}
}
+96 -280
View File
@@ -5,9 +5,7 @@ import (
"errors"
"fmt"
"strings"
"time"
"reasonix/internal/ablation"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/tool"
@@ -349,10 +347,10 @@ func buildVisibleCompressionProjection(visible []provider.Message, plan visibleC
func compactionTelemetryFromSummary(trigger, cacheState string, sourceTokens int, res foldSummary) CompactionTelemetry {
tele := CompactionTelemetry{
Trigger: trigger, CacheState: cacheState, Mode: res.Mode,
Native: res.Mode == CompactionModeNative, SourceTokens: sourceTokens,
SourceTokens: sourceTokens,
ProviderRequestID: res.RequestID,
FoldTokens: res.FoldTokens,
Spans: res.Spans,
Spans: 1, // one application-layer summary request per transaction
}
usage := res.Usage
if usage == nil {
@@ -376,11 +374,10 @@ func (a *Agent) compact(ctx context.Context, trigger, instructions string, force
return err
}
// compactToProjection summarizes the older middle of the session into a model-
// visible projection. The canonical transcript is never rewritten. force
// bypasses the fold-economics skip. CompactionNoop means no projection was
// installed (nothing to fold); callers at the force threshold must treat that
// as a hard failure rather than sending the oversized canonical prompt.
// compactToProjection installs one content-driven summary checkpoint:
// stable prefix + one structured digest + recent verbatim tail.
// The canonical transcript is never rewritten. CompactionNoop means nothing
// was foldable; callers at physical overflow must treat that as hard failure.
func (a *Agent) compactToProjection(ctx context.Context, trigger, instructions string, force bool) (CompactionOutcome, error) {
a.compactionRunMu.Lock()
defer a.compactionRunMu.Unlock()
@@ -394,32 +391,25 @@ func (a *Agent) compactToProjection(ctx context.Context, trigger, instructions s
startProjectionVersion := a.compactionState.Projection.ProjectionVersion
startGeneration := a.compactionState.Generation
a.compactionMu.Unlock()
visibleInput := canonical
if projectionValid(stateSnapshot, canonical, transcriptVersion, a.currentPromptCacheKey()) {
if projected := modelVisibleFromProjection(stateSnapshot.Projection, canonical); len(projected) > 0 {
visibleInput = projected
}
}
viewInputHash := providerVisibleFingerprint(provider.ModelMessages(visibleInput))
msgs := a.visibleInputForFold(stateSnapshot, canonical, transcriptVersion)
viewInputHash := providerVisibleFingerprint(provider.ModelMessages(msgs))
if trigger != CompactionTriggerManual && stateSnapshot.LastReceipt != nil && stateSnapshot.LastReceipt.Status == "applied" && stateSnapshot.LastReceipt.Action == "summary" && stateSnapshot.LastReceipt.InputHash == viewInputHash {
return CompactionNoop, nil
}
msgs := a.foldSource(canonical)
head, start, ok := a.planFoldRegion(msgs)
head, start, ok := a.planFoldRegion(msgs, force)
if !ok {
return CompactionNoop, nil
}
region := msgs[head:start]
early, carried, kept, fold := a.partitionFoldForProjection(region)
if len(fold) == 0 {
_, _, kept, fold := a.partitionFoldForProjection(msgs[head:start])
if len(fold) == 0 || (!force && !foldEconomics(fold)) {
return CompactionNoop, nil
}
if !force && !foldEconomics(fold) {
return CompactionNoop, nil
fixedPrefixTokens := estimateMessagesTokens(a.providerProjectionMessages(msgs[:head]))
if a.contextWindow > 0 && fixedPrefixTokens >= a.compactTrigger() {
return CompactionNoop, fmt.Errorf("%w: fixed prefix (%d tokens) already exceeds trigger (%d)", errCheckpointRejected, fixedPrefixTokens, a.compactTrigger())
}
a.sink.Emit(event.Event{Kind: event.CompactionStarted, Compaction: event.Compaction{Trigger: trigger}})
if a.hooks != nil {
if hookInstr := a.hooks.PreCompact(ctx, trigger); hookInstr != "" {
if instructions != "" {
@@ -428,7 +418,6 @@ func (a *Agent) compactToProjection(ctx context.Context, trigger, instructions s
instructions += hookInstr
}
}
var err error
fold, instructions, err = a.interceptCompactionPrepare(ctx, fold, instructions)
if err != nil {
@@ -440,7 +429,7 @@ func (a *Agent) compactToProjection(ctx context.Context, trigger, instructions s
return CompactionNoop, nil
}
sourceTokens := a.estimatedPromptTokens(visibleInput)
sourceTokens := a.estimatedPromptTokens(msgs)
res, err := a.foldToSummary(ctx, fold, instructions)
summary := res.Text
tele := compactionTelemetryFromSummary(trigger, a.CacheState(), sourceTokens, res)
@@ -457,23 +446,18 @@ func (a *Agent) compactToProjection(ctx context.Context, trigger, instructions s
a.emitCompactionAborted(trigger)
return CompactionNoop, err
}
archived := ""
projMsgs := make([]provider.Message, 0, head+len(early)+len(carried)+1+len(kept)+len(msgs)-start)
projMsgs = append(projMsgs, msgs[:head]...)
projMsgs = append(projMsgs, early...)
projMsgs = append(projMsgs, carried...)
projMsgs = append(projMsgs, formatSummaryMessage(summary))
projMsgs = append(projMsgs, kept...)
projMsgs = append(projMsgs, msgs[start:]...)
projMsgs = provider.ModelMessages(projMsgs)
projTokens := estimateMessagesTokens(a.providerProjectionMessages(projMsgs))
projMsgs := checkpointProjectionMessages(msgs, head, start, kept, summary)
projTokens := a.estimatedPromptTokens(projMsgs)
fixedPrefixTokens = a.estimatedPromptTokens(msgs[:head])
tele.ProjectionTokens = projTokens
a.emitCompactionTelemetry(tele)
if err := a.acceptCheckpointCandidate(trigger, force, sourceTokens, projTokens, fixedPrefixTokens); err != nil {
a.emitCompactionAborted(trigger)
return CompactionNoop, err
}
viewOutputHash := providerVisibleFingerprint(provider.ModelMessages(projMsgs))
st, err := a.commitSummaryProjection(summaryProjectionCommit{
_, err = a.commitSummaryProjection(summaryProjectionCommit{
canonical: canonical, fold: fold, projected: projMsgs, result: res,
transcriptVersion: transcriptVersion, projectionVersion: startProjectionVersion,
generation: startGeneration, activeTurn: activeTurn, trigger: trigger,
@@ -484,21 +468,81 @@ func (a *Agent) compactToProjection(ctx context.Context, trigger, instructions s
a.emitCompactionAborted(trigger)
return CompactionNoop, err
}
archived = st.LastReceipt.Archive
a.sink.Emit(event.Event{Kind: event.CompactionDone, Compaction: event.Compaction{
Trigger: trigger, Messages: len(fold), Summary: summary, Archive: archived,
Trigger: trigger, Messages: len(fold), Summary: summary,
}})
return CompactionInstalled, nil
}
// planFoldRegion locates msgs[head:start] for a fold, stopping short of an
// active turn so a tool loop is never folded mid-flight. ok is false when there
// is nothing left to fold.
func (a *Agent) planFoldRegion(msgs []provider.Message) (head, start int, ok bool) {
head, start, ok = a.planCompaction(msgs, minCompactMessages)
// visibleInputForFold prefers the prior projection + new history over full canonical.
func (a *Agent) visibleInputForFold(state CompactionState, canonical []provider.Message, transcriptVersion uint64) []provider.Message {
if projectionValid(state, canonical, transcriptVersion, a.currentPromptCacheKey()) {
if projected := modelVisibleFromProjection(state.Projection, canonical); len(projected) > 0 {
return projected
}
}
return canonical
}
func checkpointProjectionMessages(msgs []provider.Message, head, start int, kept []provider.Message, summary string) []provider.Message {
projMsgs := make([]provider.Message, 0, head+1+len(kept)+len(msgs)-start)
projMsgs = append(projMsgs, msgs[:head]...)
projMsgs = append(projMsgs, formatSummaryMessage(summary))
projMsgs = append(projMsgs, kept...)
projMsgs = append(projMsgs, msgs[start:]...)
return provider.ModelMessages(projMsgs)
}
// acceptCheckpointCandidate: ≤50% + smaller for auto; force may exceed 50%
// only if still below trigger; manual below trigger accepts any savings.
func (a *Agent) acceptCheckpointCandidate(trigger string, force bool, sourceTokens, candidateTokens, fixedPrefixTokens int) error {
if candidateTokens >= sourceTokens {
return fmt.Errorf("%w: candidate would not reduce tokens (%d >= %d)", errCheckpointRejected, candidateTokens, sourceTokens)
}
triggerTokens := a.compactTrigger()
ceiling := a.checkpointCeiling()
hard := a.hardInputCeiling()
manualBelowTrigger := trigger == CompactionTriggerManual && sourceTokens < triggerTokens
if manualBelowTrigger {
// Directed manual compress: any real savings is acceptable.
return nil
}
if fixedPrefixTokens > ceiling {
// Exceptional path: fixed prefix alone already exceeds 50%.
savings := sourceTokens - candidateTokens
if savings < a.exceptionalMinimumSavings() {
return fmt.Errorf("%w: fixed-prefix exception requires ≥%d token savings, got %d", errCheckpointRejected, a.exceptionalMinimumSavings(), savings)
}
if triggerTokens > 0 && candidateTokens >= triggerTokens {
return fmt.Errorf("%w: candidate %d still at or above trigger %d", errCheckpointRejected, candidateTokens, triggerTokens)
}
if hard > 0 && candidateTokens >= hard {
return fmt.Errorf("%w: candidate %d still at or above physical ceiling %d", errCheckpointRejected, candidateTokens, hard)
}
return nil
}
if ceiling > 0 && candidateTokens > ceiling {
// keep / recent_keep / active-turn protection made the candidate large;
// this is not a fixed-prefix exception. Force/overflow may still land
// a strictly smaller view below the trigger when the ceiling cannot.
if !force {
return fmt.Errorf("%w: candidate %d exceeds checkpoint ceiling %d (protected content too large)", errCheckpointRejected, candidateTokens, ceiling)
}
}
if triggerTokens > 0 && candidateTokens >= triggerTokens && !force {
return fmt.Errorf("%w: candidate %d still at or above trigger %d", errCheckpointRejected, candidateTokens, triggerTokens)
}
if force && triggerTokens > 0 && candidateTokens >= triggerTokens {
return fmt.Errorf("%w: forced candidate %d still at or above trigger %d", errCheckpointRejected, candidateTokens, triggerTokens)
}
return nil
}
// planFoldRegion returns [head:start] to fold; force shrinks the recent tail.
func (a *Agent) planFoldRegion(msgs []provider.Message, force bool) (head, start int, ok bool) {
head, start, ok = a.planCompaction(msgs, minCompactMessages, force)
if !ok {
head, start, ok = a.planCompaction(msgs, 1)
head, start, ok = a.planCompaction(msgs, 1, force)
}
if !ok {
return head, start, false
@@ -509,40 +553,13 @@ func (a *Agent) planFoldRegion(msgs []provider.Message) (head, start int, ok boo
return head, start, start > head
}
// foldSource picks what a fold reads. By default every fold re-derives its
// digest from the canonical transcript, so digests never chain — at the cost of
// re-reading the whole session each time. The incremental experiment folds the
// model-visible view instead, which feeds the previous digest back through the
// summarizer: cheaper per fold, and lossy in a way CompactionBench measures.
func (a *Agent) foldSource(canonical []provider.Message) []provider.Message {
if !a.ablation.Off(ablation.FullFold) {
return canonical
}
if visible := a.modelVisibleMessages(); len(visible) > 0 {
return visible
}
return canonical
}
// partitionFoldForProjection splits the fold region three ways: user turns
// hoisted verbatim ahead of the digest, messages the keep policy protects, and
// the remainder that folds (prior digests included, so a merge yields one
// summary). The groups partition the region — one pass decides each message
// once, so no turn can fall between a hoist rule and a fold rule that disagree.
func (a *Agent) partitionFoldForProjection(region []provider.Message) (early, carried, kept, fold []provider.Message) {
policyKeep := keepIndexes(region, a.keepPolicy)
hoist := a.earlyUserTurns(region)
carryDigests := a.carryPriorDigests(region)
for i, m := range region {
switch {
case m.LocalOnly: // display-only output never reaches a provider
case hoist[i]:
early = append(early, m)
case isCompactionSummary(m):
if carryDigests {
carried = append(carried, m)
continue
}
// Always merge prior digests into the single next summary.
fold = append(fold, m)
case policyKeep[i]:
kept = append(kept, a.keptForProjection(m))
@@ -550,215 +567,14 @@ func (a *Agent) partitionFoldForProjection(region []provider.Message) (early, ca
fold = append(fold, m)
}
}
return early, carried, kept, fold
return nil, nil, kept, fold
}
// carryPriorDigests reports whether the digests already in the region survive
// this fold verbatim instead of being re-summarized. Re-summarizing a digest is
// the only step in a fold where a fact can be dropped and never recovered, so
// an incremental fold carries them — until they outgrow their budget, when one
// consolidating fold merges them and the chain starts over.
func (a *Agent) carryPriorDigests(region []provider.Message) bool {
if !a.ablation.Off(ablation.FullFold) {
return false
}
total := 0
for _, m := range region {
if isCompactionSummary(m) {
total += summaryInputTokens([]provider.Message{m})
}
}
if total > maxCarriedDigestTokens {
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf(
"consolidating %d tokens est. of carried digests into one; earlier facts now depend on this merge", total)})
return false
}
return true
}
// earlyUserTurns marks the region positions of the small user turns hoisted
// verbatim ahead of the digest. Selecting from the fold region alone keeps the
// set disjoint from the verbatim tail, and taking the first N (never the latest
// N) keeps the hoisted bytes identical across folds: the region only ever grows
// at its end, so the set can gain a member but never reorder or lose one.
func (a *Agent) earlyUserTurns(region []provider.Message) []bool {
hoist := make([]bool, len(region))
n := 0
for i, m := range region {
if n == maxEarlyUserTurns {
break
}
if m.LocalOnly || m.Role != provider.RoleUser || isCompactionSummary(m) {
continue
}
if !a.fixedPinnableUserTurn(m) {
continue
}
hoist[i] = true
n++
}
return hoist
}
// runCompactionSummary tries native compaction first, then summarizeWithRetry.
// On total failure it returns the error without a mechanical marker.
// runCompactionSummary uses the single local summarizer path for every provider.
func (a *Agent) runCompactionSummary(ctx context.Context, fold []provider.Message, instructions string) (summary, mode string, usage *provider.Usage, providerReqID string, err error) {
if nc, ok := provider.AsNativeCompactor(a.prov); ok {
maxOut := 0
if cc, ok := provider.AsCompactionCapabler(a.prov); ok {
caps := cc.CompactionCapabilities()
maxOut = caps.CompactionOutputTokens
if maxOut <= 0 {
maxOut = caps.MaxOutputTokens
}
}
a.compactionMu.Lock()
sessionPath := a.sessionPath
a.compactionMu.Unlock()
res, nerr := nc.Compact(ctx, provider.CompactionRequest{
Messages: fold,
Instructions: instructions,
MaxOutputTokens: maxOut,
PromptCacheKey: promptCacheKey(a.workspaceID, BranchID(sessionPath), a.modelRef),
SessionID: BranchID(sessionPath),
})
if nerr == nil && res.Valid() {
if res.Summary != "" {
return res.Summary, CompactionModeNative, res.Usage, res.ProviderRequestID, nil
}
// Provider returned a full projection; extract summary text if present,
// otherwise render the projection as the digest body.
if s := extractLatestSummary(res.Projection); s != "" {
return s, CompactionModeNative, res.Usage, res.ProviderRequestID, nil
}
return renderTranscript(res.Projection), CompactionModeNative, res.Usage, res.ProviderRequestID, nil
}
if nerr != nil && !errors.Is(nerr, provider.ErrCompactionUnsupported) {
// Hard native failure: still try ordinary summarize fallback.
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "Native compaction unavailable; using summary fallback.", Detail: nerr.Error()})
}
}
summary, usage, err = a.summarizeWithRetry(ctx, fold, instructions)
summary, usage, err = a.summarizeOnce(ctx, fold, instructions)
if err != nil {
return "", CompactionModeSummarized, usage, "", err
}
return summary, CompactionModeSummarized, usage, "", nil
}
// installPruneProjection stores a projection whose messages are a snipped/pruned
// view of the canonical transcript (no summarizer call).
func (a *Agent) installPruneProjection(view []provider.Message, st PruneStats) error {
canonical, version := a.session.snapshotMessagesVersion()
view = provider.ModelMessages(view)
if len(view) == 0 {
return nil
}
// The maintenance input must still be the current visible view. This is the
// compare half of the two-phase projection transaction; a concurrent append,
// model switch or newer maintenance pass causes a safe retry instead of a
// last-writer-wins overwrite.
a.compactionMu.Lock()
current := a.compactionState
a.compactionMu.Unlock()
currentVisible := canonical
if projectionValid(current, canonical, version, a.currentPromptCacheKey()) {
if projected := modelVisibleFromProjection(current.Projection, canonical); len(projected) > 0 {
currentVisible = projected
}
}
currentInputHash := providerVisibleFingerprint(provider.ModelMessages(currentVisible))
if st.InputHash != "" && currentInputHash != st.InputHash {
return fmt.Errorf("context maintenance became stale")
}
src := estimateMessagesTokens(provider.ModelMessages(currentVisible))
dst := estimateMessagesTokens(view)
if src <= dst && st.Results > 0 {
return nil
}
// Do not pay a cache-prefix break for a negligible rewrite while the view is
// still inside the hard input ceiling. Near/over the ceiling this guard is
// intentionally bypassed: safety takes precedence over cache preservation.
if st.Results > 0 && !st.Force && src < a.hardInputCeiling() && src-dst < a.minimumMaintenanceSavingsTokens() {
return nil
}
// Planning uses a stable placeholder and performs no I/O. Archive only after
// the compare/min-savings gates prove this projection can be installed.
if st.Results > 0 && a.archiveDir != "" {
originals := make([]provider.Message, 0, st.Results)
for i := range currentVisible {
if i >= len(view) || currentVisible[i].Content == view[i].Content {
continue
}
originals = append(originals, currentVisible[i])
}
if len(originals) > 0 {
archive, err := archiveMessages(a.archiveDir, originals)
if err != nil {
return fmt.Errorf("archive: %w", err)
}
st.Archive = archive
for i := range currentVisible {
if i >= len(view) || currentVisible[i].Content == view[i].Content {
continue
}
m := currentVisible[i]
replacement, ok := a.maintenanceReplacement(m, st.Mode, archive)
if !ok {
continue
}
m.Content = replacement
view[i] = m
}
dst = estimateMessagesTokens(view)
}
}
projVersion := current.Projection.ProjectionVersion + 1
inputHash := currentInputHash
outputHash := providerVisibleFingerprint(view)
operationID := fmt.Sprintf("%s-%d-%s", CompactionTriggerSnip, projVersion, outputHash)
action := "snip"
if st.Mode == toolResultPrune {
action = "prune"
}
now := time.Now().UTC()
state := CompactionState{
SchemaVersion: compactionStateSchemaCurrent,
TranscriptVersion: version,
Generation: current.Generation + 1,
NativeContextEditingAccepted: a.nativeContextEditingAccepted.Load(),
ContextEditingFallbackLocal: a.contextEditingRuntimeFallback.Load(),
Projection: ContextProjection{
Messages: view,
TranscriptVersion: version,
ProjectionVersion: projVersion,
CoveredCount: len(canonical),
CoveredPrefixHash: coveredPrefixHash(canonical, len(canonical)),
SourceTokens: src,
ProjectionTokens: dst,
ViewInputHash: inputHash,
ViewOutputHash: outputHash,
CreatedAt: now,
},
PromptCacheKey: a.currentPromptCacheKey(),
LastCacheState: a.CacheState(),
LastTrigger: CompactionTriggerPressure,
LastMode: CompactionModeSnip,
LastSourceTokens: src,
LastResultTokens: dst,
LastReceipt: &ContextMaintenanceReceipt{
OperationID: operationID, Status: "applied", Action: action,
Trigger: CompactionTriggerPressure, SourceProjection: current.Projection.ProjectionVersion,
ProjectionVersion: projVersion,
CoveredCount: len(canonical), CoveredPrefixHash: coveredPrefixHash(canonical, len(canonical)),
InputHash: inputHash, OutputHash: outputHash, InputTokens: src, ResultTokens: dst,
SavedTokens: max(0, src-dst), AffectedToolResults: st.Results, Archive: st.Archive,
CacheBreak: true, CreatedAt: now,
},
UpdatedAt: now,
}
if err := a.installProjectionIfCurrent(state, current.Projection.ProjectionVersion, current.Generation); err != nil {
return err
}
a.emitContextMaintenance(state.LastReceipt)
return nil
}
+18 -12
View File
@@ -12,22 +12,26 @@ import (
)
func TestMaybeCompactIgnoresRetryAggregateBelowContextThreshold(t *testing.T) {
// compact_ratio is the sole trigger; LatestPromptTokens (context attempt)
// must drive the decision, not the billable retry aggregate.
const window = 10_000
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: strings.Repeat("large earlier request ", 500)},
{Role: provider.RoleAssistant, Content: "earlier answer"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, Content: strings.Repeat("large earlier request ", 500)},
{Role: provider.RoleUser, Content: "current request"},
{Role: provider.RoleAssistant, Content: "current answer"},
}}
a := New(&fakeProvider{reply: "summary"}, tool.NewRegistry(), sess, Options{
ContextWindow: 100,
ContextWindow: window,
CompactRatio: 0.85,
RecentKeep: 2,
ArchiveDir: t.TempDir(),
}, event.Discard)
// Aggregate 9000 would be above fold (8500); latest context attempt 4000 is not.
prepareForObservedUsage(a, context.Background(), &provider.Usage{
PromptTokens: 80,
ContextPromptTokens: 40,
PromptTokens: 9000,
ContextPromptTokens: 4000,
})
if hasCompactionSummary(visibleContext(a)) {
@@ -36,22 +40,24 @@ func TestMaybeCompactIgnoresRetryAggregateBelowContextThreshold(t *testing.T) {
}
func TestMaybeCompactStillTriggersAtLatestContextThreshold(t *testing.T) {
const window = 10_000
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: strings.Repeat("large earlier request ", 500)},
{Role: provider.RoleAssistant, Content: "earlier answer"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, Content: strings.Repeat("large earlier request ", 500)},
{Role: provider.RoleUser, Content: "current request"},
{Role: provider.RoleAssistant, Content: "current answer"},
}}
a := New(&fakeProvider{reply: "summary"}, tool.NewRegistry(), sess, Options{
ContextWindow: 100,
ContextWindow: window,
CompactRatio: 0.85,
RecentKeep: 2,
ArchiveDir: t.TempDir(),
}, event.Discard)
// Latest context attempt at/above fold trigger (8500) must install a summary.
prepareForObservedUsage(a, context.Background(), &provider.Usage{
PromptTokens: 160,
ContextPromptTokens: 80,
PromptTokens: 16000,
ContextPromptTokens: 8600,
})
if !hasCompactionSummary(visibleContext(a)) {
+80 -72
View File
@@ -233,15 +233,15 @@ func TestCompactEmitsEvents(t *testing.T) {
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, Content: "step one"},
{Role: provider.RoleAssistant, Content: strings.Repeat("step one work ", 200)},
{Role: provider.RoleUser, Content: "more"},
{Role: provider.RoleAssistant, Content: "step two"},
{Role: provider.RoleAssistant, Content: strings.Repeat("step two work ", 200)},
{Role: provider.RoleUser, Content: "next"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
var got []event.Event
sink := event.FuncSink(func(e event.Event) { got = append(got, e) })
a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2}, sink)
a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 50_000, RecentKeep: 2}, sink)
if err := a.compact(context.Background(), "auto", "", true); err != nil {
t.Fatalf("compact: %v", err)
@@ -278,16 +278,20 @@ func TestCompactEmitsEvents(t *testing.T) {
// a PreCompact hook's output both reach the summarizer's system prompt.
func TestCompactInjectsFocusAndPreCompactHook(t *testing.T) {
prov := &fakeProvider{reply: "- ok"}
big := strings.Repeat("step work detail ", 200)
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, Content: "step one"},
{Role: provider.RoleAssistant, Content: big},
{Role: provider.RoleUser, Content: "more"},
{Role: provider.RoleAssistant, Content: "step two"},
{Role: provider.RoleAssistant, Content: big},
{Role: provider.RoleUser, Content: "next"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, Hooks: &stubHooks{preCompactOut: "KEEP-THE-MIGRATION-PLAN"}}, event.Discard)
a := New(prov, tool.NewRegistry(), sess, Options{
ContextWindow: 50_000, RecentKeep: 2,
Hooks: &stubHooks{preCompactOut: "KEEP-THE-MIGRATION-PLAN"},
}, event.Discard)
if err := a.compact(context.Background(), "manual", "focus on the auth refactor", true); err != nil {
t.Fatalf("compact: %v", err)
@@ -326,70 +330,59 @@ func TestCompactSkipsSingleSmallMessage(t *testing.T) {
}
func TestMaybeCompactThreshold(t *testing.T) {
// A large early user message gives the fold real value; with a 100-token window
// the soft (50%), trigger (80%), and force (90%) thresholds are easy to hit.
// compact_ratio is the sole trigger. Use a realistic window so hardInputCeiling
// (window−protocolReserve) stays above the fold trigger; tiny synthetic
// windows collapse hard to 1 and force every observation.
const window = 10_000
const ratio = 0.8 // fold trigger = 8000
newSess := func() *Session {
return &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: strings.Repeat("a ", 500)},
{Role: provider.RoleAssistant, Content: "b"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, Content: strings.Repeat("a ", 5000)},
{Role: provider.RoleUser, Content: "c"},
{Role: provider.RoleAssistant, Content: "d"},
{Role: provider.RoleAssistant, Content: strings.Repeat("b ", 5000)},
{Role: provider.RoleUser, Content: "e"},
{Role: provider.RoleAssistant, Content: "f"},
}}
}
opts := Options{ContextWindow: window, CompactRatio: ratio, RecentKeep: 2}
// Below 50% of the window: untouched.
// Below compact_ratio: untouched, no summarizer call.
sess := newSess()
a := New(&fakeProvider{reply: "s"}, tool.NewRegistry(), sess, Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 49})
prov := &fakeProvider{reply: "s"}
a := New(prov, tool.NewRegistry(), sess, opts, event.Discard)
prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 7000})
if len(sess.Messages) != 7 {
t.Errorf("below threshold should not compact, len = %d", len(sess.Messages))
}
// At/above 50% only emits a soft notice; it does not rewrite the cache prefix.
sess = newSess()
prov := &fakeProvider{reply: "s"}
var notices []event.Event
a = New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice {
notices = append(notices, e)
}
}))
prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 50})
if len(sess.Messages) != 7 {
t.Errorf("soft threshold should not compact, len = %d", len(sess.Messages))
}
if len(prov.got) != 0 {
t.Fatalf("soft threshold called summarizer: %+v", prov.got)
}
if len(notices) != 1 || notices[0].Text != "Context is getting large; preserving cache until cleanup is needed." || !strings.Contains(notices[0].Detail, "context reached 50%") {
t.Fatalf("soft threshold notice = %+v", notices)
}
prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 60})
if len(notices) != 1 {
t.Fatalf("soft threshold notice should only emit once, got %d", len(notices))
t.Fatalf("below threshold called summarizer: %+v", prov.got)
}
// At/above 80%: compacts when the fold is economically worthwhile. The
// token-budgeted tail keeps the small recent messages, so the large early
// message is the only foldable region — folding it installs a summary at
// index 1 (the count is unchanged because one message becomes one summary).
// 60% is below the sole 80% trigger: still no maintenance.
sess = newSess()
a = New(&fakeProvider{reply: "s"}, tool.NewRegistry(), sess, Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 80})
prov = &fakeProvider{reply: "s"}
a = New(prov, tool.NewRegistry(), sess, opts, event.Discard)
prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 6000})
if a.currentProjectionVersion() != 0 || len(prov.got) != 0 {
t.Fatalf("60%% should not maintain: version=%d calls=%d", a.currentProjectionVersion(), len(prov.got))
}
// At/above compact_ratio: one summary projection; canonical stays full.
sess = newSess()
a = New(&fakeProvider{reply: "s"}, tool.NewRegistry(), sess, opts, event.Discard)
prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 8500})
if !hasCompactionSummary(visibleContext(a)) {
t.Errorf("compact threshold should fold the large early message into projection, got: %+v", visibleContext(a))
t.Errorf("compact threshold should install a summary projection, got: %+v", visibleContext(a))
}
// Canonical transcript remains full.
if len(sess.Messages) != 7 {
t.Errorf("canonical should stay full after projection compact, len=%d", len(sess.Messages))
}
// No context window: compaction disabled.
sess = newSess()
a = New(&fakeProvider{reply: "s"}, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
a = New(&fakeProvider{reply: "s"}, tool.NewRegistry(), sess, Options{RecentKeep: 2}, event.Discard)
prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 1 << 30})
if len(sess.Messages) != 7 {
t.Errorf("no window should disable compaction, len = %d", len(sess.Messages))
@@ -397,22 +390,27 @@ func TestMaybeCompactThreshold(t *testing.T) {
}
func TestMaybeCompactForceCeilingBypassesEconomics(t *testing.T) {
// Physical hard ceiling (window−reserve) forces a summary even when the fold
// is below the minFoldTokens economics floor — but the fold must still be
// large enough that the candidate lands under the compact_ratio trigger.
const window = 10_000
big := strings.Repeat("old analysis detail ", 400)
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "small old request"},
{Role: provider.RoleAssistant, Content: "small old answer"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, Content: big},
{Role: provider.RoleUser, Content: "next"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
prov := &fakeProvider{reply: "forced summary"}
a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2}, event.Discard)
prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 90})
// Force bypasses economics and installs a projection summary; canonical stays.
// hard = 10000-256 = 9744; observe just above it.
prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 9800})
if got := len(sess.Messages); got != 5 {
t.Fatalf("canonical len = %d, want 5: %+v", got, sess.Messages)
}
if sess.Messages[1].Content != "small old request" {
if sess.Messages[1].Content != "task" {
t.Fatalf("first user turn not pinned verbatim in canonical: %+v", sess.Messages[1])
}
proj := visibleContext(a)
@@ -425,6 +423,9 @@ func TestMaybeCompactForceCeilingBypassesEconomics(t *testing.T) {
}
func TestMaybeCompactSkipsLowValueRegionBeforeForceCeiling(t *testing.T) {
// Above compact_ratio but below the physical hard ceiling: low-value folds
// are rejected by foldEconomics without calling the summarizer.
const window = 10_000
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "small old request"},
@@ -433,29 +434,38 @@ func TestMaybeCompactSkipsLowValueRegionBeforeForceCeiling(t *testing.T) {
{Role: provider.RoleAssistant, Content: "ok"},
}}
prov := &fakeProvider{reply: "should not summarize"}
a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2}, event.Discard)
prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 80})
// fold trigger = 8500; hard = 9744. Observe between them.
prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 8600})
if got := len(sess.Messages); got != 5 {
t.Fatalf("low-value region should not compact before force ceiling, len = %d", got)
}
if len(prov.got) != 0 {
t.Fatalf("summarizer was called for low-value non-forced region: %+v", prov.got)
}
if a.currentProjectionVersion() != 0 {
t.Fatalf("low-value region installed projection version %d", a.currentProjectionVersion())
}
}
func TestMaybeCompactFoldsSingleLargeMessageAtThreshold(t *testing.T) {
const window = 10_000
// Large assistant work (not the first user turn) so it is foldable, not pinned.
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: strings.Repeat("large prompt chunk ", 500)},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, Content: strings.Repeat("large prompt chunk ", 500)},
{Role: provider.RoleUser, Content: "next"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
a := New(&fakeProvider{reply: "single large summary"}, tool.NewRegistry(), sess, Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
a := New(&fakeProvider{reply: "single large summary"}, tool.NewRegistry(), sess, Options{
ContextWindow: window, CompactRatio: 0.8, RecentKeep: 2,
}, event.Discard)
prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 80})
if got := len(sess.Messages); got != 4 {
t.Fatalf("canonical len = %d, want 4: %+v", got, sess.Messages)
prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 8500})
if got := len(sess.Messages); got != 5 {
t.Fatalf("canonical len = %d, want 5: %+v", got, sess.Messages)
}
proj := visibleContext(a)
if !hasCompactionSummary(proj) || !strings.Contains(joinContents(proj), "single large summary") {
@@ -526,7 +536,7 @@ func TestCompactKeepsActiveTurnVerbatim(t *testing.T) {
result,
}}
a := New(&fakeProvider{reply: "old work summary"}, tool.NewRegistry(), sess, Options{
ContextWindow: 100, RecentKeep: 1, ArchiveDir: t.TempDir(),
ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 1,
}, event.Discard)
a.activeTurnCreatedAt.Store(currentCreatedAt)
@@ -635,22 +645,20 @@ func TestMaybeCompactDefersWhenOnlyActiveTurnRemains(t *testing.T) {
}
}
func TestCompactThresholdsReserveConfiguredOutputBudget(t *testing.T) {
func TestCompactTriggerIgnoresConfiguredOutputBudget(t *testing.T) {
a := &Agent{
contextWindow: 100_000,
maxOutputTokens: 20_000,
softCompactRatio: 0.5,
toolResultSnipRatio: 0.8,
compactRatio: 0.9,
compactForceRatio: 0.95,
contextWindow: 100_000,
maxOutputTokens: 20_000,
compactRatio: 0.85,
}
soft, snip, high := a.compactThresholds()
// hard = 100000 - 20000 output - 256 protocol reserve.
if soft != 50_000 || snip != 79_744 || high != 79_744 {
t.Fatalf("thresholds = %d/%d/%d, want 50000/79744/79744", soft, snip, high)
if got := a.compactTrigger(); got != 85_000 {
t.Fatalf("trigger = %d, want 85000 (output budget must not change it)", got)
}
if got := a.minimumMaintenanceSavingsTokens(); got != 4096 {
t.Fatalf("minimum maintenance savings = %d, want 4096", got)
if got := a.hardInputCeiling(); got != 100_000-protocolReserveTokens {
t.Fatalf("hard ceiling = %d, want window minus protocol reserve only", got)
}
if got := a.checkpointCeiling(); got != 50_000 {
t.Fatalf("checkpoint ceiling = %d, want 50000", got)
}
}
+70 -34
View File
@@ -2,46 +2,82 @@ package agent
import "testing"
// An output budget larger than the window it shares (DeepSeek defaults to 128K,
// and 128000 was the value the custom-provider wizard wrote unconditionally)
// used to drive the hard ceiling to its one-token floor, so every turn crossed
// every trigger and compacted.
func TestCompactThresholdsSurviveOversizedOutputBudget(t *testing.T) {
// An output budget larger than the window it shares must never change the
// sole compact_ratio trigger. Output is clipped only at send time.
func TestCompactTriggerIndependentOfOutputBudget(t *testing.T) {
a := &Agent{
prov: &sharedWindowTestProvider{budget: 131_072, shared: true},
contextWindow: 128_000,
outputBudgetState: outputBudgetState{outputBudget: 131_072},
softCompactRatio: defaultSoftCompactRatio,
toolResultSnipRatio: defaultToolResultSnipRatio,
compactRatio: defaultCompactRatio,
compactForceRatio: defaultCompactForceRatio,
prov: &sharedWindowTestProvider{budget: 131_072, shared: true},
contextWindow: 128_000,
outputBudgetState: outputBudgetState{outputBudget: 131_072},
compactRatio: defaultCompactRatio,
}
soft, snip, fold := a.compactThresholds()
force := a.forceCompactThreshold(fold)
// reserve = min(131072, 0.25*128000) = 32000; hard = 128000 - 32000 - 256.
if soft != 64_000 || snip != 76_800 || fold != 95_744 || force != 95_744 {
t.Fatalf("thresholds = %d/%d/%d/%d, want 64000/76800/95744/95744", soft, snip, fold, force)
trigger := a.compactTrigger()
want := int(float64(128_000) * defaultCompactRatio)
if trigger != want {
t.Fatalf("trigger = %d, want %d (compact_ratio only)", trigger, want)
}
if !(soft < snip && snip < fold) {
t.Fatalf("thresholds collapsed onto each other: soft=%d snip=%d fold=%d", soft, snip, fold)
// Oversized output budget must not lower the trigger.
a.outputBudget = 200_000
if got := a.compactTrigger(); got != want {
t.Fatalf("trigger after larger output budget = %d, want %d", got, want)
}
hard := a.hardInputCeiling()
if hard != 128_000-protocolReserveTokens {
t.Fatalf("hard ceiling = %d, want window-protocolReserve", hard)
}
}
// A declared window that genuinely fits its output budget keeps reserving the
// whole budget: the clamp must not loosen the ceiling for correct setups.
func TestCompactThresholdsReserveWholeBudgetWhenWindowFits(t *testing.T) {
a := &Agent{
prov: &sharedWindowTestProvider{budget: 131_072, shared: true},
contextWindow: 1_000_000,
outputBudgetState: outputBudgetState{outputBudget: 131_072},
softCompactRatio: defaultSoftCompactRatio,
toolResultSnipRatio: defaultToolResultSnipRatio,
compactRatio: defaultCompactRatio,
compactForceRatio: defaultCompactForceRatio,
func TestRecentTailBudgetClamp(t *testing.T) {
cases := []struct {
window int
want int
}{
{128_000, minRecentTailTokens}, // 10% = 12.8K → clamp to 32K
{400_000, 40_000}, // 10% = 40K
{1_000_000, maxRecentTailTokens}, // 10% = 100K → clamp to 96K
{2_000_000, maxRecentTailTokens}, // still 96K
}
soft, snip, fold := a.compactThresholds()
force := a.forceCompactThreshold(fold)
if soft != 500_000 || snip != 600_000 || fold != 800_000 || force != 868_672 {
t.Fatalf("thresholds = %d/%d/%d/%d, want 500000/600000/800000/868672", soft, snip, fold, force)
for _, tc := range cases {
a := &Agent{contextWindow: tc.window, compactRatio: defaultCompactRatio}
if got := a.recentTailBudget(); got != tc.want {
t.Fatalf("window %d: recentTailBudget = %d, want %d", tc.window, got, tc.want)
}
}
}
func TestCheckpointCeilingAndExceptionalSavings(t *testing.T) {
a := &Agent{contextWindow: 1_000_000, compactRatio: 0.85}
if got := a.checkpointCeiling(); got != 500_000 {
t.Fatalf("checkpointCeiling = %d, want 500000", got)
}
if got := a.exceptionalMinimumSavings(); got != 250_000 {
t.Fatalf("exceptionalMinimumSavings = %d, want 250000", got)
}
if got := a.compactTrigger(); got != 850_000 {
t.Fatalf("compactTrigger = %d, want 850000", got)
}
}
func TestAcceptCheckpointCandidateRules(t *testing.T) {
a := &Agent{contextWindow: 1_000_000, compactRatio: 0.85}
// 20% candidate under normal path: accept.
if err := a.acceptCheckpointCandidate(CompactionTriggerPressure, false, 850_000, 180_000, 50_000); err != nil {
t.Fatalf("20%% candidate: %v", err)
}
// 51% candidate: reject.
if err := a.acceptCheckpointCandidate(CompactionTriggerPressure, false, 850_000, 510_000, 50_000); err == nil {
t.Fatal("51% candidate should be rejected")
}
// Fixed prefix > 50% with enough savings: accept.
if err := a.acceptCheckpointCandidate(CompactionTriggerPressure, false, 900_000, 600_000, 520_000); err != nil {
t.Fatalf("fixed-prefix exception: %v", err)
}
// Fixed prefix > 50% without 25% savings: reject.
if err := a.acceptCheckpointCandidate(CompactionTriggerPressure, false, 600_000, 550_000, 520_000); err == nil {
t.Fatal("fixed-prefix without savings should reject")
}
// Manual below trigger: accept any reduction.
if err := a.acceptCheckpointCandidate(CompactionTriggerManual, false, 100_000, 80_000, 10_000); err != nil {
t.Fatalf("manual below trigger: %v", err)
}
}
+4 -6
View File
@@ -59,19 +59,17 @@ func TestCompressContextBeforePreservesCanonicalAndTail(t *testing.T) {
if len(prov.got) < 2 || strings.Contains(prov.got[1].Content, local.Content) {
t.Fatalf("LocalOnly content reached summarizer: %+v", prov.got)
}
if reasons := sess.DrainContentRewriteReasons(); !reflect.DeepEqual(reasons, []string{"compact_tool"}) {
t.Fatalf("cache reasons = %v, want [compact_tool]", reasons)
}
state := a.compactionState
if state.Generation != 1 || state.Projection.ViewInputHash == "" || state.Projection.ViewOutputHash == "" {
t.Fatalf("range compression did not install complete v3 lineage: %+v", state)
}
if state.LastReceipt == nil || state.LastReceipt.Status != "applied" || state.LastReceipt.Action != "summary" ||
state.LastReceipt.Trigger != CompactionTriggerTool || state.LastReceipt.Archive == "" {
state.LastReceipt.Trigger != CompactionTriggerTool {
t.Fatalf("range compression receipt = %+v", state.LastReceipt)
}
if _, err := os.Stat(state.LastReceipt.Archive); err != nil {
t.Fatalf("committed archive: %v", err)
// New summary checkpoints do not create archives; full originals stay in canonical.
if state.LastReceipt.Archive != "" {
t.Fatalf("summary checkpoint should not create archive, got %q", state.LastReceipt.Archive)
}
}
+15 -302
View File
@@ -1,11 +1,6 @@
package agent
import (
"context"
"errors"
"path/filepath"
"strings"
"sync"
"testing"
"reasonix/internal/event"
@@ -13,304 +8,22 @@ import (
"reasonix/internal/tool"
)
type nativeContextEditingProvider struct {
*fakeProvider
}
func (p *nativeContextEditingProvider) ContextEditingCapabilities() provider.ContextEditingCapabilities {
return provider.ContextEditingCapabilities{
NativeToolUseClear: true,
PolicyVersion: "clear_tool_uses_test",
}
}
type nativeFallbackProvider struct {
mu sync.Mutex
rejectNative bool
requestModes []string
}
func (p *nativeFallbackProvider) Name() string { return "native-fallback" }
func (p *nativeFallbackProvider) ContextEditingCapabilities() provider.ContextEditingCapabilities {
return provider.ContextEditingCapabilities{
NativeToolUseClear: true,
PolicyVersion: "clear_tool_uses_test",
}
}
func (p *nativeFallbackProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
mode := "local"
if req.ContextEditing != nil {
mode = req.ContextEditing.Mode
}
p.mu.Lock()
p.requestModes = append(p.requestModes, mode)
reject := p.rejectNative && mode == "native"
p.mu.Unlock()
if reject {
return nil, errors.Join(provider.ErrNativeContextEditingUnsupported, &provider.APIError{
Provider: "native-fallback", Status: 400,
Body: "context_management: extra inputs are not permitted",
})
}
ch := make(chan provider.Chunk, 2)
ch <- provider.Chunk{Type: provider.ChunkText, Text: "ok"}
ch <- provider.Chunk{Type: provider.ChunkDone}
close(ch)
return ch, nil
}
func (p *nativeFallbackProvider) setRejectNative(reject bool) {
p.mu.Lock()
p.rejectNative = reject
p.mu.Unlock()
}
func (p *nativeFallbackProvider) modes() []string {
p.mu.Lock()
defer p.mu.Unlock()
return append([]string(nil), p.requestModes...)
}
func TestContextEditingResolvesProviderCapabilityBeforeRequestAndCacheLineage(t *testing.T) {
opts := Options{
ContextEditing: "native",
ContextWindow: 100_000,
MaxOutputTokens: 1_024,
WorkspaceID: "workspace",
ModelRef: "model",
ToolResultSnipRatio: 0.6,
}
sess := &Session{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hello"}}}
native := New(&nativeContextEditingProvider{fakeProvider: &fakeProvider{}}, tool.NewRegistry(), sess, opts, event.Discard)
if native.requestedContextEditing != "native" || native.contextEditing != "native" {
t.Fatalf("native modes = requested %q effective %q", native.requestedContextEditing, native.contextEditing)
}
prepared, err := native.prepareSamplingRequest(context.Background())
if err != nil {
t.Fatalf("prepare native request: %v", err)
}
policy := prepared.req.ContextEditing
if policy == nil || policy.Mode != "native" || policy.KeepToolUses != 3 || policy.ClearToolInputs {
t.Fatalf("native policy = %+v", policy)
}
if policy.TriggerInputTokens != 60_000 || policy.ClearAtLeastInputTokens != 4_096 {
t.Fatalf("native thresholds = trigger %d min %d", policy.TriggerInputTokens, policy.ClearAtLeastInputTokens)
}
if key := native.currentPromptCacheKey(); !strings.Contains(key, "clear_tool_uses_test-t60000-k3-m4096-ifalse") {
t.Fatalf("native cache lineage does not include the effective policy: %q", key)
}
unsupported := New(&fakeProvider{}, tool.NewRegistry(), NewSession(""), opts, event.Discard)
localOpts := opts
localOpts.ContextEditing = "local"
local := New(&fakeProvider{}, tool.NewRegistry(), NewSession(""), localOpts, event.Discard)
if unsupported.requestedContextEditing != "native" || unsupported.contextEditing != "local" {
t.Fatalf("unsupported modes = requested %q effective %q", unsupported.requestedContextEditing, unsupported.contextEditing)
}
if unsupported.currentPromptCacheKey() != local.currentPromptCacheKey() {
t.Fatalf("unsupported native split cache lineage: %q != local %q", unsupported.currentPromptCacheKey(), local.currentPromptCacheKey())
}
unsupportedPrepared, err := unsupported.prepareSamplingRequest(context.Background())
if err != nil {
t.Fatalf("prepare unsupported request: %v", err)
}
if unsupportedPrepared.req.ContextEditing != nil {
t.Fatalf("unsupported provider received native policy: %+v", unsupportedPrepared.req.ContextEditing)
}
}
func TestUnsupportedNativeContextEditingNoticeIsOneShot(t *testing.T) {
sink := &recordSink{}
a := New(&fakeProvider{}, tool.NewRegistry(), NewSession(""), Options{
ContextEditing: "native",
ContextWindow: 100_000,
MaxOutputTokens: 1_024,
}, sink)
for range 2 {
if _, err := a.prepareSamplingRequest(context.Background()); err != nil {
t.Fatalf("prepare request: %v", err)
}
}
var notices int
for _, got := range sink.kinds(event.Notice) {
if got.Code == event.NoticeCodeContextEditingFallback {
notices++
}
}
if notices != 1 {
t.Fatalf("fallback notices = %d, want 1", notices)
}
}
func TestNativeContextEditingFallsBackOnceAndPersistsLocalLineage(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
prov := &nativeFallbackProvider{rejectNative: true}
sess := &Session{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hello"}}}
opts := Options{
ContextEditing: "native", ContextWindow: 100_000, MaxOutputTokens: 1_024,
WorkspaceID: "workspace", ModelRef: "model",
}
a := New(prov, tool.NewRegistry(), sess, opts, event.Discard)
a.BindSessionPath(path, false)
got := a.streamWithSamplingRecovery(context.Background(), 1)
if got.err != nil || got.text != "ok" {
t.Fatalf("native fallback result = text %q err %v", got.text, got.err)
}
if modes := prov.modes(); len(modes) != 2 || modes[0] != "native" || modes[1] != "local" {
t.Fatalf("request modes = %v, want [native local]", modes)
}
if a.effectiveContextEditing() != "local" || !a.contextEditingRuntimeFallback.Load() || a.nativeContextEditingAccepted.Load() {
t.Fatalf("fallback state = effective %q fallback %t accepted %t",
a.effectiveContextEditing(), a.contextEditingRuntimeFallback.Load(), a.nativeContextEditingAccepted.Load())
}
state, ok, err := LoadCompactionState(path)
if err != nil || !ok {
t.Fatalf("load fallback sidecar: ok=%t err=%v", ok, err)
}
if !state.ContextEditingFallbackLocal || state.NativeContextEditingAccepted {
t.Fatalf("persisted fallback state = %+v", state)
}
localOpts := opts
localOpts.ContextEditing = "local"
local := New(&fakeProvider{}, tool.NewRegistry(), NewSession(""), localOpts, event.Discard)
local.SetSessionPath(path)
if a.currentPromptCacheKey() != local.currentPromptCacheKey() {
t.Fatalf("fallback lineage = %q, want local %q", a.currentPromptCacheKey(), local.currentPromptCacheKey())
}
resumed := New(&nativeFallbackProvider{}, tool.NewRegistry(), sess, opts, event.Discard)
resumed.BindSessionPath(path, true)
if resumed.effectiveContextEditing() != "local" || !resumed.contextEditingRuntimeFallback.Load() {
t.Fatalf("resumed fallback = effective %q fallback %t",
resumed.effectiveContextEditing(), resumed.contextEditingRuntimeFallback.Load())
}
prepared, err := resumed.prepareSamplingRequest(context.Background())
if err != nil {
t.Fatal(err)
}
if prepared.req.ContextEditing != nil {
t.Fatalf("resumed local request contains native policy: %+v", prepared.req.ContextEditing)
}
}
func TestNativeContextEditingSuccessLatchesRequestShapeAcrossResume(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
prov := &nativeFallbackProvider{}
sess := &Session{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hello"}}}
opts := Options{
ContextEditing: "native", ContextWindow: 100_000, MaxOutputTokens: 1_024,
WorkspaceID: "workspace", ModelRef: "model",
}
a := New(prov, tool.NewRegistry(), sess, opts, event.Discard)
a.BindSessionPath(path, false)
first := a.streamWithSamplingRecovery(context.Background(), 1)
if first.err != nil || !a.nativeContextEditingAccepted.Load() {
t.Fatalf("first native request = err %v accepted %t", first.err, a.nativeContextEditingAccepted.Load())
}
prov.setRejectNative(true)
second := a.streamWithSamplingRecovery(context.Background(), 2)
if !provider.IsNativeContextEditingUnsupported(second.err) {
t.Fatalf("latched native rejection = %v, want unsupported error", second.err)
}
if modes := prov.modes(); len(modes) != 2 || modes[0] != "native" || modes[1] != "native" {
t.Fatalf("latched request modes = %v, want [native native]", modes)
}
if a.effectiveContextEditing() != "native" || a.contextEditingRuntimeFallback.Load() {
t.Fatalf("latched mode silently changed: effective %q fallback %t",
a.effectiveContextEditing(), a.contextEditingRuntimeFallback.Load())
}
resumed := New(&nativeFallbackProvider{}, tool.NewRegistry(), sess, opts, event.Discard)
resumed.BindSessionPath(path, true)
if resumed.effectiveContextEditing() != "native" || !resumed.nativeContextEditingAccepted.Load() {
t.Fatalf("resumed latch = effective %q accepted %t",
resumed.effectiveContextEditing(), resumed.nativeContextEditingAccepted.Load())
}
prepared, err := resumed.prepareSamplingRequest(context.Background())
if err != nil {
t.Fatal(err)
}
if prepared.req.ContextEditing == nil || prepared.req.ContextEditing.Mode != "native" {
t.Fatalf("resumed native policy = %+v", prepared.req.ContextEditing)
}
}
func TestNativeContextEditingReplacesLocalToolMaintenanceButKeepsFullFold(t *testing.T) {
newAgent := func() (*Agent, *fakeProvider, *Session) {
fake := &fakeProvider{reply: "summary"}
sess := pruneFixture(strings.Repeat("line\n", 1000))
a := New(&nativeContextEditingProvider{fakeProvider: fake}, tool.NewRegistry(), sess, Options{
ContextEditing: "native",
ContextWindow: 1_000,
RecentKeep: 2,
ArchiveDir: t.TempDir(),
}, event.Discard)
return a, fake, sess
}
a, fake, sess := newAgent()
prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 650})
if fake.got != nil {
t.Fatal("summarizer was called in the native tool-clear pressure band")
// Native provider context editing was removed. All providers use the local
// summary checkpoint path. These tests lock that contract so native flags and
// request-shape switches cannot reappear silently.
func TestNativeContextEditingRemoved(t *testing.T) {
a := New(nil, tool.NewRegistry(), NewSession("sys"), Options{
ContextWindow: 100_000,
CompactRatio: 0.85,
ContextEditing: "native", // deprecated input; ignored
}, event.Discard)
// No native lineage suffix on the prompt cache key.
if key := a.currentPromptCacheKey(); key != a.currentPromptCacheKey() {
t.Fatal("prompt cache key unstable")
}
// ObserveUsage must never mutate the checkpoint.
a.contextManager().ObserveUsage(&provider.Usage{PromptTokens: 90_000})
if got := a.currentProjectionVersion(); got != 0 {
t.Fatalf("native mode installed a local snip projection, version = %d", got)
}
if got := sess.Snapshot()[3].Content; strings.HasPrefix(got, snippedMarker) || strings.HasPrefix(got, prunedMarker) {
t.Fatalf("native mode rewrote canonical tool result: %.80q", got)
}
a, fake, _ = newAgent()
prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 850})
if fake.got == nil {
t.Fatal("native mode suppressed the full-fold fallback above the fold trigger")
}
}
func TestNativeContextEditingAppliedEditWritesUnifiedReceipt(t *testing.T) {
sink := &recordSink{}
a := New(&nativeContextEditingProvider{fakeProvider: &fakeProvider{}}, tool.NewRegistry(), NewSession("system"), Options{
ContextEditing: "native", ContextWindow: 100_000,
}, sink)
a.contextManager().ObserveUsage(&provider.Usage{
PromptTokens: 25_000, ContextEditingType: "clear_tool_uses_20250919",
ContextEditingClearedToolUses: 8, ContextEditingClearedTokens: 50_000,
})
snapshot := a.ContextMaintenanceSnapshot()
if snapshot.LastReceipt == nil || snapshot.LastReceipt.Action != "native_tool_clear" ||
snapshot.LastReceipt.SavedTokens != 50_000 || snapshot.LastReceipt.AffectedToolResults != 8 {
t.Fatalf("native maintenance receipt = %+v", snapshot.LastReceipt)
}
if got := sink.kinds(event.ContextMaintenanceEvent); len(got) != 1 || got[0].Maintenance.SavedTokens != 50_000 {
t.Fatalf("native maintenance events = %+v", got)
}
// Duplicate observation for the same request is idempotent.
a.contextManager().ObserveUsage(&provider.Usage{
PromptTokens: 25_000, ContextEditingType: "clear_tool_uses_20250919",
ContextEditingClearedToolUses: 8, ContextEditingClearedTokens: 50_000,
})
if got := len(sink.kinds(event.ContextMaintenanceEvent)); got != 1 {
t.Fatalf("duplicate native maintenance events = %d, want 1", got)
}
}
func TestTaskSubagentsPreserveRequestedContextEditingForTheirProvider(t *testing.T) {
task := NewTaskToolWithOptions(TaskToolOptions{
Provider: &fakeProvider{},
ParentRegistry: tool.NewRegistry(),
ContextEditing: "native",
})
opts := task.subagentOptions(context.Background(), 5, nil, 100_000, 1, "child", nil)
if opts.ContextEditing != "native" {
t.Fatalf("subagent ContextEditing = %q, want requested native", opts.ContextEditing)
}
child := New(&fakeProvider{}, tool.NewRegistry(), NewSession(""), opts, event.Discard)
if child.contextEditing != "local" {
t.Fatalf("incompatible child effective mode = %q, want local", child.contextEditing)
t.Fatalf("ObserveUsage installed projection version %d", got)
}
}
@@ -11,9 +11,13 @@ import (
"reasonix/internal/tool"
)
// Issue #7935 used to install prune projections every time many tool results
// crossed a snip threshold. Automatic maintenance is now summary-only and
// generation-scoped: below compact_ratio nothing happens; at the threshold
// exactly one summary lands; appends before the next threshold do not re-pay.
func TestIssue7935Maintains206ToolResultsOnceAndOnlyProcessesNewTail(t *testing.T) {
const staleResults = 206
big := strings.Repeat("line\n", 1_000)
const staleResults = 40
big := strings.Repeat("line\n", 200)
messages := []provider.Message{
{Role: provider.RoleSystem, Content: "system"},
{Role: provider.RoleUser, Content: "first task"},
@@ -31,32 +35,30 @@ func TestIssue7935Maintains206ToolResultsOnceAndOnlyProcessesNewTail(t *testing.
)
sess := &Session{Messages: messages}
sink := &recordSink{}
a := New(nil, tool.NewRegistry(), sess, Options{
ContextWindow: 1_000,
a := New(&fakeProvider{reply: "structured history digest"}, tool.NewRegistry(), sess, Options{
ContextWindow: 8_000,
CompactRatio: 0.80,
RecentKeep: 2,
ArchiveDir: t.TempDir(),
}, sink)
// Below the fold trigger nothing may be rewritten: a prefix change voids the
// prompt cache from that point on, so it waits for the reset it comes with.
prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 700})
// Below the fold trigger nothing may be rewritten.
prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 5_000})
if got := a.currentProjectionVersion(); got != 0 {
t.Fatalf("history was rewritten below the fold trigger: projection version %d", got)
}
prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 850})
// Crossing the sole trigger installs one summary checkpoint, never prune.
prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 7_000})
firstVersion := a.currentProjectionVersion()
if firstVersion == 0 {
t.Fatal("first maintenance did not install a projection")
if firstVersion != 1 {
t.Fatalf("first maintenance version = %d, want 1", firstVersion)
}
if got := countToolResultsWithPrefix(a.modelVisibleMessages(), prunedMarker); got != staleResults {
t.Fatalf("first maintenance elided %d results, want %d", got, staleResults)
if got := countToolResultsWithPrefix(a.modelVisibleMessages(), prunedMarker); got != 0 {
t.Fatalf("automatic prune markers installed = %d, want 0", got)
}
status := a.ContextMaintenanceSnapshot()
if status.ProjectedTokens <= 0 || status.CanonicalTokens <= status.ProjectedTokens {
t.Fatalf("maintenance snapshot canonical/projected = %d/%d", status.CanonicalTokens, status.ProjectedTokens)
}
if status.LastReceipt == nil || status.LastReceipt.Action != "prune" || status.LastReceipt.AffectedToolResults != staleResults {
if status.LastReceipt == nil || status.LastReceipt.Action != "summary" || status.LastReceipt.Status != "applied" {
t.Fatalf("maintenance snapshot receipt = %+v", status.LastReceipt)
}
for i, msg := range sess.Snapshot() {
@@ -65,29 +67,24 @@ func TestIssue7935Maintains206ToolResultsOnceAndOnlyProcessesNewTail(t *testing.
}
}
// Already-elided results give the next pass nothing to do; only the new tail
// is maintained, and the applied-notification count below proves the earlier
// 206 are not rewritten a second time.
// Appends that stay under the next threshold must not re-summarize.
sess.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "new", Name: "read_file", Arguments: "{}"}}})
sess.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "new", Name: "read_file", Content: big})
sess.Add(provider.Message{Role: provider.RoleUser, Content: "move new result out of the protected tail"})
sess.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "new", Name: "read_file", Content: "small"})
sess.Add(provider.Message{Role: provider.RoleUser, Content: "continue"})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"})
prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 850})
if got := a.currentProjectionVersion(); got != firstVersion+1 {
t.Fatalf("new tail maintenance version = %d, want %d", got, firstVersion+1)
}
if got := countToolResultsWithPrefix(a.modelVisibleMessages(), prunedMarker); got != staleResults+1 {
t.Fatalf("maintained visible results = %d, want %d", got, staleResults+1)
prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 1_500})
if got := a.currentProjectionVersion(); got != firstVersion {
t.Fatalf("below-threshold append advanced version to %d, want %d", got, firstVersion)
}
var applied int
for _, got := range sink.kinds(event.ContextMaintenanceEvent) {
if got.Maintenance != nil && got.Maintenance.Status == "applied" && got.Maintenance.Action == "prune" {
if got.Maintenance != nil && got.Maintenance.Status == "applied" && got.Maintenance.Action == "summary" {
applied++
}
}
if applied != 2 {
t.Fatalf("maintenance notifications = %d, want first pass plus new tail only", applied)
if applied != 1 {
t.Fatalf("summary applied events = %d, want exactly 1", applied)
}
}
@@ -0,0 +1,159 @@
package agent
import (
"context"
"fmt"
"path/filepath"
"strings"
"testing"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
const restartMaintenanceWindow = 1_000_000
// largeRestartToolHistory builds a realistic long coding session from capped
// tool results. Reusing the same immutable string keeps the test allocation
// small while every message still contributes to the provider request size.
func largeRestartToolHistory(results int) []provider.Message {
result := strings.Repeat("x", 32*1024)
messages := []provider.Message{
{Role: provider.RoleSystem, Content: "system"},
{Role: provider.RoleUser, Content: "long-running task"},
}
for i := range results {
id := fmt.Sprintf("tool-%d", i)
messages = append(messages,
provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "read_file", Arguments: "{}"}}},
provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "read_file", Content: result},
)
}
return append(messages,
provider.Message{Role: provider.RoleUser, Content: "write the handover now"},
provider.Message{Role: provider.RoleAssistant, Content: "working"},
)
}
func newRestartMaintenanceAgent(path string, messages []provider.Message, sink event.Sink) *Agent {
// Explicit 0.80 matches the v1.22.0 user scenario and the plan's regression
// fixture. New configs default to 0.85; users who set 0.80 keep 0.80.
return New(nil, tool.NewRegistry(), &Session{Messages: append([]provider.Message(nil), messages...)}, Options{
ContextWindow: restartMaintenanceWindow,
CompactRatio: 0.80,
RecentKeep: 2,
SessionPath: path,
WorkspaceID: "workspace",
ModelRef: "provider/model",
}, sink)
}
func appliedMaintenanceEvents(sink *recordSink) int {
count := 0
for _, got := range sink.kinds(event.ContextMaintenanceEvent) {
if got.Maintenance != nil && got.Maintenance.Status == "applied" {
count++
}
}
return count
}
// 80 capped tool results put the request just above 60% of a one-million-token
// window. In v1.22.0 that crossed tool_result_snip_ratio, rewrote the prompt,
// and did so again after reopening. compact_ratio=80% must be the sole trigger.
func TestContextMaintenanceWaitsForCompactRatioAcrossRestart(t *testing.T) {
messages := largeRestartToolHistory(80)
path := filepath.Join(t.TempDir(), "session.jsonl")
firstSink := &recordSink{}
first := newRestartMaintenanceAgent(path, messages, firstSink)
before := first.ContextMaintenanceSnapshot()
if before.ProjectedTokens < 600_000 || before.ProjectedTokens >= before.FoldTrigger {
t.Fatalf("fixture prompt/trigger = %d/%d, want prompt in [600000, fold)", before.ProjectedTokens, before.FoldTrigger)
}
if _, err := first.contextManager().Prepare(context.Background(), ContextPreparePolicy{Trigger: CompactionTriggerPressure}); err != nil {
t.Fatal(err)
}
if got := first.currentProjectionVersion(); got != 0 {
t.Fatalf("60%% prompt installed projection version %d below compact_ratio", got)
}
if got := appliedMaintenanceEvents(firstSink); got != 0 {
t.Fatalf("60%% prompt emitted %d applied maintenance events", got)
}
reopenedSink := &recordSink{}
reopened := newRestartMaintenanceAgent(path, messages, reopenedSink)
if _, err := reopened.contextManager().Prepare(context.Background(), ContextPreparePolicy{Trigger: CompactionTriggerPressure}); err != nil {
t.Fatal(err)
}
if got := reopened.currentProjectionVersion(); got != 0 {
t.Fatalf("reopened 60%% prompt installed projection version %d", got)
}
if got := appliedMaintenanceEvents(reopenedSink); got != 0 {
t.Fatalf("reopened 60%% prompt emitted %d applied maintenance events", got)
}
}
// Once the prompt really crosses 80%, one summary checkpoint is installed.
// Reopening must restore that sidecar without re-summarizing or replaying events.
func TestContextMaintenanceRestoresAppliedProjectionWithoutReapplying(t *testing.T) {
messages := largeRestartToolHistory(110)
path := filepath.Join(t.TempDir(), "session.jsonl")
// A deterministic fake provider so the single summary transaction can land.
firstSink := &recordSink{}
first := New(&fakeProvider{reply: "structured resume briefing"}, tool.NewRegistry(),
&Session{Messages: append([]provider.Message(nil), messages...)}, Options{
ContextWindow: restartMaintenanceWindow,
CompactRatio: 0.80,
RecentKeep: 2,
SessionPath: path,
WorkspaceID: "workspace",
ModelRef: "provider/model",
}, firstSink)
before := first.ContextMaintenanceSnapshot()
if before.ProjectedTokens < before.FoldTrigger {
t.Fatalf("fixture prompt/trigger = %d/%d, want prompt at or above fold", before.ProjectedTokens, before.FoldTrigger)
}
if _, err := first.contextManager().Prepare(context.Background(), ContextPreparePolicy{Trigger: CompactionTriggerPressure}); err != nil {
t.Fatal(err)
}
if got := first.currentProjectionVersion(); got != 1 {
t.Fatalf("first maintenance projection version = %d, want 1", got)
}
if got := appliedMaintenanceEvents(firstSink); got != 1 {
t.Fatalf("first maintenance applied events = %d, want 1", got)
}
// Content-driven checkpoint should land well under the 50% ceiling.
after := first.ContextMaintenanceSnapshot()
if after.ProjectedTokens > after.HardInputCeiling/2 && after.ProjectedTokens > 300_000 {
t.Fatalf("checkpoint projected tokens = %d, want content-driven low occupancy", after.ProjectedTokens)
}
reopenedSink := &recordSink{}
reopened := New(nil, tool.NewRegistry(),
&Session{Messages: append([]provider.Message(nil), messages...)}, Options{
ContextWindow: restartMaintenanceWindow,
CompactRatio: 0.80,
RecentKeep: 2,
SessionPath: path,
WorkspaceID: "workspace",
ModelRef: "provider/model",
}, reopenedSink)
if got := reopened.currentProjectionVersion(); got != 1 {
t.Fatalf("restored projection version = %d, want 1", got)
}
if got := reopened.ContextMaintenanceSnapshot().CheckpointState; got != "restored" {
t.Fatalf("checkpoint state = %q, want restored", got)
}
if _, err := reopened.contextManager().Prepare(context.Background(), ContextPreparePolicy{Trigger: CompactionTriggerPressure}); err != nil {
t.Fatal(err)
}
if got := reopened.currentProjectionVersion(); got != 1 {
t.Fatalf("reopen advanced unchanged projection to version %d", got)
}
if got := appliedMaintenanceEvents(reopenedSink); got != 0 {
t.Fatalf("reopen reapplied maintenance %d times", got)
}
}
+85 -110
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"log/slog"
"reasonix/internal/event"
"reasonix/internal/provider"
)
@@ -37,180 +36,133 @@ type PreparedContext struct {
func (a *Agent) contextManager() ContextManager { return ContextManager{agent: a} }
// ObserveUsage records provider observations only. It cannot mutate the
// projection; native applied-edit receipts are observational sidecar metadata,
// while any local cleanup they motivate happens in the next request's Prepare.
func (m ContextManager) ObserveUsage(u *provider.Usage) {
// Sampling recovery already stores and calibrates the latest single-request
// usage before the run loop reaches this observer. Do not overwrite it with
// a multi-attempt billable aggregate here.
if m.agent != nil {
m.agent.observeNativeContextEditing(u)
}
// PrepareContext is the public automatic-maintenance entry used by smoke tools
// and controllers that need a one-shot Prepare without sampling.
func (a *Agent) PrepareContext(ctx context.Context) error {
_, err := a.contextManager().Prepare(ctx, ContextPreparePolicy{Trigger: CompactionTriggerPressure})
return err
}
// Prepare converges the current visible view before sampling. Automatic stale
// compare failures are planned once more; all other failed fingerprints are
// durably blocked until transcript, model, provider policy, or projection
// lineage changes.
// ObserveUsage is retained as a compatibility hook. Usage observations never
// mutate the provider-visible checkpoint.
func (m ContextManager) ObserveUsage(u *provider.Usage) {
_ = u
}
// Prepare is the sole automatic maintenance entry. Below compact_ratio it does
// nothing. At or above the trigger it runs one summary transaction that either
// installs a checkpoint or records a generation-scoped blocked/failed receipt.
func (m ContextManager) Prepare(ctx context.Context, policy ContextPreparePolicy) (PreparedContext, error) {
if policy.Trigger == "" {
policy.Trigger = CompactionTriggerPressure
}
for range 2 {
prepared, retry, err := m.prepareOnce(ctx, policy)
if !retry {
return prepared, err
}
}
return PreparedContext{}, errCompressStaleContext
return m.prepareOnce(ctx, policy)
}
func (m ContextManager) prepareOnce(ctx context.Context, policy ContextPreparePolicy) (PreparedContext, bool, error) {
func (m ContextManager) prepareOnce(ctx context.Context, policy ContextPreparePolicy) (PreparedContext, error) {
a := m.agent
if a == nil || a.session == nil {
return PreparedContext{}, false, nil
return PreparedContext{}, nil
}
visible := a.modelVisibleMessages()
// Threshold uses the stable pre-interceptor request shape (messages + tools
// + role projection). Extension interceptors run only on the real sampling
// request so side-effecting plugins are not double-invoked; if they expand
// the prompt past the hard ceiling, overflow recovery still fires.
est := a.estimatedVisibleRequestTokens(visible)
prepared := PreparedContext{
Messages: append([]provider.Message(nil), visible...),
InputTokens: a.estimatedPromptTokens(visible),
InputTokens: est,
ProjectionVersion: a.currentProjectionVersion(),
}
if a.contextWindow <= 0 || len(visible) == 0 {
return prepared, false, nil
return prepared, nil
}
soft, snip, fold := a.compactThresholds()
force := a.forceCompactThreshold(fold)
est := prepared.InputTokens
fold := a.compactTrigger()
hard := a.hardInputCeiling()
if policy.ObservedInputTokens > 0 {
est = policy.ObservedInputTokens
prepared.InputTokens = est
}
inputHash := a.contextMaintenanceInputHash(visible)
if blocked, reason := a.contextMaintenanceBlocked(inputHash); blocked {
if policy.Trigger == CompactionTriggerManual {
return PreparedContext{}, false, fmt.Errorf("context maintenance is blocked for the current view: %s", reason)
if blocked, reason := a.contextMaintenanceBlocked(inputHash); blocked && policy.Trigger != CompactionTriggerManual {
if policy.Trigger == CompactionTriggerOverflow || est >= hard {
return PreparedContext{}, fmt.Errorf("%w: %s", ErrCompactionRequired, reason)
}
if policy.Trigger == CompactionTriggerOverflow || (sharesContextWindow(a.prov) && est >= a.hardInputCeiling()) {
return PreparedContext{}, false, fmt.Errorf("%w: %s", ErrCompactionRequired, reason)
}
return prepared, false, nil
return prepared, nil
}
if est < fold {
a.consecutiveCompacts = 0
a.compactStuck = false
}
if a.compactStuck && policy.Trigger == CompactionTriggerPressure {
return prepared, false, nil
return prepared, nil
}
if soft > 0 && est >= soft && est < fold && !a.softCompactNoticed {
a.softCompactNoticed = true
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo,
Text: "Context is getting large; preserving cache until cleanup is needed.",
Detail: fmt.Sprintf("context reached %.0f%% of window; keeping cache-first prefix until compact threshold %.0f%%", a.softCompactRatio*100, a.compactRatio*100)})
}
// One trigger. Rewriting stale tool results invalidates the prompt prefix
// from the rewrite point on, and a cache miss costs 50x a hit, so it only
// pays at the fold point — where the prefix is being reset anyway.
forceFold := policy.Force || policy.Trigger == CompactionTriggerManual || policy.Trigger == CompactionTriggerOverflow || est >= force
// One user trigger. Overflow is a one-shot physical recovery path only.
forceFold := policy.Force || policy.Trigger == CompactionTriggerManual || policy.Trigger == CompactionTriggerOverflow || est >= hard
if est < fold && !forceFold {
return prepared, false, nil
return prepared, nil
}
// Native Anthropic tool clearing replaces only local snip/prune. Full-fold
// summary remains the hard/manual convergence path.
if maintained, handled, retry, err := m.tryToolMaintenance(visible, prepared, policy, est, snip, forceFold, inputHash); handled {
return maintained, retry, err
}
return m.foldContext(ctx, prepared, policy, inputHash, est, fold, force, forceFold)
return m.foldContext(ctx, prepared, policy, inputHash, est, fold, hard, forceFold)
}
// tryToolMaintenance is the first step of a fold, not a trigger of its own:
// dropping stale tool results is cheap and sometimes enough, which saves the
// summarizer round trip. sufficient is how far down the result must land to
// stand in for the summary; anything above it falls through to the fold.
func (m ContextManager) tryToolMaintenance(visible []provider.Message, prepared PreparedContext, policy ContextPreparePolicy, est, sufficient int, forceFold bool, inputHash string) (PreparedContext, bool, bool, error) {
a := m.agent
if a.effectiveContextEditing() == "native" {
return PreparedContext{}, false, false, nil
}
if forceFold || policy.Trigger == CompactionTriggerManual {
return PreparedContext{}, false, false, nil
}
// Elide in one pass rather than shortening now and eliding next turn: each
// rewrite costs a prefix, so a two-step escalation pays it twice.
maintained, stats := a.applyToolResultMaintenanceView(visible, toolResultPrune)
if stats.Results == 0 {
return PreparedContext{}, false, false, nil
}
maintainedTokens := a.estimatedPromptTokens(maintained)
if policy.ObservedInputTokens > 0 {
maintainedTokens = max(0, est-int(float64(stats.SavedChars)*a.tokPerChar()))
}
if maintainedTokens >= sufficient {
return PreparedContext{}, false, false, nil
}
before := a.currentProjectionVersion()
if err := a.installPruneProjection(maintained, stats); err != nil {
if errors.Is(err, errCompressStaleContext) || err.Error() == "context maintenance became stale" {
return PreparedContext{}, true, true, nil
}
a.recordContextMaintenanceBlocked(inputHash, policy.Trigger, "noop", fmt.Sprintf("tool-result maintenance failed: %v", err))
return prepared, true, false, nil
}
if a.currentProjectionVersion() > before {
return m.currentPrepared(), true, false, nil
}
return prepared, true, false, nil
}
func (m ContextManager) foldContext(ctx context.Context, prepared PreparedContext, policy ContextPreparePolicy, inputHash string, est, fold, force int, forceFold bool) (PreparedContext, bool, error) {
func (m ContextManager) foldContext(ctx context.Context, prepared PreparedContext, policy ContextPreparePolicy, inputHash string, est, fold, hard int, forceFold bool) (PreparedContext, error) {
a := m.agent
outcome, err := a.compactToProjection(ctx, policy.Trigger, policy.Instructions, forceFold)
if err != nil {
if errors.Is(err, errCompressStaleContext) && policy.Trigger != CompactionTriggerManual {
return PreparedContext{}, true, nil
// Transcript changed during the summary call: discard the candidate
// and block this generation so we do not pay for a second summary.
reason := "context changed during summary; automatic retry blocked for this generation"
a.recordContextMaintenanceBlocked(inputHash, policy.Trigger, "summary", reason)
if policy.Trigger == CompactionTriggerOverflow || est >= hard {
return PreparedContext{}, fmt.Errorf("%w: %s", ErrCompactionRequired, reason)
}
return prepared, nil
}
status := "failed"
if errors.Is(err, errSummaryOutputTruncated) || errors.Is(err, errCheckpointRejected) {
status = "blocked"
}
reason := fmt.Sprintf("context summary failed: %v", err)
a.recordContextMaintenanceBlocked(inputHash, policy.Trigger, "summary", reason)
a.recordContextMaintenanceOutcome(inputHash, policy.Trigger, "summary", status, reason)
if policy.Trigger == CompactionTriggerManual {
return PreparedContext{}, false, err
return PreparedContext{}, err
}
if policy.Trigger == CompactionTriggerOverflow || (sharesContextWindow(a.prov) && est >= a.hardInputCeiling()) {
return PreparedContext{}, false, fmt.Errorf("%w: %w", ErrCompactionRequired, err)
if policy.Trigger == CompactionTriggerOverflow || est >= hard {
return PreparedContext{}, fmt.Errorf("%w: %w", ErrCompactionRequired, err)
}
return prepared, false, nil
return prepared, nil
}
if outcome == CompactionNoop {
canonical, _ := a.session.snapshotMessagesVersion()
if policy.Trigger == CompactionTriggerPressure && a.activeTurnStart(canonical) >= 0 {
return prepared, false, nil
return prepared, nil
}
reason := "context is above the maintenance threshold but no foldable region remains"
a.recordContextMaintenanceBlocked(inputHash, policy.Trigger, "summary", reason)
if policy.Trigger == CompactionTriggerOverflow || policy.Force || est >= force || (sharesContextWindow(a.prov) && est >= a.hardInputCeiling()) {
return PreparedContext{}, false, fmt.Errorf("%w: %s", ErrCompactionRequired, reason)
if policy.Trigger == CompactionTriggerOverflow || policy.Force || est >= hard {
return PreparedContext{}, fmt.Errorf("%w: %s", ErrCompactionRequired, reason)
}
return prepared, false, nil
return prepared, nil
}
result := m.currentPrepared()
if policy.Trigger == CompactionTriggerManual {
return result, false, nil
return result, nil
}
if result.InputTokens >= fold {
reason := fmt.Sprintf("summary result remains above fold trigger (%d >= %d)", result.InputTokens, fold)
a.recordContextMaintenanceBlocked(a.contextMaintenanceInputHash(result.Messages), policy.Trigger, "summary", reason)
a.compactStuck = true
a.consecutiveCompacts++
if policy.Trigger == CompactionTriggerOverflow || (sharesContextWindow(a.prov) && result.InputTokens >= a.hardInputCeiling()) {
return PreparedContext{}, false, fmt.Errorf("%w: %s", ErrCompactionRequired, reason)
if policy.Trigger == CompactionTriggerOverflow || result.InputTokens >= hard {
return PreparedContext{}, fmt.Errorf("%w: %s", ErrCompactionRequired, reason)
}
slog.Info("agent: context maintenance paused below hard ceiling", "reason", reason)
}
return result, false, nil
return result, nil
}
func (m ContextManager) currentPrepared() PreparedContext {
@@ -220,7 +172,30 @@ func (m ContextManager) currentPrepared() PreparedContext {
visible := m.agent.modelVisibleMessages()
return PreparedContext{
Messages: append([]provider.Message(nil), visible...),
InputTokens: m.agent.estimatedPromptTokens(visible),
InputTokens: m.agent.estimatedVisibleRequestTokens(visible),
ProjectionVersion: m.agent.currentProjectionVersion(),
}
}
// estimatedVisibleRequestTokens sizes the pre-interceptor sampling shape:
// ModelMessages + role projection + tool schemas. Extension interceptors are
// intentionally omitted here (see prepareOnce) to avoid double side effects.
func (a *Agent) estimatedVisibleRequestTokens(visible []provider.Message) int {
if a == nil {
return 0
}
msgs := a.providerProjectionMessages(provider.ModelMessages(append([]provider.Message(nil), visible...)))
for i := range msgs {
msgs[i].CreatedAt = 0
}
var tools []provider.ToolSchema
if a.tools != nil {
tools = a.tools.Schemas()
}
return a.estimatedRequestTokens(provider.Request{
Messages: msgs,
Tools: tools,
MaxTokens: a.maxOutputTokens,
Temperature: provider.OptionalTemperature(a.temperature),
})
}
+81 -11
View File
@@ -2,12 +2,16 @@ package agent
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"reasonix/internal/event"
"reasonix/internal/extension"
"reasonix/internal/extension/protocol"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
@@ -25,9 +29,13 @@ func (p *failingSummaryProvider) Stream(context.Context, provider.Request) (<-ch
}
func TestContextManagerPersistsAndRestoresBlockedFailureFingerprint(t *testing.T) {
// Above compact_ratio but below the physical hard ceiling: a failed summary
// records a generation-scoped blocked receipt and does not reject the request.
// Below hard, Prepare returns the uncompacted view rather than ErrCompactionRequired.
const window = 10_000
messages := []provider.Message{
{Role: provider.RoleSystem, Content: "system"},
{Role: provider.RoleUser, Content: strings.Repeat("old task ", 500)},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, Content: strings.Repeat("old work ", 500)},
{Role: provider.RoleUser, Content: "current"},
{Role: provider.RoleAssistant, Content: "tail"},
@@ -35,7 +43,8 @@ func TestContextManagerPersistsAndRestoresBlockedFailureFingerprint(t *testing.T
path := filepath.Join(t.TempDir(), "session.jsonl")
newAgent := func(p *failingSummaryProvider) *Agent {
a := New(p, tool.NewRegistry(), &Session{Messages: append([]provider.Message(nil), messages...)}, Options{
ContextWindow: 100, RecentKeep: 2, WorkspaceID: "workspace", ModelRef: "model",
ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2,
WorkspaceID: "workspace", ModelRef: "model",
}, event.Discard)
a.BindSessionPath(path, true)
return a
@@ -43,27 +52,40 @@ func TestContextManagerPersistsAndRestoresBlockedFailureFingerprint(t *testing.T
firstProvider := &failingSummaryProvider{}
first := newAgent(firstProvider)
policy := ContextPreparePolicy{Trigger: CompactionTriggerPressure, ObservedInputTokens: 80}
// fold = 8500; hard = 9744. Observe between them so failure is non-fatal.
policy := ContextPreparePolicy{Trigger: CompactionTriggerPressure, ObservedInputTokens: 8600}
if _, err := first.contextManager().Prepare(context.Background(), policy); err != nil {
t.Fatalf("soft-threshold failure should persist blocked state without rejecting this request: %v", err)
t.Fatalf("above-ratio failure should persist blocked state without rejecting this request: %v", err)
}
if firstProvider.calls != 2 { // summarizeWithRetry makes two bounded attempts
t.Fatalf("summary calls = %d, want 2", firstProvider.calls)
if firstProvider.calls != 1 { // single summary attempt; no summarizeOnce second pass
t.Fatalf("summary calls = %d, want 1", firstProvider.calls)
}
if first.compactionState.BlockedInputHash == "" {
t.Fatal("failed summary did not persist a blocked input hash")
if first.compactionState.LastReceipt == nil {
t.Fatal("failed summary did not persist a maintenance receipt")
}
if status := first.compactionState.LastReceipt.Status; status != "blocked" && status != "failed" {
t.Fatalf("receipt status = %q, want blocked or failed", status)
}
if first.compactionState.LastReceipt.BlockedInputHash == "" {
t.Fatal("failure receipt missing input hash")
}
if first.compactionState.BlockedInputHash != "" {
t.Fatalf("top-level blocked mirror should not be written: %q", first.compactionState.BlockedInputHash)
}
if _, err := first.contextManager().Prepare(context.Background(), policy); err != nil {
t.Fatal(err)
}
if firstProvider.calls != 2 {
if firstProvider.calls != 1 {
t.Fatalf("same in-memory fingerprint retried summary: calls=%d", firstProvider.calls)
}
resumedProvider := &failingSummaryProvider{}
resumed := newAgent(resumedProvider)
if resumed.compactionState.BlockedInputHash == "" {
t.Fatal("blocked-only v3 sidecar was not restored")
if resumed.compactionState.LastReceipt == nil {
t.Fatal("failure receipt was not restored")
}
if status := resumed.compactionState.LastReceipt.Status; status != "blocked" && status != "failed" {
t.Fatalf("restored receipt status = %q, want blocked or failed", status)
}
if _, err := resumed.contextManager().Prepare(context.Background(), policy); err != nil {
t.Fatal(err)
@@ -73,6 +95,54 @@ func TestContextManagerPersistsAndRestoresBlockedFailureFingerprint(t *testing.T
}
}
// TestPrepareThresholdSkipsExtensionInterceptors locks the overflow-only
// contract: automatic compact_ratio uses the pre-interceptor request shape
// (messages + tools + role projection). context.prepare / provider.request
// interceptors run only on the real sampling path so side-effecting plugins
// are not double-invoked for threshold decisions.
func TestPrepareThresholdSkipsExtensionInterceptors(t *testing.T) {
var prepareHits, providerHits atomic.Int32
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
switch ev {
case protocol.EventContextPrepare:
prepareHits.Add(1)
case protocol.EventProviderRequest:
providerHits.Add(1)
}
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
}}
d := newExtDispatcher(client, true, nil, extension.PointContextPrepare, extension.PointProviderRequest)
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "system"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
a := New(&fakeProvider{reply: "unused"}, tool.NewRegistry(), sess, Options{
ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 2,
Extensions: d, WorkspaceID: "ws", ModelRef: "m",
}, event.Discard)
// Below fold: Prepare sizes the view and must not touch interceptors.
if _, err := a.contextManager().Prepare(context.Background(), ContextPreparePolicy{
Trigger: CompactionTriggerPressure, ObservedInputTokens: 100,
}); err != nil {
t.Fatalf("Prepare: %v", err)
}
if prepareHits.Load() != 0 || providerHits.Load() != 0 {
t.Fatalf("threshold Prepare invoked interceptors: prepare=%d provider=%d",
prepareHits.Load(), providerHits.Load())
}
// Real sampling assembly still runs both interceptor points once.
if _, err := a.buildSamplingRequest(context.Background(), CompactionTriggerPressure); err != nil {
t.Fatalf("buildSamplingRequest: %v", err)
}
if prepareHits.Load() != 1 || providerHits.Load() != 1 {
t.Fatalf("sampling path interceptors: prepare=%d provider=%d, want 1 each",
prepareHits.Load(), providerHits.Load())
}
}
func TestStrictAlternatingRolesStillConvergesBeforeSampling(t *testing.T) {
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "system"},
+39 -48
View File
@@ -21,55 +21,27 @@ func (a *Agent) contextMaintenanceInputHash(visible []provider.Message) string {
}
func (a *Agent) contextMaintenanceBlocked(inputHash string) (bool, string) {
if a == nil || inputHash == "" {
if a == nil {
return false, ""
}
a.compactionMu.Lock()
defer a.compactionMu.Unlock()
if a.compactionState.BlockedInputHash != inputHash {
r := a.compactionState.LastReceipt
if r == nil {
// Legacy sidecars may only have BlockedInputHash without a receipt.
if a.compactionState.BlockedInputHash != "" &&
(inputHash == "" || a.compactionState.BlockedInputHash == inputHash) {
return true, a.compactionState.BlockedReason
}
return false, ""
}
return true, a.compactionState.BlockedReason
}
func (a *Agent) observeNativeContextEditing(u *provider.Usage) {
if a == nil || u == nil || a.effectiveContextEditing() != "native" ||
u.ContextEditingType == "" || u.ContextEditingClearedTokens <= 0 {
return
if r.Status != "blocked" && r.Status != "failed" {
return false, ""
}
inputHash := a.contextMaintenanceInputHash(a.modelVisibleMessages())
operationID := fmt.Sprintf("native-tool-clear-%s-%d-%d", inputHash, u.ContextEditingClearedToolUses, u.ContextEditingClearedTokens)
a.compactionMu.Lock()
state := a.compactionState
if state.LastReceipt != nil && state.LastReceipt.OperationID == operationID {
a.compactionMu.Unlock()
return
}
now := time.Now().UTC()
resultTokens := u.LatestPromptTokens()
receipt := &ContextMaintenanceReceipt{
OperationID: operationID, Status: "applied", Action: "native_tool_clear",
Trigger: CompactionTriggerPressure, SourceProjection: state.Projection.ProjectionVersion,
ProjectionVersion: state.Projection.ProjectionVersion, InputHash: inputHash,
InputTokens: resultTokens + u.ContextEditingClearedTokens, ResultTokens: resultTokens,
SavedTokens: u.ContextEditingClearedTokens, AffectedToolResults: u.ContextEditingClearedToolUses,
CacheBreak: true, CreatedAt: now,
}
previous := state
state.SchemaVersion = compactionStateSchemaCurrent
state.Generation++
state.LastTrigger = CompactionTriggerPressure
state.LastMode = CompactionModeNative
state.LastReceipt = receipt
state.UpdatedAt = now
a.compactionState = state
if err := a.persistCompactionStateLocked(); err != nil {
a.compactionState = previous
a.compactionMu.Unlock()
return
}
a.compactionMu.Unlock()
a.emitContextMaintenance(receipt)
// Generation-scoped: once this generation fails, automatic maintenance
// does not pay for another summary until a successful install, manual
// compress, or lineage change advances the generation.
return true, firstNonEmpty(a.compactionState.BlockedReason, r.Reason)
}
func (a *Agent) emitContextMaintenance(r *ContextMaintenanceReceipt) {
@@ -84,8 +56,15 @@ func (a *Agent) emitContextMaintenance(r *ContextMaintenanceReceipt) {
}})
}
// recordContextMaintenanceBlocked persists one failed visible-view fingerprint.
// recordContextMaintenanceBlocked persists a generation-scoped blocked receipt.
func (a *Agent) recordContextMaintenanceBlocked(inputHash, trigger, action, reason string) {
a.recordContextMaintenanceOutcome(inputHash, trigger, action, "blocked", reason)
}
// recordContextMaintenanceOutcome records blocked or failed for the current
// generation. Automatic Prepare will not re-enter summary until the generation
// advances (successful install, manual compress, or lineage change).
func (a *Agent) recordContextMaintenanceOutcome(inputHash, trigger, action, status, reason string) {
if a == nil || a.session == nil {
return
}
@@ -96,14 +75,19 @@ func (a *Agent) recordContextMaintenanceBlocked(inputHash, trigger, action, reas
trigger = CompactionTriggerPressure
}
if action == "" {
action = "noop"
action = "summary"
}
if status != "failed" {
status = "blocked"
}
_, transcriptVersion := a.session.snapshotMessagesVersion()
promptCacheKey := a.currentPromptCacheKey()
a.compactionMu.Lock()
state := a.compactionState
previous := state
if state.BlockedInputHash == inputHash && state.LastReceipt != nil && state.LastReceipt.Status == "blocked" {
if state.LastReceipt != nil &&
(state.LastReceipt.Status == "blocked" || state.LastReceipt.Status == "failed") &&
state.LastReceipt.Action == action {
a.compactionMu.Unlock()
return
}
@@ -111,11 +95,18 @@ func (a *Agent) recordContextMaintenanceBlocked(inputHash, trigger, action, reas
state.SchemaVersion = compactionStateSchemaCurrent
state.TranscriptVersion = transcriptVersion
state.PromptCacheKey = promptCacheKey
// Do not advance projection version on failure; generation still advances so
// CAS losers and concurrent writers cannot overwrite a newer success.
state.Generation++
state.BlockedInputHash = inputHash
state.BlockedReason = reason
// LastReceipt carries the blocked signal; clear legacy top-level mirrors.
state.BlockedInputHash = ""
state.BlockedReason = ""
state.LastTrigger = ""
state.LastMode = ""
state.LastSourceTokens = 0
state.LastResultTokens = 0
state.LastReceipt = &ContextMaintenanceReceipt{
OperationID: fmt.Sprintf("blocked-%s", inputHash), Status: "blocked", Action: action,
OperationID: fmt.Sprintf("%s-%s-%d", status, action, state.Generation), Status: status, Action: action,
Trigger: trigger, SourceProjection: state.Projection.ProjectionVersion,
ProjectionVersion: state.Projection.ProjectionVersion, InputHash: inputHash,
BlockedInputHash: inputHash, Reason: reason, CreatedAt: now,
+19 -6
View File
@@ -54,9 +54,7 @@ func (a *Agent) ContextReport() ContextReport {
rep.Projected = rep.ProjectionTokens != rep.CanonicalTokens
if a.contextWindow > 0 {
soft, snip, fold := a.compactThresholds()
rep.SoftThreshold, rep.SnipThreshold, rep.FoldThreshold = soft, snip, fold
rep.ForceThreshold = a.forceCompactThreshold(fold)
rep.FoldThreshold = a.compactTrigger()
if _, reason := a.contextMaintenanceBlocked(a.contextMaintenanceInputHash(visible)); reason != "" {
rep.BlockedReason = reason
}
@@ -65,10 +63,25 @@ func (a *Agent) ContextReport() ContextReport {
a.compactionMu.Lock()
st := a.compactionState
a.compactionMu.Unlock()
rep.LastTrigger, rep.LastMode = st.LastTrigger, st.LastMode
rep.LastSource, rep.LastResult = st.LastSourceTokens, st.LastResultTokens
// Prefer LastReceipt; fall back to legacy top-level mirrors from older sidecars.
if r := st.LastReceipt; r != nil && r.Status == "applied" {
rep.LastTrigger = r.Trigger
if r.Action == "summary" {
rep.LastMode = CompactionModeSummarized
} else if r.Action != "" {
rep.LastMode = r.Action
}
rep.LastSource, rep.LastResult = r.InputTokens, r.ResultTokens
} else {
rep.LastTrigger, rep.LastMode = st.LastTrigger, st.LastMode
rep.LastSource, rep.LastResult = st.LastSourceTokens, st.LastResultTokens
}
if rep.BlockedReason == "" {
rep.BlockedReason = st.BlockedReason
if r := st.LastReceipt; r != nil && (r.Status == "blocked" || r.Status == "failed") {
rep.BlockedReason = r.Reason
} else {
rep.BlockedReason = st.BlockedReason
}
}
return rep
}
+8 -9
View File
@@ -29,21 +29,20 @@ func TestContextReportUsesLatestPromptNotBillableAggregate(t *testing.T) {
}
}
// Thresholds must come from the same helper the decision uses, so the report
// cannot drift from what compaction will actually do.
// The sole trigger must come from the same helper the decision uses.
func TestContextReportThresholdsMatchTheDecision(t *testing.T) {
a := New(&fakeProvider{reply: "unused"}, tool.NewRegistry(), NewSession("sys"), Options{
ContextWindow: 200_000, CompactRatio: 0.8, CompactForceRatio: 0.9, RecentKeep: 2,
ContextWindow: 200_000, CompactRatio: 0.85, RecentKeep: 2,
}, event.Discard)
rep := a.ContextReport()
soft, snip, fold := a.compactThresholds()
if rep.SoftThreshold != soft || rep.SnipThreshold != snip || rep.FoldThreshold != fold {
t.Errorf("report thresholds (%d/%d/%d) differ from compactThresholds (%d/%d/%d)",
rep.SoftThreshold, rep.SnipThreshold, rep.FoldThreshold, soft, snip, fold)
fold := a.compactTrigger()
if rep.FoldThreshold != fold {
t.Errorf("report FoldThreshold %d differs from compactTrigger %d", rep.FoldThreshold, fold)
}
if want := a.forceCompactThreshold(fold); rep.ForceThreshold != want {
t.Errorf("ForceThreshold = %d, want %d", rep.ForceThreshold, want)
if rep.SoftThreshold != 0 || rep.SnipThreshold != 0 || rep.ForceThreshold != 0 {
t.Errorf("legacy multi-threshold fields should stay zero: soft=%d snip=%d force=%d",
rep.SoftThreshold, rep.SnipThreshold, rep.ForceThreshold)
}
}
+36 -9
View File
@@ -12,6 +12,8 @@ type ContextMaintenanceSnapshot struct {
SnipTrigger int
FoldTrigger int
ForceTrigger int
TriggerTokens int
CheckpointState string
HardInputCeiling int
Headroom int
ProjectionVersion uint64
@@ -26,20 +28,28 @@ func (a *Agent) ContextMaintenanceSnapshot() ContextMaintenanceSnapshot {
canonical, version := a.session.snapshotMessagesVersion()
a.compactionMu.Lock()
state := a.compactionState
checkpointState := a.checkpointState
a.compactionMu.Unlock()
visible := canonical
if projectionValid(state, canonical, version, a.currentPromptCacheKey()) {
valid := projectionValid(state, canonical, version, a.currentPromptCacheKey())
if valid {
if projected := modelVisibleFromProjection(state.Projection, canonical); len(projected) > 0 {
visible = projected
}
}
_, snip, fold := a.compactThresholds()
trigger := a.compactTrigger()
// UI checkpoint label requires a still-valid covered prefix, not merely
// that the sidecar loaded.
uiCheckpoint := "none"
if valid && len(state.Projection.Messages) > 0 {
uiCheckpoint = stateCheckpointState(checkpointState, state)
}
snapshot := ContextMaintenanceSnapshot{
CanonicalTokens: a.estimatedPromptTokens(provider.ModelMessages(canonical)),
ProjectedTokens: a.estimatedPromptTokens(provider.ModelMessages(visible)),
SnipTrigger: snip,
FoldTrigger: fold,
ForceTrigger: a.forceCompactThreshold(fold),
CanonicalTokens: a.estimatedVisibleRequestTokens(canonical),
ProjectedTokens: a.estimatedVisibleRequestTokens(visible),
FoldTrigger: trigger,
TriggerTokens: trigger,
CheckpointState: uiCheckpoint,
HardInputCeiling: a.hardInputCeiling(),
ProjectionVersion: state.Projection.ProjectionVersion,
}
@@ -50,13 +60,30 @@ func (a *Agent) ContextMaintenanceSnapshot() ContextMaintenanceSnapshot {
}
snapshot.Headroom = max(0, snapshot.HardInputCeiling-snapshot.ProjectedTokens)
currentHash := a.contextMaintenanceInputHash(visible)
snapshot.Blocked = state.BlockedInputHash != "" && state.BlockedInputHash == currentHash
if state.LastReceipt != nil {
receipt := *state.LastReceipt
snapshot.LastReceipt = &receipt
if receipt.Status == "applied" && (receipt.Action == "snip" || receipt.Action == "prune" || receipt.Action == "native_tool_clear") {
if receipt.Status == "applied" && (receipt.Action == "prune" || receipt.Action == "summary") {
snapshot.LastSavedTokens = receipt.SavedTokens
}
// Generation-scoped blocked/failed receipts match contextMaintenanceBlocked.
if receipt.Status == "blocked" || receipt.Status == "failed" {
snapshot.Blocked = true
}
}
// Legacy sidecars may only have top-level BlockedInputHash.
if !snapshot.Blocked && state.BlockedInputHash != "" && state.BlockedInputHash == currentHash {
snapshot.Blocked = true
}
return snapshot
}
func stateCheckpointState(runtimeState string, state CompactionState) string {
if len(state.Projection.Messages) == 0 {
return "none"
}
if runtimeState == "applied" {
return "applied"
}
return "restored"
}
+2
View File
@@ -16,6 +16,7 @@ import (
// batchExecution is the result of one provider tool-call batch.
type batchExecution struct {
results []string
outcomes []toolOutcome
images [][]string
executions []*tool.ShellExecution
recoveryStopTurn bool
@@ -349,6 +350,7 @@ func (a *Agent) executeBatch(ctx context.Context, calls []provider.ToolCall) bat
}
return batchExecution{
results: results,
outcomes: outcomes,
images: images,
executions: executions,
recoveryStopTurn: recoveryBatchStop,
+19 -6
View File
@@ -343,8 +343,12 @@ func (a *Agent) applyPlanModeAndProxy(ctx context.Context, plan *toolCallPlan) (
if rc.Unavailable {
return toolOutcome{output: result, errMsg: firstLine(rc.UnavailableReason)}, true
}
body, truncMsg := truncateToolOutput(result)
return toolOutcome{output: body, truncated: truncMsg != "", truncMsg: truncMsg}, true
body, truncMsg := truncateToolOutputFor(result, plan.call.Name, plan.call.ID)
out := toolOutcome{output: body, truncated: truncMsg != "", truncMsg: truncMsg}
if truncMsg != "" {
out.rawOutput = result
}
return out, true
}
} else if outcome, blocked := a.readOnlyExecutionBlock(t, nil); blocked {
return outcome, true
@@ -817,11 +821,16 @@ func (a *Agent) finishToolExecution(ctx context.Context, plan *toolCallPlan) too
detail = strings.TrimRight(detail, "\n") + "\nThe arguments were not valid JSON. Re-emit them exactly per this schema:\n" + string(t.Schema())
}
a.recordRepeatFailure(call, t, err)
body, truncMsg := truncateToolOutput(fmt.Sprintf("error: %v\n%s", err, detail))
return toolOutcome{
rawErr := fmt.Sprintf("error: %v\n%s", err, detail)
body, truncMsg := truncateToolOutputFor(rawErr, call.Name, call.ID)
out := toolOutcome{
output: body, errMsg: firstLine(err.Error()), truncated: truncMsg != "", truncMsg: truncMsg,
execution: execution, recoveryGeneration: recoveryGen,
}
if truncMsg != "" {
out.rawOutput = rawErr
}
return out
}
if mutates {
a.clearRepeatFailuresAfterMutation(evidenceName, evidenceArgs, readOnly)
@@ -833,11 +842,15 @@ func (a *Agent) finishToolExecution(ctx context.Context, plan *toolCallPlan) too
if a.hooks != nil && call.Name == "task" && !isBackgroundTaskCall(call.Arguments) {
a.hooks.SubagentStop(ctx, result)
}
body, truncMsg := truncateToolOutput(result)
return toolOutcome{
body, truncMsg := truncateToolOutputFor(result, call.Name, call.ID)
out := toolOutcome{
output: body, images: images, truncated: truncMsg != "", truncMsg: truncMsg,
execution: execution, recoveryGeneration: recoveryGen,
}
if truncMsg != "" {
out.rawOutput = result
}
return out
}
// observeBeforeMutation captures preimages for Previewable writers and records
+8 -5
View File
@@ -1107,21 +1107,24 @@ func TestToolAfterFailurePolicy(t *testing.T) {
// newCompactionAgent builds an agent whose session has a foldable middle
// (large assistant turns) so CompactNow always finds a region, with the
// summarizer scripted to answer "SUMMARY TEXT".
// summarizer scripted to answer "SUMMARY TEXT". The recent tail stays small so
// the content-driven candidate lands well under compact_ratio and the 50% ceiling.
func newCompactionAgent(t *testing.T, d *dispatch.Dispatcher) (*mockProvider, *Agent) {
t.Helper()
mp := &mockProvider{name: "p", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "SUMMARY TEXT"}, {Type: provider.ChunkDone},
}}
sess := NewSession("sys")
big := strings.Repeat("a", 4000)
big := strings.Repeat("a", 8000)
sess.Add(provider.Message{Role: provider.RoleUser, Content: "task"})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: big})
sess.Add(provider.Message{Role: provider.RoleUser, Content: "more"})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: big})
sess.Add(provider.Message{Role: provider.RoleUser, Content: "again"})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: big})
return mp, New(mp, tool.NewRegistry(), sess, Options{ContextWindow: 1000, Extensions: d}, event.Discard)
sess.Add(provider.Message{Role: provider.RoleUser, Content: "next"})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"})
return mp, New(mp, tool.NewRegistry(), sess, Options{
ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 2, Extensions: d,
}, event.Discard)
}
func TestCompactionPrepareReplaceGuidance(t *testing.T) {
+43
View File
@@ -0,0 +1,43 @@
package agent
import (
"testing"
"reasonix/internal/provider"
)
func TestLineageKeyCompatibleNativeSuffix(t *testing.T) {
current := "ws|sess|model"
stored := current + "|context-editing-native-anthropic"
got, ok := lineageKeyCompatible(stored, current)
if !ok || got != current {
t.Fatalf("lineageKeyCompatible(%q,%q)=(%q,%v), want (%q,true)", stored, current, got, ok, current)
}
if _, ok := lineageKeyCompatible("other|sess|model", current); ok {
t.Fatal("foreign lineage must not match")
}
if got, ok := lineageKeyCompatible(current, current); !ok || got != current {
t.Fatalf("exact key: got (%q,%v)", got, ok)
}
}
func TestProjectionValidAcceptsLegacyNativeKey(t *testing.T) {
msgs := []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "u"},
}
hash := coveredPrefixHash(msgs, len(msgs))
st := CompactionState{
PromptCacheKey: "ws|s|m|context-editing-native-x",
Projection: ContextProjection{
Messages: msgs,
CoveredCount: len(msgs),
CoveredPrefixHash: hash,
ProjectionVersion: 1,
},
TranscriptVersion: 1,
}
if !projectionValid(st, msgs, 1, "ws|s|m") {
t.Fatal("projectionValid rejected legacy native lineage key")
}
}
+2 -5
View File
@@ -233,11 +233,8 @@ func isCJKRune(r rune) bool {
(r >= 0xAC00 && r <= 0xD7AF)
}
// effectiveOutputBudget returns an explicit smaller output budget only when a
// shared-window request would otherwise exceed the configured context window.
// The final extension-adjusted request is measured, so later payload rewrites
// cannot invalidate the decision. An exhausted window fails locally instead of
// sending a request the provider will reject with HTTP 400.
// effectiveOutputBudget clips completion tokens at send time only; it never
// moves compact_ratio. Exhausted windows fail locally before HTTP 400.
func (a *Agent) effectiveOutputBudget(req provider.Request) (int, bool, error) {
if a == nil || a.contextWindow <= 0 || !sharesContextWindow(a.prov) {
return 0, false, nil
+40 -9
View File
@@ -90,9 +90,16 @@ func TestSharedWindowFoldUsesGuardedInputBudget(t *testing.T) {
outputBudgetState: outputBudgetState{outputBudget: prov.budget},
sink: event.Discard,
}
// 200K han runes is ~150K cold-start tokens against a 100K window: the fold
// genuinely cannot ride one call, which is what the guard exists to catch.
fold := []provider.Message{{Role: provider.RoleUser, Content: strings.Repeat("字", 200_000)}}
// Oversized tool bodies are deterministically shortened for the single
// summarizer request (no multi-span). The guard must still fit one call.
toolBody := strings.Repeat("file line content here. ", 20_000) // ~480K chars
fold := []provider.Message{
{Role: provider.RoleUser, Content: "read large files"},
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "read_file", Arguments: "{}"}}},
{Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: toolBody},
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "2", Name: "read_file", Arguments: "{}"}}},
{Role: provider.RoleTool, ToolCallID: "2", Name: "read_file", Content: toolBody},
}
if _, err := a.foldToSummary(context.Background(), fold, ""); err != nil {
t.Fatalf("foldToSummary: %v", err)
@@ -100,14 +107,39 @@ func TestSharedWindowFoldUsesGuardedInputBudget(t *testing.T) {
if prov.calls == 0 || len(prov.last.Messages) < 2 {
t.Fatalf("guarded fold produced no summarizer request: calls=%d request=%+v", prov.calls, prov.last)
}
if got := prov.last.Messages[1].Content; len(got) >= len(renderTranscript(fold)) || !strings.Contains(got, "omitted") {
t.Fatalf("cold CJK fold was not bounded before summarize: len=%d original=%d", len(got), len(renderTranscript(fold)))
got := prov.last.Messages[1].Content
if len(got) >= len(renderTranscript(fold)) {
t.Fatalf("oversized tool fold was not shortened before summarize: len=%d original=%d", len(got), len(renderTranscript(fold)))
}
// Snip / omit markers prove the temporary request was bounded for the guard.
if !strings.Contains(got, "omitted") && !strings.Contains(got, "retained") && !strings.Contains(got, "snip") {
t.Fatalf("shortened fold missing a truncation marker:\n%.200q", got)
}
if got := prov.last.MaxTokens; got < summaryOutputReserve {
t.Fatalf("summarizer MaxTokens = %d, below summaryOutputReserve %d", got, summaryOutputReserve)
}
}
// A single unshortenable fold that still exceeds the single-request budget after
// all deterministic shorteners must fail once (no multi-span split).
func TestSharedWindowFoldRejectsUnshortenableOverBudgetInput(t *testing.T) {
prov := &sharedWindowTestProvider{budget: 128 * 1024, shared: true}
a := &Agent{
prov: prov,
contextWindow: 100_000,
outputBudgetState: outputBudgetState{outputBudget: prov.budget},
sink: event.Discard,
}
fold := []provider.Message{{Role: provider.RoleUser, Content: strings.Repeat("字", 200_000)}}
_, err := a.foldToSummary(context.Background(), fold, "")
if err == nil || !strings.Contains(err.Error(), "exceeds single-request budget") {
t.Fatalf("foldToSummary err = %v, want single-request budget failure", err)
}
if prov.calls != 0 {
t.Fatalf("over-budget unshortenable fold still called summarizer %d times", prov.calls)
}
}
func (p *sharedWindowTestProvider) OutputBudget() int { return p.budget }
func (p *sharedWindowTestProvider) SharesContextWindow() bool { return p.shared }
@@ -285,8 +317,7 @@ func TestPrepareSamplingRequestClipsSharedWindowOutput(t *testing.T) {
tools: tool.NewRegistry(),
session: sess,
contextWindow: 1_048_576,
compactRatio: 2,
compactForceRatio: 2,
compactRatio: 2, // disable auto maintenance for this output-clip test
outputBudgetState: outputBudgetState{outputBudget: prov.budget},
}
a.lastUsage.Store(&provider.Usage{PromptTokens: 950_000})
@@ -369,11 +400,11 @@ func TestSummarizeRejectsLengthTruncation(t *testing.T) {
sink: event.Discard,
}
_, _, err := a.summarizeWithRetry(context.Background(), []provider.Message{{
_, _, err := a.summarizeOnce(context.Background(), []provider.Message{{
Role: provider.RoleUser, Content: "retain every durable fact",
}}, "")
if err == nil || !strings.Contains(err.Error(), "truncated") {
t.Fatalf("summarizeWithRetry error = %v, want truncation failure", err)
t.Fatalf("summarizeOnce error = %v, want truncation failure", err)
}
if prov.calls != 1 {
t.Fatalf("length-truncated summary calls = %d, want no identical retry", prov.calls)
+22 -23
View File
@@ -2,8 +2,6 @@ package agent
import (
"context"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
@@ -14,20 +12,24 @@ import (
)
func TestSummarizeFromPreservesLocalOnlyOutsideModelAndArchive(t *testing.T) {
archiveDir := t.TempDir()
local := provider.Message{
Role: provider.RoleTool, ToolCallID: provider.LocalOnlyToolID, Name: provider.LocalOnlyToolName,
LocalOnly: true, Content: "visible interrupted output", ReasoningContent: "private interrupted reasoning",
InterruptedTurn: &provider.InterruptedTurnRecovery{Pending: true, InterruptedTools: []string{"bash"}},
}
prov := &fakeProvider{reply: "later summary"}
// Enough foldable assistant work so the projection candidate reduces tokens.
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
local,
{Role: provider.RoleAssistant, Content: "safe answer " + strings.Repeat("work ", 200)},
{Role: provider.RoleAssistant, Content: "safe answer " + strings.Repeat("work ", 400)},
{Role: provider.RoleUser, Content: "more"},
{Role: provider.RoleAssistant, Content: strings.Repeat("more work ", 400)},
}}
a := New(prov, tool.NewRegistry(), sess, Options{ArchiveDir: archiveDir}, event.Discard)
a := New(prov, tool.NewRegistry(), sess, Options{
ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 2,
}, event.Discard)
before := sess.Snapshot()
if err := a.SummarizeFrom(context.Background(), 1); err != nil {
@@ -39,11 +41,10 @@ func TestSummarizeFromPreservesLocalOnlyOutsideModelAndArchive(t *testing.T) {
if len(a.compactionState.Projection.Messages) == 0 {
t.Fatal("expected summarize-from projection")
}
assertLocalOnlyAbsentFromSummaryAndArchive(t, prov, archiveDir, local)
assertLocalOnlyAbsentFromSummary(t, prov, a, local)
}
func TestSummarizeUpToPreservesLocalOnlyOutsideModelAndArchive(t *testing.T) {
archiveDir := t.TempDir()
local := provider.Message{
Role: provider.RoleTool, ToolCallID: provider.LocalOnlyToolID, Name: provider.LocalOnlyToolName,
LocalOnly: true, Content: "visible earlier interruption", ReasoningContent: "private earlier reasoning",
@@ -54,11 +55,13 @@ func TestSummarizeUpToPreservesLocalOnlyOutsideModelAndArchive(t *testing.T) {
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "old task"},
local,
{Role: provider.RoleAssistant, Content: "old answer " + strings.Repeat("work ", 200)},
{Role: provider.RoleAssistant, Content: "old answer " + strings.Repeat("work ", 400)},
{Role: provider.RoleUser, Content: "new task"},
{Role: provider.RoleAssistant, Content: "new answer"},
}}
a := New(prov, tool.NewRegistry(), sess, Options{ArchiveDir: archiveDir}, event.Discard)
a := New(prov, tool.NewRegistry(), sess, Options{
ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 2,
}, event.Discard)
before := sess.Snapshot()
if err := a.SummarizeUpTo(context.Background(), 4); err != nil {
@@ -70,7 +73,7 @@ func TestSummarizeUpToPreservesLocalOnlyOutsideModelAndArchive(t *testing.T) {
if len(a.compactionState.Projection.Messages) == 0 {
t.Fatal("expected summarize-up-to projection")
}
assertLocalOnlyAbsentFromSummaryAndArchive(t, prov, archiveDir, local)
assertLocalOnlyAbsentFromSummary(t, prov, a, local)
}
func TestSummarizeAtProjectionBoundaryReportsFoldedAnchor(t *testing.T) {
@@ -117,23 +120,19 @@ func TestSummarizeAtProjectionBoundaryReportsNoSavings(t *testing.T) {
}
}
func assertLocalOnlyAbsentFromSummaryAndArchive(t *testing.T, prov *fakeProvider, archiveDir string, local provider.Message) {
// assertLocalOnlyAbsentFromSummary checks local-only content stays out of the
// summarizer request and the installed projection. Archives are no longer written.
func assertLocalOnlyAbsentFromSummary(t *testing.T, prov *fakeProvider, a *Agent, local provider.Message) {
t.Helper()
if len(prov.got) < 2 || strings.Contains(prov.got[1].Content, local.Content) || strings.Contains(prov.got[1].Content, local.ReasoningContent) {
t.Fatalf("local-only output leaked into summarizer prompt: %+v", prov.got)
}
entries, err := os.ReadDir(archiveDir)
if err != nil {
t.Fatalf("ReadDir archive: %v", err)
for _, m := range a.compactionState.Projection.Messages {
if m.LocalOnly || strings.Contains(m.Content, local.Content) || strings.Contains(m.Content, local.ReasoningContent) {
t.Fatalf("local-only output entered projection: %+v", m)
}
}
if len(entries) != 1 {
t.Fatalf("archive entries = %d, want 1", len(entries))
}
b, err := os.ReadFile(filepath.Join(archiveDir, entries[0].Name()))
if err != nil {
t.Fatalf("ReadFile archive: %v", err)
}
if strings.Contains(string(b), local.Content) || strings.Contains(string(b), local.ReasoningContent) {
t.Fatalf("local-only output leaked into archive: %s", b)
if a.compactionState.LastReceipt != nil && a.compactionState.LastReceipt.Archive != "" {
t.Fatalf("checkpoint must not create archives, got %q", a.compactionState.LastReceipt.Archive)
}
}
+65 -104
View File
@@ -52,8 +52,7 @@ func (a *Agent) currentPromptCacheKey() string {
}
func (a *Agent) currentPromptCacheKeyLocked() string {
key := promptCacheKey(a.workspaceID, BranchID(a.sessionPath), a.modelRef)
return key + a.contextEditingLineageSuffixLocked()
return promptCacheKey(a.workspaceID, BranchID(a.sessionPath), a.modelRef)
}
// InvalidateProjection drops the in-memory and on-disk projection after
@@ -84,15 +83,11 @@ func (a *Agent) LoadProjectionSidecar(sessionPath string) {
if a == nil {
return
}
_, effective, policyVersion := resolveContextEditing(a.requestedContextEditing, a.prov)
a.compactionMu.Lock()
a.sessionPath = sessionPath
a.contextEditing = effective
a.contextEditingPolicyVersion = policyVersion
a.compactionState = CompactionState{}
a.checkpointState = "none"
a.compactionMu.Unlock()
a.nativeContextEditingAccepted.Store(false)
a.contextEditingRuntimeFallback.Store(false)
if sessionPath == "" {
a.resetCompactionState()
return
@@ -109,31 +104,80 @@ func (a *Agent) LoadProjectionSidecar(sessionPath string) {
return
}
a.compactionMu.Lock()
if st.ContextEditingFallbackLocal && !st.NativeContextEditingAccepted && a.contextEditing == "native" {
a.contextEditing = "local"
a.contextEditingRuntimeFallback.Store(true)
}
// Fail closed: known lineage requires an exact stored key (including
// rejecting blank keys on early sidecars written before this field).
key := a.currentPromptCacheKeyLocked()
if (key != "" && st.PromptCacheKey != key) ||
(st.Projection.CoveredPrefixHash == "" && st.BlockedInputHash == "" &&
!st.ContextEditingFallbackLocal && !st.NativeContextEditingAccepted) {
a.contextEditing = effective
a.contextEditingPolicyVersion = policyVersion
normalized, keyOK := lineageKeyCompatible(st.PromptCacheKey, key)
// Keep receipt-only blocked/failed sidecars (no projection body) and legacy
// top-level BlockedInputHash so generation-scoped suppressions survive restart.
hasMaintenanceSignal := st.Projection.CoveredPrefixHash != "" ||
st.BlockedInputHash != "" ||
(st.LastReceipt != nil && (st.LastReceipt.Status == "blocked" || st.LastReceipt.Status == "failed" ||
st.LastReceipt.Status == "applied"))
if (key != "" && !keyOK) || !hasMaintenanceSignal {
a.compactionState = CompactionState{}
a.contextEditingRuntimeFallback.Store(false)
a.checkpointState = "none"
a.compactionMu.Unlock()
return
}
// Only rewrite legacy native-editing lineage keys; exact matches stay pure-read.
needsNormalization := false
if keyOK && key != "" && normalized != st.PromptCacheKey {
st.PromptCacheKey = normalized
needsNormalization = true
}
// Only mark restored when the projection still matches the transcript.
var msgs []provider.Message
var version uint64
if a.session != nil {
msgs, version = a.session.snapshotMessagesVersion()
}
valid := len(st.Projection.Messages) > 0 && projectionValid(st, msgs, version, key)
if !valid && len(st.Projection.Messages) > 0 {
// Keep blocked receipts / telemetry; drop unusable projection body.
st.Projection = ContextProjection{}
}
a.compactionState = st
if valid {
a.checkpointState = "restored"
if needsNormalization {
if err := a.persistCompactionStateLocked(); err != nil {
slog.Warn("agent: persist normalized projection lineage", "err", err)
}
}
} else {
a.checkpointState = "none"
}
a.compactionMu.Unlock()
a.nativeContextEditingAccepted.Store(st.NativeContextEditingAccepted)
}
// lineageKeyCompatible reports whether a stored PromptCacheKey still belongs to
// the current session/model lineage. Legacy native context-editing keys used a
// "|context-editing-native-..." suffix on an otherwise matching base key.
func lineageKeyCompatible(stored, current string) (normalized string, ok bool) {
stored, current = strings.TrimSpace(stored), strings.TrimSpace(current)
if current == "" {
// Unknown current lineage: accept any stored key as-is.
return stored, true
}
if stored == "" {
return "", false
}
if stored == current {
return current, true
}
const nativeSuffix = "|context-editing-native"
if strings.HasPrefix(stored, current+nativeSuffix) {
return current, true
}
if i := strings.Index(stored, nativeSuffix); i > 0 && stored[:i] == current {
return current, true
}
return "", false
}
func (a *Agent) resetCompactionState() {
a.compactionMu.Lock()
a.compactionState = CompactionState{}
a.checkpointState = "none"
a.compactionMu.Unlock()
}
@@ -148,16 +192,12 @@ func (a *Agent) BindSessionPath(path string, loadSidecar bool) {
a.LoadProjectionSidecar(path)
return
}
_, effective, policyVersion := resolveContextEditing(a.requestedContextEditing, a.prov)
a.compactionMu.Lock()
a.sessionPath = path
a.contextEditing = effective
a.contextEditingPolicyVersion = policyVersion
a.compactionState = CompactionState{}
a.checkpointState = "none"
a.cacheState = CacheStateUnknown
a.compactionMu.Unlock()
a.nativeContextEditingAccepted.Store(false)
a.contextEditingRuntimeFallback.Store(false)
a.compactStuck = false
a.consecutiveCompacts = 0
a.lastCompactionTurn.Store(0)
@@ -223,85 +263,6 @@ func (a *Agent) persistCompactionStateLocked() error {
return SaveCompactionState(a.sessionPath, a.compactionState)
}
// maintenanceReplacement is the rewrite for one stale tool result. Both the
// planning pass and the re-write that follows archiving go through it, so a
// result cannot be shortened one way while planning and another way on install.
// ok is false when the policy protects the message outright.
func (a *Agent) maintenanceReplacement(m provider.Message, mode toolResultMaintenanceMode, archive string) (string, bool) {
if a.keepPolicy&KeepErrors != 0 && isErrorMessage(m) {
// A recorded failure stays recognisable once its text is rewritten, so
// its passing noise can go. A text-only failure has no such anchor:
// eliding it would hide that it was ever an error.
if !failedExecution(m.ToolExecution) {
return "", false
}
return snipFailureResult(m.Content), true
}
return rewriteToolResult(m, mode, archive, a.snipStrategyFor(m.Name)), true
}
// applyToolResultMaintenanceView returns a copy of msgs with stale tool results
// snipped or pruned. The canonical transcript is never modified.
func (a *Agent) applyToolResultMaintenanceView(msgs []provider.Message, mode toolResultMaintenanceMode) ([]provider.Message, PruneStats) {
st := PruneStats{Mode: mode}
if a.contextWindow <= 0 || len(msgs) == 0 {
return msgs, st
}
st.InputHash = providerVisibleFingerprint(provider.ModelMessages(msgs))
head, start, ok := a.planCompaction(msgs, 1)
if !ok {
if mode != toolResultPrune {
return msgs, st
}
head = 1
start = len(msgs) - a.recentKeep
if start < head {
return msgs, st
}
}
next := append([]provider.Message(nil), msgs...)
changed := false
for i := head; i < start; i++ {
m := next[i]
if !shouldMaintainToolResult(m, mode) {
continue
}
replacement, ok := a.maintenanceReplacement(m, mode, "projection-view")
if !ok {
continue
}
if replacement == m.Content {
continue
}
st.SavedChars += len(m.Content) - len(replacement)
m.Content = replacement
next[i] = m
st.Results++
changed = true
}
if !changed {
return msgs, st
}
return next, st
}
// installProjectionIfCurrent closes the compare-and-install window for
// maintenance callers that performed network work from an earlier snapshot.
func (a *Agent) installProjectionIfCurrent(st CompactionState, projectionVersion, generation uint64) error {
a.compactionMu.Lock()
defer a.compactionMu.Unlock()
if a.compactionState.Projection.ProjectionVersion != projectionVersion || a.compactionState.Generation != generation {
return errCompressStaleContext
}
prev := a.compactionState
a.compactionState = st
if err := a.persistCompactionStateLocked(); err != nil {
a.compactionState = prev
return err
}
return nil
}
// promptCacheKey builds a stable lineage key for session + model identity.
// It deliberately excludes message counts, timestamps, and projection hashes.
func promptCacheKey(workspaceID, sessionLineage, modelRef string) string {
+23 -54
View File
@@ -6,11 +6,10 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"time"
"reasonix/internal/fileutil"
"reasonix/internal/provider"
"reasonix/internal/store"
)
@@ -185,55 +184,40 @@ func LoadCompactionState(sessionPath string) (CompactionState, bool, error) {
return st, true, nil
}
// SaveCompactionState writes the sidecar via temp file + atomic rename.
// SaveCompactionState writes the sidecar via strict atomic publish (temp +
// file fsync + rename + best-effort parent-dir fsync). Checkpoint sidecars are
// commit pointers: EXDEV/copy fallbacks that can tear an existing file are
// rejected so a failed write leaves the previous checkpoint intact. A returned
// error means the on-disk pointer was not published.
func SaveCompactionState(sessionPath string, st CompactionState) error {
path := ContextStatePath(sessionPath)
if path == "" {
return fmt.Errorf("empty session path")
}
// V3 preserves logical user-turn boundaries and coalesces roles only on
// outbound copies. Previous readers reject this boundary and fall back to
// canonical history instead of misreading the changed V1 invariant.
// V3 keeps logical user-turn boundaries; previous readers fall back to
// canonical history rather than misreading the V1 coalesced invariant.
st.SchemaVersion = compactionStateSchemaCurrent
if st.UpdatedAt.IsZero() {
st.UpdatedAt = time.Now().UTC()
}
// LastReceipt is authoritative. Drop mirrored top-level last_*/blocked_*
// writer fields so new sidecars do not re-emit the pre-v3 dual schema.
// Old files with those keys still decode into the struct for readers.
st.LastTrigger = ""
st.LastMode = ""
st.LastSourceTokens = 0
st.LastResultTokens = 0
st.LastCompactionCost = 0
if st.LastReceipt != nil {
st.BlockedInputHash = ""
st.BlockedReason = ""
}
b, err := json.MarshalIndent(st, "", " ")
if err != nil {
return err
}
b = append(b, '\n')
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp.*")
if err != nil {
return err
}
tmpName := tmp.Name()
cleanup := true
defer func() {
if cleanup {
_ = os.Remove(tmpName)
}
}()
if _, err := tmp.Write(b); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmpName, path); err != nil {
return err
}
cleanup = false
return nil
return fileutil.AtomicWriteFileStrict(path, b, 0o644)
}
// RemoveCompactionState deletes a corrupt or invalidated projection sidecar.
@@ -335,9 +319,9 @@ func projectionValid(st CompactionState, msgs []provider.Message, transcriptVers
if n <= 0 || n > len(msgs) {
return false
}
// Current lineage known: stored key must be present and equal.
// Current lineage known: stored key must match (legacy native suffix ok).
if cacheKey != "" {
if st.PromptCacheKey == "" || st.PromptCacheKey != cacheKey {
if _, ok := lineageKeyCompatible(st.PromptCacheKey, cacheKey); !ok {
return false
}
}
@@ -410,18 +394,3 @@ func formatSummaryMessage(summary string) provider.Message {
summaryTagClose,
}
}
// extractLatestSummary returns the body of the newest compaction summary in msgs.
func extractLatestSummary(msgs []provider.Message) string {
for i := range slices.Backward(msgs) {
if !isCompactionSummary(msgs[i]) {
continue
}
body := msgs[i].Content
body = strings.TrimPrefix(strings.TrimLeft(body, "\n "), summaryTagOpen)
body = strings.TrimSuffix(strings.TrimRight(body, "\n "), summaryTagClose)
body = strings.TrimPrefix(body, "\nSummary of earlier conversation (older messages were compacted to save context):\n")
return strings.TrimSpace(body)
}
return ""
}
+113 -77
View File
@@ -35,8 +35,6 @@ func TestCompactionStateAtomicSaveLoad(t *testing.T) {
},
PromptCacheKey: "ws|sess|model",
LastCacheState: CacheStateCold,
LastTrigger: CompactionTriggerPressure,
LastMode: CompactionModeSummarized,
Generation: 7,
LastReceipt: &ContextMaintenanceReceipt{
Status: "applied", Action: "summary", ProjectionVersion: 1,
@@ -50,7 +48,7 @@ func TestCompactionStateAtomicSaveLoad(t *testing.T) {
if err != nil || !ok {
t.Fatalf("load: ok=%v err=%v", ok, err)
}
if got.SchemaVersion != compactionStateSchemaCurrent || got.TranscriptVersion != 3 || got.LastMode != CompactionModeSummarized {
if got.SchemaVersion != compactionStateSchemaCurrent || got.TranscriptVersion != 3 {
t.Fatalf("loaded state = %+v", got)
}
if len(got.Projection.Messages) != 2 || got.Projection.CoveredCount != 10 {
@@ -146,9 +144,9 @@ func TestCompactToProjectionLeavesCanonicalIntact(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "s.jsonl")
a := New(fp, nil, sess, Options{
ContextWindow: 2000,
ContextWindow: 50_000,
CompactRatio: 0.85,
RecentKeep: 2,
ArchiveDir: dir,
SessionPath: sessionPath,
ModelRef: "test/model",
}, event.Discard)
@@ -174,13 +172,17 @@ func TestCompactToProjectionLeavesCanonicalIntact(t *testing.T) {
estimateMessagesTokens(a.compactionState.Projection.Messages),
estimateMessagesTokens(before))
}
// Sidecar must exist and reload.
// Sidecar must exist and reload with an applied summary receipt (v3 does not
// persist the legacy last_mode field).
st, ok, err := LoadCompactionState(sessionPath)
if err != nil || !ok {
t.Fatalf("reload sidecar: ok=%v err=%v", ok, err)
}
if st.LastMode != CompactionModeSummarized {
t.Fatalf("mode = %q, want summarized", st.LastMode)
if st.LastReceipt == nil || st.LastReceipt.Status != "applied" || st.LastReceipt.Action != "summary" {
t.Fatalf("last receipt = %+v, want applied summary", st.LastReceipt)
}
if st.Projection.ProjectionVersion == 0 {
t.Fatal("reloaded projection version is zero")
}
// Model-visible must use projection.
visible := a.modelVisibleMessages()
@@ -317,24 +319,35 @@ func TestLocalOnlyExcludedFromCompactionRequest(t *testing.T) {
}
}
func TestArchiveFailureLeavesNoProjection(t *testing.T) {
// Checkpoint installation no longer writes archive copies — the canonical
// transcript is the lossless store. ArchiveDir misconfiguration must not block
// a successful summary install.
func TestArchiveDirIgnoredOnCheckpointInstall(t *testing.T) {
fp := &fakeProvider{reply: "digest"}
sess := NewSession("sys")
for range 8 {
sess.Add(provider.Message{Role: provider.RoleUser, Content: strings.Repeat("u", 80)})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("a", 120)})
big := strings.Repeat("assistant work detail ", 200)
for range 6 {
sess.Add(provider.Message{Role: provider.RoleUser, Content: "turn"})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: big})
}
// Point archive at a file path so MkdirAll/Create fails.
sess.Add(provider.Message{Role: provider.RoleUser, Content: "next"})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"})
// Point archive at a file path so MkdirAll/Create would fail if archives were written.
badArchive := filepath.Join(t.TempDir(), "not-a-dir")
if err := writeFile(badArchive, []byte("x")); err != nil {
t.Fatal(err)
}
a := New(fp, nil, sess, Options{ContextWindow: 2000, RecentKeep: 2, ArchiveDir: badArchive}, event.Discard)
if err := a.CompactNow(context.Background(), ""); err == nil {
t.Fatal("expected archive failure")
a := New(fp, nil, sess, Options{
ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 2, ArchiveDir: badArchive,
}, event.Discard)
if err := a.CompactNow(context.Background(), ""); err != nil {
t.Fatalf("CompactNow with unusable ArchiveDir: %v", err)
}
if len(a.compactionState.Projection.Messages) != 0 {
t.Fatal("half-finished projection after archive failure")
if len(a.compactionState.Projection.Messages) == 0 {
t.Fatal("expected projection despite unusable ArchiveDir")
}
if a.compactionState.LastReceipt != nil && a.compactionState.LastReceipt.Archive != "" {
t.Fatalf("checkpoint must not create archives, got %q", a.compactionState.LastReceipt.Archive)
}
}
@@ -370,18 +383,20 @@ func joinContents(msgs []provider.Message) string {
func TestCompactReplacesHistory(t *testing.T) {
prov := &fakeProvider{reply: "- goal: do X\n- changed file Y"}
bigStep := strings.Repeat("important implementation detail ", 80)
bigStep := strings.Repeat("important implementation detail ", 200)
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task " + bigStep},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "read_file", Arguments: "{}"}}},
{Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: "file contents"},
{Role: provider.RoleAssistant, Content: "did a step"},
{Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: bigStep},
{Role: provider.RoleAssistant, Content: bigStep},
{Role: provider.RoleUser, Content: "next"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
dir := t.TempDir()
a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: dir}, event.Discard)
a := New(prov, tool.NewRegistry(), sess, Options{
ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 2, ArchiveDir: dir,
}, event.Discard)
beforeLen := len(sess.Messages)
if err := a.compact(context.Background(), "manual", "", true); err != nil {
@@ -408,7 +423,7 @@ func TestCompactReplacesHistory(t *testing.T) {
}
var foundSummary bool
for _, m := range proj {
if strings.Contains(m.Content, "Summary of earlier") && strings.Contains(m.Content, "do X") {
if strings.Contains(m.Content, "do X") {
foundSummary = true
}
}
@@ -416,19 +431,13 @@ func TestCompactReplacesHistory(t *testing.T) {
t.Errorf("summary missing do X: %+v", proj)
}
// No new archive files: canonical is the lossless store.
entries, err := os.ReadDir(dir)
if err != nil || len(entries) != 1 {
t.Fatalf("archive dir: entries=%d err=%v", len(entries), err)
}
data, err := os.ReadFile(filepath.Join(dir, entries[0].Name()))
if err != nil {
t.Fatalf("read archive: %v", err)
t.Fatalf("archive dir: %v", err)
}
if lines := strings.Count(strings.TrimSpace(string(data)), "\n") + 1; lines < 1 {
t.Errorf("archived %d lines, want >=1:\n%s", lines, data)
}
if !strings.HasSuffix(entries[0].Name(), ".jsonl") {
t.Errorf("archive name = %q, want .jsonl", entries[0].Name())
if len(entries) != 0 {
t.Fatalf("archive dir entries = %d, want 0 (no new archives)", len(entries))
}
}
@@ -478,70 +487,77 @@ func TestCompactFallsBackToMechanicalFoldWhenSummaryFails(t *testing.T) {
}
func TestCompactRewriteVersionFeedsCacheDiagnostics(t *testing.T) {
// Projection checkpoints do not rewrite the canonical transcript, so they
// must not bump LogRewriteVersion or queue compact_* content-rewrite reasons.
// The provider-visible change is the projection sidecar (version + summary).
prov := &fakeProvider{reply: "- summary"}
big := strings.Repeat("work detail ", 200)
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "a"},
{Role: provider.RoleAssistant, Content: "b"},
{Role: provider.RoleUser, Content: "c"},
{Role: provider.RoleAssistant, Content: "d"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, Content: big},
{Role: provider.RoleUser, Content: "more"},
{Role: provider.RoleAssistant, Content: big},
{Role: provider.RoleUser, Content: "e"},
{Role: provider.RoleAssistant, Content: "f"},
}}
a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2}, event.Discard)
before := CaptureShape("sys", nil, sess.RewriteVersion())
a := New(prov, tool.NewRegistry(), sess, Options{
ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 2,
}, event.Discard)
beforeVersion := sess.RewriteVersion()
if err := a.compact(context.Background(), "auto", "", true); err != nil {
t.Fatalf("compact: %v", err)
}
// Canonical rewriteVersion stays put; projection notes compact_auto for diagnostics.
if sess.RewriteVersion() != before.LogRewriteVersion {
t.Fatalf("canonical rewrite version changed: %d -> %d", before.LogRewriteVersion, sess.RewriteVersion())
if sess.RewriteVersion() != beforeVersion {
t.Fatalf("canonical rewrite version changed: %d -> %d", beforeVersion, sess.RewriteVersion())
}
if !hasCompactionSummary(visibleContext(a)) {
t.Fatal("expected projection summary")
}
reasons := sess.DrainContentRewriteReasons()
// Reasons alone attribute the prefix change; rewrite version need not bump.
after := CaptureShape("sys", nil, sess.RewriteVersion())
diag := CompareShape(before, after, &provider.Usage{CacheMissTokens: 10}, reasons)
if !diag.PrefixChanged {
t.Fatalf("diagnostics should report prefix change: %+v", diag)
if got := a.currentProjectionVersion(); got != 1 {
t.Fatalf("projection version = %d, want 1", got)
}
if len(diag.PrefixChangeReasons) != 1 || diag.PrefixChangeReasons[0] != "compact_auto" {
t.Fatalf("change reasons = %v, want [compact_auto]", diag.PrefixChangeReasons)
if reasons := sess.DrainContentRewriteReasons(); len(reasons) != 0 {
t.Fatalf("projection compact queued canonical rewrite reasons %v; want none", reasons)
}
}
func TestCompactKeepsMidSessionUserTurns(t *testing.T) {
big := strings.Repeat("work output ", 100)
// Small window so the recent-tail budget cannot swallow the mid-session
// user turn (production 32K floor only applies to large windows).
const window = 8_000
// ~1500 tokens of work after the mid-fact pushes it out of the ~800-token tail.
big := strings.Repeat("work output line with detail. ", 250)
midFact := "by the way, always use pnpm not npm"
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "first task"},
{Role: provider.RoleAssistant, Content: big},
{Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: big},
{Role: provider.RoleUser, Content: "by the way, always use pnpm not npm"},
{Role: provider.RoleUser, Content: midFact},
{Role: provider.RoleAssistant, Content: big},
{Role: provider.RoleTool, ToolCallID: "2", Name: "read_file", Content: big},
{Role: provider.RoleUser, Content: "next"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
// Summarizer preserves the mid-session fact in the rolling digest.
// Summarizer carries the mid-session fact into the single rolling digest.
// No early-user-turn hoist: only the stable prefix (system + first user) is
// kept verbatim; mid-session user turns in the fold region merge into the summary.
a := New(&fakeProvider{reply: "Standing facts: always use pnpm not npm"}, tool.NewRegistry(), sess,
Options{RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
Options{ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2}, event.Discard)
if err := a.compact(context.Background(), "manual", "", true); err != nil {
t.Fatalf("compact: %v", err)
}
// Canonical retains every user turn; projection keeps fixed early turns
// and folds later small user turns into the single summary.
// Canonical retains every user turn.
var pinnedFirst, keptMidCanonical bool
for _, m := range sess.Snapshot() {
if m.Role == provider.RoleUser && m.Content == "first task" {
pinnedFirst = true
}
if m.Role == provider.RoleUser && strings.Contains(m.Content, "always use pnpm not npm") {
if m.Role == provider.RoleUser && strings.Contains(m.Content, midFact) {
keptMidCanonical = true
}
}
@@ -549,7 +565,7 @@ func TestCompactKeepsMidSessionUserTurns(t *testing.T) {
t.Fatalf("canonical lost user turns (first=%v mid=%v)", pinnedFirst, keptMidCanonical)
}
proj := visibleContext(a)
var projFirst, projMid bool
var projFirst, projMidVerbatim bool
for _, m := range proj {
if isCompactionSummary(m) {
continue
@@ -557,17 +573,18 @@ func TestCompactKeepsMidSessionUserTurns(t *testing.T) {
if m.Role == provider.RoleUser && m.Content == "first task" {
projFirst = true
}
if m.Role == provider.RoleUser && strings.Contains(m.Content, "always use pnpm not npm") {
projMid = true
if m.Role == provider.RoleUser && m.Content == midFact {
projMidVerbatim = true
}
}
if !projFirst {
t.Fatalf("fixed early user turn missing from projection: %+v", proj)
}
// The mid-session fact is still among the first few small user turns by
// position, so the fixed early window keeps it verbatim (stable prefix).
if !projMid {
t.Fatalf("early-position user fact missing from projection: %+v", proj)
if projMidVerbatim {
t.Fatalf("mid-session user turn must fold into summary, not stay verbatim: %+v", proj)
}
if !strings.Contains(joinContents(proj), "pnpm") {
t.Fatalf("mid-session fact missing from rolling summary: %+v", proj)
}
if strings.Contains(joinContents(proj), big) {
t.Errorf("assistant/tool work was not folded out of projection")
@@ -628,17 +645,21 @@ func TestCompactKeepsPriorDigests(t *testing.T) {
func TestCompactKeepsErrorMessages(t *testing.T) {
prov := &fakeProvider{reply: "- normal work summarized"}
big := strings.Repeat("normal work output ", 200)
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "bash", Arguments: `{"cmd":"bad"}`}}},
{Role: provider.RoleTool, ToolCallID: "1", Name: "bash", Content: "error: command failed"},
{Role: provider.RoleAssistant, Content: big},
{Role: provider.RoleUser, Content: "continue"},
{Role: provider.RoleAssistant, Content: "continued"},
{Role: provider.RoleAssistant, Content: big},
{Role: provider.RoleUser, Content: "next"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir(), KeepPolicy: KeepErrors}, event.Discard)
a := New(prov, tool.NewRegistry(), sess, Options{
ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 2, KeepPolicy: KeepErrors,
}, event.Discard)
if err := a.compact(context.Background(), "manual", "", true); err != nil {
t.Fatalf("compact: %v", err)
@@ -667,16 +688,23 @@ func TestCompactKeepsErrorMessages(t *testing.T) {
func TestCompactKeepsUserMarkedMessages(t *testing.T) {
prov := &fakeProvider{reply: "- unmarked work summarized"}
marked := "[[keep]] exact requirement " + strings.Repeat("must stay verbatim ", 200)
// Keep-marked text stays in the projection as protected content; surrounding
// work must be large enough that folding it still reduces the candidate.
marked := "[[keep]] exact requirement " + strings.Repeat("must stay verbatim ", 40)
big := strings.Repeat("unmarked work output ", 300)
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleUser, Content: marked},
{Role: provider.RoleAssistant, Content: "worked"},
{Role: provider.RoleAssistant, Content: big},
{Role: provider.RoleUser, Content: "more"},
{Role: provider.RoleAssistant, Content: big},
{Role: provider.RoleUser, Content: "next"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: t.TempDir(), KeepPolicy: KeepUserMarked}, event.Discard)
a := New(prov, tool.NewRegistry(), sess, Options{
ContextWindow: 50_000, CompactRatio: 0.85, RecentKeep: 2, KeepPolicy: KeepUserMarked,
}, event.Discard)
if err := a.compact(context.Background(), "manual", "", true); err != nil {
t.Fatalf("compact: %v", err)
@@ -706,24 +734,32 @@ func TestCompactKeepsUserMarkedMessages(t *testing.T) {
}
func TestRunCompactsAfterFinalAnswer(t *testing.T) {
// A turn that ends with a final answer (no trailing tool batch) must still
// compact when the context is over the trigger; otherwise a large context
// carries into the next turn un-folded and overflows the model window.
big := strings.Repeat("old work ", 200)
// Maintenance runs on Prepare before sampling (ObserveUsage is a no-op).
// A turn whose estimated prompt already crosses compact_ratio must install
// the summary checkpoint on the sampling path so the final-answer request
// rides the reduced view.
const window = 10_000
// ~2×4K tokens of foldable work so estimatedPromptTokens ≥ fold (8500).
big := strings.Repeat("old work detail line with substance. ", 800)
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, Content: big},
{Role: provider.RoleAssistant, Content: big},
}}
a := New(&fakeProvider{reply: "done", promptTokens: 95}, tool.NewRegistry(), sess,
Options{ContextWindow: 100, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
// fakeProvider replies "done" for the main sample; compact also uses the same
// provider for the summary call (also returns "done", which is fine as a digest).
a := New(&fakeProvider{reply: "done"}, tool.NewRegistry(), sess,
Options{ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2}, event.Discard)
if before := a.estimatedPromptTokens(a.modelVisibleMessages()); before < a.compactTrigger() {
t.Fatalf("fixture est=%d below fold trigger %d", before, a.compactTrigger())
}
if err := a.Run(context.Background(), "what's the status?"); err != nil {
t.Fatalf("run: %v", err)
}
if !hasCompactionSummary(visibleContext(a)) {
t.Fatalf("final-answer turn over the trigger did not install projection summary")
t.Fatalf("turn over the trigger did not install projection summary")
}
// Canonical rewrite version stays 0; projection carries the fold.
if got := sess.RewriteVersion(); got != 0 {
+6 -16
View File
@@ -186,7 +186,7 @@ func TestForceThresholdNoopReturnsCompactionRequired(t *testing.T) {
}
}
func TestSummarizeWithRetryMergesUsage(t *testing.T) {
func TestSummarizeOnceDoesNotRetry(t *testing.T) {
fp := &retryUsageProvider{
failOnce: errors.New("transient"),
reply: "digest body",
@@ -194,24 +194,14 @@ func TestSummarizeWithRetryMergesUsage(t *testing.T) {
usage2: &provider.Usage{PromptTokens: 11, CompletionTokens: 3, TotalTokens: 14, RequestCount: 1},
}
a := New(fp, tool.NewRegistry(), NewSession("sys"), Options{}, event.Discard)
summary, usage, err := a.summarizeWithRetry(context.Background(), []provider.Message{
_, _, err := a.summarizeOnce(context.Background(), []provider.Message{
{Role: provider.RoleUser, Content: "fold me"},
}, "")
if err != nil {
t.Fatalf("summarizeWithRetry: %v", err)
if err == nil {
t.Fatal("expected first-attempt failure to surface without retry")
}
if summary != "digest body" {
t.Fatalf("summary = %q", summary)
}
if usage == nil {
t.Fatal("usage is nil")
}
// Both attempts contribute billable tokens and request count.
if usage.PromptTokens < 21 || usage.CompletionTokens < 5 {
t.Fatalf("merged usage under-counted: %+v", usage)
}
if usage.RequestCount < 2 {
t.Fatalf("RequestCount = %d, want >= 2 (both attempts)", usage.RequestCount)
if fp.calls != 1 {
t.Fatalf("provider calls = %d, want exactly 1", fp.calls)
}
}
+9 -122
View File
@@ -8,10 +8,8 @@ import (
"reasonix/internal/tool"
)
// Tool-result maintenance is the free half of context management: stale tool
// results are re-derivable (files can be re-read, commands re-run), so rewriting
// them needs no summarizer call and never drops a message. tool_call/result
// pairing and assistant content (including signed reasoning) are untouched.
// Tool-result helpers serve first-visible bounding and summary fold input.
// Automatic prune/snip projections are gone; the public APIs are no-ops.
const (
snippedMarker = "[snipped tool result — "
prunedMarker = "[elided tool result — "
@@ -35,88 +33,25 @@ type PruneStats struct {
Force bool
}
// SnipStaleToolResults shortens stale tool-result content older than the
// protected recent tail, archiving the originals first. Idempotent; a no-op
// when compaction is disabled (no context window).
// SnipStaleToolResults is a no-op: automatic prune/snip projections are gone.
func (a *Agent) SnipStaleToolResults() (PruneStats, error) {
return a.maintainStaleToolResults(toolResultSnip)
return PruneStats{Mode: toolResultSnip}, nil
}
// PruneStaleToolResults elides stale tool-result content older than the
// protected recent tail, archiving the originals first. It can upgrade already
// snipped results to a shorter placeholder.
// PruneStaleToolResults is a no-op: automatic prune/snip projections are gone.
func (a *Agent) PruneStaleToolResults() (PruneStats, error) {
return a.maintainStaleToolResults(toolResultPrune)
}
func (a *Agent) maintainStaleToolResults(mode toolResultMaintenanceMode) (PruneStats, error) {
st := PruneStats{Mode: mode}
if a.contextWindow <= 0 {
return st, nil
}
visible := a.modelVisibleMessages()
next, st := a.applyToolResultMaintenanceView(visible, mode)
st.Force = true
if st.Results == 0 {
return st, nil
}
before := a.currentProjectionVersion()
if err := a.installPruneProjection(next, st); err != nil {
return PruneStats{Mode: mode}, err
}
if a.currentProjectionVersion() == before {
return PruneStats{Mode: mode}, nil
}
a.compactionMu.Lock()
if a.compactionState.LastReceipt != nil {
st.Archive = a.compactionState.LastReceipt.Archive
}
a.compactionMu.Unlock()
return st, nil
}
func shouldMaintainToolResult(m provider.Message, mode toolResultMaintenanceMode) bool {
if m.LocalOnly || m.Role != provider.RoleTool {
return false
}
if strings.HasPrefix(m.Content, prunedMarker) {
return false
}
if mode == toolResultSnip {
return len(m.Content) >= minPruneBytes && !strings.HasPrefix(m.Content, snippedMarker)
}
if strings.HasPrefix(m.Content, snippedMarker) {
return true
}
return len(m.Content) >= minPruneBytes
}
func rewriteToolResult(m provider.Message, mode toolResultMaintenanceMode, archive string, strategy snipStrategy) string {
if mode == toolResultPrune {
return pruneToolResult(m, archive)
}
return snipToolResult(m, archive, strategy)
}
func pruneToolResult(m provider.Message, archive string) string {
if prior := originalToolArchive(m.Content); prior != "" {
archive = prior
}
if archive == "" {
archive = "not archived"
}
return fmt.Sprintf("%s%s, %d bytes archived to %s; re-run the tool if the data is needed again]", prunedMarker, m.Name, originalToolBytes(m.Content), archive)
return PruneStats{Mode: toolResultPrune}, nil
}
func snipToolResult(m provider.Message, archive string, strategy snipStrategy) string {
if archive == "" {
archive = "not archived"
archive = "the canonical transcript"
}
lines := strings.Split(m.Content, "\n")
if len(lines) <= strategy.head+strategy.tail {
headChars := minInt(strategy.headChars, len(m.Content)/2)
tailChars := minInt(strategy.tailChars, len(m.Content)/4)
return fmt.Sprintf("%s%s, %d bytes archived to %s; single large line truncated]\n%s\n[... %d bytes omitted ...]\n%s",
return fmt.Sprintf("%s%s, %d bytes; full original retained in %s; single large line truncated]\n%s\n[... %d bytes omitted ...]\n%s",
snippedMarker, m.Name, len(m.Content), archive,
firstRunes(m.Content, headChars),
omittedBytes(m.Content, headChars, tailChars),
@@ -124,7 +59,7 @@ func snipToolResult(m provider.Message, archive string, strategy snipStrategy) s
}
head := strings.Join(lines[:strategy.head], "\n")
tail := strings.Join(lines[len(lines)-strategy.tail:], "\n")
return fmt.Sprintf("%s%s, %d bytes archived to %s; showing first %d lines and last %d lines]\n%s\n[... %d lines omitted ...]\n%s",
return fmt.Sprintf("%s%s, %d bytes; full original retained in %s; showing first %d lines and last %d lines]\n%s\n[... %d lines omitted ...]\n%s",
snippedMarker, m.Name, len(m.Content), archive, strategy.head, strategy.tail,
head, len(lines)-strategy.head-strategy.tail, tail)
}
@@ -136,23 +71,11 @@ type snipStrategy struct {
tailChars int
}
// Defaults for tools that do not implement tool.SnipHinter, tiered by side
// effect. A read-only tool's output is front-loaded (the first lines are the
// answer), so it keeps a long head and short tail. A side-effecting tool — bash
// and any plugin — can carry a failure at either end (a build error at the tail,
// the command at the head), so it keeps both ends evenly. These are deliberately
// the only two defaults: a registered tool that fits neither must implement
// SnipHinter, and the contract test fails until it does.
var (
defaultReadOnlySnip = snipStrategy{head: 80, tail: 12, headChars: 10000, tailChars: 2000}
defaultSideEffectingSnip = snipStrategy{head: 40, tail: 40, headChars: 8000, tailChars: 8000}
)
// snipStrategyFor resolves the snip geometry for a tool result by asking the
// registered tool itself (tool.SnipHinter), so the policy travels with the tool
// definition and a rename cannot silently desync it from a name-keyed table.
// When the tool is absent (e.g. an MCP server detached after producing the
// result) or declines to hint, it falls back to the ReadOnly-tiered default.
func (a *Agent) snipStrategyFor(name string) snipStrategy {
if a.tools != nil {
if t, ok := a.tools.Get(name); ok {
@@ -172,42 +95,6 @@ func snipStrategyFromHint(h tool.SnipHint) snipStrategy {
return snipStrategy{head: h.Head, tail: h.Tail, headChars: h.HeadChars, tailChars: h.TailChars}
}
func originalToolBytes(content string) int {
if strings.HasPrefix(content, snippedMarker) {
end := strings.Index(content, " bytes archived to ")
if end > len(snippedMarker) {
fields := strings.Fields(content[len(snippedMarker):end])
if len(fields) > 0 {
var n int
if _, err := fmt.Sscanf(fields[len(fields)-1], "%d", &n); err == nil && n > 0 {
return n
}
}
}
}
return len(content)
}
func originalToolArchive(content string) string {
if !strings.HasPrefix(content, snippedMarker) {
return ""
}
start := strings.Index(content, " bytes archived to ")
if start < 0 {
return ""
}
start += len(" bytes archived to ")
end := strings.Index(content[start:], ";")
if end < 0 {
return ""
}
archive := strings.TrimSpace(content[start : start+end])
if archive == "not archived" {
return ""
}
return archive
}
func firstRunes(s string, n int) string {
if len(s) <= n {
return s
+58 -475
View File
@@ -2,10 +2,6 @@ package agent
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
@@ -14,502 +10,89 @@ import (
"reasonix/internal/tool"
)
func pruneFixture(toolContent string) *Session {
return &Session{Messages: []provider.Message{
// Automatic prune/snip projections are gone. These APIs remain as no-ops so
// older call sites do not panic, but they never rewrite the model view.
func TestPruneAndSnipAreNoOps(t *testing.T) {
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "read_file", Arguments: "{}"}}},
{Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: toolContent},
{Role: provider.RoleAssistant, Content: "step done"},
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "t1", Name: "read_file", Arguments: "{}"}}},
{Role: provider.RoleTool, ToolCallID: "t1", Name: "read_file", Content: strings.Repeat("x", 8000)},
{Role: provider.RoleUser, Content: "next"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
}
func TestPruneStaleToolResults(t *testing.T) {
big := strings.Repeat("x", 5000)
sess := pruneFixture(big)
dir := t.TempDir()
a := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: dir}, event.Discard)
a := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 100_000, RecentKeep: 2}, event.Discard)
st, err := a.PruneStaleToolResults()
if err != nil {
t.Fatalf("prune: %v", err)
}
if st.Results != 1 {
t.Fatalf("Results = %d, want 1", st.Results)
}
if st.SavedChars < 4000 {
t.Errorf("SavedChars = %d, want > 4000", st.SavedChars)
}
msgs := visibleContext(a)
if len(msgs) != 7 {
t.Fatalf("message count changed: %d", len(msgs))
}
pruned := msgs[3]
if !strings.HasPrefix(pruned.Content, prunedMarker) {
t.Errorf("tool content not elided: %.60q", pruned.Content)
}
if pruned.ToolCallID != "1" || pruned.Name != "read_file" || pruned.Role != provider.RoleTool {
t.Errorf("tool pairing fields changed: %+v", pruned)
}
if len(msgs[2].ToolCalls) != 1 || msgs[2].ToolCalls[0].ID != "1" {
t.Errorf("assistant tool_calls touched: %+v", msgs[2])
}
if got := sess.RewriteVersion(); got != 0 {
t.Errorf("canonical RewriteVersion = %d, want 0", got)
}
if got := sess.Snapshot()[3].Content; got != big {
t.Fatal("projection maintenance rewrote canonical tool content")
}
if st.Archive == "" {
t.Fatal("no archive written")
}
raw, err := os.ReadFile(st.Archive)
if err != nil {
t.Fatalf("read archive: %v", err)
}
if !strings.Contains(string(raw), big[:64]) {
t.Error("archive does not contain the original tool output")
}
if filepath.Dir(st.Archive) != dir {
t.Errorf("archive outside dir: %s", st.Archive)
}
st2, err := a.PruneStaleToolResults()
if err != nil {
t.Fatalf("second prune: %v", err)
}
if st2.Results != 0 {
t.Errorf("second pass pruned %d, want 0 (idempotent)", st2.Results)
}
if got := sess.RewriteVersion(); got != 0 {
t.Errorf("no-op pass bumped RewriteVersion to %d", got)
}
}
func TestArchiveMessagesIsContentAddressedAndRetryStable(t *testing.T) {
dir := t.TempDir()
msgs := []provider.Message{{Role: provider.RoleTool, Name: "read_file", Content: strings.Repeat("x", 5000)}}
first, err := archiveMessages(dir, msgs)
if err != nil {
t.Fatal(err)
}
second, err := archiveMessages(dir, msgs)
if st.Results != 0 {
t.Fatalf("prune results = %d, want 0", st.Results)
}
st, err = a.SnipStaleToolResults()
if err != nil {
t.Fatal(err)
}
if first == "" || first != second {
t.Fatalf("same archive input produced different paths: %q vs %q", first, second)
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 {
t.Fatalf("retry created duplicate archives: %d", len(entries))
}
}
func TestPruneNeverRewritesLocalInterruptedDisplay(t *testing.T) {
m := provider.Message{
Role: provider.RoleTool, LocalOnly: true,
ToolCallID: provider.LocalOnlyToolID, Name: provider.LocalOnlyToolName,
Content: strings.Repeat("partial output", 1024),
}
if shouldMaintainToolResult(m, toolResultSnip) || shouldMaintainToolResult(m, toolResultPrune) {
t.Fatal("local interrupted display must remain verbatim across tool-result maintenance")
}
}
func TestSnipStaleToolResults(t *testing.T) {
var lines []string
for range 1000 {
lines = append(lines, "line")
}
big := strings.Join(lines, "\n")
sess := pruneFixture(big)
dir := t.TempDir()
a := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: dir}, event.Discard)
st, err := a.SnipStaleToolResults()
if err != nil {
t.Fatalf("snip: %v", err)
}
if st.Results != 1 {
t.Fatalf("Results = %d, want 1", st.Results)
}
snipped := visibleContext(a)[3].Content
if !strings.HasPrefix(snipped, snippedMarker) {
t.Fatalf("tool content not snipped: %.80q", snipped)
}
if !strings.Contains(snipped, "[... ") || !strings.Contains(snipped, "lines omitted") {
t.Fatalf("snipped content missing omission marker: %.120q", snipped)
}
if st.SavedChars <= 0 {
t.Fatalf("SavedChars = %d, want positive", st.SavedChars)
}
if st.Archive == "" {
t.Fatal("no archive written")
}
st2, err := a.SnipStaleToolResults()
if err != nil {
t.Fatalf("second snip: %v", err)
}
if st2.Results != 0 {
t.Fatalf("second pass snipped %d, want 0", st2.Results)
}
}
func TestSnipCanUpgradeToPrune(t *testing.T) {
big := strings.Join([]string{strings.Repeat("a\n", 800), strings.Repeat("b\n", 800)}, "")
sess := pruneFixture(big)
a := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
snipStats, err := a.SnipStaleToolResults()
if err != nil || snipStats.Results != 1 {
t.Fatalf("snip st=%+v err=%v, want one result", snipStats, err)
}
if pruneStats, err := a.PruneStaleToolResults(); err != nil || pruneStats.Results != 1 {
t.Fatalf("prune st=%+v err=%v, want one upgraded result", pruneStats, err)
}
if got := visibleContext(a)[3].Content; !strings.HasPrefix(got, prunedMarker) {
t.Fatalf("snipped result was not upgraded to prune: %.80q", got)
} else if !strings.Contains(got, snipStats.Archive) {
t.Fatalf("pruned marker did not preserve original archive path %q: %.120q", snipStats.Archive, got)
}
}
func TestPruneNoopWithoutWindow(t *testing.T) {
sess := pruneFixture(strings.Repeat("x", 5000))
a := New(nil, tool.NewRegistry(), sess, Options{RecentKeep: 2}, event.Discard)
st, err := a.PruneStaleToolResults()
if err != nil || st.Results != 0 {
t.Fatalf("st=%+v err=%v, want no-op", st, err)
}
}
func TestPruneSkipsSmallResults(t *testing.T) {
sess := pruneFixture(strings.Repeat("x", 200))
a := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2}, event.Discard)
st, err := a.PruneStaleToolResults()
if err != nil || st.Results != 0 {
t.Fatalf("st=%+v err=%v, want small result kept", st, err)
}
if got := visibleContext(a)[3].Content; !strings.HasPrefix(got, "xxx") {
t.Errorf("small tool result was rewritten: %.40q", got)
}
}
func TestMaybeCompactPruneAvoidsFold(t *testing.T) {
prov := &fakeProvider{reply: "summary"}
sess := pruneFixture(strings.Repeat("x", 5000))
a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 850})
if prov.got != nil {
t.Fatal("summarizer was called although pruning cleared the trigger")
}
// Canonical stays intact; prune lands only in the projection view.
if got := sess.Snapshot()[3].Content; !strings.HasPrefix(got, "xxx") {
t.Errorf("canonical tool result rewritten: %.60q", got)
}
proj := visibleContext(a)
found := false
for _, m := range proj {
if m.Role == provider.RoleTool && strings.HasPrefix(m.Content, prunedMarker) {
found = true
}
}
if !found {
t.Errorf("tool result not pruned in projection: %+v", proj)
}
}
// Rewriting a stale tool result invalidates the prompt prefix from that point
// on, so below the fold trigger history is left exactly as the provider cached
// it, however large it has grown.
func TestMaintenanceLeavesHistoryAloneBelowFoldTrigger(t *testing.T) {
prov := &fakeProvider{reply: "summary"}
sess := pruneFixture(strings.Repeat("line\n", 1000))
a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 650})
if prov.got != nil {
t.Fatal("summarizer was called below the fold trigger")
if st.Results != 0 {
t.Fatalf("snip results = %d, want 0", st.Results)
}
if got := a.currentProjectionVersion(); got != 0 {
t.Fatalf("projection installed below the fold trigger: version %d", got)
t.Fatalf("projection version = %d, want 0", got)
}
for _, m := range visibleContext(a) {
if m.Role == provider.RoleTool && (strings.HasPrefix(m.Content, snippedMarker) || strings.HasPrefix(m.Content, prunedMarker)) {
t.Fatalf("tool result rewritten below the fold trigger: %.80q", m.Content)
for _, m := range sess.Snapshot() {
if m.Role == provider.RoleTool && m.Content != strings.Repeat("x", 8000) {
t.Fatal("canonical tool result was rewritten")
}
}
}
// At the fold trigger, dropping stale tool results comes first: when that gets
// the prompt low enough it stands in for the summary, saving the round trip for
// the same single prefix switch.
func TestMaintenancePrefersPruneOverSummaryAtFoldTrigger(t *testing.T) {
prov := &fakeProvider{reply: "summary"}
sess := pruneFixture(strings.Repeat("line\n", 1000))
a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 850})
if prov.got != nil {
t.Fatal("summarizer was called although pruning was enough")
}
if got := sess.Snapshot()[3].Content; !strings.HasPrefix(got, "line") {
t.Errorf("canonical tool result rewritten: %.80q", got)
}
found := false
for _, m := range visibleContext(a) {
if m.Role == provider.RoleTool && strings.HasPrefix(m.Content, prunedMarker) {
found = true
}
}
if !found {
t.Errorf("tool result not elided in projection: %+v", visibleContext(a))
}
}
func TestProjectionMaintenanceContinuesFromVisibleView(t *testing.T) {
sess := pruneFixture(strings.Repeat("line\n", 1000))
a := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 850})
first := a.compactionState.Projection.ProjectionVersion
if first == 0 || !strings.HasPrefix(visibleContext(a)[3].Content, prunedMarker) {
t.Fatalf("first maintenance did not install an elided projection: %+v", visibleContext(a))
}
prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 650})
if got := a.compactionState.Projection.ProjectionVersion; got != first {
t.Fatalf("same visible tool result was maintained again: projection version %d -> %d", first, got)
}
}
func TestMaybeCompactPruneFallsThroughWhenStillOverThreshold(t *testing.T) {
prov := &fakeProvider{reply: "summary"}
sess := &Session{Messages: []provider.Message{
// At the sole compact_ratio trigger, maintenance calls the summarizer once —
// it does not install an intermediate prune projection.
func TestMaintenanceUsesSummaryNotPruneAtFoldTrigger(t *testing.T) {
big := strings.Repeat("tool body line\n", 400)
msgs := []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, Content: strings.Repeat("foldable assistant work\n", 500)},
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "read_file", Arguments: "{}"}}},
{Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: strings.Repeat("x", 1200)},
{Role: provider.RoleAssistant, Content: "step done"},
{Role: provider.RoleUser, Content: "next"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 10000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 8900})
if prov.got == nil {
t.Fatal("summarizer was not called although pruning still left prompt above compact threshold")
}
if !hasCompactionSummary(visibleContext(a)) {
t.Fatal("summary compaction did not install a projection")
for i := range 12 {
id := "t" + string(rune('a'+i))
msgs = append(msgs,
provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "read_file", Arguments: "{}"}}},
provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "read_file", Content: big},
)
}
msgs = append(msgs,
provider.Message{Role: provider.RoleUser, Content: "tail"},
provider.Message{Role: provider.RoleAssistant, Content: "ok"},
)
prov := &countingProvider{reply: "digest"}
a := New(prov, tool.NewRegistry(), &Session{Messages: msgs}, Options{
ContextWindow: 20_000, CompactRatio: 0.5, RecentKeep: 2,
}, event.Discard)
if _, err := a.contextManager().Prepare(context.Background(), ContextPreparePolicy{Trigger: CompactionTriggerPressure}); err != nil {
t.Fatal(err)
}
if a.currentProjectionVersion() != 1 {
t.Fatalf("projection version = %d, want 1", a.currentProjectionVersion())
}
if len(prov.got) != 1 {
t.Fatalf("summarizer calls = %d, want 1", len(prov.got))
}
if got := countToolResultsWithPrefix(a.modelVisibleMessages(), prunedMarker); got != 0 {
t.Fatalf("prune markers in projection = %d, want 0", got)
}
}
func TestMaybeCompactForceRatioStillFolds(t *testing.T) {
prov := &fakeProvider{reply: "summary"}
// A big assistant turn in the foldable region (after the pinned task, before the
// recent tail) survives pruning — only tool results prune — so the forced fold
// has real content to compact while the task turn stays pinned verbatim.
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, Content: strings.Repeat("y", 5000)},
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "read_file", Arguments: "{}"}}},
{Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: strings.Repeat("x", 5000)},
{Role: provider.RoleAssistant, Content: "step done"},
{Role: provider.RoleUser, Content: "next"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
prepareForObservedUsage(a, context.Background(), &provider.Usage{PromptTokens: 950})
if prov.got == nil {
t.Fatal("force ratio crossed but summarizer never called")
func TestSnipStrategyStillAvailableForFirstVisibleAndSummaryInput(t *testing.T) {
a := &Agent{tools: tool.NewRegistry()}
s := a.snipStrategyFor("read_file")
if s.head <= 0 || s.tail <= 0 {
t.Fatalf("snip strategy for read_file = %+v", s)
}
if got := sess.Snapshot()[1].Content; got != "task" {
t.Errorf("first user turn not pinned verbatim in canonical: %.40q", got)
body, notice := truncateToolOutputFor(strings.Repeat("x", maxToolOutputBytes+100), "read_file", "call-1")
if notice == "" || !strings.Contains(body, "call_id=call-1") {
t.Fatalf("first-visible truncation missing marker: notice=%q body=%.200q", notice, body)
}
if !hasCompactionSummary(visibleContext(a)) {
t.Error("no compaction summary in projection after forced fold")
}
}
func TestPruneSkipsRecentTail(t *testing.T) {
old := strings.Repeat("old\n", 1000)
recent := strings.Repeat("recent\n", 1000)
sess := &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "old", Name: "read_file", Arguments: "{}"}}},
{Role: provider.RoleTool, ToolCallID: "old", Name: "read_file", Content: old},
{Role: provider.RoleUser, Content: "next"},
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "recent", Name: "read_file", Arguments: "{}"}}},
{Role: provider.RoleTool, ToolCallID: "recent", Name: "read_file", Content: recent},
}}
a := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 3, ArchiveDir: t.TempDir()}, event.Discard)
st, err := a.PruneStaleToolResults()
if err != nil {
t.Fatalf("prune: %v", err)
}
if st.Results != 1 {
t.Fatalf("Results = %d, want only the stale result pruned", st.Results)
}
msgs := visibleContext(a)
if !strings.HasPrefix(msgs[3].Content, prunedMarker) {
t.Fatalf("old result was not pruned: %.80q", msgs[3].Content)
}
if msgs[6].Content != recent {
t.Fatalf("recent tail tool result was rewritten")
}
if canonical := sess.Snapshot(); canonical[3].Content != old || canonical[6].Content != recent {
t.Fatal("projection prune rewrote canonical tool results")
}
}
func TestPruneHonorsKeepErrors(t *testing.T) {
// KeepErrors must carry error/blocked tool results through pruning verbatim;
// eliding here rewrites Content to the [elided ...] marker, so compact()'s
// KeepErrors predicate sees only the placeholder and the failure is lost on
// the next fold.
for _, prefix := range []string{"error:", "blocked:"} {
content := prefix + strings.Repeat(" detail", 200)
sess := pruneFixture(content)
a := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2, KeepPolicy: KeepErrors}, event.Discard)
st, err := a.PruneStaleToolResults()
if err != nil {
t.Fatalf("prune (%s): %v", prefix, err)
}
if st.Results != 0 {
t.Errorf("%s: Results = %d, want 0 (KeepErrors preserves error tool results)", prefix, st.Results)
}
if got := visibleContext(a)[3].Content; !strings.HasPrefix(got, prefix) {
t.Errorf("%s: error tool result was elided: %.60q", prefix, got)
}
}
}
func TestPruneElidesErrorsWithoutKeepPolicy(t *testing.T) {
// Without KeepErrors, a large error tool result prunes like any other — a
// regression guard for the policy-gated skip.
content := "error: build failed\n" + strings.Repeat("x", 5000)
sess := pruneFixture(content)
a := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 1000, RecentKeep: 2}, event.Discard)
st, err := a.PruneStaleToolResults()
if err != nil {
t.Fatalf("prune: %v", err)
}
if st.Results != 1 {
t.Errorf("Results = %d, want 1 (no keep policy)", st.Results)
}
if got := visibleContext(a)[3].Content; !strings.HasPrefix(got, prunedMarker) {
t.Errorf("error tool result not elided without keep policy: %.60q", got)
}
}
// hintingTool is a read-only tool that advertises a distinctive snip geometry,
// so a test can prove the maintainer honored the tool's own SnipHint rather
// than a name-keyed table or a generic default.
type hintingTool struct {
name string
hint tool.SnipHint
}
func (h hintingTool) Name() string { return h.name }
func (hintingTool) Description() string { return "" }
func (hintingTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (hintingTool) ReadOnly() bool { return true }
func (hintingTool) Execute(context.Context, json.RawMessage) (string, error) { return "", nil }
func (h hintingTool) SnipHint() tool.SnipHint { return h.hint }
func snipFixtureFor(toolName, content string) *Session {
return &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "task"},
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: toolName, Arguments: "{}"}}},
{Role: provider.RoleTool, ToolCallID: "1", Name: toolName, Content: content},
{Role: provider.RoleAssistant, Content: "step done"},
{Role: provider.RoleUser, Content: "next"},
{Role: provider.RoleAssistant, Content: "ok"},
}}
}
func TestSnipUsesRegisteredToolHint(t *testing.T) {
// 600 numbered lines; a SnipHinter keeping head=3, tail=2 must yield exactly
// those boundary lines, which no default geometry would produce.
var lines []string
for i := range 600 {
lines = append(lines, fmt.Sprintf("L%d", i))
}
content := strings.Join(lines, "\n")
sess := snipFixtureFor("custom_reader", content)
reg := tool.NewRegistry()
reg.Add(hintingTool{name: "custom_reader", hint: tool.SnipHint{Head: 3, Tail: 2, HeadChars: 100, TailChars: 100}})
a := New(nil, reg, sess, Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
if st, err := a.SnipStaleToolResults(); err != nil || st.Results != 1 {
t.Fatalf("snip st=%+v err=%v, want one result", st, err)
}
got := visibleContext(a)[3].Content
if !strings.Contains(got, "showing first 3 lines and last 2 lines") {
t.Fatalf("snip did not honor the tool's SnipHint geometry: %.120q", got)
}
if !strings.Contains(got, "\nL0\nL1\nL2\n") {
t.Errorf("kept head is not the first 3 lines: %.160q", got)
}
if !strings.Contains(got, "\nL598\nL599") {
t.Errorf("kept tail is not the last 2 lines: %.160q", got)
}
}
func TestSnipFallsBackByReadOnlyTier(t *testing.T) {
var lines []string
for i := range 600 {
lines = append(lines, fmt.Sprintf("L%d", i))
}
content := strings.Join(lines, "\n")
// A side-effecting tool with no SnipHint takes the even-split default
// (head==tail), while a read-only one keeps a longer head than tail.
cases := []struct {
name string
readOnly bool
evenEnds bool
}{
{"side_effecting", false, true},
{"read_only", true, false},
}
for _, tc := range cases {
sess := snipFixtureFor(tc.name, content)
reg := tool.NewRegistry()
reg.Add(fakeTool{name: tc.name, readOnly: tc.readOnly})
a := New(nil, reg, sess, Options{ContextWindow: 1000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
if st, err := a.SnipStaleToolResults(); err != nil || st.Results != 1 {
t.Fatalf("%s: snip st=%+v err=%v, want one result", tc.name, st, err)
}
got := visibleContext(a)[3].Content
want := "showing first 40 lines and last 40 lines"
if !tc.evenEnds {
want = "showing first 80 lines and last 12 lines"
}
if !strings.Contains(got, want) {
t.Errorf("%s: fallback geometry wrong, want %q in: %.120q", tc.name, want, got)
}
if len(body) > maxToolOutputBytes+200 {
t.Fatalf("bounded body still oversized: %d", len(body))
}
}
+5
View File
@@ -880,6 +880,11 @@ func (a *Agent) handleToolRound(ctx context.Context, state *runLoopState, step i
ToolCallID: call.ID,
Name: call.Name,
}
// First-visible Content is always the bounded form in results[i].
// Full originals ride on RawContent only when truncation applied.
if i < len(batch.outcomes) && batch.outcomes[i].rawOutput != "" && batch.outcomes[i].rawOutput != results[i] {
msg.RawContent = batch.outcomes[i].rawOutput
}
if i < len(batch.executions) {
msg.ToolExecution = toProviderToolExecution(batch.executions[i])
}
+41 -199
View File
@@ -3,175 +3,11 @@ package agent
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"sync/atomic"
"time"
"reasonix/internal/event"
"reasonix/internal/provider"
)
func normalizeContextEditing(mode string) string {
if strings.EqualFold(strings.TrimSpace(mode), "native") {
return "native"
}
return "local"
}
type contextEditingState struct {
requestedContextEditing string // normalized user intent: local or native
contextEditing string // effective mode after provider capability resolution
contextEditingPolicyVersion string // provider-declared native request-shape version
contextEditingFallbackEmitted atomic.Bool
nativeContextEditingAccepted atomic.Bool
contextEditingRuntimeFallback atomic.Bool
}
func newContextEditingState(mode string, prov provider.Provider) contextEditingState {
requested, effective, version := resolveContextEditing(mode, prov)
return contextEditingState{
requestedContextEditing: requested,
contextEditing: effective,
contextEditingPolicyVersion: version,
}
}
func resolveContextEditing(mode string, prov provider.Provider) (requested, effective, policyVersion string) {
requested = normalizeContextEditing(mode)
if requested != "native" {
return requested, "local", ""
}
caps := provider.ContextEditingCapabilitiesOf(prov)
policyVersion = strings.TrimSpace(caps.PolicyVersion)
if !caps.NativeToolUseClear || policyVersion == "" {
return requested, "local", ""
}
return requested, "native", policyVersion
}
func (a *Agent) contextEditingPolicy() *provider.ContextEditingPolicy {
if a == nil {
return nil
}
a.compactionMu.Lock()
defer a.compactionMu.Unlock()
return a.contextEditingPolicyLocked()
}
func (a *Agent) contextEditingPolicyLocked() *provider.ContextEditingPolicy {
if a.contextEditing != "native" {
return nil
}
_, snip, _ := a.compactThresholds()
return &provider.ContextEditingPolicy{
Mode: "native",
TriggerInputTokens: snip,
KeepToolUses: 3,
ClearAtLeastInputTokens: max(4096, a.minimumMaintenanceSavingsTokens()),
ClearToolInputs: false,
}
}
func (a *Agent) contextEditingLineageSuffixLocked() string {
policy := a.contextEditingPolicyLocked()
if policy == nil {
return ""
}
return fmt.Sprintf("|context-editing-native-%s-t%d-k%d-m%d-i%t",
a.contextEditingPolicyVersion,
policy.TriggerInputTokens,
policy.KeepToolUses,
policy.ClearAtLeastInputTokens,
policy.ClearToolInputs,
)
}
func (a *Agent) effectiveContextEditing() string {
if a == nil {
return "local"
}
a.compactionMu.Lock()
defer a.compactionMu.Unlock()
return a.contextEditing
}
func (a *Agent) emitContextEditingFallbackNotice() {
if a == nil || a.requestedContextEditing != "native" || a.effectiveContextEditing() == "native" ||
a.usageSource != event.UsageSourceExecutor || !a.contextEditingFallbackEmitted.CompareAndSwap(false, true) {
return
}
detail := "context_editing=native requires the official Anthropic endpoint; compatible gateways and other providers remain local"
if a.contextEditingRuntimeFallback.Load() {
detail = "the official endpoint rejected native context editing before this session successfully used it; the session is now pinned to local maintenance"
}
a.sink.Emit(event.Event{
Kind: event.Notice,
Level: event.LevelInfo,
Code: event.NoticeCodeContextEditingFallback,
Text: "Native context editing is unavailable for this provider; using local context maintenance.",
Detail: detail,
Source: a.usageSource,
})
}
func (a *Agent) markNativeContextEditingAccepted() {
if a == nil || !a.nativeContextEditingAccepted.CompareAndSwap(false, true) {
return
}
_, transcriptVersion := a.session.snapshotMessagesVersion()
a.compactionMu.Lock()
state := a.compactionState
if state.NativeContextEditingAccepted {
a.compactionMu.Unlock()
return
}
previous := state
state.SchemaVersion = compactionStateSchemaCurrent
state.TranscriptVersion = transcriptVersion
state.Generation++
state.NativeContextEditingAccepted = true
state.ContextEditingFallbackLocal = false
state.PromptCacheKey = a.currentPromptCacheKeyLocked()
state.UpdatedAt = time.Now().UTC()
a.compactionState = state
if err := a.persistCompactionStateLocked(); err != nil {
a.compactionState = previous
slog.Warn("agent: persist native context-editing acceptance", "err", err)
}
a.compactionMu.Unlock()
}
func (a *Agent) fallbackNativeContextEditingToLocal() bool {
if a == nil || a.nativeContextEditingAccepted.Load() {
return false
}
_, transcriptVersion := a.session.snapshotMessagesVersion()
a.compactionMu.Lock()
if a.contextEditing != "native" || a.nativeContextEditingAccepted.Load() {
a.compactionMu.Unlock()
return false
}
previous := a.compactionState
a.contextEditing = "local"
a.contextEditingRuntimeFallback.Store(true)
state := CompactionState{
SchemaVersion: compactionStateSchemaCurrent, TranscriptVersion: transcriptVersion,
Generation: previous.Generation + 1, PromptCacheKey: a.currentPromptCacheKeyLocked(),
LastCacheState: a.cacheState, ContextEditingFallbackLocal: true, UpdatedAt: time.Now().UTC(),
}
a.compactionState = state
if err := a.persistCompactionStateLocked(); err != nil {
slog.Warn("agent: persist local context-editing fallback", "err", err)
}
a.compactionMu.Unlock()
a.compactStuck = false
a.consecutiveCompacts = 0
a.lastCompactionTurn.Store(0)
return true
}
// samplingRequest is a once-prepared, frozen provider request for one model
// round. All stream retries replay this exact payload — no synthetic recovery
// messages, no schema reorder, no previous_response_id drift from failed attempts.
@@ -180,11 +16,7 @@ type samplingRequest struct {
}
func (a *Agent) streamProviderRequest(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
ch, err := a.prov.Stream(ctx, req)
if err == nil && req.ContextEditing != nil && req.ContextEditing.Mode == "native" {
a.markNativeContextEditingAccepted()
}
return ch, err
return a.prov.Stream(ctx, req)
}
func (a *Agent) handleSamplingError(
@@ -196,17 +28,6 @@ func (a *Agent) handleSamplingError(
result, last streamedTurn,
billable *provider.Usage,
) (retry bool, terminal streamedTurn) {
if provider.IsNativeContextEditingUnsupported(result.err) && frozen.req.ContextEditing != nil &&
frozen.req.ContextEditing.Mode == "native" && a.fallbackNativeContextEditingToLocal() {
streamSink.Discard()
a.emitStreamAttempt(attemptID, event.StreamAttemptDiscard, attempt, "native_context_editing_unsupported", result.err)
localRequest, err := a.prepareSamplingRequest(ctx)
if err != nil {
return false, streamedTurn{usage: finalizeSamplingUsage(billable, result.usage), err: err}
}
*frozen = localRequest
return true, streamedTurn{}
}
if provider.IsStreamInterrupted(result.err) && attempt < maxSamplingAttempts {
streamSink.Discard()
reason := provider.StreamInterruptReason(result.err)
@@ -228,12 +49,48 @@ func (a *Agent) handleSamplingError(
}
// prepareSamplingRequest freezes one model-round request (preflight + interceptors).
// Output budgets are resolved only here and never change the compact_ratio
// trigger. Physical overflow may attempt at most one recovery summary.
func (a *Agent) prepareSamplingRequest(ctx context.Context) (samplingRequest, error) {
a.emitContextEditingFallbackNotice()
frozen, err := a.buildSamplingRequest(ctx, CompactionTriggerPressure)
if err != nil {
return samplingRequest{}, err
}
if budget, clipped, budgetErr := a.effectiveOutputBudget(frozen.req); budgetErr != nil {
// One-shot physical overflow recovery. Do not loop.
if _, perr := a.contextManager().Prepare(ctx, ContextPreparePolicy{
Trigger: CompactionTriggerOverflow,
Force: true,
}); perr != nil {
return samplingRequest{}, budgetErr
}
rebuilt, rerr := a.buildSamplingRequest(ctx, CompactionTriggerPressure)
if rerr != nil {
return samplingRequest{}, rerr
}
if _, _, budgetErr2 := a.effectiveOutputBudget(rebuilt.req); budgetErr2 != nil {
return samplingRequest{}, budgetErr2
}
// Re-apply clipping on the recovered view.
if budget2, clipped2, err2 := a.effectiveOutputBudget(rebuilt.req); err2 == nil && clipped2 {
rebuilt.req.MaxTokens = budget2
}
shape := a.requestCalibrationShape(rebuilt.req)
a.activeReqShape.Store(&shape)
return samplingRequest{req: freezeProviderRequest(rebuilt.req)}, nil
} else if clipped {
frozen.req.MaxTokens = budget
}
shape := a.requestCalibrationShape(frozen.req)
a.activeReqShape.Store(&shape)
return samplingRequest{req: freezeProviderRequest(frozen.req)}, nil
}
func (a *Agent) buildSamplingRequest(ctx context.Context, trigger string) (samplingRequest, error) {
// CreatedAt is durable UI metadata, not model input. Strip it from the
// transport copy so wall-clock differences never invalidate the provider's
// prompt-cache prefix (and custom providers cannot accidentally send it).
prepared, err := a.contextManager().Prepare(ctx, ContextPreparePolicy{Trigger: CompactionTriggerPressure})
prepared, err := a.contextManager().Prepare(ctx, ContextPreparePolicy{Trigger: trigger})
if err != nil {
return samplingRequest{}, err
}
@@ -244,7 +101,7 @@ func (a *Agent) prepareSamplingRequest(ctx context.Context) (samplingRequest, er
}
// context.prepare: extensions may rewrite the message copy feeding THIS
// request. The session log is never touched — the replacement is
// ephemeral, so the next request starts from the unmodified history and
// ephemeral, so the next request starts from the unmodified history.
requestMessages, err = a.interceptContextPrepare(ctx, requestMessages)
if err != nil {
return samplingRequest{}, err
@@ -257,24 +114,13 @@ func (a *Agent) prepareSamplingRequest(ctx context.Context) (samplingRequest, er
ResponseFormat: responseFormatFromRequest(ctx),
EffortOverride: a.governorOverride(),
}
req.ContextEditing = a.contextEditingPolicy()
// provider.request: the fully assembled request gets one last ruling
// (revalidated by the payload registry) before it goes on the wire.
req, err = a.interceptProviderRequest(ctx, req)
if err != nil {
return samplingRequest{}, err
}
// Enforce the shared-window invariant on the final extension-adjusted
// payload. This keeps prompt + output inside the provider context window
// without changing message bytes, tool order, or ordinary request defaults.
if budget, clipped, budgetErr := a.effectiveOutputBudget(req); budgetErr != nil {
return samplingRequest{}, budgetErr
} else if clipped {
req.MaxTokens = budget
}
shape := a.requestCalibrationShape(req)
a.activeReqShape.Store(&shape)
return samplingRequest{req: freezeProviderRequest(req)}, nil
return samplingRequest{req: req}, nil
}
// providerProjectionMessages applies provider-specific role compatibility to a
@@ -326,9 +172,5 @@ func freezeProviderRequest(req provider.Request) provider.Request {
rf := *req.ResponseFormat
out.ResponseFormat = &rf
}
if req.ContextEditing != nil {
policy := *req.ContextEditing
out.ContextEditing = &policy
}
return out
}
+38 -49
View File
@@ -248,13 +248,10 @@ type TaskTool struct {
parentReg *tool.Registry
maxSteps int
contextWindow int
softCompactRatio float64
toolResultSnipRatio float64
compactRatio float64
compactForceRatio float64
recentKeep int
temperature float64
contextEditing, archiveDir string
archiveDir string
keepPolicy KeepPolicy
sysPrompt string
gate Gate
@@ -326,26 +323,22 @@ func NewTaskToolWithOptions(opts TaskToolOptions) *TaskTool {
sysPrompt = DefaultTaskSystemPrompt
}
return &TaskTool{
prov: opts.Provider,
pricing: opts.Pricing,
parentReg: opts.ParentRegistry,
maxSteps: opts.MaxSteps,
contextWindow: opts.ContextWindow,
recentKeep: opts.RecentKeep,
softCompactRatio: opts.SoftCompactRatio,
toolResultSnipRatio: opts.ToolResultSnipRatio,
compactRatio: opts.CompactRatio,
compactForceRatio: opts.CompactForceRatio,
contextEditing: normalizeContextEditing(opts.ContextEditing),
temperature: opts.Temperature,
archiveDir: opts.ArchiveDir,
keepPolicy: opts.KeepPolicy,
sysPrompt: sysPrompt,
gate: opts.Gate,
subagentModel: opts.SubagentModel,
subagentEffort: opts.SubagentEffort,
resolveProvider: opts.ResolveProvider,
maxSubagentDepth: DefaultMaxSubagentDepth,
prov: opts.Provider,
pricing: opts.Pricing,
parentReg: opts.ParentRegistry,
maxSteps: opts.MaxSteps,
contextWindow: opts.ContextWindow,
recentKeep: opts.RecentKeep,
compactRatio: opts.CompactRatio,
temperature: opts.Temperature,
archiveDir: opts.ArchiveDir,
keepPolicy: opts.KeepPolicy,
sysPrompt: sysPrompt,
gate: opts.Gate,
subagentModel: opts.SubagentModel,
subagentEffort: opts.SubagentEffort,
resolveProvider: opts.ResolveProvider,
maxSubagentDepth: DefaultMaxSubagentDepth,
}
}
@@ -1619,31 +1612,27 @@ func (t *TaskTool) runReadOnlySubSession(ctx context.Context, prompt string, sub
// must stay uniform across those paths — add new fields here, not at call sites.
func (t *TaskTool) subagentOptions(ctx context.Context, maxSteps int, pricing *provider.Pricing, ctxWin, childDepth int, recoveryTaskID string, mutationObserver *checkpoint.MutationObserver) Options {
opts := Options{
MaxSteps: maxSteps,
Temperature: t.temperature,
Pricing: pricing,
UsageSource: event.UsageSourceSubagent,
Gate: t.gate,
ContextWindow: ctxWin,
RecentKeep: t.recentKeep,
SoftCompactRatio: t.softCompactRatio,
ToolResultSnipRatio: t.toolResultSnipRatio,
CompactRatio: t.compactRatio,
CompactForceRatio: t.compactForceRatio,
ContextEditing: t.contextEditing,
ArchiveDir: t.archiveDir,
KeepPolicy: t.keepPolicy,
ResponseLanguage: ResponseLanguageFromContext(ctx),
ReasoningLanguage: ReasoningLanguageFromContext(ctx),
SubagentDepth: childDepth,
MaxSubagentDepth: t.maxDepth(),
DeliveryProfile: t.deliveryProfile,
Ablation: t.ablation,
WorkspaceLease: t.workspaceLease,
RecoveryGate: t.recoveryGate,
RecoveryAgentID: "subagent",
RecoveryTaskID: recoveryTaskID,
MutationObserver: mutationObserver,
MaxSteps: maxSteps,
Temperature: t.temperature,
Pricing: pricing,
UsageSource: event.UsageSourceSubagent,
Gate: t.gate,
ContextWindow: ctxWin,
RecentKeep: t.recentKeep,
CompactRatio: t.compactRatio,
ArchiveDir: t.archiveDir,
KeepPolicy: t.keepPolicy,
ResponseLanguage: ResponseLanguageFromContext(ctx),
ReasoningLanguage: ReasoningLanguageFromContext(ctx),
SubagentDepth: childDepth,
MaxSubagentDepth: t.maxDepth(),
DeliveryProfile: t.deliveryProfile,
Ablation: t.ablation,
WorkspaceLease: t.workspaceLease,
RecoveryGate: t.recoveryGate,
RecoveryAgentID: "subagent",
RecoveryTaskID: recoveryTaskID,
MutationObserver: mutationObserver,
}
return opts
}
+1 -5
View File
@@ -199,13 +199,9 @@ func assertTaskToolConfigEqual(t *testing.T, a, b *TaskTool) {
[3]int{a.maxSteps, a.contextWindow, a.recentKeep},
[3]int{b.maxSteps, b.contextWindow, b.recentKeep})
}
if a.softCompactRatio != b.softCompactRatio || a.toolResultSnipRatio != b.toolResultSnipRatio ||
a.compactRatio != b.compactRatio || a.compactForceRatio != b.compactForceRatio || a.temperature != b.temperature {
if a.compactRatio != b.compactRatio || a.temperature != b.temperature {
t.Fatalf("ratio/temp mismatch")
}
if a.contextEditing != b.contextEditing {
t.Fatalf("context editing mismatch: %q/%q", a.contextEditing, b.contextEditing)
}
if a.archiveDir != b.archiveDir || a.sysPrompt != b.sysPrompt || a.keepPolicy != b.keepPolicy {
t.Fatalf("archive/sys/keep mismatch: archive=%q/%q sys=%q/%q keep=%v/%v",
a.archiveDir, b.archiveDir, a.sysPrompt, b.sysPrompt, a.keepPolicy, b.keepPolicy)
-3
View File
@@ -13,7 +13,4 @@ func applyLatestContextShape(dst, latest *provider.Usage) {
dst.ContextReasoningTokens = latest.ReasoningTokens
dst.ContextCacheHitTokens = latest.CacheHitTokens
dst.ContextCacheMissTokens = latest.CacheMissTokens
dst.ContextEditingType = latest.ContextEditingType
dst.ContextEditingClearedToolUses = latest.ContextEditingClearedToolUses
dst.ContextEditingClearedTokens = latest.ContextEditingClearedTokens
}
+12
View File
@@ -227,6 +227,7 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
stepLimitsMigrated, stepLimitMigErr := config.MigrateLegacyAgentStepLimitsForRoot(root)
redactToolOutputMigrated, redactToolOutputMigErr := config.MigrateLegacyRedactToolOutputForRoot(root)
memoryCompilerMigrated, memoryCompilerMigErr := config.MigrateLegacyMemoryCompilerForRoot(root)
multiThresholdMigrated, multiThresholdMigErr := config.MigrateLegacyMultiThresholdCompactionForRoot(root)
cfg, err := config.LoadForRoot(root)
if err != nil {
return nil, err
@@ -484,6 +485,17 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
}
sink.Emit(event.Event{Kind: event.Notice, Level: level, Text: text, Detail: detail})
}
if multiThresholdMigrated || multiThresholdMigErr != nil {
level := event.LevelInfo
text := "上下文维护已简化为单一自动压缩阈值。"
detail := "Context maintenance now uses a single automatic compact_ratio (default 0.85). soft_compact_ratio, tool_result_snip_ratio, compact_force_ratio, cold_resume_prune, and context_editing were removed from config."
if multiThresholdMigErr != nil {
level = event.LevelWarn
text = "Deprecated multi-threshold compaction keys were ignored."
detail += " The old keys could not be removed: " + multiThresholdMigErr.Error()
}
sink.Emit(event.Event{Kind: event.Notice, Level: level, Text: text, Detail: detail})
}
migration.MigrateLegacyMemorySources(sink)
migration.MigrateLegacySessionSources(sink)
if ignored := cfg.IgnoredProjectDefaultModel(); ignored != "" {
+1 -1
View File
@@ -850,7 +850,7 @@ func TestConfigCompactRatioQueryReportsBuiltInDefault(t *testing.T) {
t.Fatalf("config compact-ratio query rc = %d, want 0", rc)
}
})
if out != "compact_ratio = 80% (built-in default)\n" {
if out != "compact_ratio = 85% (built-in default)\n" {
t.Fatalf("config compact-ratio query output = %q", out)
}
}
+18 -15
View File
@@ -1302,14 +1302,12 @@ type AgentConfig struct {
// Deprecated compatibility field paired with AutoPlan. Old TOML remains
// readable, but loading clears it and rendering omits it.
AutoPlanClassifier string `toml:"auto_plan_classifier"`
// Compaction window fractions: soft = notice only, compact = trigger, force = hard ceiling.
// Soft/snip/force are retired compatibility keys; only CompactRatio is active.
SoftCompactRatio float64 `toml:"soft_compact_ratio"`
ToolResultSnipRatio float64 `toml:"tool_result_snip_ratio"`
CompactRatio float64 `toml:"compact_ratio"`
CompactForceRatio float64 `toml:"compact_force_ratio"`
// ContextEditing selects local maintenance (default) or explicitly opted-in
// Anthropic native tool clearing. Native is only honored by official
// Anthropic endpoints; compatible gateways remain local.
// ContextEditing is retired; native tool clearing is no longer an auto path.
ContextEditing string `toml:"context_editing"`
// Keep controls which compactable messages stay verbatim beyond the current
// user-fact/digest floor and recent tail. Empty uses the conservative default
@@ -1353,9 +1351,13 @@ type ProviderEntry struct {
resolvedSource CredentialSource
BalanceURL string `toml:"balance_url"` // optional; a provider-specific wallet-balance endpoint (DeepSeek: https://api.deepseek.com/user/balance). Empty = no balance readout.
ContextWindow int `toml:"context_window"`
// MaxOutputTokens is a protocol-neutral total output budget. Zero lets the
// provider choose a safe default, a positive value is explicit, and a
// negative value omits optional wire limits. Anthropic still requires one.
// MaxOutputTokens is a protocol-neutral total output budget for one turn.
// Zero means automatic (not unlimited): ordinary 16K, reasoning 32K, high/max
// 64K — DeepSeek's default effort is high, so auto is typically ~64K.
// User guidance: 0 recommended; 32768 ordinary coding/cost control;
// 65536 heavy reasoning/long tools; 131072 only after finish_reason=length.
// A negative value omits optional wire limits when the protocol allows;
// Anthropic still requires max_tokens. Never feeds compact_ratio.
MaxOutputTokens int `toml:"max_output_tokens"`
Price *provider.Pricing `toml:"price"` // legacy/provider-wide fallback
Prices map[string]*provider.Pricing `toml:"prices"` // optional per-model prices; keys are model ids
@@ -1827,14 +1829,15 @@ func Default() *Config {
SystemPrompt: DefaultSystemPrompt,
// Normal interactive execution has no configurable total round cap. It
// is bounded by adaptive progress guards and context compaction instead.
MaxSteps: 0,
PlannerMaxSteps: 0,
AutoPlan: "off",
SoftCompactRatio: 0.5,
ToolResultSnipRatio: 0.6,
CompactRatio: 0.8,
CompactForceRatio: 0.9,
ContextEditing: "local",
MaxSteps: 0,
PlannerMaxSteps: 0,
AutoPlan: "off",
// Soft/snip/force are load-only compatibility; CompactRatio alone drives maintenance.
SoftCompactRatio: 0,
ToolResultSnipRatio: 0,
CompactRatio: 0.85,
CompactForceRatio: 0,
ContextEditing: "",
MaxSubagentDepth: 2,
MaxSubagentConcurrency: 6,
MaxParallelWriters: 3,
+2 -12
View File
@@ -421,22 +421,12 @@ func (c *Config) SetColdResumePrune(enabled bool) error {
return nil
}
// SetCompactRatio updates the user-controlled auto-compaction threshold.
// Keep the editable range inside the default snip/force guard rails so lowering
// the threshold cannot accidentally turn normal cache growth into constant
// compaction, while higher values still retain context-exhaustion headroom.
// SetCompactRatio updates the sole user-controlled automatic compaction
// threshold. Allowed range is 0.65–0.85; presets are 0.70 / 0.80 / 0.85.
func (c *Config) SetCompactRatio(ratio float64) error {
if math.IsNaN(ratio) || math.IsInf(ratio, 0) || ratio < 0.65 || ratio > 0.85 {
return fmt.Errorf("compact ratio %v: must be between 0.65 and 0.85", ratio)
}
snip := c.Agent.ToolResultSnipRatio
force := c.Agent.CompactForceRatio
if snip > 0 && ratio <= snip {
return fmt.Errorf("compact ratio %.2f: must be greater than tool result snip ratio %.2f", ratio, snip)
}
if force > 0 && ratio >= force {
return fmt.Errorf("compact ratio %.2f: must be less than force ratio %.2f", ratio, force)
}
c.Agent.CompactRatio = ratio
return nil
}
+6 -6
View File
@@ -628,14 +628,14 @@ func TestSetCompactRatio(t *testing.T) {
}
}
// Deprecated snip/force ratios no longer constrain SetCompactRatio.
c.Agent.ToolResultSnipRatio = 0.75
if err := c.SetCompactRatio(0.7); err == nil {
t.Fatal("SetCompactRatio should reject a value at or below the configured snip ratio")
}
c.Agent.ToolResultSnipRatio = 0.6
c.Agent.CompactForceRatio = 0.8
if err := c.SetCompactRatio(0.8); err == nil {
t.Fatal("SetCompactRatio should reject a value at or above the configured force ratio")
if err := c.SetCompactRatio(0.7); err != nil {
t.Fatalf("SetCompactRatio(0.7) with legacy snip/force fields: %v", err)
}
if err := c.SetCompactRatio(0.8); err != nil {
t.Fatalf("SetCompactRatio(0.8) with legacy force field: %v", err)
}
}
+69
View File
@@ -789,6 +789,7 @@ func normalizeConfigForEdit(cfg *Config) bool {
normalizeLegacyEffort(cfg)
normalizeLegacyAgentStepLimits(cfg)
changed := normalizeRetiredAutoPlan(cfg)
changed = normalizeRetiredMultiThresholdCompaction(cfg) || changed
normalizeLegacyMCPTiers(cfg)
changed = normalizeLegacyStepFunBaseURLs(cfg) || changed
changed = normalizeLegacyLongCatContextWindows(cfg) || changed
@@ -805,6 +806,30 @@ func normalizeConfigForEdit(cfg *Config) bool {
return changed
}
// normalizeRetiredMultiThresholdCompaction clears retired multi-threshold keys
// so they never reach the Agent. Disk migration removes them on ordinary start;
// loading still ignores them if migration could not rewrite the file.
func normalizeRetiredMultiThresholdCompaction(c *Config) bool {
if c == nil {
return false
}
changed := c.Agent.SoftCompactRatio != 0 ||
c.Agent.ToolResultSnipRatio != 0 ||
c.Agent.CompactForceRatio != 0 ||
c.Agent.ColdResumePrune != nil ||
strings.TrimSpace(c.Agent.ContextEditing) != ""
c.Agent.SoftCompactRatio = 0
c.Agent.ToolResultSnipRatio = 0
c.Agent.CompactForceRatio = 0
c.Agent.ColdResumePrune = nil
c.Agent.ContextEditing = ""
if c.Agent.CompactRatio <= 0 {
c.Agent.CompactRatio = Default().Agent.CompactRatio
changed = true
}
return changed
}
// normalizeRetiredAutoPlan keeps pre-v5 configs readable while enforcing the
// single explicit-plan experience. The deprecated fields remain in AgentConfig
// only so old TOML and older desktop payloads decode safely.
@@ -1077,6 +1102,50 @@ func stripLegacyMemoryCompilerLines(raw string) (string, bool) {
return stripTOMLKeyLines(raw, "agent", "memory_compiler")
}
// MigrateLegacyMultiThresholdCompactionForRoot strips retired soft/snip/force keys.
func MigrateLegacyMultiThresholdCompactionForRoot(root string) (bool, error) {
root = resolveRoot(root)
paths := make([]string, 0, 2)
if userPath := userConfigLoadPath(); userPath != "" {
paths = append(paths, userPath)
}
projectPath := "reasonix.toml"
if root != "." {
projectPath = filepath.Join(root, "reasonix.toml")
}
paths = append(paths, projectPath)
changedAny := false
seen := make(map[string]struct{}, len(paths))
for _, path := range paths {
clean := filepath.Clean(path)
if _, ok := seen[clean]; ok {
continue
}
seen[clean] = struct{}{}
changed, err := migrateLegacyMultiThresholdCompactionFile(path)
if err != nil {
return changedAny, fmt.Errorf("migrate deprecated multi-threshold compaction keys in %s: %w", path, err)
}
changedAny = changedAny || changed
}
return changedAny, nil
}
func migrateLegacyMultiThresholdCompactionFile(path string) (bool, error) {
return migrateRetiredConfigKeysFile(path, stripLegacyMultiThresholdCompactionLines)
}
func stripLegacyMultiThresholdCompactionLines(raw string) (string, bool) {
return stripTOMLKeyLines(raw, "agent",
"soft_compact_ratio",
"tool_result_snip_ratio",
"compact_force_ratio",
"cold_resume_prune",
"context_editing",
)
}
func migrateLegacyMCPTiersFile(path string) error {
_, err := migrateRetiredConfigKeysFile(path, stripLegacyMCPTierLines)
return err
+7 -33
View File
@@ -227,15 +227,7 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string {
} else {
b.WriteString("# reasoning_language = \"zh\" # visible reasoning language: auto|zh|en\n")
}
fmt.Fprintf(&b, "soft_compact_ratio = %s # notice only; keeps cache-first prefix intact\n", formatFloat(c.Agent.SoftCompactRatio))
fmt.Fprintf(&b, "tool_result_snip_ratio = %s # at compact_ratio, dropping stale tool results replaces the summary if it gets under this\n", formatFloat(c.Agent.ToolResultSnipRatio))
fmt.Fprintf(&b, "compact_ratio = %s # try compacting when prompt reaches this fraction\n", formatFloat(c.Agent.CompactRatio))
fmt.Fprintf(&b, "compact_force_ratio = %s # force compacting at this high-water mark\n", formatFloat(c.Agent.CompactForceRatio))
if strings.TrimSpace(c.Agent.ContextEditing) == "native" {
b.WriteString("context_editing = \"native\" # opt in to official Anthropic native tool clearing\n")
} else {
b.WriteString("# context_editing = \"native\" # opt in only for official Anthropic native tool clearing; default is local\n")
}
fmt.Fprintf(&b, "compact_ratio = %s # sole auto trigger; presets 0.70/0.80/0.85 (default 0.85)\n", formatFloat(c.Agent.CompactRatio))
if c.Agent.Keep != nil {
fmt.Fprintf(&b, "keep = %s # compaction keep policy: errors, user_marked\n", renderStringArray(c.Agent.Keep))
} else {
@@ -246,7 +238,6 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string {
} else {
b.WriteString("# recent_keep = 2 # minimum recent messages kept verbatim\n")
}
fmt.Fprintf(&b, "cold_resume_prune = %v # elide stale tool results when reopening a session past the provider cache window\n", c.ColdResumePruneEnabled())
if len(c.Agent.PlanModeReadOnlyCommands) > 0 {
fmt.Fprintf(&b, "plan_mode_read_only_commands = %s # legacy compatibility only; Plan bash uses Permissions\n", renderStringArray(c.Agent.PlanModeReadOnlyCommands))
} else {
@@ -348,7 +339,12 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string {
fmt.Fprintf(&b, "context_window = %d # tokens; compaction triggers near this limit\n", p.ContextWindow)
}
if p.MaxOutputTokens != 0 {
fmt.Fprintf(&b, "max_output_tokens = %d # total output cap; 0 = provider default, negative = omit when optional\n", p.MaxOutputTokens)
fmt.Fprintf(&b, "max_output_tokens = %d # per-turn total output; 0 = auto (~64K on DeepSeek high), 32768/65536/131072 explicit; never affects compact_ratio\n", p.MaxOutputTokens)
} else {
b.WriteString("# max_output_tokens = 0 # recommended: automatic (DeepSeek default high → ~64K; not unlimited)\n")
b.WriteString("# max_output_tokens = 32768 # ordinary coding / cost control\n")
b.WriteString("# max_output_tokens = 65536 # heavy reasoning / long tool loops\n")
b.WriteString("# max_output_tokens = 131072 # only after repeated finish_reason=length\n")
}
if p.Price != nil {
fmt.Fprintf(&b, "price = %s # provider-wide fallback, per 1M tokens\n", renderPricingInline(p.Price))
@@ -930,28 +926,10 @@ func RenderTOMLProjectDelta(c *Config) string {
anyAgent = true
}
}
if c.Agent.SoftCompactRatio != d.Agent.SoftCompactRatio {
fmt.Fprintf(&agentBuf, "soft_compact_ratio = %s\n", formatFloat(c.Agent.SoftCompactRatio))
anyAgent = true
}
if c.Agent.ToolResultSnipRatio != d.Agent.ToolResultSnipRatio {
fmt.Fprintf(&agentBuf, "tool_result_snip_ratio = %s\n", formatFloat(c.Agent.ToolResultSnipRatio))
anyAgent = true
}
if c.Agent.CompactRatio != d.Agent.CompactRatio {
fmt.Fprintf(&agentBuf, "compact_ratio = %s\n", formatFloat(c.Agent.CompactRatio))
anyAgent = true
}
if c.Agent.CompactForceRatio != d.Agent.CompactForceRatio {
fmt.Fprintf(&agentBuf, "compact_force_ratio = %s\n", formatFloat(c.Agent.CompactForceRatio))
anyAgent = true
}
if c.Agent.ContextEditing != d.Agent.ContextEditing {
if strings.TrimSpace(c.Agent.ContextEditing) == "native" {
fmt.Fprintf(&agentBuf, "context_editing = %q\n", "native")
anyAgent = true
}
}
if c.Agent.Keep != nil && !reflect.DeepEqual(c.Agent.Keep, d.Agent.Keep) {
fmt.Fprintf(&agentBuf, "keep = %s\n", renderStringArray(c.Agent.Keep))
anyAgent = true
@@ -960,10 +938,6 @@ func RenderTOMLProjectDelta(c *Config) string {
fmt.Fprintf(&agentBuf, "recent_keep = %d\n", c.Agent.RecentKeep)
anyAgent = true
}
if c.Agent.ColdResumePrune != d.Agent.ColdResumePrune {
fmt.Fprintf(&agentBuf, "cold_resume_prune = %v\n", c.ColdResumePruneEnabled())
anyAgent = true
}
if len(c.Agent.PlanModeReadOnlyCommands) > 0 && !reflect.DeepEqual(c.Agent.PlanModeReadOnlyCommands, d.Agent.PlanModeReadOnlyCommands) {
fmt.Fprintf(&agentBuf, "plan_mode_read_only_commands = %s\n", renderStringArray(c.Agent.PlanModeReadOnlyCommands))
anyAgent = true
+5 -9
View File
@@ -217,7 +217,7 @@ func TestRenderTOMLRoundTrips(t *testing.T) {
orig.Agent.RecoveryModel = "mimo-pro"
orig.Agent.RecoveryTemperature = 0.15
orig.Agent.ReasoningLanguage = "zh"
orig.Agent.ToolResultSnipRatio = 0.65
orig.Agent.CompactRatio = 0.8
orig.Agent.SubagentModel = "mimo-pro"
orig.Agent.SubagentModels = map[string]string{"review": "deepseek-pro"}
orig.Agent.MaxSubagentDepth = 3
@@ -421,17 +421,13 @@ func TestRenderTOMLRoundTrips(t *testing.T) {
if got.Agent.ReasoningLanguage != "zh" {
t.Errorf("reasoning_language = %q, want zh", got.Agent.ReasoningLanguage)
}
if got.Agent.SoftCompactRatio != orig.Agent.SoftCompactRatio {
t.Errorf("soft_compact_ratio = %v, want %v", got.Agent.SoftCompactRatio, orig.Agent.SoftCompactRatio)
}
if got.Agent.ToolResultSnipRatio != orig.Agent.ToolResultSnipRatio {
t.Errorf("tool_result_snip_ratio = %v, want %v", got.Agent.ToolResultSnipRatio, orig.Agent.ToolResultSnipRatio)
}
if got.Agent.CompactRatio != orig.Agent.CompactRatio {
t.Errorf("compact_ratio = %v, want %v", got.Agent.CompactRatio, orig.Agent.CompactRatio)
}
if got.Agent.CompactForceRatio != orig.Agent.CompactForceRatio {
t.Errorf("compact_force_ratio = %v, want %v", got.Agent.CompactForceRatio, orig.Agent.CompactForceRatio)
// Deprecated multi-threshold fields must not reappear after render/load.
if got.Agent.SoftCompactRatio != 0 || got.Agent.ToolResultSnipRatio != 0 || got.Agent.CompactForceRatio != 0 {
t.Errorf("deprecated compact ratios survived round-trip: soft=%v snip=%v force=%v",
got.Agent.SoftCompactRatio, got.Agent.ToolResultSnipRatio, got.Agent.CompactForceRatio)
}
if strings.Join(got.Agent.Keep, ",") != strings.Join(orig.Agent.Keep, ",") {
t.Errorf("keep = %v, want %v", got.Agent.Keep, orig.Agent.Keep)
+1 -1
View File
@@ -3560,7 +3560,7 @@ func (c *Controller) cacheColdAfter() time.Duration {
}
// 查询路径只读:LoadForRootReadOnly 不触发配置迁移写盘(评审 #7168
// 第 4 点);失败时保守回退 24h(DeepSeek/未知 vendor 默认),避免
// 提前触发 PruneStaleToolResults 改写仍可命中的缓存历史。
// 把 cache TTL 过期误当成历史改写信号(resume 只记录 warm/cold/unknown)。
cfg, err := config.LoadForRootReadOnly(c.workspaceRoot)
if err != nil {
return 24 * time.Hour
+1 -1
View File
@@ -301,7 +301,7 @@ func TestPositionalCompressionPreservesCheckpointLineage(t *testing.T) {
if err != nil || !ok {
t.Fatalf("load projection sidecar: ok=%v err=%v", ok, err)
}
if state.LastTrigger != agent.CompactionTriggerManual || state.Projection.ProjectionVersion == 0 {
if state.LastReceipt == nil || state.LastReceipt.Trigger != agent.CompactionTriggerManual || state.Projection.ProjectionVersion == 0 {
t.Fatalf("projection state = %+v", state)
}
if _, ok := c.checkpoints.boundary(1); !ok {
+61 -10
View File
@@ -1,9 +1,12 @@
package fileutil
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"syscall"
"time"
)
@@ -29,24 +32,40 @@ func Crash(op, path string) {
}
}
// AtomicWriteFile writes data to a sibling temporary file, fsyncs it, then
// publishes it via ReplaceFile. On filesystems that support replacement rename,
// readers see either the old file or the complete new file. ReplaceFile retains
// its compatibility copy fallback for Windows filter drivers that reject a
// same-directory rename as cross-device; callers that cannot tolerate that
// non-atomic fallback must use AtomicWriteFileStrict.
// AtomicWriteFile writes via temp + fsync + ReplaceFile. On rename-capable
// filesystems readers see only the old or complete new file. ReplaceFile may
// copy on Windows filter-driver EXDEV; callers that cannot tolerate that must
// use AtomicWriteFileStrict.
func AtomicWriteFile(path string, data []byte, perm os.FileMode) error {
return atomicWriteFile(path, data, perm, true)
}
// AtomicWriteFileStrict publishes data only through an atomic rename. Unlike
// AtomicWriteFile, a cross-device/filter-driver error is returned without ever
// truncating path. Use it for commit pointers whose corruption would make the
// surrounding state impossible to recover automatically.
// AtomicWriteFileStrict publishes only via atomic rename (no EXDEV copy).
// After a successful rename it best-effort fsyncs the parent directory so the
// directory entry can survive power loss. A returned error always means the
// destination was not published; post-rename dir-sync problems are not errors
// (callers that roll back in-memory state on error would otherwise fork from
// the on-disk pointer).
func AtomicWriteFileStrict(path string, data []byte, perm os.FileMode) error {
return atomicWriteFile(path, data, perm, false)
}
// syncParentDirFn is the post-publish parent-dir fsync implementation.
// Tests replace it via SetSyncParentDirForTest.
var syncParentDirFn = syncParentDir
// SetSyncParentDirForTest replaces post-rename parent-dir fsync. Restore with
// the returned function. Production must leave the default in place.
func SetSyncParentDirForTest(fn func(path string) error) (restore func()) {
prev := syncParentDirFn
if fn == nil {
syncParentDirFn = syncParentDir
} else {
syncParentDirFn = fn
}
return func() { syncParentDirFn = prev }
}
func atomicWriteFile(path string, data []byte, perm os.FileMode, allowCrossDeviceCopy bool) error {
Crash("atomic-write", path)
tmpPath, err := writeAtomicTemp(path, data, perm)
@@ -57,9 +76,41 @@ func atomicWriteFile(path string, data []byte, perm os.FileMode, allowCrossDevic
os.Remove(tmpPath)
return err
}
// Strict only: parent-dir fsync is power-loss durability after publish.
// Never surface failures here — rename already committed the new file.
if !allowCrossDeviceCopy {
_ = syncParentDirFn(path)
}
return nil
}
// syncParentDir fsyncs path's parent after rename (including "."). Unsupported
// dir sync on Windows / some network FS is ignored.
func syncParentDir(path string) error {
dirPath := filepath.Dir(path)
if dirPath == "" {
dirPath = "."
}
f, err := os.Open(dirPath)
if err != nil {
return fmt.Errorf("open parent dir for fsync %s: %w", path, err)
}
defer f.Close()
if err := f.Sync(); err != nil {
if runtime.GOOS == "windows" || isDirSyncUnsupported(err) {
return nil
}
return fmt.Errorf("fsync parent dir for %s: %w", path, err)
}
return nil
}
func isDirSyncUnsupported(err error) bool {
return errors.Is(err, syscall.EINVAL) ||
errors.Is(err, syscall.ENOTSUP) ||
errors.Is(err, syscall.ENOSYS)
}
// AtomicCreateFile publishes a complete file only when path is still absent.
// It is the non-overwriting counterpart to AtomicWriteFile: a concurrent writer
// that creates path wins, and its file is never replaced.
+86
View File
@@ -145,6 +145,92 @@ func TestReplaceFileCrossDeviceCopiesImmediately(t *testing.T) {
}
}
func TestAtomicWriteFileStrictSyncsParentDir(t *testing.T) {
calls := 0
restore := SetSyncParentDirForTest(func(path string) error {
calls++
return syncParentDir(path)
})
t.Cleanup(restore)
dir := t.TempDir()
dest := filepath.Join(dir, "pointer.json")
if err := AtomicWriteFileStrict(dest, []byte(`{"ok":true}`), 0o644); err != nil {
t.Fatalf("AtomicWriteFileStrict: %v", err)
}
if calls != 1 {
t.Fatalf("parent dir sync calls = %d, want 1", calls)
}
if got, err := os.ReadFile(dest); err != nil || string(got) != `{"ok":true}` {
t.Fatalf("dest = %q, err=%v", got, err)
}
}
func TestAtomicWriteFileStrictDirSyncFailureStillPublishes(t *testing.T) {
// Rename already committed the new file; dir fsync failure must not look
// like a pre-publish failure or callers will fork memory from disk.
restore := SetSyncParentDirForTest(func(string) error {
return errors.New("injected parent dir fsync failure")
})
t.Cleanup(restore)
dir := t.TempDir()
dest := filepath.Join(dir, "pointer.json")
if err := os.WriteFile(dest, []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
if err := AtomicWriteFileStrict(dest, []byte("new"), 0o644); err != nil {
t.Fatalf("post-publish dir sync must not fail the write: %v", err)
}
if got, err := os.ReadFile(dest); err != nil || string(got) != "new" {
t.Fatalf("dest = %q, err=%v, want published new content", got, err)
}
}
func TestAtomicWriteFileStrictSyncsRelativeParentDir(t *testing.T) {
// Relative destinations resolve to "."; that parent must still be synced.
cwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(cwd) })
synced := ""
restore := SetSyncParentDirForTest(func(path string) error {
synced = filepath.Dir(path)
return syncParentDir(path)
})
t.Cleanup(restore)
if err := AtomicWriteFileStrict("pointer.json", []byte(`{"ok":true}`), 0o644); err != nil {
t.Fatal(err)
}
if synced != "." {
t.Fatalf("synced parent = %q, want \".\"", synced)
}
if got, err := os.ReadFile("pointer.json"); err != nil || string(got) != `{"ok":true}` {
t.Fatalf("dest = %q, err=%v", got, err)
}
}
func TestAtomicWriteFileDoesNotRequireParentDirSync(t *testing.T) {
// Non-strict path keeps the historical file-only durability contract.
restore := SetSyncParentDirForTest(func(string) error {
t.Fatal("AtomicWriteFile must not require parent-dir sync")
return nil
})
t.Cleanup(restore)
path := filepath.Join(t.TempDir(), "config.toml")
if err := AtomicWriteFile(path, []byte("ok"), 0o644); err != nil {
t.Fatal(err)
}
}
func TestAtomicWriteFileStrictCrossDeviceKeepsExistingDestination(t *testing.T) {
oldRename := renameFile
renameFile = func(oldpath, newpath string) error {
+1 -4
View File
@@ -96,10 +96,7 @@ func NewSession(prov provider.Provider, readOnlyReg *tool.Registry, policyPrompt
// Use the shared context window so the guardian session can compact
// itself when it grows too large across many reviews.
ContextWindow: 100_000,
CompactRatio: 0.8,
SoftCompactRatio: 0.5,
ToolResultSnipRatio: 0.6,
CompactForceRatio: 0.9,
CompactRatio: 0.85,
StrictAlternatingRoles: true,
// Guardian's own sink drops everything — the audit line (emitTo) is the
// only user-visible output. Usage events are captured internally for
+30 -38
View File
@@ -25,7 +25,6 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -52,14 +51,10 @@ const (
// defaultBaseURL is the first-party endpoint; config may override it (e.g. a
// gateway). Bedrock/Vertex use a different request shape and are out of scope.
defaultBaseURL = "https://api.anthropic.com"
// defaultMaxTokens is the conservative output ceiling used when neither the
// provider config nor the request supplies one. Anthropic requires max_tokens,
// but support is model-specific, so native Anthropic and unknown compatible
// gateways must not inherit a universal 128K request.
defaultMaxTokens = provider.DefaultReasoningOutputTokens
// deepSeekDefaultMaxTokens is safe only for the official DeepSeek Anthropic-
// compatible endpoint, whose reasoning models support the higher ceiling.
deepSeekDefaultMaxTokens = provider.DefaultHighOutputTokens
// defaultMaxTokens is the mandatory Anthropic fallback when neither config
// nor request supplies max_tokens. Ordinary turns use 16K; reasoning-capable
// paths raise via AutoOutputBudget. 128K is never automatic.
defaultMaxTokens = provider.DefaultOrdinaryOutputTokens
)
func init() {
@@ -111,11 +106,21 @@ func New(cfg provider.Config) (provider.Provider, error) {
authHeader, _ := cfg.Extra["auth_header"].(bool)
maxOutputTokens, _ := cfg.Extra["max_output_tokens"].(int)
if maxOutputTokens <= 0 {
// Messages requires max_tokens, so an optional-budget disable request
// falls back to the provider's stable mandatory default.
maxOutputTokens = defaultMaxTokens
// Messages requires max_tokens. 0 = automatic; negative also falls back
// because the wire field is mandatory.
reasoningOn := officialDeepSeek &&
!strings.EqualFold(thinking, "disabled") &&
!strings.EqualFold(effort, "disabled") &&
!strings.EqualFold(effort, "off") &&
!strings.EqualFold(effort, "none")
if officialDeepSeek {
maxOutputTokens = deepSeekDefaultMaxTokens
maxOutputTokens = provider.AutoOutputBudget(reasoningOn, effort)
} else {
// Native Anthropic and unknown gateways: conservative ordinary default.
maxOutputTokens = defaultMaxTokens
if strings.EqualFold(thinking, "adaptive") || strings.EqualFold(thinking, "enabled") {
maxOutputTokens = provider.AutoOutputBudget(true, effort)
}
}
}
httpClient, err := newHTTPClient(cfg)
@@ -298,19 +303,12 @@ func (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provi
httpReq.Header.Set("x-api-key", c.apiKey)
}
httpReq.Header.Set("anthropic-version", anthropicVersion)
if req.ContextEditing != nil && req.ContextEditing.Mode == "native" && c.nativeAnthropic {
httpReq.Header.Set("anthropic-beta", anthropicContextManagementBeta)
}
applyCustomHeaders(httpReq.Header, c.headers)
return httpReq, nil
}
resp, err := provider.SendWithRetry(requestCtx, c.http, c.sendOpts(), newReq)
if err != nil {
annotated := provider.AnnotateToolSchemaError(err, req.Tools)
if req.ContextEditing != nil && req.ContextEditing.Mode == "native" && c.nativeAnthropic && nativeContextEditingUnsupported(annotated) {
return nil, errors.Join(provider.ErrNativeContextEditingUnsupported, annotated)
}
return nil, annotated
return nil, provider.AnnotateToolSchemaError(err, req.Tools)
}
c.authed.Store(true)
@@ -449,7 +447,6 @@ func (c *client) buildRequest(_ context.Context, req provider.Request) anthReque
Tools: tools,
Stream: true,
}
applyNativeContextEditing(&r, req, c.nativeAnthropic)
// Extended thinking is provider-specific. DeepSeek defaults to enabled and
// accepts output_config.effort alongside its binary toggle. Anthropic proper
// uses type=adaptive plus display/output_config. LongCat-style compatible
@@ -543,7 +540,6 @@ func (c *client) readStream(ctx context.Context, resp *http.Response, out chan<-
tools := map[int]*provider.ToolCall{} // tool_use blocks, keyed by content index
argBuckets := map[int]int{} // last emitted 2KB progress bucket per block
var inTok, outTok, cacheCreate, cacheRead int
var contextEdits contextEditUsage
var stopReason string
haveUsage := false
mergeUsage := func(usage *wireUsage) {
@@ -664,7 +660,6 @@ func (c *client) readStream(ctx context.Context, resp *http.Response, out chan<-
stopReason = ev.Delta.StopReason
}
mergeUsage(ev.Usage)
contextEdits.observe(ev.ContextManagement)
case "message_stop":
// Anthropic's terminal event. Tool blocks may already have closed;
// without this, the attempt stays speculative and is not committed.
@@ -722,7 +717,6 @@ finalize:
CacheWriteBilledTokens: cacheWriteBilledTokens,
FinishReason: mapStopReason(stopReason),
}
contextEdits.apply(usage)
provider.ApplyRequestAttemptCount(ctx, usage)
if !send(provider.Chunk{Type: provider.ChunkUsage, Usage: usage}) {
return
@@ -807,16 +801,15 @@ type cacheControl struct {
}
type anthRequest struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
System []textBlock `json:"system,omitempty"`
Messages []anthMessage `json:"messages"`
Tools []anthTool `json:"tools,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
Thinking *thinkingConfig `json:"thinking,omitempty"`
OutputConfig *outputConfig `json:"output_config,omitempty"`
Stream bool `json:"stream"`
ContextManagement *contextManagement `json:"context_management,omitempty"`
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
System []textBlock `json:"system,omitempty"`
Messages []anthMessage `json:"messages"`
Tools []anthTool `json:"tools,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
Thinking *thinkingConfig `json:"thinking,omitempty"`
OutputConfig *outputConfig `json:"output_config,omitempty"`
Stream bool `json:"stream"`
}
type thinkingConfig struct {
@@ -910,9 +903,8 @@ type streamEvent struct {
StopReason string `json:"stop_reason"` // message_delta
WebSearchResults json.RawMessage `json:"results"` // web_search_tool_result_delta
} `json:"delta"`
Usage *wireUsage `json:"usage"` // message_delta (cumulative output_tokens)
ContextManagement *responseContextManagement `json:"context_management"`
Error *struct {
Usage *wireUsage `json:"usage"` // message_delta (cumulative output_tokens)
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
} `json:"error"`
@@ -160,9 +160,10 @@ func TestNewSelectsMaxOutputTokenDefaultByEndpoint(t *testing.T) {
extra map[string]any
want int
}{
{name: "native anthropic", want: provider.DefaultReasoningOutputTokens},
{name: "unknown compatible gateway", baseURL: "https://proxy.example.com/anthropic", want: provider.DefaultReasoningOutputTokens},
{name: "official deepseek", baseURL: "https://api.deepseek.com/anthropic", want: provider.DefaultHighOutputTokens},
{name: "native anthropic", want: provider.DefaultOrdinaryOutputTokens},
{name: "unknown compatible gateway", baseURL: "https://proxy.example.com/anthropic", want: provider.DefaultOrdinaryOutputTokens},
{name: "official deepseek", baseURL: "https://api.deepseek.com/anthropic", want: provider.DefaultReasoningOutputTokens},
{name: "official deepseek high", baseURL: "https://api.deepseek.com/anthropic", extra: map[string]any{"effort": "high"}, want: provider.DefaultHighReasoningOutputTokens},
{name: "explicit override", baseURL: "https://api.deepseek.com/anthropic", extra: map[string]any{"max_output_tokens": 8192}, want: 8192},
} {
t.Run(tc.name, func(t *testing.T) {
@@ -1,124 +0,0 @@
package anthropic
import (
"errors"
"strings"
"reasonix/internal/provider"
)
const (
anthropicContextManagementBeta = "context-management-2025-06-27"
anthropicToolClearPolicyVersion = "clear_tool_uses_20250919"
)
func (c *client) ContextEditingCapabilities() provider.ContextEditingCapabilities {
if c == nil || !c.nativeAnthropic {
return provider.ContextEditingCapabilities{}
}
return provider.ContextEditingCapabilities{
NativeToolUseClear: true,
PolicyVersion: anthropicToolClearPolicyVersion,
}
}
type contextManagement struct {
Edits []contextEdit `json:"edits,omitempty"`
}
type contextEdit struct {
Type string `json:"type"`
Trigger *contextEditTrigger `json:"trigger,omitempty"`
Keep *contextEditKeep `json:"keep,omitempty"`
ClearAtLeast *contextEditMinimum `json:"clear_at_least,omitempty"`
ClearToolInputs bool `json:"clear_tool_inputs,omitempty"`
}
type contextEditTrigger struct {
Type string `json:"type"`
Value int `json:"value"`
}
type contextEditKeep struct {
Type string `json:"type"`
Value int `json:"value"`
}
type contextEditMinimum struct {
Type string `json:"type"`
Value int `json:"value"`
}
func applyNativeContextEditing(r *anthRequest, req provider.Request, nativeAnthropic bool) {
if !nativeAnthropic || req.ContextEditing == nil || req.ContextEditing.Mode != "native" {
return
}
policy := req.ContextEditing
r.ContextManagement = &contextManagement{Edits: []contextEdit{{
Type: anthropicToolClearPolicyVersion,
Trigger: &contextEditTrigger{Type: "input_tokens", Value: policy.TriggerInputTokens},
Keep: &contextEditKeep{Type: "tool_uses", Value: policy.KeepToolUses},
ClearAtLeast: &contextEditMinimum{Type: "input_tokens", Value: policy.ClearAtLeastInputTokens},
ClearToolInputs: policy.ClearToolInputs,
}}}
}
func nativeContextEditingUnsupported(err error) bool {
var apiErr *provider.APIError
if !errors.As(err, &apiErr) || (apiErr.Status != 400 && apiErr.Status != 422) {
return false
}
body := strings.ToLower(apiErr.Body)
mentionsFeature := strings.Contains(body, "context_management") ||
strings.Contains(body, "context management") ||
strings.Contains(body, anthropicContextManagementBeta) ||
strings.Contains(body, anthropicToolClearPolicyVersion) ||
strings.Contains(body, "anthropic-beta")
if !mentionsFeature {
return false
}
for _, marker := range []string{
"unsupported", "not supported", "unrecognized", "unknown", "not permitted",
"not allowed", "extra inputs", "invalid beta", "beta header",
} {
if strings.Contains(body, marker) {
return true
}
}
return false
}
type responseContextManagement struct {
AppliedEdits []appliedContextEdit `json:"applied_edits"`
}
type appliedContextEdit struct {
Type string `json:"type"`
ClearedToolUses int `json:"cleared_tool_uses"`
ClearedInputTokens int `json:"cleared_input_tokens"`
}
type contextEditUsage struct {
typeName string
clearedToolUses int
clearedInputTokens int
}
func (u *contextEditUsage) observe(management *responseContextManagement) {
if management == nil {
return
}
for _, edit := range management.AppliedEdits {
if edit.Type == anthropicToolClearPolicyVersion {
u.typeName = edit.Type
u.clearedToolUses += edit.ClearedToolUses
u.clearedInputTokens += edit.ClearedInputTokens
}
}
}
func (u contextEditUsage) apply(usage *provider.Usage) {
usage.ContextEditingType = u.typeName
usage.ContextEditingClearedToolUses = u.clearedToolUses
usage.ContextEditingClearedTokens = u.clearedInputTokens
}
@@ -1,113 +0,0 @@
package anthropic
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"reasonix/internal/provider"
)
func TestBuildRequestNativeContextEditingIsExplicitAndOfficialOnly(t *testing.T) {
policy := &provider.ContextEditingPolicy{
Mode: "native", TriggerInputTokens: 12000, KeepToolUses: 3,
ClearAtLeastInputTokens: 4096,
}
official := (&client{model: "claude-test", nativeAnthropic: true}).buildRequest(context.Background(), provider.Request{
Messages: []provider.Message{{Role: provider.RoleUser, Content: "hello"}}, ContextEditing: policy,
})
b, err := json.Marshal(official)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), `"context_management"`) ||
!strings.Contains(string(b), `"clear_tool_uses_20250919"`) ||
!strings.Contains(string(b), `"value":12000`) {
t.Fatalf("native context editing missing from official request: %s", b)
}
gateway := (&client{model: "gateway", nativeAnthropic: false}).buildRequest(context.Background(), provider.Request{
Messages: []provider.Message{{Role: provider.RoleUser, Content: "hello"}}, ContextEditing: policy,
})
gb, err := json.Marshal(gateway)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(gb), `"context_management"`) {
t.Fatalf("compatible gateway must not receive native context editing: %s", gb)
}
}
func TestStreamClassifiesOnlyNativeContextEditingRejections(t *testing.T) {
responses := []struct {
name string
body string
want bool
}{
{name: "feature rejection", body: `{"error":{"message":"context_management: extra inputs are not permitted"}}`, want: true},
{name: "unrelated bad request", body: `{"error":{"message":"tools[0].input_schema is invalid"}}`, want: false},
}
for _, tc := range responses {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("anthropic-beta"); got != anthropicContextManagementBeta {
t.Errorf("anthropic-beta = %q", got)
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read request body: %v", err)
}
if !strings.Contains(string(body), `"context_management"`) {
t.Errorf("request lacks native context management: %s", body)
}
w.WriteHeader(http.StatusBadRequest)
_, _ = io.WriteString(w, tc.body)
}))
defer server.Close()
c := &client{
name: "anthropic-test", apiKey: "test", baseURL: server.URL, model: "claude-test",
nativeAnthropic: true, http: server.Client(),
}
_, err := c.Stream(context.Background(), provider.Request{
Messages: []provider.Message{{Role: provider.RoleUser, Content: "hello"}},
ContextEditing: &provider.ContextEditingPolicy{
Mode: "native", TriggerInputTokens: 12_000, KeepToolUses: 3,
ClearAtLeastInputTokens: 4_096,
},
})
if got := provider.IsNativeContextEditingUnsupported(err); got != tc.want {
t.Fatalf("IsNativeContextEditingUnsupported(%v) = %t, want %t", err, got, tc.want)
}
})
}
}
func TestReadStreamReportsAppliedNativeToolClearing(t *testing.T) {
fixture := `event: message_start
data: {"type":"message_start","message":{"usage":{"input_tokens":100}}}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":2},"context_management":{"applied_edits":[{"type":"clear_tool_uses_20250919","cleared_tool_uses":8,"cleared_input_tokens":50000}]}}
event: message_stop
data: {"type":"message_stop"}
`
c := &client{name: "anthropic", nativeAnthropic: true}
resp := &http.Response{Body: io.NopCloser(strings.NewReader(fixture))}
ch := make(chan provider.Chunk)
go c.readStream(context.Background(), resp, ch)
var usage *provider.Usage
for chunk := range ch {
if chunk.Type == provider.ChunkUsage {
usage = chunk.Usage
}
}
if usage == nil || usage.ContextEditingType != anthropicToolClearPolicyVersion ||
usage.ContextEditingClearedToolUses != 8 || usage.ContextEditingClearedTokens != 50000 {
t.Fatalf("native applied edits usage = %+v", usage)
}
}
@@ -3,11 +3,11 @@ package anthropic
import "testing"
func TestSharedWindowOutputBudgetCapability(t *testing.T) {
deepseek := &client{deepseek: true, defaultMaxTokens: 128 * 1024}
if !deepseek.SharesContextWindow() || deepseek.OutputBudget() != 128*1024 {
deepseek := &client{deepseek: true, defaultMaxTokens: 32 * 1024}
if !deepseek.SharesContextWindow() || deepseek.OutputBudget() != 32*1024 {
t.Fatalf("DeepSeek capability = shared:%v budget:%d", deepseek.SharesContextWindow(), deepseek.OutputBudget())
}
anthropic := &client{defaultMaxTokens: 32 * 1024}
anthropic := &client{defaultMaxTokens: 16 * 1024}
if anthropic.SharesContextWindow() {
t.Fatal("native Anthropic mode must keep its independent output ceiling")
}
-74
View File
@@ -1,74 +0,0 @@
package provider
import (
"context"
"errors"
"time"
)
// ErrCompactionUnsupported is returned by a NativeCompactor when the provider
// or model has no native compaction endpoint. Agents treat it as a capability
// miss and fall back to ordinary summarization; it is not a request failure.
var ErrCompactionUnsupported = errors.New("provider: native compaction unsupported")
// NativeCompactor is an optional Provider capability. Providers that do not
// implement it are handled by the agent's summarize fallback.
type NativeCompactor interface {
Compact(ctx context.Context, req CompactionRequest) (CompactionResult, error)
}
// CompactionRequest is a provider-neutral native compaction call.
type CompactionRequest struct {
Messages []Message
Instructions string
MaxOutputTokens int
PromptCacheKey string
SessionID string
}
// CompactionResult is the provider-neutral outcome of a native compaction call.
// Projection and Summary must not both be empty.
type CompactionResult struct {
Projection []Message
Summary string
Usage *Usage
ProviderRequestID string
}
// Valid reports whether the result carries a usable projection and/or summary.
func (r CompactionResult) Valid() bool {
return len(r.Projection) > 0 || r.Summary != ""
}
// CompactionCapabilities describes model/provider compaction and cache limits.
// Defaults come only from an explicit vendor/model capability table; unknown
// compatible gateways must not inherit a large shared default.
type CompactionCapabilities struct {
NativeCompaction bool
MaxOutputTokens int
CompactionOutputTokens int
CacheTTL time.Duration
}
// CompactionCapabler is an optional Provider surface for capability lookup.
type CompactionCapabler interface {
CompactionCapabilities() CompactionCapabilities
}
// AsNativeCompactor returns p when it implements NativeCompactor.
func AsNativeCompactor(p Provider) (NativeCompactor, bool) {
if p == nil {
return nil, false
}
nc, ok := p.(NativeCompactor)
return nc, ok
}
// AsCompactionCapabler returns p when it implements CompactionCapabler.
func AsCompactionCapabler(p Provider) (CompactionCapabler, bool) {
if p == nil {
return nil, false
}
cc, ok := p.(CompactionCapabler)
return cc, ok
}
-80
View File
@@ -1,80 +0,0 @@
package provider_test
import (
"context"
"errors"
"testing"
"reasonix/internal/provider"
"reasonix/internal/provider/responses"
)
type unsupportedCompactor struct{}
func (unsupportedCompactor) Name() string { return "unsupported" }
func (unsupportedCompactor) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
return nil, errors.New("not implemented")
}
func (unsupportedCompactor) Compact(context.Context, provider.CompactionRequest) (provider.CompactionResult, error) {
return provider.CompactionResult{}, provider.ErrCompactionUnsupported
}
func TestNativeCompactorCapabilityMiss(t *testing.T) {
var p provider.Provider = unsupportedCompactor{}
nc, ok := provider.AsNativeCompactor(p)
if !ok {
t.Fatal("expected NativeCompactor")
}
_, err := nc.Compact(context.Background(), provider.CompactionRequest{
Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
PromptCacheKey: "ws|sess|model",
SessionID: "sess",
})
if !errors.Is(err, provider.ErrCompactionUnsupported) {
t.Fatalf("err = %v, want ErrCompactionUnsupported", err)
}
}
func TestResponsesCompactionCapabilities(t *testing.T) {
p := responses.New(responses.Config{
Name: "deepseek",
BaseURL: "https://api.deepseek.com",
Model: "deepseek-v4-flash",
})
cc, ok := provider.AsCompactionCapabler(p)
if !ok {
t.Fatal("responses client must expose CompactionCapabler")
}
caps := cc.CompactionCapabilities()
if caps.NativeCompaction {
t.Fatal("DeepSeek Responses must not claim native compaction yet")
}
if caps.CompactionOutputTokens != 4096 {
t.Fatalf("CompactionOutputTokens = %d, want 4096", caps.CompactionOutputTokens)
}
if caps.MaxOutputTokens != provider.DefaultHighOutputTokens {
t.Fatalf("MaxOutputTokens = %d, want %d", caps.MaxOutputTokens, provider.DefaultHighOutputTokens)
}
unknown := responses.New(responses.Config{
Name: "compat",
BaseURL: "https://example.invalid/v1",
Model: "mystery",
})
ucaps := unknown.(provider.CompactionCapabler).CompactionCapabilities()
if ucaps.MaxOutputTokens != 0 || ucaps.CompactionOutputTokens != 0 {
t.Fatalf("unknown gateway must not inherit large defaults: %+v", ucaps)
}
}
func TestCompactionResultValid(t *testing.T) {
if (provider.CompactionResult{}).Valid() {
t.Fatal("empty result must be invalid")
}
if !(provider.CompactionResult{Summary: "x"}).Valid() {
t.Fatal("summary-only result must be valid")
}
if !(provider.CompactionResult{Projection: []provider.Message{{Role: provider.RoleUser, Content: "y"}}}).Valid() {
t.Fatal("projection-only result must be valid")
}
}
-46
View File
@@ -1,46 +0,0 @@
package provider
import "errors"
// ErrNativeContextEditingUnsupported marks an explicit provider rejection of
// the native context-management request shape. Callers may use it for the one
// permitted pre-success fallback to local maintenance; unrelated 4xx errors
// must remain ordinary request failures.
var ErrNativeContextEditingUnsupported = errors.New("native context editing is unsupported")
func IsNativeContextEditingUnsupported(err error) bool {
return errors.Is(err, ErrNativeContextEditingUnsupported)
}
// ContextEditingPolicy describes the stable Anthropic native tool-result edit
// policy. It is provider-neutral so frozen requests can include it without
// coupling agent code to Anthropic wire structs.
type ContextEditingPolicy struct {
Mode string `json:"mode,omitempty"` // local | native
TriggerInputTokens int `json:"triggerInputTokens,omitempty"`
KeepToolUses int `json:"keepToolUses,omitempty"`
ClearAtLeastInputTokens int `json:"clearAtLeastInputTokens,omitempty"`
ClearToolInputs bool `json:"clearToolInputs,omitempty"`
}
// ContextEditingCapabilities is explicit because protocol compatibility alone
// does not make an endpoint support Anthropic's native edit semantics.
type ContextEditingCapabilities struct {
NativeToolUseClear bool
PolicyVersion string
}
type ContextEditingCapabler interface {
ContextEditingCapabilities() ContextEditingCapabilities
}
func ContextEditingCapabilitiesOf(p Provider) ContextEditingCapabilities {
if p == nil {
return ContextEditingCapabilities{}
}
capable, ok := p.(ContextEditingCapabler)
if !ok {
return ContextEditingCapabilities{}
}
return capable.ContextEditingCapabilities()
}
+20 -7
View File
@@ -219,11 +219,14 @@ func New(cfg provider.Config) (provider.Provider, error) {
thinkingType: thinkingType, effort: effort, deepseek: deepseek, flash: deepseekV4Flash,
minimax: minimax, zhipu: zhipu, longcat: longcat, ollamaCloud: ollamaCloud,
explicit: hasExplicitEfforts, supported: supportedEfforts})
// The automatic cap protects DeepSeek reasoning, not ordinary long-form
// output. Preserve an explicit user budget in either mode, but leave a
// thinking-disabled request uncapped unless the user configured one.
if maxOutputTokens == 0 && officialDeepSeek && thinkingType != "disabled" {
maxOutputTokens = provider.DefaultHighOutputTokens // DeepSeek supports up to 384K; 128K is a safe default for reasoning
// max_output_tokens=0 means automatic (not unlimited). DeepSeek reasoning
// uses 32K / high-max 64K; thinking-disabled stays ordinary 16K. 128K is
// never automatic — users must set it explicitly after length truncations.
// This budget never participates in compact_ratio.
autoMaxOutput := maxOutputTokens == 0 && officialDeepSeek
if autoMaxOutput {
reasoningOn := thinkingType != "disabled" && effort != "disabled" && effort != "off" && effort != "none"
maxOutputTokens = provider.AutoOutputBudget(reasoningOn, effort)
}
httpClient, err := newHTTPClient(cfg)
if err != nil {
@@ -250,6 +253,7 @@ func New(cfg provider.Config) (provider.Provider, error) {
vision: vision,
visionDetail: visionDetail,
maxOutputTokens: maxOutputTokens,
autoMaxOutput: autoMaxOutput,
effort: effort,
requestEfforts: requestEfforts,
http: httpClient,
@@ -288,7 +292,8 @@ type client struct {
thinkingType string // explicit `thinking` config override (enabled|disabled); "" = no override
vision bool // model accepts image input — embed attached images as image_url parts
visionDetail string // image_url detail hint (low|high); "" = auto/omit
maxOutputTokens int // configured/default total output budget; <=0 omits the optional field
maxOutputTokens int // resolved total output budget; <=0 omits the optional field
autoMaxOutput bool // true when max_output_tokens=0 (automatic ladder)
effort string // reasoning_effort for OpenAI; thinking.type for MiniMax; "" = auto/provider default
requestEfforts []string // depth levels a per-request EffortOverride may take; empty = overrides ignored
idleTimeout time.Duration // SSE stall watchdog window; defaultStreamIdleTimeout unless a test overrides
@@ -772,7 +777,15 @@ func (c *client) buildRequest(req provider.Request) chatRequest {
maxOutputTokens := req.MaxTokens
if maxOutputTokens == 0 {
maxOutputTokens = c.maxOutputTokens
if c.autoMaxOutput && c.deepseek {
// Re-resolve so per-request EffortOverride (high/max) can raise 32K→64K.
effort := c.requestEffort(req)
reasoningOn := c.thinkingType != "disabled" &&
effort != "disabled" && effort != "off" && effort != "none"
maxOutputTokens = provider.AutoOutputBudget(reasoningOn, effort)
} else {
maxOutputTokens = c.maxOutputTokens
}
}
if maxOutputTokens < 0 {
maxOutputTokens = 0
+21 -6
View File
@@ -1244,9 +1244,24 @@ func TestBuildRequestUsesProviderSpecificOutputBudget(t *testing.T) {
return p.(*client)
}
// Official DeepSeek defaults effort to high when unset, so the automatic
// ladder lands on the 64K high-reasoning tier — not 128K.
deepseek := newClient(t, "https://api.deepseek.com", "deepseek-v4-flash", 0).buildRequest(provider.Request{})
if deepseek.MaxTokens != 131072 || deepseek.MaxCompletionTokens != 0 {
t.Fatalf("DeepSeek output budget = max_tokens %d, max_completion_tokens %d", deepseek.MaxTokens, deepseek.MaxCompletionTokens)
if deepseek.MaxTokens != provider.DefaultHighReasoningOutputTokens || deepseek.MaxCompletionTokens != 0 {
t.Fatalf("DeepSeek auto budget = max_tokens %d, max_completion_tokens %d, want high-reasoning %d",
deepseek.MaxTokens, deepseek.MaxCompletionTokens, provider.DefaultHighReasoningOutputTokens)
}
lowEffort, err := New(provider.Config{
Name: "test", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash",
Extra: map[string]any{"effort": "low", "max_output_tokens": 0},
})
if err != nil {
t.Fatalf("New low-effort DeepSeek: %v", err)
}
lowReq := lowEffort.(*client).buildRequest(provider.Request{})
if lowReq.MaxTokens != provider.DefaultReasoningOutputTokens {
t.Fatalf("low-effort auto budget = %d, want ordinary reasoning %d", lowReq.MaxTokens, provider.DefaultReasoningOutputTokens)
}
thinkingDisabledProvider, err := New(provider.Config{
@@ -1257,8 +1272,8 @@ func TestBuildRequestUsesProviderSpecificOutputBudget(t *testing.T) {
t.Fatalf("New thinking-disabled DeepSeek: %v", err)
}
thinkingDisabled := thinkingDisabledProvider.(*client).buildRequest(provider.Request{})
if thinkingDisabled.MaxTokens != 0 || thinkingDisabled.MaxCompletionTokens != 0 {
t.Fatalf("thinking-disabled DeepSeek received an automatic output budget: %+v", thinkingDisabled)
if thinkingDisabled.MaxTokens != provider.DefaultOrdinaryOutputTokens {
t.Fatalf("thinking-disabled DeepSeek auto budget = %d, want ordinary %d", thinkingDisabled.MaxTokens, provider.DefaultOrdinaryOutputTokens)
}
effortDisabledProvider, err := New(provider.Config{
Name: "test", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-pro",
@@ -1268,8 +1283,8 @@ func TestBuildRequestUsesProviderSpecificOutputBudget(t *testing.T) {
t.Fatalf("New effort-disabled DeepSeek: %v", err)
}
effortDisabled := effortDisabledProvider.(*client).buildRequest(provider.Request{})
if effortDisabled.MaxTokens != 0 || effortDisabled.Thinking == nil || effortDisabled.Thinking.Type != "disabled" {
t.Fatalf("effort-disabled DeepSeek request = %+v, want thinking disabled without an automatic budget", effortDisabled)
if effortDisabled.MaxTokens != provider.DefaultOrdinaryOutputTokens || effortDisabled.Thinking == nil || effortDisabled.Thinking.Type != "disabled" {
t.Fatalf("effort-disabled DeepSeek request = %+v, want thinking disabled with ordinary %d budget", effortDisabled, provider.DefaultOrdinaryOutputTokens)
}
explicitDisabledProvider, err := New(provider.Config{
@@ -3,11 +3,11 @@ package openai
import "testing"
func TestSharedWindowOutputBudgetCapability(t *testing.T) {
deepseek := &client{deepseek: true, maxOutputTokens: 128 * 1024}
if !deepseek.SharesContextWindow() || deepseek.OutputBudget() != 128*1024 {
deepseek := &client{deepseek: true, maxOutputTokens: 32 * 1024}
if !deepseek.SharesContextWindow() || deepseek.OutputBudget() != 32*1024 {
t.Fatalf("DeepSeek capability = shared:%v budget:%d", deepseek.SharesContextWindow(), deepseek.OutputBudget())
}
openai := &client{maxOutputTokens: 128 * 1024}
openai := &client{maxOutputTokens: 32 * 1024}
if openai.SharesContextWindow() {
t.Fatal("ordinary OpenAI mode must keep its independent output ceiling")
}
+24 -23
View File
@@ -47,9 +47,11 @@ type Message struct {
// Content is the provider-visible conversation content. Keeping this legacy
// field provider-visible preserves replay for older CLI/Desktop releases.
Content string `json:"content,omitempty"`
// RawContent is the user-authored form of a user turn, when it differs from
// Content because the host added transient context. Older releases ignore
// this field and still replay the provider-visible Content safely.
// RawContent holds the full original when it differs from Content:
// for user turns, the user-authored text before host-injected context;
// for tool turns, the complete tool result when first-visible Content was
// bounded. ModelMessages always clears it so provider serialization, prompt
// cache hashes, and projection hashes never include it.
RawContent string `json:"raw_content,omitempty"`
// ProviderContent is a transitional field written by early Context Engine v2
// builds. Loaders migrate it into Content/RawContent before normal use.
@@ -221,10 +223,6 @@ type Request struct {
// entirely — the common path must stay byte-stable for prompt caching.
ResponseFormat *ResponseFormat `json:"ResponseFormat,omitempty"`
EffortOverride string `json:"EffortOverride,omitempty"` // per-call reasoning-depth override; adapters apply it only when the endpoint's effort vocabulary accepts it
// ContextEditing is an explicit provider capability request. Providers that
// do not support native editing ignore it; local compaction remains the
// default when it is nil.
ContextEditing *ContextEditingPolicy `json:"ContextEditing,omitempty"`
}
// ResponseFormat asks a provider to constrain its output shape.
@@ -234,19 +232,26 @@ type ResponseFormat struct {
Type string `json:"type"`
}
// DefaultReasoningOutputTokens is the conservative provider-side budget used
// for official reasoning APIs whose documented contract safely accepts 32K.
// Unknown compatible gateways must opt in through configuration instead of
// inheriting this value merely because they implement an OpenAI-shaped wire.
const DefaultReasoningOutputTokens = 32 * 1024
// Auto ladder for max_output_tokens=0. Bounds completion only; never compact_ratio.
const (
DefaultOrdinaryOutputTokens = 16 * 1024 // non-reasoning
DefaultReasoningOutputTokens = 32 * 1024 // ordinary reasoning
DefaultHighReasoningOutputTokens = 64 * 1024 // high/max effort
DefaultHighOutputTokens = 128 * 1024 // explicit only; never auto
)
// DefaultHighOutputTokens is the raised output budget for reasoning APIs whose
// documented contract safely accepts 128K-class ceilings (DeepSeek Responses
// API allows up to 384K; MiMo allows up to 131072). Long reasoning turns
// truncate under 32K, forcing many small write→test→fix iterations; a 128K
// budget lets the model finish in one pass. Kept in one place so the three
// protocols (Responses / Chat Completions / Anthropic) cannot drift apart.
const DefaultHighOutputTokens = 128 * 1024
// AutoOutputBudget maps max_output_tokens=0 to 16K/32K/64K by reasoning effort.
func AutoOutputBudget(reasoningEnabled bool, effort string) int {
if !reasoningEnabled {
return DefaultOrdinaryOutputTokens
}
switch strings.ToLower(strings.TrimSpace(effort)) {
case "high", "max":
return DefaultHighReasoningOutputTokens
default:
return DefaultReasoningOutputTokens
}
}
// TemperaturePtr wraps v in a pointer so callers that explicitly want a
// specific temperature, including 0 for deterministic output, can distinguish
@@ -747,10 +752,6 @@ type Usage struct {
ContextReasoningTokens int
ContextCacheHitTokens int
ContextCacheMissTokens int
// Native context-editing observations from the latest committed request.
ContextEditingType string
ContextEditingClearedToolUses int
ContextEditingClearedTokens int
}
// ContextFillTokens returns the latest-attempt context fill (prompt+completion)
-30
View File
@@ -1,30 +0,0 @@
package responses
import (
"context"
"reasonix/internal/provider"
)
// CompactionCapabilities reports model-level compaction budgets from the
// vendor capability table. Unknown endpoints report zero budgets so agents
// do not inherit a large default merely for being OpenAI-compatible.
func (c *client) CompactionCapabilities() provider.CompactionCapabilities {
if c == nil {
return provider.CompactionCapabilities{}
}
return provider.CompactionCapabilities{
NativeCompaction: c.caps.nativeCompaction,
MaxOutputTokens: c.caps.defaultMaxOutputTokens,
CompactionOutputTokens: c.caps.compactionOutputTokens,
}
}
// Compact implements provider.NativeCompactor. Responses vendors do not yet
// expose a dedicated compact endpoint, so this always returns
// ErrCompactionUnsupported and the agent falls back to ordinary summarize.
func (c *client) Compact(ctx context.Context, req provider.CompactionRequest) (provider.CompactionResult, error) {
_ = ctx
_ = req
return provider.CompactionResult{}, provider.ErrCompactionUnsupported
}
@@ -3,8 +3,8 @@ package responses
import "testing"
func TestSharedWindowOutputBudgetCapability(t *testing.T) {
deepseek := &client{vendor: "deepseek", maxOutputTokens: 128 * 1024}
if !deepseek.SharesContextWindow() || deepseek.OutputBudget() != 128*1024 {
deepseek := &client{vendor: "deepseek", maxOutputTokens: 32 * 1024}
if !deepseek.SharesContextWindow() || deepseek.OutputBudget() != 32*1024 {
t.Fatalf("DeepSeek capability = shared:%v budget:%d", deepseek.SharesContextWindow(), deepseek.OutputBudget())
}
policy := deepseek.SharedWindowInputPolicy()
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more