Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions docs/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ Episode extraction runs **asynchronously** — it does not block the agent loop.
"name": "memory",
"description": "Manage persistent memory across sessions.",
"parameters": {
"action": { "enum": ["add", "replace", "remove", "consolidate", "read", "search", "view", "add_atom", "search_atoms", "forget_atom", "list_quarantine", "pin_atom", "confirm_pending_review", "reject_pending_review", "list_pending_review"] },
"target": { "enum": ["user", "env"], "description": "For add/replace/remove/consolidate" },
"action": { "enum": ["add", "replace", "remove", "stats", "consolidate", "read", "search", "view", "add_atom", "search_atoms", "forget_atom", "list_quarantine", "pin_atom", "confirm_pending_review", "reject_pending_review", "list_pending_review"] },
"target": { "enum": ["user", "env"], "description": "For add/replace/remove/consolidate/stats" },
"content": { "type": "string", "description": "For add/replace" },
"old_text": { "type": "string", "description": "Unique substring for replace/remove" },
"query": { "type": "string", "description": "For search — facts + episodes" }
Expand All @@ -72,10 +72,39 @@ Episode extraction runs **asynchronously** — it does not block the agent loop.
| `add` | user/env | ✅ new entry | — | Appends to file. Check: dedup + cap + merge |
| `replace` | user/env | ✅ replacement | ✅ substring | Finds entry by substring, replaces it |
| `remove` | user/env | — | ✅ substring | Finds entry by substring, removes it |
| `stats` | user/env | — | — | Per-entry sizes + fill (used/cap) — pre-flight check before writes |
| `consolidate` | user/env | — | — | SimpleCall: merge related entries for density |
| `read` | — | — | — | Returns full content of both user.md + env.md |
| `search` | — | — | ✅ query | LLM ranker by default (relevance-oriented); `llm_search: false` switches to RP cosine ranking (zero LLM calls) |

## Automatic Cap Maintenance (LLM-driven eviction)

The agent maintains its own fact files. When a fact file fills up, the **agent itself** evicts older entries — there is no background rewriter and no silent consolidation. Every eviction is an explicit, auditable `remove`/`replace` call inside the normal agent loop.

Three affordances make this work:

1. **Decision-ready cap errors.** When `add`/`replace` would exceed the cap, the error carries the full entry index — one-based index, size, preview — plus the instruction to free space with `memory remove`/`replace` and retry. The agent can pick an eviction target without an extra read.

```
memory: adding entry (210 chars) would exceed cap (2500 chars); current: 2438, max: 2500.
Entries (oldest first):
[1] 480c "20260830 — odek v1.30.0 released: sub-agent cancel flow (PR #156, squas…"
[2] 1051c "20260830 — Skill Self-Learning removal scoped (SKILL_LEARNING_REMOVAL_P…"
Free space by removing or replacing older entries (memory remove/replace), then retry
```

2. **`stats` action.** `memory(action: "stats", target: "env")` returns `{used, cap, entries: [{index, chars, preview}]}` — a pre-flight fill check before large writes. `view` remains episodes-only by design (provenance gate).

3. **Eviction policy (tool contract).** The `memory` tool description codifies the priority: when a target is at cap, remove or replace the lowest-value entries — records recoverable from git/GitHub (release notes, merged PRs) evict first; pointers to untracked local work (plan docs, pending-review findings) evict last.

Additionally, the system-prompt memory block appends a one-line warning when a fact file is at ≥90% of its cap:

```
⚠ env fact file 97% full — evict stale entries via memory remove before your next add.
```

Caps are configured via `facts_limit_user` / `facts_limit_env` (defaults: 4,000 / 8,000 chars, counted as bytes — consistent with cap accounting). Deliberately **not** built: automatic consolidation on write (a mid-flow LLM rewrite risks dropping load-bearing details and amplifies provider throttling) and date-based auto-eviction (age alone says nothing about value — a days-old pointer to untracked work can be the only durable record of it).

## Merge-on-Write (go-vector Integration)

When adding a fact, a **two-tier merge detector** classifies the new entry:
Expand Down
263 changes: 263 additions & 0 deletions internal/memory/auto_eviction_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
package memory

// RED-first tests for the memory auto-eviction workstream (M1–M4):
//
// M1 — cap errors carry a decision-ready entry index + eviction guidance
// M2 — `stats` action: per-entry sizes/fill without a raw read
// M3 — tool description codifies the LLM eviction policy
// M4 — system-prompt memory block warns when a fact file is ≥90% full
//
// The eviction judgment itself stays in the agent's ReAct loop: every
// eviction remains an explicit, auditable memory remove/replace call.

import (
"encoding/json"
"strings"
"testing"
"unicode/utf8"
)

func evictionCfg() MemoryConfig {
cfg := DefaultMemoryConfig()
cfg.FactsLimitUser = 200
cfg.FactsLimitEnv = 100
return cfg
}

// ── M1: cap errors list entries + guidance ──────────────────────────

func TestAutoEviction_CapErrorListsEntries(t *testing.T) {
mm := NewMemoryManager(t.TempDir(), &dummyLLM{}, evictionCfg())
if err := mm.facts.Add("env", strings.Repeat("a", 40)); err != nil {
t.Fatalf("seed entry 1: %v", err)
}
if err := mm.facts.Add("env", strings.Repeat("b", 45)); err != nil {
t.Fatalf("seed entry 2: %v", err)
}

err := mm.facts.Add("env", strings.Repeat("c", 20))
if err == nil {
t.Fatal("expected cap error, got nil")
}
msg := err.Error()
for _, want := range []string{
"would exceed cap (100 chars)",
"current: 89, max: 100",
"[1] 40c",
"[2] 45c",
"memory remove/replace",
} {
if !strings.Contains(msg, want) {
t.Errorf("cap error missing %q:\n%s", want, msg)
}
}
}

func TestAutoEviction_CapErrorEmptyFile(t *testing.T) {
mm := NewMemoryManager(t.TempDir(), &dummyLLM{}, evictionCfg())
err := mm.facts.Add("env", strings.Repeat("x", 150))
if err == nil {
t.Fatal("expected cap error for oversized single entry, got nil")
}
msg := err.Error()
if !strings.Contains(msg, "would exceed cap (100 chars)") {
t.Errorf("missing cap report:\n%s", msg)
}
if !strings.Contains(msg, "empty") {
t.Errorf("empty-file case should say the file has no entries:\n%s", msg)
}
}

func TestAutoEviction_ReplaceCapErrorListsEntries(t *testing.T) {
mm := NewMemoryManager(t.TempDir(), &dummyLLM{}, evictionCfg())
if err := mm.facts.Add("env", strings.Repeat("a", 40)); err != nil {
t.Fatalf("seed entry: %v", err)
}
err := mm.facts.Replace("env", "aaa", strings.Repeat("z", 101))
if err == nil {
t.Fatal("expected replace cap error, got nil")
}
msg := err.Error()
if !strings.Contains(msg, "[1] 40c") {
t.Errorf("replace cap error should list entries:\n%s", msg)
}
if !strings.Contains(msg, "memory remove") {
t.Errorf("replace cap error should carry eviction guidance:\n%s", msg)
}
}

// ── M2: stats action ────────────────────────────────────────────────

func TestMemoryStatsAction(t *testing.T) {
mm := NewMemoryManager(t.TempDir(), &dummyLLM{}, evictionCfg())
if err := mm.facts.Add("env", strings.Repeat("a", 40)); err != nil {
t.Fatalf("seed entry 1: %v", err)
}
if err := mm.facts.Add("env", strings.Repeat("b", 45)); err != nil {
t.Fatalf("seed entry 2: %v", err)
}
tool := NewMemoryTool(mm)

res, _ := tool.Call(`{"action":"stats","target":"env"}`)
var out struct {
Success bool `json:"success"`
Target string `json:"target"`
Used int `json:"used"`
Cap int `json:"cap"`
Entries []struct {
Index int `json:"index"`
Chars int `json:"chars"`
Preview string `json:"preview"`
} `json:"entries"`
}
if err := json.Unmarshal([]byte(res), &out); err != nil {
t.Fatalf("invalid JSON response: %v", err)
}
if !out.Success {
t.Fatalf("expected success, got %s", res)
}
if out.Target != "env" {
t.Errorf("target = %q, want env", out.Target)
}
if out.Used != 89 {
t.Errorf("used = %d, want 89 (40 + 4-byte separator + 45)", out.Used)
}
if out.Cap != 100 {
t.Errorf("cap = %d, want 100", out.Cap)
}
if len(out.Entries) != 2 {
t.Fatalf("entries = %d, want 2", len(out.Entries))
}
if out.Entries[0].Chars != 40 || out.Entries[1].Chars != 45 {
t.Errorf("entry chars = %d/%d, want 40/45", out.Entries[0].Chars, out.Entries[1].Chars)
}
if out.Entries[0].Index != 1 || out.Entries[1].Index != 2 {
t.Errorf("entry indexes = %d/%d, want 1/2", out.Entries[0].Index, out.Entries[1].Index)
}
if !strings.HasPrefix(out.Entries[0].Preview, "aaaa") {
t.Errorf("preview = %q, want aaa... prefix", out.Entries[0].Preview)
}
}

func TestMemoryStatsActionMissingFile(t *testing.T) {
mm := NewMemoryManager(t.TempDir(), &dummyLLM{}, evictionCfg())
tool := NewMemoryTool(mm)
res, _ := tool.Call(`{"action":"stats","target":"env"}`)
var out struct {
Success bool `json:"success"`
Used int `json:"used"`
Cap int `json:"cap"`
Entries []struct {
Index int `json:"index"`
} `json:"entries"`
}
if err := json.Unmarshal([]byte(res), &out); err != nil {
t.Fatalf("invalid JSON response: %v", err)
}
if !out.Success {
t.Fatalf("stats on a not-yet-created file must succeed with zero fill, got %s", res)
}
if out.Used != 0 || len(out.Entries) != 0 {
t.Errorf("expected empty stats, got used=%d entries=%d", out.Used, len(out.Entries))
}
if out.Cap != 100 {
t.Errorf("cap = %d, want 100", out.Cap)
}
}

func TestMemoryStatsActionGuards(t *testing.T) {
mm := NewMemoryManager(t.TempDir(), &dummyLLM{}, evictionCfg())
tool := NewMemoryTool(mm)

res, _ := tool.Call(`{"action":"stats","target":"episodes"}`)
if !strings.Contains(res, "must be 'user' or 'env'") {
t.Errorf("episodes target must be rejected (view is the episodes surface), got %s", res)
}
res, _ = tool.Call(`{"action":"stats"}`)
if !strings.Contains(res, "target is required") {
t.Errorf("missing target must be rejected, got %s", res)
}
}

func TestMemoryStatsPreviewRuneSafe(t *testing.T) {
cfg := evictionCfg()
cfg.FactsLimitEnv = 400 // room for one 60-rune multibyte entry (180 bytes)
mm := NewMemoryManager(t.TempDir(), &dummyLLM{}, cfg)
entry := strings.Repeat("—", 60)
if err := mm.facts.Add("env", entry); err != nil {
t.Fatalf("seed multibyte entry: %v", err)
}
tool := NewMemoryTool(mm)
res, _ := tool.Call(`{"action":"stats","target":"env"}`)
var out struct {
Entries []struct {
Chars int `json:"chars"`
Preview string `json:"preview"`
} `json:"entries"`
}
if err := json.Unmarshal([]byte(res), &out); err != nil {
t.Fatalf("invalid JSON response: %v", err)
}
if len(out.Entries) != 1 {
t.Fatalf("entries = %d, want 1", len(out.Entries))
}
p := out.Entries[0].Preview
if !utf8.ValidString(p) {
t.Errorf("preview is not valid UTF-8 (mid-rune cut): %q", p)
}
if n := len([]rune(p)); n > 61 { // 60 + ellipsis
t.Errorf("preview = %d runes, want ≤61", n)
}
}

// ── M3: eviction policy in the tool description ─────────────────────

func TestMemoryToolDescriptionEvictionPolicy(t *testing.T) {
mm := NewMemoryManager(t.TempDir(), &dummyLLM{}, evictionCfg())
desc := NewMemoryTool(mm).Description()
for _, want := range []string{"at cap", "evict", "stats"} {
if !strings.Contains(desc, want) {
t.Errorf("tool description missing eviction-policy term %q: %s", want, desc)
}
}
}

// ── M4: near-cap hint in the system-prompt memory block ─────────────

func TestPromptNearCapHint(t *testing.T) {
t.Run("env at 95 percent gets a hint", func(t *testing.T) {
mm := NewMemoryManager(t.TempDir(), &dummyLLM{}, evictionCfg())
if err := mm.facts.Add("env", strings.Repeat("e", 95)); err != nil {
t.Fatalf("seed: %v", err)
}
block := mm.BuildSystemPrompt()
if !strings.Contains(block, "env fact file 95% full") {
t.Errorf("missing near-cap hint for env:\n%s", block)
}
if !strings.Contains(block, "memory remove") {
t.Errorf("hint must point at the eviction action:\n%s", block)
}
})

t.Run("env well below cap stays clean", func(t *testing.T) {
mm := NewMemoryManager(t.TempDir(), &dummyLLM{}, evictionCfg())
if err := mm.facts.Add("env", strings.Repeat("e", 50)); err != nil {
t.Fatalf("seed: %v", err)
}
block := mm.BuildSystemPrompt()
if strings.Contains(block, "full — evict") {
t.Errorf("hint must not appear below the near-cap threshold:\n%s", block)
}
})

t.Run("user file at 95 percent gets a hint", func(t *testing.T) {
mm := NewMemoryManager(t.TempDir(), &dummyLLM{}, evictionCfg())
if err := mm.facts.Add("user", strings.Repeat("u", 190)); err != nil {
t.Fatalf("seed: %v", err)
}
block := mm.BuildSystemPrompt()
if !strings.Contains(block, "user fact file 95% full") {
t.Errorf("missing near-cap hint for user:\n%s", block)
}
})
}
36 changes: 32 additions & 4 deletions internal/memory/facts.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,32 @@ func (f *FactStore) sizeOf(entries []string) int {
return size
}

// truncateRunes cuts s to at most n runes on a rune boundary, appending an
// ellipsis. Entry previews surface in agent-facing errors, where a mid-rune
// cut would corrupt the UTF-8 output.
func truncateRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}

