群聊/陌生人查记忆只读不写、不泄露主人敏感;owner 默认行为完全不变。 - loop/sessionenv.go:SessionEnv 类型 + WithSessionEnv/sessionEnvFrom/OwnerEnv——每次 Run 携带会话 隔离上下文,经 context.Context 传递(不改 Run 签名、不存 Loop/Options 字段,避免 16 并发渠道 消息共享 Loop 的数据竞争;Chatter 接口与测试桩签名不变)。 - loop/memory.go 门控:injectMemory 读——MemWritable=true(owner)走原 Retriever.Inject 全量不变; false(群聊/陌生人)改 Retrieve 后只留 ScopeProject(人人可读)、剔除 ScopeUser(owner习惯私有)与 ScopePersona(角色日常=owner敏感),块首加「群聊隔离」标记,无项目记忆不注入;extractMemory 写 false 直接 return 跳过(不调用 extractor 不落库)。 - 渠道入站(Channelbridge)传真实会话 MemWritable/Env;Web/REST/WS/voice chat 绑 OwnerEnv(JWT=owner 行为不变);backthink/automations/orchestration 默认 owner 全量。 - 测试:memory_gating_test 6 用例(owner/群聊只读不写/陌生人受限/无项目记忆不注入/默认owner全量)全绿; loop/session/gateway 既有测试无回归。go build/vet/test 47 包全绿。 遗留:ScopeProject 过滤在 loop 内后处理实现(未改 memory 包),若后续允许可在 Retriever 加 scope 过滤选项消除与 Inject 模板的重复(2处)。
154 lines
6.2 KiB
Go
154 lines
6.2 KiB
Go
package loop
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"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"
|
|
)
|
|
|
|
// seedMemories fills the store with one memory per scope so a test can assert the
|
|
// scope filtering of the memory gate.
|
|
func seedMemories(t *testing.T, store *memory.Store) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
seeds := []memory.MemoryItem{
|
|
{PersonaID: "persona-a", Content: "用户习惯:喜欢冰美式", Scope: memory.ScopeUser, Kind: memory.KindRelationship},
|
|
{PersonaID: "persona-a", Content: "角色日常:周末喜欢爬山", Scope: memory.ScopePersona, Kind: memory.KindRelationship},
|
|
{PersonaID: "persona-a", Content: "项目记忆:Cyrene 用 Go 编写", Scope: memory.ScopeProject, Kind: memory.KindWorldEvent},
|
|
}
|
|
for _, it := range seeds {
|
|
if _, err := store.Add(ctx, it); err != nil {
|
|
t.Fatalf("seed memory: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestInjectMemory_OwnerEnv_InjectsAllScopes proves the owner-full env keeps the
|
|
// pre-existing full recall: user, persona and project memories are all injected.
|
|
func TestInjectMemory_OwnerEnv_InjectsAllScopes(t *testing.T) {
|
|
t.Parallel()
|
|
store := newTestMemoryStore(t)
|
|
seedMemories(t, store)
|
|
l := New(Options{Retriever: memory.NewRetriever(store), PersonaID: "persona-a"})
|
|
|
|
msgs := []model.Message{model.TextMessage(model.RoleUser, "go")}
|
|
ctx := WithSessionEnv(context.Background(), OwnerEnv())
|
|
view := l.assembleContext(ctx, msgs, 1)
|
|
if len(view) != 2 {
|
|
t.Fatalf("owner view = %d messages, want 2 (memory injected)", len(view))
|
|
}
|
|
block := messageText(view[0])
|
|
for _, want := range []string{"喜欢冰美式", "喜欢爬山", "Cyrene 用 Go 编写", "相关记忆"} {
|
|
if !strings.Contains(block, want) {
|
|
t.Fatalf("owner block missing %q:\n%s", want, block)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestInjectMemory_RestrictedEnv_OnlyProject proves the group-chat/stranger env is
|
|
// read-only isolation: only the non-owner-private project memory is injected, the
|
|
// owner's user/persona memory is excluded, and the block is marked 群聊隔离.
|
|
func TestInjectMemory_RestrictedEnv_OnlyProject(t *testing.T) {
|
|
t.Parallel()
|
|
store := newTestMemoryStore(t)
|
|
seedMemories(t, store)
|
|
l := New(Options{Retriever: memory.NewRetriever(store), PersonaID: "persona-a"})
|
|
|
|
msgs := []model.Message{model.TextMessage(model.RoleUser, "go")}
|
|
ctx := WithSessionEnv(context.Background(), SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false})
|
|
view := l.assembleContext(ctx, msgs, 1)
|
|
if len(view) != 2 {
|
|
t.Fatalf("restricted view = %d messages, want 2 (project memory injected)", len(view))
|
|
}
|
|
block := messageText(view[0])
|
|
if !strings.Contains(block, "群聊隔离") || !strings.Contains(block, "Cyrene 用 Go 编写") {
|
|
t.Fatalf("restricted block missing project memory / marker:\n%s", block)
|
|
}
|
|
for _, banned := range []string{"喜欢冰美式", "喜欢爬山"} {
|
|
if strings.Contains(block, banned) {
|
|
t.Fatalf("restricted block leaked owner memory %q:\n%s", banned, block)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestInjectMemory_Restricted_NoProjectMemory_NoInject proves that when a group
|
|
// or stranger context has no non-owner-private (project) memory, nothing is
|
|
// injected — the conversation is not seeded with owner context.
|
|
func TestInjectMemory_Restricted_NoProjectMemory_NoInject(t *testing.T) {
|
|
t.Parallel()
|
|
store := newTestMemoryStore(t)
|
|
// Only owner-private (user/persona) memory exists.
|
|
for _, it := range []memory.MemoryItem{
|
|
{PersonaID: "persona-a", Content: "用户习惯:喜欢冰美式", Scope: memory.ScopeUser, Kind: memory.KindRelationship},
|
|
} {
|
|
if _, err := store.Add(context.Background(), it); err != nil {
|
|
t.Fatalf("seed memory: %v", err)
|
|
}
|
|
}
|
|
l := New(Options{Retriever: memory.NewRetriever(store), PersonaID: "persona-a"})
|
|
|
|
msgs := []model.Message{model.TextMessage(model.RoleUser, "go")}
|
|
ctx := WithSessionEnv(context.Background(), SessionEnv{ActorEnv: restrict.ActorStranger, MemWritable: false})
|
|
view := l.assembleContext(ctx, msgs, 1)
|
|
if len(view) != 1 {
|
|
t.Fatalf("restricted (no project) view = %d messages, want 1 (no injected memory)", len(view))
|
|
}
|
|
}
|
|
|
|
// TestExtractMemory_OwnerEnv_Writes proves the owner-full env persists extracted
|
|
// memory as before.
|
|
func TestExtractMemory_OwnerEnv_Writes(t *testing.T) {
|
|
t.Parallel()
|
|
store := newTestMemoryStore(t)
|
|
l := New(Options{Memory: store, Extractor: memory.NewManualExtractor("persona-a", "owner"), 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("list: %v", err)
|
|
}
|
|
if len(items) != 1 {
|
|
t.Fatalf("owner extract persisted %d items, want 1", len(items))
|
|
}
|
|
}
|
|
|
|
// TestExtractMemory_RestrictedEnv_SkipsWrite proves the group-chat/stranger env
|
|
// never writes shared memory: extraction is skipped, the store stays empty.
|
|
func TestExtractMemory_RestrictedEnv_SkipsWrite(t *testing.T) {
|
|
t.Parallel()
|
|
store := newTestMemoryStore(t)
|
|
l := New(Options{Memory: store, Extractor: memory.NewManualExtractor("persona-a", "owner"), PersonaID: "persona-a"})
|
|
|
|
ctx := WithSessionEnv(context.Background(), SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false})
|
|
l.extractMemory(ctx, []model.Message{model.TextMessage(model.RoleUser, "记住 我爱喝冰美式")}, 1)
|
|
|
|
items, err := store.ListByPersona(context.Background(), "persona-a")
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if len(items) != 0 {
|
|
t.Fatalf("restricted extract wrote %d items, want 0 (read-only isolation)", len(items))
|
|
}
|
|
}
|
|
|
|
// TestSessionEnvFrom_DefaultOwnerFull locks the backward-compatible default: a
|
|
// context with no attached env resolves to the owner-full environment, so callers
|
|
// that do not set an env (Web main session, internal automated tasks) keep full
|
|
// memory read+write.
|
|
func TestSessionEnvFrom_DefaultOwnerFull(t *testing.T) {
|
|
t.Parallel()
|
|
env := sessionEnvFrom(context.Background())
|
|
if env.MemWritable != true || env.Restricted() {
|
|
t.Fatalf("default env = %+v, want owner-full (MemWritable=true, not restricted)", env)
|
|
}
|
|
if env.ActorEnv != restrict.ActorOwner {
|
|
t.Fatalf("default env actor = %q, want %q", env.ActorEnv, restrict.ActorOwner)
|
|
}
|
|
}
|