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」红
This commit is contained in:
1 parent
3be70f8232
commit
18381f65fd
9 files changed
+838
-11
No files matched your search
@@ -2,14 +2,24 @@ package io
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// ErrKnowledgeUnavailable 标记 knowledge 工具在**本次调用里根本不可用**:
|
||||
// 没有接知识库,或本回合没有解析出可用角色。它与「查得到但没命中」以及
|
||||
// 「检索中途报错」都不同 —— 它对模型而言**不可自行修复**,因此拒绝文案必须
|
||||
// 写明原因并要求改道,而不是让它对着同一堵墙反复重试(切片 KB1:安卓侧实测
|
||||
// 连续两次「工具调用失败 · knowledge」,两次都是同一条无从纠正的
|
||||
// ErrMissingPersona)。
|
||||
var ErrKnowledgeUnavailable = errors.New("knowledge unavailable")
|
||||
|
||||
// KnowledgeRetriever is the subset of the runtime memory retriever the knowledge
|
||||
// tool depends on. *memory.Retriever implements it; nil means no knowledge base
|
||||
// is configured.
|
||||
@@ -50,17 +60,19 @@ func (k *knowledgeTool) Parameters() (map[string]any, error) {
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{"type": "string", "description": "Query topic."},
|
||||
"persona": map[string]any{"type": "string", "description": "Persona scope id (optional)."},
|
||||
"persona": map[string]any{"type": "string", "description": "Persona scope id (optional; defaults to the persona of the current turn)."},
|
||||
"top_k": map[string]any{"type": "integer", "description": "Max results."},
|
||||
},
|
||||
"required": []any{"query"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Execute implements sdk.Tool.
|
||||
// Execute implements sdk.Tool. 它按「角色作用域 + query」检索记忆,并在工具
|
||||
// 本身不可用时(未接知识库 / 本回合没有角色)返回一条**可消化的拒绝**:写明
|
||||
// 原因并要求模型改道,而不是透出一个它无从纠正的底层错误。
|
||||
func (k *knowledgeTool) Execute(ctx context.Context, args any) (any, error) {
|
||||
if k.retriever == nil {
|
||||
return nil, fmt.Errorf("io: knowledge: no knowledge base configured")
|
||||
return nil, unavailableKnowledgeError("没有配置知识库")
|
||||
}
|
||||
m, ok := args.(map[string]any)
|
||||
if !ok {
|
||||
@@ -70,7 +82,10 @@ func (k *knowledgeTool) Execute(ctx context.Context, args any) (any, error) {
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return nil, fmt.Errorf("io: knowledge: query is required")
|
||||
}
|
||||
persona := argString(m, "persona")
|
||||
persona, err := k.resolvePersona(ctx, m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
topK := k.maxResults
|
||||
if v, ok := argInt(m, "top_k"); ok && v > 0 {
|
||||
topK = v
|
||||
@@ -99,3 +114,31 @@ func (k *knowledgeTool) Execute(ctx context.Context, args any) (any, error) {
|
||||
}
|
||||
return map[string]any{"query": query, "count": len(out), "results": out}, nil
|
||||
}
|
||||
|
||||
// resolvePersona 解析本次检索的角色作用域:显式参数 `persona` 优先;缺席时取
|
||||
// **本回合角色**(循环在 Run 顶部经 tool.WithTurnPersona 发布,见
|
||||
// internal/runtime/tool/turncontext.go)。
|
||||
//
|
||||
// 为什么必须解析:记忆检索按角色作用域隔离(design/03 §3/§4),拿不到角色就
|
||||
// 取不到正确的候选集。把空角色透给记忆存储只会换来它自己的 ErrMissingPersona
|
||||
// (memory.Store.ListByPersona 的硬校验)—— 模型既不知道要填哪个角色 id,参数
|
||||
// 说明还写着 optional,于是只能对同一堵墙反复重试。两者都拿不到时返回可消化的
|
||||
// 拒绝,让模型改道(design/24 §122:knowledge 无库/不可用时**明确报错**,
|
||||
// 而不是从广告面消失)。
|
||||
func (k *knowledgeTool) resolvePersona(ctx context.Context, m map[string]any) (string, error) {
|
||||
if persona := strings.TrimSpace(argString(m, "persona")); persona != "" {
|
||||
return persona, nil
|
||||
}
|
||||
if persona := tool.TurnPersonaFrom(ctx); persona != "" {
|
||||
return persona, nil
|
||||
}
|
||||
return "", unavailableKnowledgeError("本次会话没有解析出可用角色")
|
||||
}
|
||||
|
||||
// unavailableKnowledgeError 构造「本工具不可用」这一类失败的可消化拒绝:
|
||||
// 原因 + 明确要求改道。错误包住 ErrKnowledgeUnavailable,调用方据此以
|
||||
// errors.Is 分类,文案本身则是给模型(以及工具失败卡片)看的。
|
||||
func unavailableKnowledgeError(reason string) error {
|
||||
return fmt.Errorf("io: knowledge: %w(%s):重试不会成功,请改用其他工具或直接作答,不要重复调用本工具。",
|
||||
ErrKnowledgeUnavailable, reason)
|
||||
}
|
||||
@@ -7,17 +7,22 @@ import (
|
||||
"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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -28,12 +33,102 @@ func knowledgeToolForTest(t *testing.T, opts ...Option) *knowledgeTool {
|
||||
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 err == nil || !strings.Contains(err.Error(), "no knowledge base") {
|
||||
t.Fatalf("expected no-knowledge-base error, got %v", err)
|
||||
if !errors.Is(err, ErrKnowledgeUnavailable) {
|
||||
t.Fatalf("expected ErrKnowledgeUnavailable, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "没有配置知识库") {
|
||||
t.Fatalf("拒绝文案没有说明原因: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +159,8 @@ func TestKnowledge_ReturnsResults(t *testing.T) {
|
||||
func TestKnowledge_EmptyResults(t *testing.T) {
|
||||
t.Parallel()
|
||||
kt := knowledgeToolForTest(t, WithKnowledgeRetriever(&stubRetriever{}))
|
||||
got, err := kt.Execute(context.Background(), map[string]any{"query": "nothing"})
|
||||
ctx := tool.WithTurnPersona(context.Background(), "p1")
|
||||
got, err := kt.Execute(ctx, map[string]any{"query": "nothing"})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
@@ -83,7 +179,8 @@ func TestKnowledge_EmptyResults(t *testing.T) {
|
||||
func TestKnowledge_RetrieverError(t *testing.T) {
|
||||
t.Parallel()
|
||||
kt := knowledgeToolForTest(t, WithKnowledgeRetriever(&stubRetriever{err: errors.New("boom")}))
|
||||
_, err := kt.Execute(context.Background(), map[string]any{"query": "x"})
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user