package io import ( "context" "errors" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/db" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" ) // stubRetriever is a fake KnowledgeRetriever for testing knowledge responses. // 它同时记下每次检索收到的角色,使测试能断言「角色作用域到底是谁」。 type stubRetriever struct { results []memory.Result err error personas []string } func (s *stubRetriever) Retrieve(ctx context.Context, personaID, query string, topK int) ([]memory.Result, error) { _ = ctx s.personas = append(s.personas, personaID) return s.results, s.err } // personaScopedRetriever 按**检索角色**返回不同的命中集:以 personaID 检索时才会 // 拿到该角色的角色日常记忆。它使「越权」断言可检验 —— 只要检索器收到了 B,B 的 // 记忆就一定出现在结果里;因此「B 的内容一条都不许出现」等价于「不许以 B 检索」。 type personaScopedRetriever struct { byPersona map[string][]memory.Result personas []string } func (s *personaScopedRetriever) Retrieve(_ context.Context, personaID, query string, topK int) ([]memory.Result, error) { s.personas = append(s.personas, personaID) return s.byPersona[personaID], nil } // personaScopedResults 给 persona-scoped 替身造一份固定命中:每个角色各有一条 // 只有它自己看得到的角色日常记忆(scope=persona)。 func personaScopedResults(persona, content string) []memory.Result { return []memory.Result{{ Item: memory.MemoryItem{ID: persona + "1", Content: content, Kind: memory.KindRelationship, Scope: memory.ScopePersona}, Score: 0.9, }} } // knowledgeContentsOrEmpty 尽力从工具返回里抽出 content 集合;返回不是结果对象 // (例如越权被拒时 got 为 nil)时给空集,使「不得出现某条内容」的断言在成功与 // 失败两条路径上都能成立。 func knowledgeContentsOrEmpty(got any) map[string]bool { out := map[string]bool{} m, ok := got.(map[string]any) if !ok { return out } results, ok := m["results"].([]map[string]any) if !ok { return out } for _, r := range results { if s, ok := r["content"].(string); ok { out[s] = true } } return out } // knowledgeToolForTest builds a tools set with opts and returns the knowledge // tool. func knowledgeToolForTest(t *testing.T, opts ...Option) *knowledgeTool { t.Helper() return findTool(t, buildTools(t, &config.Config{}, opts...), "knowledge").(*knowledgeTool) } // TestKnowledge_EmptyPersonaWallsInTheStore 是切片 KB1 根因①的证据型测试: // 把**空角色**透给记忆检索器时,存储会以 ErrMissingPersona 硬失败(不是「查不到 // 命中」)。修复前 knowledge 工具在模型省略可选参数 persona 时正是这么做的 —— // 于是模型拿到一条它无从纠正的错误(它不知道该填哪个角色 id),只能对着同一堵墙 // 反复重试(安卓侧实测:连续两次「工具调用失败 · knowledge」)。 func TestKnowledge_EmptyPersonaWallsInTheStore(t *testing.T) { t.Parallel() ctx := context.Background() conn, err := db.Open(ctx, ":memory:", silentLogger()) if err != nil { t.Fatalf("db.Open: %v", err) } t.Cleanup(func() { _ = conn.Close() }) store, err := memory.NewStore(ctx, conn, silentLogger()) if err != nil { t.Fatalf("memory.NewStore: %v", err) } retriever := memory.NewRetriever(store) if _, err := retriever.Retrieve(ctx, "", "W15-key-test", 3); !errors.Is(err, memory.ErrMissingPersona) { t.Fatalf("空角色检索的错误 = %v, want memory.ErrMissingPersona", err) } if _, err := retriever.Retrieve(ctx, "cyrene", "W15-key-test", 3); err != nil { t.Fatalf("有角色时不应失败: %v", err) } } // TestKnowledge_MissingPersonaIsDigestibleRefusal 锁定根因①的修复:角色既不在参数 // 里、也不在本回合 ctx 上时,工具返回一条**可消化的拒绝** —— 写明不可用并要求改道, // 而不是把底层的 missing persona 透出去(那正是「连撞两次同墙」的成因)。 func TestKnowledge_MissingPersonaIsDigestibleRefusal(t *testing.T) { t.Parallel() sr := &stubRetriever{} kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr)) _, err := kt.Execute(context.Background(), map[string]any{"query": "W15-key-test"}) if err == nil { t.Fatal("没有角色时应当明确拒绝,而不是静默检索") } if !errors.Is(err, ErrKnowledgeUnavailable) { t.Fatalf("err = %v, want ErrKnowledgeUnavailable(调用方要能以 errors.Is 分类)", err) } msg := err.Error() if !strings.Contains(msg, "不要重复调用本工具") { t.Fatalf("拒绝文案没有要求改道,模型会继续撞墙: %q", msg) } if strings.Contains(msg, "missing persona") { t.Fatalf("拒绝文案把底层不可纠正的错误直接透给了模型: %q", msg) } if len(sr.personas) != 0 { t.Fatalf("拒绝的调用不该触达检索器, got %v", sr.personas) } } // TestKnowledge_UsesTurnPersonaFromContext 锁定运行时路径:模型省略 persona 参数时, // 工具取循环发布在 ctx 上的**本回合角色**(tool.WithTurnPersona,见 // internal/runtime/tool/turncontext.go + loop 的发布点)。 func TestKnowledge_UsesTurnPersonaFromContext(t *testing.T) { t.Parallel() sr := &stubRetriever{} kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr)) ctx := tool.WithTurnPersona(context.Background(), "persona-turn") if _, err := kt.Execute(ctx, map[string]any{"query": "回忆"}); err != nil { t.Fatalf("Execute: %v", err) } if len(sr.personas) != 1 || sr.personas[0] != "persona-turn" { t.Fatalf("检索角色 = %v, want [persona-turn]", sr.personas) } } // TestKnowledge_RejectsPersonaArgForAnotherRole 是跨角色检索(design/03 §7.1 判据 + // §7.6 R4)的证据型测试:本回合角色是 A,模型(或提示词注入)在参数里指名 B 时, // **绝不许**以 B 的身份去检索 —— 必须明确拒绝,且结果里不得出现任何只有 B 看得到 // 的角色日常记忆。 // // 旧用例 TestKnowledge_ExplicitPersonaWins 断言的正是缺陷行为本身(「显式参数优先, // 检索角色 = [persona-arg]」)—— 它把「模型点名即换角色检索」钉成了规格,本次缺陷 // 修复把它替换为本用例。 func TestKnowledge_RejectsPersonaArgForAnotherRole(t *testing.T) { t.Parallel() const ( personaA = "persona-a" personaB = "persona-b" contentB = "角色日常:B 深夜写歌" ) sr := &personaScopedRetriever{byPersona: map[string][]memory.Result{ personaA: personaScopedResults(personaA, "角色日常:A 周末喜欢爬山"), personaB: personaScopedResults(personaB, contentB), }} kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr)) ctx := tool.WithTurnPersona(context.Background(), personaA) got, err := kt.Execute(ctx, map[string]any{"query": "回忆", "persona": personaB}) if err == nil { t.Fatalf("以他角色的身份检索必须被拒绝,got %+v", got) } if !errors.Is(err, ErrKnowledgeUnavailable) { t.Fatalf("err = %v, want ErrKnowledgeUnavailable(调用方要能以 errors.Is 分类)", err) } if len(sr.personas) != 0 { t.Fatalf("被拒的调用绝不该触达检索器(否则 B 的记忆已被读出): %v", sr.personas) } // 硬断言:任何一条来自 persona=B 的内容都不许出现(成功路径也不许)。 if contents := knowledgeContentsOrEmpty(got); contents[contentB] { t.Fatalf("越权检索泄露了 persona=B 的记忆: %v", contents) } msg := err.Error() for _, want := range []string{personaA, personaB, "不能以其他角色的身份检索", "去掉 persona 参数", "不要以其他角色重复调用本工具"} { if !strings.Contains(msg, want) { t.Fatalf("拒绝文案缺少 %q(可消化的拒绝要写明原因 + 改道动作): %q", want, msg) } } } // TestKnowledge_RejectsPersonaArgWhenTurnHasNoPersona 钉住另一半:本回合 ctx 上 // **没有**角色时,参数里指名一个角色同样被拒绝(旧行为是以该参数当角色去检索, // 即「凭空颁一个身份」,同样是越权面)。 func TestKnowledge_RejectsPersonaArgWhenTurnHasNoPersona(t *testing.T) { t.Parallel() sr := &personaScopedRetriever{byPersona: map[string][]memory.Result{ "persona-b": personaScopedResults("persona-b", "角色日常:B 深夜写歌"), }} kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr)) got, err := kt.Execute(context.Background(), map[string]any{"query": "回忆", "persona": "persona-b"}) if err == nil { t.Fatalf("本回合没有角色时不许以参数里的角色检索,got %+v", got) } if !errors.Is(err, ErrKnowledgeUnavailable) { t.Fatalf("err = %v, want ErrKnowledgeUnavailable", err) } if len(sr.personas) != 0 { t.Fatalf("被拒的调用不该触达检索器: %v", sr.personas) } if !strings.Contains(err.Error(), "本回合没有解析出可用角色") { t.Fatalf("拒绝文案没有说明原因: %q", err.Error()) } } // TestKnowledge_AcceptsPersonaArgMatchingTurn 钉住形状兼容:参数里的角色**等于** // 本回合角色时照常检索(旧调用形状不被破坏),且检索角色始终是本回合角色。 func TestKnowledge_AcceptsPersonaArgMatchingTurn(t *testing.T) { t.Parallel() const personaA = "persona-a" for _, args := range []map[string]any{ {"query": "回忆", "persona": personaA}, {"query": "回忆", "persona": " " + personaA + " "}, // 前后空白等价 {"query": "回忆"}, // 缺席 } { sr := &stubRetriever{} kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr)) ctx := tool.WithTurnPersona(context.Background(), personaA) if _, err := kt.Execute(ctx, args); err != nil { t.Fatalf("args=%v Execute: %v", args, err) } if len(sr.personas) != 1 || sr.personas[0] != personaA { t.Fatalf("args=%v 检索角色 = %v, want [%s]", args, sr.personas, personaA) } } } // TestKnowledge_SubagentTurnReadsThatRole 钉住设计要保留的**唯一跨角色通路** // (design/03 §7.2「B 作子代理查 B 的记忆」):子代理回合的角色由编排层盖章 // (orchestration withPersonaEnv → loop personaFor → WithTurnPersona),此时 ctx // 里的本回合角色本来就是 B,因此去掉参数优先语义**不破坏**这条通路。 func TestKnowledge_SubagentTurnReadsThatRole(t *testing.T) { t.Parallel() const ( personaB = "persona-b" contentB = "角色日常:B 深夜写歌" ) for _, args := range []map[string]any{ {"query": "最近在忙什么"}, // 省略 persona {"query": "最近在忙什么", "persona": personaB}, // 形状兼容:与本回合角色一致 } { sr := &personaScopedRetriever{byPersona: map[string][]memory.Result{ personaB: personaScopedResults(personaB, contentB), }} kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr)) ctx := tool.WithTurnPersona(context.Background(), personaB) got, err := kt.Execute(ctx, args) if err != nil { t.Fatalf("args=%v 子代理通路不该被拒: %v", args, err) } if len(sr.personas) != 1 || sr.personas[0] != personaB { t.Fatalf("args=%v 检索角色 = %v, want [%s]", args, sr.personas, personaB) } if !knowledgeContentsOrEmpty(got)[contentB] { t.Fatalf("args=%v 子代理查不到 B 自己的角色日常: %+v", args, got) } } } // TestKnowledge_MalformedPersonaArgIsAbsent 钉住畸形输入落到「缺席」分支且不 panic: // 空串 / 纯空白 / 非字符串(数字、nil、布尔)都不是「点名了别的角色」。 func TestKnowledge_MalformedPersonaArgIsAbsent(t *testing.T) { t.Parallel() const personaA = "persona-a" for _, persona := range []any{"", " ", 42, nil, true, map[string]any{}, []any{"persona-b"}} { sr := &stubRetriever{} kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr)) ctx := tool.WithTurnPersona(context.Background(), personaA) if _, err := kt.Execute(ctx, map[string]any{"query": "回忆", "persona": persona}); err != nil { t.Fatalf("persona=%#v 应当按缺席处理: %v", persona, err) } if len(sr.personas) != 1 || sr.personas[0] != personaA { t.Fatalf("persona=%#v 检索角色 = %v, want [%s]", persona, sr.personas, personaA) } } // 没有任何角色可解析时,畸形参数同样落「缺席」分支 ⇒ 可消化的拒绝,而不是 panic。 kt := knowledgeToolForTest(t, WithKnowledgeRetriever(&stubRetriever{})) for _, persona := range []any{"", " ", 42, nil} { _, err := kt.Execute(context.Background(), map[string]any{"query": "回忆", "persona": persona}) if !errors.Is(err, ErrKnowledgeUnavailable) { t.Fatalf("persona=%#v err = %v, want ErrKnowledgeUnavailable", persona, err) } } } func TestKnowledge_NoKnowledgeBase(t *testing.T) { t.Parallel() kt := knowledgeToolForTest(t) // no retriever wired _, err := kt.Execute(context.Background(), map[string]any{"query": "cyrene"}) if !errors.Is(err, ErrKnowledgeUnavailable) { t.Fatalf("expected ErrKnowledgeUnavailable, got %v", err) } if !strings.Contains(err.Error(), "没有配置知识库") { t.Fatalf("拒绝文案没有说明原因: %v", err) } } func TestKnowledge_ReturnsResults(t *testing.T) { t.Parallel() sr := &stubRetriever{results: []memory.Result{ {Item: memory.MemoryItem{ID: "1", Content: "Cyrene 是主人", Kind: memory.KindWorldEvent, Scope: memory.ScopeProject}, Score: 0.9}, }} kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr)) got, err := kt.Execute(tool.WithTurnPersona(context.Background(), "p1"), map[string]any{"query": "cyrene", "top_k": 5}) if err != nil { t.Fatalf("Execute: %v", err) } m := got.(map[string]any) if m["count"].(int) != 1 { t.Fatalf("count = %v", m["count"]) } results := m["results"].([]map[string]any) if results[0]["content"] != "Cyrene 是主人" { t.Fatalf("content = %v", results[0]["content"]) } if results[0]["kind"] != "world_event" { t.Fatalf("kind = %v", results[0]["kind"]) } } func TestKnowledge_EmptyResults(t *testing.T) { t.Parallel() kt := knowledgeToolForTest(t, WithKnowledgeRetriever(&stubRetriever{})) ctx := tool.WithTurnPersona(context.Background(), "p1") got, err := kt.Execute(ctx, map[string]any{"query": "nothing"}) if err != nil { t.Fatalf("Execute: %v", err) } m := got.(map[string]any) if m["count"].(int) != 0 { t.Fatalf("count = %v", m["count"]) } if m["note"] != "no relevant knowledge found" { t.Fatalf("note = %v", m["note"]) } if _, ok := m["results"].([]map[string]any); !ok { t.Fatalf("results absent or wrong type: %T", m["results"]) } } func TestKnowledge_RetrieverError(t *testing.T) { t.Parallel() kt := knowledgeToolForTest(t, WithKnowledgeRetriever(&stubRetriever{err: errors.New("boom")})) ctx := tool.WithTurnPersona(context.Background(), "p1") _, err := kt.Execute(ctx, map[string]any{"query": "x"}) if err == nil || !strings.Contains(err.Error(), "retrieve") { t.Fatalf("expected retrieve error, got %v", err) } } func TestKnowledge_MissingQuery(t *testing.T) { t.Parallel() kt := knowledgeToolForTest(t, WithKnowledgeRetriever(&stubRetriever{})) _, err := kt.Execute(context.Background(), map[string]any{}) if err == nil || !strings.Contains(err.Error(), "query is required") { t.Fatalf("expected query-required error, got %v", err) } } // restrictedKnowledgeResults 是群聊/陌生人场景的固定命中集:共享域非敏感项 // (用户习惯 user + 项目 project)与两类敏感项(角色日常 persona + L4 意图记录)。 func restrictedKnowledgeResults() []memory.Result { return []memory.Result{ {Item: memory.MemoryItem{ID: "u", Content: "用户习惯:喜欢冰美式", Kind: memory.KindRelationship, Scope: memory.ScopeUser}, Score: 0.9}, {Item: memory.MemoryItem{ID: "p", Content: "项目记忆:Cyrene 用 Go 编写", Kind: memory.KindWorldEvent, Scope: memory.ScopeProject}, Score: 0.8}, {Item: memory.MemoryItem{ID: "s", Content: "角色日常:周末喜欢爬山", Kind: memory.KindRelationship, Scope: memory.ScopePersona}, Score: 0.7}, {Item: memory.MemoryItem{ID: "i", Content: "intent:isTask=true;taskKind=task;confidence=0.9;text=帮我订一杯冰美式", Kind: memory.KindRelationship, Scope: memory.ScopeUser}, Score: 0.6}, } } // knowledgeContents 把工具返回的 results 抽成 content 集合,便于断言泄露/保留。 func knowledgeContents(t *testing.T, got any) map[string]bool { t.Helper() results := got.(map[string]any)["results"].([]map[string]any) out := make(map[string]bool, len(results)) for _, r := range results { out[r["content"].(string)] = true } return out } // TestKnowledge_RestrictedEnv_OnlySharedVisible 是审计 2026-10-05 高风险项的证据型 // 测试:群聊/陌生人(受限环境)调用 knowledge 时,只返回非敏感共享域记忆;角色日常 // (ScopePersona,主人私有)与 L4 意图记录(主人逐句原话)绝不返回 —— 复用循环注入 // 路径的同一隔离判据(loop.FilterSharedVisible),不另造第二套。 func TestKnowledge_RestrictedEnv_OnlySharedVisible(t *testing.T) { t.Parallel() kt := knowledgeToolForTest(t, WithKnowledgeRetriever(&stubRetriever{results: restrictedKnowledgeResults()})) ctx := loop.WithSessionEnv( tool.WithTurnPersona(context.Background(), "persona-a"), loop.SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false}, ) got, err := kt.Execute(ctx, map[string]any{"query": "回忆"}) if err != nil { t.Fatalf("Execute: %v", err) } if n := got.(map[string]any)["count"].(int); n != 2 { t.Fatalf("受限环境 count = %d, want 2(只留共享域非敏感项)", n) } contents := knowledgeContents(t, got) for _, want := range []string{"用户习惯:喜欢冰美式", "项目记忆:Cyrene 用 Go 编写"} { if !contents[want] { t.Fatalf("受限环境漏了可查的共享域记忆 %q: %v", want, contents) } } for _, leaked := range []string{"角色日常:周末喜欢爬山", "intent:isTask=true;taskKind=task;confidence=0.9;text=帮我订一杯冰美式"} { if contents[leaked] { t.Fatalf("受限环境泄露了敏感记忆 %q: %v", leaked, contents) } } } // TestKnowledge_RestrictedEnv_PersonaArgDoesNotWidenScope 钉住受限环境的**双层**防护 // 在有 persona 参数时不塌陷:本回合角色 = A 的群聊/陌生人回合里, // ① 参数点名 B ⇒ 越权拒绝(换了身份也不行); // ② 参数等于 A(形状兼容)⇒ 照常检索,但返回仍被 loop.FilterSharedVisible 收窄 —— // 角色日常与 L4 意图记录一条都不许出现(design/03 §5/§7.6 R7)。 func TestKnowledge_RestrictedEnv_PersonaArgDoesNotWidenScope(t *testing.T) { t.Parallel() t.Run("他角色被拒", func(t *testing.T) { t.Parallel() sr := &personaScopedRetriever{byPersona: map[string][]memory.Result{ "persona-b": personaScopedResults("persona-b", "角色日常:B 深夜写歌"), }} kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr)) ctx := loop.WithSessionEnv( tool.WithTurnPersona(context.Background(), "persona-a"), loop.SessionEnv{ActorEnv: restrict.ActorStranger, MemWritable: false}, ) got, err := kt.Execute(ctx, map[string]any{"query": "回忆", "persona": "persona-b"}) if !errors.Is(err, ErrKnowledgeUnavailable) { t.Fatalf("受限环境里的跨角色尝试必须被拒,got err=%v got=%+v", err, got) } if len(sr.personas) != 0 { t.Fatalf("被拒的调用不该触达检索器: %v", sr.personas) } }) t.Run("同角色仍过滤", func(t *testing.T) { t.Parallel() kt := knowledgeToolForTest(t, WithKnowledgeRetriever(&stubRetriever{results: restrictedKnowledgeResults()})) ctx := loop.WithSessionEnv( tool.WithTurnPersona(context.Background(), "persona-a"), loop.SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false}, ) got, err := kt.Execute(ctx, map[string]any{"query": "回忆", "persona": "persona-a"}) if err != nil { t.Fatalf("Execute: %v", err) } if n := got.(map[string]any)["count"].(int); n != 2 { t.Fatalf("受限环境 count = %d, want 2(只留共享域非敏感项)", n) } contents := knowledgeContents(t, got) for _, leaked := range []string{"角色日常:周末喜欢爬山", "intent:isTask=true;taskKind=task;confidence=0.9;text=帮我订一杯冰美式"} { if contents[leaked] { t.Fatalf("受限环境泄露了敏感记忆 %q: %v", leaked, contents) } } }) } // TestKnowledge_SurfacesLinkedProject 钉住审计 2026-10-05 §4:knowledge 工具此前 // 丢掉了 Result.Project,模型因此够不着「项目索引 → 项目描述」层级。修复后项目 // 作用域命中必须带出链接的项目条目(id/title/desc),非项目命中则不带该键。 func TestKnowledge_SurfacesLinkedProject(t *testing.T) { t.Parallel() sr := &stubRetriever{results: []memory.Result{ { Item: memory.MemoryItem{ID: "m1", Content: "项目记忆:Cyrene 用 Go 编写", Kind: memory.KindWorldEvent, Scope: memory.ScopeProject}, Score: 0.7, Project: &projectindex.Project{ID: "proj.1", Title: "Cyrene", Desc: "陪伴型 AI 助手"}, }, { Item: memory.MemoryItem{ID: "m2", Content: "用户习惯:喜欢冰美式", Kind: memory.KindRelationship, Scope: memory.ScopeUser}, Score: 0.4, }, }} kt := knowledgeToolForTest(t, WithKnowledgeRetriever(sr)) ctx := loop.WithSessionEnv(tool.WithTurnPersona(context.Background(), "persona-a"), loop.OwnerEnv()) got, err := kt.Execute(ctx, map[string]any{"query": "Cyrene"}) if err != nil { t.Fatalf("Execute: %v", err) } results := got.(map[string]any)["results"].([]map[string]any) proj, ok := results[0]["project"].(map[string]any) if !ok { t.Fatalf("项目作用域命中未带出链接项目:%+v", results[0]) } if proj["id"] != "proj.1" || proj["title"] != "Cyrene" || proj["desc"] != "陪伴型 AI 助手" { t.Fatalf("project = %+v, want proj.1/Cyrene/陪伴型 AI 助手", proj) } if _, present := results[1]["project"]; present { t.Fatalf("非项目命中不该带 project 键:%+v", results[1]) } } // TestKnowledge_OwnerEnv_AllScopes 钉住反向边界:owner(全权限)环境不做隔离过滤, // 四类命中都照常返回 —— 修复只收窄群聊/陌生人,绝不误伤主人自己的完整读取。 func TestKnowledge_OwnerEnv_AllScopes(t *testing.T) { t.Parallel() kt := knowledgeToolForTest(t, WithKnowledgeRetriever(&stubRetriever{results: restrictedKnowledgeResults()})) ctx := loop.WithSessionEnv( tool.WithTurnPersona(context.Background(), "persona-a"), loop.OwnerEnv(), ) got, err := kt.Execute(ctx, map[string]any{"query": "回忆"}) if err != nil { t.Fatalf("Execute: %v", err) } if n := got.(map[string]any)["count"].(int); n != 4 { t.Fatalf("owner 环境 count = %d, want 4(完整召回)", n) } }