主人 2026-10-03 拍板「注释一律中文」;本模块按长期分批协议(一个模块一次派工)执行。 注释行数 1329 → 1329 逐行 1:1(含中文 33 → 1304);行尾注释 5 条一并译。 校验:机械校验(非注释改动行)= 空;独立 Go AST 程序(剥注释复渲)证明 44/44 文件代码全等; 全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。 gofmt:capacity_loop_test.go / flow.go 改动前即未格式化,按铁律保留原样未动。
229 lines
7.7 KiB
Go
229 lines
7.7 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)
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|