// entryIndex renders a compact, decision-ready listing of the current entries
// for cap-failure errors: one-based index, byte size (len semantics,
// consistent with cap accounting), and a rune-safe preview. The agent can
// pick an eviction target from this alone — no extra read round-trip.
func entryIndex(entries []string, preview int) string {
if len(entries) == 0 {
return "(file is empty — the new entry alone exceeds the cap)"
}
parts := make([]string, 0, len(entries))
for i, e := range entries {
parts = append(parts, fmt.Sprintf("[%d] %dc %q", i+1, len(e), truncateRunes(e, preview)))
}
return strings.Join(parts, "\n")
}

// Read returns the full content of a fact file. Returns empty string if the
// file doesn't exist yet.
func (f *FactStore) Read(target string) (string, error) {
Expand Down Expand Up @@ -206,8 +232,8 @@ func (f *FactStore) Add(target, content string) error {

maxCap := f.cap(target)
if newSize > maxCap {
return nil, fmt.Errorf("memory: adding entry (%d chars) would exceed cap (%d chars); current: %d, max: %d",
len(content), maxCap, f.sizeOf(entries), maxCap)
return nil, fmt.Errorf("memory: adding entry (%d chars) would exceed cap (%d chars); current: %d, max: %d.\nEntries (oldest first):\n%s\nFree space by removing or replacing older entries (memory remove/replace), then retry",
len(content), maxCap, f.sizeOf(entries), maxCap, entryIndex(entries, 50))
}

// Append
Expand Down Expand Up @@ -256,7 +282,8 @@ func (f *FactStore) Replace(target, oldText, content string) error {
newSize := f.sizeOf(entries) - len(entries[matchIdx]) + len(content)
maxCap := f.cap(target)
if newSize > maxCap {
return nil, fmt.Errorf("memory: replacement (%d chars) would exceed cap (%d chars)", newSize, maxCap)
return nil, fmt.Errorf("memory: replacement (%d chars) would exceed cap (%d chars); current: %d, max: %d.\nEntries:\n%s\nFree space by removing older entries (memory remove), then retry",
newSize, maxCap, f.sizeOf(entries), maxCap, entryIndex(entries, 50))
}

entries[matchIdx] = content
Expand Down Expand Up @@ -292,7 +319,8 @@ func (f *FactStore) ReplaceAt(target string, idx int, content string) error {
newSize := f.sizeOf(entries) - len(entries[idx]) + len(content)
maxCap := f.cap(target)
if newSize > maxCap {
return nil, fmt.Errorf("memory: replacement (%d chars) would exceed cap (%d chars)", newSize, maxCap)
return nil, fmt.Errorf("memory: replacement (%d chars) would exceed cap (%d chars); current: %d, max: %d.\nEntries:\n%s\nFree space by removing older entries (memory remove), then retry",
newSize, maxCap, f.sizeOf(entries), maxCap, entryIndex(entries, 50))
}

entries[idx] = content
Expand Down
Loading
Loading