Files
Cyrene_v2/plugins/tools/io/knowledge_test.go
T
AskaEth 3e3d37a824
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(memory): 堵住 knowledge 工具绕过群聊/陌生人过滤的 P0 / owner 召回 hits 如实上报 / 静默吞点补来源日志
2026-10-05 11:30:42 +08:00

272 lines
11 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/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
}
// 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_ExplicitPersonaWins 断言显式参数优先于本回合角色:模型点名角色时
// 尊重模型的选择(既有语义不变)。
func TestKnowledge_ExplicitPersonaWins(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": "回忆", "persona": "persona-arg"}); err != nil {
t.Fatalf("Execute: %v", err)
}
if len(sr.personas) != 1 || sr.personas[0] != "persona-arg" {
t.Fatalf("检索角色 = %v, want [persona-arg]", sr.personas)
}
}
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(context.Background(), map[string]any{"query": "cyrene", "persona": "p1", "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_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)
}
}