fix(memory): re-read the facts the last recall returned before recalling again

This commit is contained in:
esengine committed 2026-10-14 09:19:58 -07:00
1 parent d7fa4c3b0f
commit 99fe3dbbf1
5 files changed
+157

No files matched your search

+10
View File
@@ -131,6 +131,16 @@ message. Host-added provider context is not fed back into the query. The selecte
facts are appended to that user turn as a bounded, low-authority suffix; they do
not mutate the system prompt or tool schema.
The search runs over the snapshot the session loaded; this session's own
memory writes refresh it. Edits made outside the session are handled as follows:
- Before each recall, the facts the previous recall returned are compared with
their files.
- If one has changed or disappeared, the snapshot reloads, so the model reads
the edit.
- A fact edited into relevance that was not a hit stays out of the index until
the session reloads: recall does no full scan.
Recall is conservative:
- generic turns such as "continue" do not trigger recall;
+14
View File
@@ -37,6 +37,9 @@ type memoryManager struct {
// the prefix naturally on the next session.
pending []string
lastRecall memory.RecallResult
// recalled is the facts the last recall that returned any held, as indexed:
// what refreshRecalled compares with their files before the next one.
recalled []memory.Memory
autoWrites map[[32]byte]int
// instructions is what the model has of the standing-instruction block. It
@@ -119,8 +122,18 @@ func (m *memoryManager) claimAutoRemember(args json.RawMessage) bool {
}
func (m *memoryManager) recall(query string) memory.RecallResult {
m.refreshRecalled()
result := m.current().AutoRecall(query, memory.RecallOptions{})
m.recordRecall(result)
if len(result.Hits) > 0 {
recalled := make([]memory.Memory, len(result.Hits))
for i, hit := range result.Hits {
recalled[i] = hit.Memory
}
m.mu.Lock()
m.recalled = recalled
m.mu.Unlock()
}
return result
}
@@ -194,6 +207,7 @@ func (m *memoryManager) applyWrite(mem *memory.Set, note string) {
m.pending = append(m.pending, note)
}
m.set = reloaded
m.recalled = nil
m.mu.Unlock()
}
@@ -0,0 +1,91 @@
package control
import (
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/state/memory"
)
func externalEditSession(t *testing.T) (*Controller, string) {
t.Helper()
root := testenv.TempDir(t)
cwd := filepath.Join(root, "project")
if err := os.MkdirAll(cwd, 0o755); err != nil {
t.Fatal(err)
}
c := New(Options{Memory: memory.Load(memory.Options{CWD: cwd, UserDir: filepath.Join(root, "user")})})
path, err := c.SaveMemory(memory.Memory{
Name: "deploy-target",
Description: "where the staging deploy goes",
Keywords: "staging deploy target",
Type: memory.TypeProject,
Body: "old body marker",
})
if err != nil {
t.Fatalf("SaveMemory: %v", err)
}
// The save queues a note that suppresses recall for one turn; spend it.
c.Compose("hello")
return c, path
}
func rewriteOnDisk(t *testing.T, path, from, to string) {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), from) {
t.Fatalf("fact file has no %q:\n%s", from, raw)
}
if err := os.WriteFile(path, []byte(strings.Replace(string(raw), from, to, 1)), 0o644); err != nil {
t.Fatal(err)
}
}
// A fact the last recall returned is checked against its file before the next
// recall, so an edit made outside this session (by the person, another process
// or another session) is what the model reads, not the snapshot loaded earlier.
func TestRecallReadsAFactEditedOutsideTheSession(t *testing.T) {
c, path := externalEditSession(t)
if first := c.Compose("staging deploy target"); !strings.Contains(first, "old body marker") {
t.Fatalf("the probe proves nothing unless the turn carries the fact:\n%s", first)
}
rewriteOnDisk(t, path, "old body marker", "new body marker")
next := c.Compose("staging deploy target")
if !strings.Contains(next, "new body marker") || strings.Contains(next, "old body marker") {
t.Fatalf("the turn still carries the stale body:\n%s", next)
}
}
func TestRecallStopsOfferingAFactDeletedOutsideTheSession(t *testing.T) {
c, path := externalEditSession(t)
if first := c.Compose("staging deploy target"); !strings.Contains(first, "old body marker") {
t.Fatalf("setup: %s", first)
}
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
if next := c.Compose("staging deploy target"); strings.Contains(next, "old body marker") {
t.Fatalf("a deleted fact is still recalled:\n%s", next)
}
}
// Re-checking is bounded to what the last recall returned: a fact edited into
// relevance that was never a hit stays invisible until the session reloads, and
// an untouched hit costs no reload.
func TestAnUntouchedRecalledFactDoesNotReloadTheSnapshot(t *testing.T) {
c, _ := externalEditSession(t)
if first := c.Compose("staging deploy target"); !strings.Contains(first, "old body marker") {
t.Fatalf("setup: %s", first)
}
before := c.Memory()
c.Compose("staging deploy target")
if c.Memory() != before {
t.Fatal("an unchanged recalled fact reloaded the whole snapshot")
}
}
@@ -0,0 +1,40 @@
package control
import "reasonix/internal/state/memory"
// refreshRecalled reloads the snapshot when a fact the last recall returned no
// longer reads the same on disk, so an edit made outside this session is what
// the next recall offers. It compares the facts, not a timestamp, and reads only
// those: no full scan, so a fact never recalled is not seen until a reload.
func (m *memoryManager) refreshRecalled() {
m.mu.Lock()
recalled := m.recalled
m.mu.Unlock()
if len(recalled) == 0 {
return
}
m.writeMu.Lock()
defer m.writeMu.Unlock()
mem := m.current()
if mem == nil {
return
}
for _, held := range recalled {
now, ok, err := mem.Store.Read(held.ID)
if err == nil && ok && sameFact(held, now) {
continue
}
// Changed, gone, or unreadable: the reload settles which, and an
// unreadable store reaches the recall as its StoreError.
m.applyWrite(mem, "")
return
}
}
func sameFact(a, b memory.Memory) bool {
return a.ID == b.ID && a.Revision == b.Revision && a.Name == b.Name && a.Title == b.Title &&
a.Description == b.Description && a.Type == b.Type && a.Scope == b.Scope &&
a.Activation == b.Activation && a.Volatility == b.Volatility && a.SubjectKey == b.SubjectKey &&
a.ExpiresAt.Equal(b.ExpiresAt) && a.LastVerifiedAt.Equal(b.LastVerifiedAt) &&
a.Keywords == b.Keywords && a.Body == b.Body
}
+2
View File
@@ -0,0 +1,2 @@
- 会话里召回过的记忆在会话外被修改或删除后,下一次召回会读到新内容(只重查上次命中的几条,不全量扫描)。关联 #12948。
- A recalled memory fact edited or deleted outside the session is re-read before the next recall (only the last hits are checked, no full scan). Refs #12948.