mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
1 parent
6b90a2cbcf
commit
397ed30ec8
105 files changed
+3284
-4211
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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 ?? "" }] };
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "原生清理",
|
||||
|
||||
@@ -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": "原生清理",
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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 会话;已经运行的会话继续使用启动时加载的阈值。
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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"},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
Reference in new issue
Block a user