feat(memory): 记忆 P2(项目索引物化 / decay 回写 / 作用域写规则 / 静默吞点 + loop 级只读红线测试)
This commit is contained in:
1 parent
9b903a89bc
commit
509dc412eb
12 files changed
+660
-55
No files matched your search
@@ -101,7 +101,10 @@ func (l *Loop) retrieveMemoryBlock(ctx context.Context, env SessionEnv, personaI
|
||||
return block, nil
|
||||
}
|
||||
rctx, span := l.span(ctx, spanNameMemoryRecall, trace.SpanKindMemory)
|
||||
results, err := l.retriever.Retrieve(rctx, personaID, query, 0)
|
||||
// 受限检索是只读的(design/03 §5「能查不能写」):关掉衰减退化的物化
|
||||
// 回写,使群聊/陌生人的一次召回不会改动记忆行(哪怕是 decay_weight 这类
|
||||
// 派生列,也不该在只读上下文里被写)。
|
||||
results, err := l.retriever.Retrieve(memory.WithoutDecayWriteback(rctx), personaID, query, 0)
|
||||
if err != nil {
|
||||
l.finishMemoryRecallSpan(span, "restricted", 0, 0, err)
|
||||
return "", err
|
||||
@@ -290,11 +293,15 @@ func (l *Loop) extractMemoryAsync(ctx context.Context, messages []model.Message,
|
||||
}
|
||||
|
||||
// persistMemories 把提取出的条目写入记忆 store,为每个条目
|
||||
// 打上产出该 turn 的角色。该赋值刻意无条件:
|
||||
// extractor 刻意不感知角色(它不再持有一个在构造期
|
||||
// 内嵌的人格,cmd/cyrene/extractor.go),否则多角色
|
||||
// 进程会把每条记忆都归到一个角色下。角色为空(完全未配置
|
||||
// persona)时条目保持 extractor 产出时的样子。
|
||||
// 打上产出该 turn 的角色。角色归属经 Store 的作用域写规则
|
||||
// (memory.Store.AddScoped,design/03 §3)落定,而不是在循环里改写:
|
||||
// extractor 刻意不感知角色(它不再持有一个在构造期内嵌的人格,
|
||||
// cmd/cyrene/extractor.go),因此它产出的条目通常不带 PersonaID,
|
||||
// AddScoped 用本回合角色(actorID)把它补上;若 extractor 送出一条
|
||||
// **归属别的角色**的角色作用域条目,AddScoped 以 ErrReadOnly 拒绝,
|
||||
// 绝不把它冒充成本回合角色的日常记忆(Store 层防线,审计 2026-10-05
|
||||
// §1.3「作用域写规则未接线」)。角色为空(完全未配置 persona)时
|
||||
// AddScoped 返回 ErrMissingPersona,条目按失败记录日志。
|
||||
//
|
||||
// 溯源同样在此盖真实 id(裁决 04.4.3,design/03 §2):
|
||||
// source_conversation 由本持久化点从运行上下文取 Run 的
|
||||
@@ -315,11 +322,8 @@ func (l *Loop) persistMemories(ctx context.Context, items []memory.MemoryItem, s
|
||||
wctx, span := l.span(ctx, spanNameMemoryWrite, trace.SpanKindMemory)
|
||||
written, failed := 0, 0
|
||||
for _, it := range items {
|
||||
if personaID != "" {
|
||||
it.PersonaID = personaID
|
||||
}
|
||||
it.SourceConversation = source
|
||||
if _, err := l.memory.Add(wctx, it); err != nil {
|
||||
if _, err := l.memory.AddScoped(wctx, personaID, it); err != nil {
|
||||
failed++
|
||||
l.log.Warn("memory store", "step", step, "error", err)
|
||||
continue
|
||||
@@ -416,7 +420,9 @@ func (l *Loop) recordIntent(ctx context.Context, messages []model.Message, step
|
||||
Confidence: judgment.Confidence,
|
||||
SourceConversation: memory.FormatSourceConversation(loopSession(ctx), loopTurn(ctx)),
|
||||
}
|
||||
if _, err := l.memory.Add(ctx, item); err != nil {
|
||||
// 经 Store 的作用域写规则落库(ScopeUser 对任何角色可写,design/03 §3),
|
||||
// 与 persistMemories 共用同一条受规则约束的写入路径,而不是绕过规则直插。
|
||||
if _, err := l.memory.AddScoped(ctx, personaID, item); err != nil {
|
||||
l.log.Warn("memory: L4 intent writeback failed", "step", step, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// seedDecayProbe 在独立 store 上放一条**会被命中**(content 含 query 词)且
|
||||
// updated_at 很旧(60 天前,衰减明显 < 1.0)的记忆,返回 store 与落库条目。
|
||||
func seedDecayProbe(t *testing.T, query string) (*memory.Store, memory.MemoryItem) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
store := newTestMemoryStore(t)
|
||||
stored, err := store.Add(ctx, memory.MemoryItem{
|
||||
PersonaID: "persona-a", Content: query + " preference",
|
||||
UpdatedAt: time.Now().UTC().Add(-60 * 24 * time.Hour),
|
||||
Scope: memory.ScopeUser, Kind: memory.KindRelationship,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed memory: %v", err)
|
||||
}
|
||||
return store, stored
|
||||
}
|
||||
|
||||
// TestMemoryBlock_ReadOnlyLane_NoDecayWrite 钉住 AGENTS §9 / design/03 §5 的红线
|
||||
// 「群聊 / 陌生人只读不写记忆」在 **loop 级**成立:真实走 memoryBlock →
|
||||
// retrieveMemoryBlock 的受限车道(群聊/陌生人,MemWritable=false)召回时,命中
|
||||
// 记忆的 decay_weight 与 updated_at 必须逐字不变(零写入)。
|
||||
//
|
||||
// 为什么必须落在 loop 级:关掉衰减回写的是**受限车道自己在检索调用点**传的
|
||||
// memory.WithoutDecayWriteback(memory.go 的 retrieveMemoryBlock)。只在 Retriever
|
||||
// 上单测「显式传入时只读」钉不住「受限车道真的传了它」——把那一行去掉,那些
|
||||
// 测试仍全绿(审计实证的测试缺口)。
|
||||
//
|
||||
// 正向对照:主人车道(OwnerEnv)走同一路径时两者必须被推进,否则本测试可能
|
||||
// 因为「根本没命中」而假绿。
|
||||
func TestMemoryBlock_ReadOnlyLane_NoDecayWrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
msgs := []model.Message{model.TextMessage(model.RoleUser, "coffee")}
|
||||
|
||||
// 正向对照:主人车道推进衰减(证明命中条件成立,不是空跑)。
|
||||
ownerStore, ownerSeed := seedDecayProbe(t, "coffee")
|
||||
owner := New(Options{Retriever: memory.NewRetriever(ownerStore), PersonaID: "persona-a"})
|
||||
owner.memoryBlock(WithSessionEnv(ctx, OwnerEnv()), msgs)
|
||||
ownerAfter, err := ownerStore.Get(ctx, ownerSeed.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("owner Get: %v", err)
|
||||
}
|
||||
if ownerAfter.DecayWeight >= ownerSeed.DecayWeight || ownerAfter.UpdatedAt.Equal(ownerSeed.UpdatedAt) {
|
||||
t.Fatalf("主人车道未推进衰减(对照不成立):before=%v/%v after=%v/%v",
|
||||
ownerSeed.DecayWeight, ownerSeed.UpdatedAt, ownerAfter.DecayWeight, ownerAfter.UpdatedAt)
|
||||
}
|
||||
|
||||
// 红线:受限车道对命中记忆零写入。
|
||||
groupEnv := SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false}
|
||||
groupStore, groupSeed := seedDecayProbe(t, "coffee")
|
||||
group := New(Options{Retriever: memory.NewRetriever(groupStore), PersonaID: "persona-a"})
|
||||
group.memoryBlock(WithSessionEnv(ctx, groupEnv), msgs)
|
||||
groupAfter, err := groupStore.Get(ctx, groupSeed.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("group Get: %v", err)
|
||||
}
|
||||
if groupAfter.DecayWeight != groupSeed.DecayWeight || !groupAfter.UpdatedAt.Equal(groupSeed.UpdatedAt) {
|
||||
t.Fatalf("受限车道写入了记忆(红线破坏):before=%v/%v after=%v/%v",
|
||||
groupSeed.DecayWeight, groupSeed.UpdatedAt, groupAfter.DecayWeight, groupAfter.UpdatedAt)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// fixedExtractor(定义见 flow_spans_test.go)返回一组固定条目,用于驱动持久化
|
||||
// 路径的边界断言。
|
||||
|
||||
// TestPersistMemories_ScopedWriteRule_RejectsForeignPersona 钉住审计 2026-10-05
|
||||
// §1.3「Store 作用域写规则未接线」:提取持久化经 Store 的作用域写规则
|
||||
// (AddScoped)落库。一条归属**别的角色**的角色作用域条目必须被拒(ErrReadOnly),
|
||||
// 绝不被冒充成本回合角色的日常记忆。
|
||||
func TestPersistMemories_ScopedWriteRule_RejectsForeignPersona(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newTestMemoryStore(t)
|
||||
l := New(Options{
|
||||
Memory: store,
|
||||
Extractor: fixedExtractor{items: []memory.MemoryItem{
|
||||
{PersonaID: "persona-b", Scope: memory.ScopePersona, Kind: memory.KindRelationship, Content: "b 的角色日常"},
|
||||
}},
|
||||
PersonaID: "persona-a",
|
||||
})
|
||||
|
||||
ctx := WithSessionEnv(context.Background(), OwnerEnv())
|
||||
l.extractMemory(ctx, []model.Message{model.TextMessage(model.RoleUser, "记住 b 的日常")}, 1)
|
||||
|
||||
items, err := store.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("跨角色的角色作用域写被接受:%d 条(作用域写规则未接线)", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPersistMemories_PersonaAgnosticExtractor_StampedByStore 证明接线后正常路径
|
||||
// 不变:与角色无关的提取器(PersonaID 为空)产出的条目仍由 Store 用本回合角色
|
||||
// (actorID)补齐归属,照常落库。
|
||||
func TestPersistMemories_PersonaAgnosticExtractor_StampedByStore(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newTestMemoryStore(t)
|
||||
l := New(Options{
|
||||
Memory: store,
|
||||
Extractor: fixedExtractor{items: []memory.MemoryItem{
|
||||
{Scope: memory.ScopePersona, Kind: memory.KindRelationship, Content: "主人爱喝冰美式"},
|
||||
}},
|
||||
PersonaID: "persona-a",
|
||||
})
|
||||
|
||||
ctx := WithSessionEnv(context.Background(), OwnerEnv())
|
||||
l.extractMemory(ctx, []model.Message{model.TextMessage(model.RoleUser, "记住 我爱喝冰美式")}, 1)
|
||||
|
||||
items, err := store.ListByPersona(context.Background(), "persona-a")
|
||||
if err != nil {
|
||||
t.Fatalf("ListByPersona: %v", err)
|
||||
}
|
||||
if len(items) != 1 || items[0].PersonaID != "persona-a" {
|
||||
t.Fatalf("角色无关提取器未被 Store 盖章:%+v", items)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user