Files
Cyrene_v2/plugins/tools/io/knowledge_test.go
T
AskaEth 18381f65fd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway,loop,tools): 失败回合必落可见回复 + knowledge 角色可信
根因(agent-191,均给出文件:行):
1) knowledge 的 persona 在 schema 里是 optional,模型省略时工具把空串透给
   memory.Retriever → 硬校验 ErrMissingPersona → 模型只能重复调用同一堵墙。
   修:新增 tool/turncontext.go(loop 播种本回合角色),knowledge 依次取
   「参数 → 本回合角色 → 可消化拒绝」,拒绝文案明确「重试不会成功」。
2) persistAssistantReply 对空文本直接 return,而三条正常收尾出口(空回复 /
   轮次用尽 / 回合内部报错)都会给空文本 ⇒ 历史只剩 user 消息、客户端静默。
   修:新增 session_turn_reply.go 的 persistTurnReply(唯一落库入口 + 按原因
   的中文诚实说明);中断 / 陈旧代次 / 静默内部流程(backthink、子代理调度)
   仍不落库,真实文本行为逐字未改。

测试:knowledge_test 改断言 + turn_persona_test(新)+ session_turn_reply_test(新,7 条)
变异:去掉 loop 角色发布 ⇒ loop×2 + gateway「工具执行次数 = 0, want 2」红
2026-10-04 22:14:42 +08:00

197 lines
7.3 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/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"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)
}
}