mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
fix(memory): re-read the facts the last recall returned before recalling again
This commit is contained in:
1 parent
d7fa4c3b0f
commit
99fe3dbbf1
5 files changed
+157
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user