- 04.4.2/1.9 读侧:Retrieve 候选集并集 = 本人 persona 域 ∪ user 共享域 ∪ project 共享域(他角色 persona 域仍排除),群聊/陌生人取受限收窄
- 写侧:群聊/陌生人整段跳过记忆提取(可查不可写),persona 域仅属主可写
- 04.4.3:source_conversation 不再由 LLM 自由生成(prompt 不索要、候选结构不含该键),落库时无条件盖真实 {session_id,message_id},编解码集中在 memory/source.go
- 新增跨角色种子用例(原用例即使关掉并集也会通过)+ 成对用例「同群聊上下文:读得到共享域、写不进 store」
- 同步 store.go/retrieve.go 文档注释,与 design/03 §3/§4 口径一致
235 lines
8.1 KiB
Go
235 lines
8.1 KiB
Go
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"
|
|
)
|
|
|
|
// LLM extractor 是 design/03 §1 背后具备异步能力的实现:循环
|
|
// 对此能力做类型断言,把提取脱离到后台
|
|
// goroutine,因此该契约在此锁定,而不是在运行时才发现。
|
|
var (
|
|
_ memory.Extractor = (*memory.LLMExtractor)(nil)
|
|
_ asyncExtractor = (*memory.LLMExtractor)(nil)
|
|
_ memory.Extractor = (*memory.ManualExtractor)(nil)
|
|
)
|
|
|
|
// gatedExtractor 是一个同时实现 asyncExtractor 的 memory.Extractor。
|
|
// 它的提取会阻塞直到 release 被关闭(或 ctx 被取消),使测试能
|
|
// 证明循环的 turn 在提取仍在途时就结束,且
|
|
// 提取能在调用方自身的取消下存活。
|
|
type gatedExtractor struct {
|
|
release chan struct{}
|
|
started chan struct{}
|
|
items []memory.MemoryItem
|
|
dispatch int
|
|
}
|
|
|
|
func newGatedExtractor() *gatedExtractor {
|
|
return &gatedExtractor{
|
|
release: make(chan struct{}),
|
|
started: make(chan struct{}, 1),
|
|
items: []memory.MemoryItem{{
|
|
Scope: memory.ScopeUser,
|
|
Kind: memory.KindRelationship,
|
|
Content: "用户习惯:爱喝冰美式",
|
|
Confidence: 0.9,
|
|
DecayWeight: 1,
|
|
}},
|
|
}
|
|
}
|
|
|
|
func (g *gatedExtractor) Extract(ctx context.Context, _ []model.Message) ([]memory.MemoryItem, error) {
|
|
select {
|
|
case g.started <- struct{}{}:
|
|
default:
|
|
}
|
|
select {
|
|
case <-g.release:
|
|
return g.items, nil
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
|
|
func (g *gatedExtractor) ExtractAsync(ctx context.Context, conv []model.Message) (<-chan memory.AsyncResult, error) {
|
|
g.dispatch++
|
|
out := make(chan memory.AsyncResult, 1)
|
|
go func() {
|
|
defer close(out)
|
|
items, err := g.Extract(ctx, conv)
|
|
out <- memory.AsyncResult{Items: items, Err: err}
|
|
}()
|
|
return out, nil
|
|
}
|
|
|
|
// TestExtractMemoryAsync_LLMExtractorThroughLoop 把真实的 LLM extractor
|
|
// (非假实现)接入循环:turn 落定后,extractor 自身的模型
|
|
// 调用在后台运行,其解析出的条目落入 store,因此
|
|
// 生产路径(design/03 §1 LLM 异步自动提取)被端到端演练。
|
|
func TestExtractMemoryAsync_LLMExtractorThroughLoop(t *testing.T) {
|
|
t.Parallel()
|
|
store := newTestMemoryStore(t)
|
|
ad := &mockAdapter{chat: []mgResp{
|
|
{resp: model.Response{Content: "记住了"}},
|
|
{resp: model.Response{Content: `[{"content":"用户爱喝冰美式","scope":"user","kind":"relationship","confidence":0.92}]`}},
|
|
}}
|
|
a := newTestAgent(t, ad, nil)
|
|
l := New(Options{
|
|
Agent: a, Memory: store, PersonaID: "persona-a", MaxRounds: 8,
|
|
Extractor: memory.NewLLMExtractor(ad, "persona-a", "owner"),
|
|
})
|
|
|
|
if _, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "我喜欢冰美式")}); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
waitForMemoryCount(t, store, "persona-a", 1)
|
|
|
|
items, err := store.ListByPersona(context.Background(), "persona-a")
|
|
if err != nil {
|
|
t.Fatalf("ListByPersona: %v", err)
|
|
}
|
|
if items[0].Content != "用户爱喝冰美式" || items[0].Scope != memory.ScopeUser {
|
|
t.Fatalf("stored item = %+v, want the extracted user-scope memory", items[0])
|
|
}
|
|
if items[0].PersonaID != "persona-a" {
|
|
t.Fatalf("item persona = %q, want persona-a", items[0].PersonaID)
|
|
}
|
|
// 裁决 04.4.3:真实 LLM 提取路径的溯源由持久化层盖 —— Run 的
|
|
// 会话 id 结构化落库(该测试未挂 turn id,故 message_id 为空)。
|
|
ref, ok := memory.ParseSourceConversation(items[0].SourceConversation)
|
|
if !ok || ref.SessionID != "sess" || ref.MessageID != "" {
|
|
t.Fatalf("source = %q → %+v(ok=%v), want structured session sess / empty message", items[0].SourceConversation, ref, ok)
|
|
}
|
|
|
|
extractCalls := 0
|
|
for _, req := range ad.reqs {
|
|
if req.Purpose == "memory_extract" {
|
|
extractCalls++
|
|
}
|
|
}
|
|
if extractCalls != 1 {
|
|
t.Fatalf("memory_extract model calls = %d, want 1 (the LLM extractor drove the background call)", extractCalls)
|
|
}
|
|
}
|
|
|
|
// waitForMemoryCount 轮询该角色已存的记忆直到达到 want,
|
|
// 提取始终未落定时让测试失败。
|
|
func waitForMemoryCount(t *testing.T, store *memory.Store, personaID string, want int) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for {
|
|
items, err := store.ListByPersona(context.Background(), personaID)
|
|
if err != nil {
|
|
t.Fatalf("ListByPersona: %v", err)
|
|
}
|
|
if len(items) == want {
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("stored memories = %d, want %d (extraction never landed)", len(items), want)
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// TestExtractMemoryAsync_DoesNotBlockTurn 证明 turn 在 LLM
|
|
// 提取仍在运行时返回,且提取在调用方的 context 已被取消之后
|
|
// 仍能完成(并持久化)—— 脱离的 context
|
|
// 使请求作用域的取消无法中止后台提取。
|
|
func TestExtractMemoryAsync_DoesNotBlockTurn(t *testing.T) {
|
|
t.Parallel()
|
|
store := newTestMemoryStore(t)
|
|
ext := newGatedExtractor()
|
|
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "记住了"}}}}
|
|
a := newTestAgent(t, ad, nil)
|
|
l := New(Options{Agent: a, Memory: store, Extractor: ext, PersonaID: "persona-a", MaxRounds: 8})
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
_, err := l.Run(ctx, "sess", []model.Message{model.TextMessage(model.RoleUser, "记住 我爱喝冰美式")})
|
|
done <- err
|
|
}()
|
|
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Run blocked on memory extraction: the async path must not gate the reply")
|
|
}
|
|
|
|
if ext.dispatch != 1 {
|
|
t.Fatalf("async dispatch count = %d, want 1 (one extraction per settled turn)", ext.dispatch)
|
|
}
|
|
items, err := store.ListByPersona(context.Background(), "persona-a")
|
|
if err != nil {
|
|
t.Fatalf("ListByPersona: %v", err)
|
|
}
|
|
if len(items) != 0 {
|
|
t.Fatalf("store already holds %d items after Run, want 0 (extraction still in flight)", len(items))
|
|
}
|
|
|
|
// 该 turn 已结束,因此其 context 被取消;后台提取
|
|
// 仍必须完成并持久化。
|
|
cancel()
|
|
select {
|
|
case <-ext.started:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("extraction goroutine never started")
|
|
}
|
|
close(ext.release)
|
|
waitForMemoryCount(t, store, "persona-a", 1)
|
|
}
|
|
|
|
// TestExtractMemoryAsync_RestrictedEnvNeverDispatches 证明群聊 /
|
|
// 陌生人隔离对异步路径同样设门:什么也不派发,因此从只读
|
|
// 对话绝无法写入主人私有记忆。
|
|
func TestExtractMemoryAsync_RestrictedEnvNeverDispatches(t *testing.T) {
|
|
t.Parallel()
|
|
store := newTestMemoryStore(t)
|
|
ext := newGatedExtractor()
|
|
l := New(Options{Memory: store, Extractor: ext, PersonaID: "persona-a"})
|
|
|
|
ctx := WithSessionEnv(context.Background(), SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false})
|
|
l.extractMemoryAsync(ctx, []model.Message{model.TextMessage(model.RoleUser, "记住 我爱喝冰美式")}, 1)
|
|
|
|
if ext.dispatch != 0 {
|
|
t.Fatalf("async dispatch count = %d, want 0 (restricted context is read-only)", ext.dispatch)
|
|
}
|
|
items, err := store.ListByPersona(context.Background(), "persona-a")
|
|
if err != nil {
|
|
t.Fatalf("ListByPersona: %v", err)
|
|
}
|
|
if len(items) != 0 {
|
|
t.Fatalf("restricted async extract wrote %d items, want 0 (read-only isolation)", len(items))
|
|
}
|
|
}
|
|
|
|
// TestExtractMemoryAsync_ManualExtractorStaysSync 证明确定性路径
|
|
// 未变:不具备异步能力的 extractor 仍就地运行,因此
|
|
// 显式的 "记住 X" 记忆在该调用返回时已存入。
|
|
func TestExtractMemoryAsync_ManualExtractorStaysSync(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.extractMemoryAsync(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 {
|
|
t.Fatalf("stored memories = %d, want 1 (deterministic extractor runs inline)", len(items))
|
|
}
|
|
}
|