Files
Cyrene_v2/plugins/tools/io/knowledge_test.go
T
AskaEth 6deab2e836
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(io): knowledge 工具不再接受「以他角色身份检索」(红线越权读)
design/03 §7.1 写死:跨角色读只有一条通路(把 B 当子代理派出去查);
「自己的检索能拿到 B 的角色日常记忆 = 已经越权(不是灰区)」。

缺陷:resolvePersona 让显式 persona 参数优先且零校验 ⇒ 模型(或提示词注入)
传 persona=B 即走 ListByPersona(B),读到 B 的角色日常记忆。

- 角色改为只能来自当前回合(tool.TurnPersonaFrom(ctx));显式 persona 非空
  且不等于本回合角色 ⇒ 明确拒绝(可消化:原因 + 改道 + 不许重复调用)
- 选「严格校验」而非「删参数」:删参数会让模型静默改用本回合角色,把 A 的
  记忆当成 B 的(归属错认)且毫无信号;删键亦属对外可见的 schema 变化
- 工具 schema 键集合与必填项不变(只改 description 文案)⇒ 契约零变化
- 子代理通路未破(子代理回合的 TurnPersonaFrom 本来就是 B,已加用例钉住)
- 顺带核对:FilterSharedVisible 仍在,受限环境过滤未因地塌陷

点名改掉两条「依赖缺陷行为」的既有用例:
- TestKnowledge_ExplicitPersonaWins(原断言把「模型点名即换角色」写成规格)
- TestKnowledge_ReturnsResults(机械依赖,只改调用形状,断言一字未动)
2026-10-07 19:17:07 +08:00

537 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}