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
@@ -54,6 +54,12 @@ func (l *Loop) Run(ctx context.Context, sessionID string, messages []model.Messa
|
||||
// Dispatcher.Invoke 据此做前置软判。作用域同样限于本次 Run,
|
||||
// 子代理 Run 各自播种一份,并发 run 绝不共享。
|
||||
ctx = tool.WithRoundAllowSet(ctx)
|
||||
// 同时把本回合**角色**发布给工具(tool/turncontext.go):knowledge 这类
|
||||
// 需要角色作用域的工具据此查到「以谁的名义」检索。不发布的话,模型省略
|
||||
// 可选参数 persona 时工具会把空角色透给记忆存储,换回一条它无从纠正的
|
||||
// ErrMissingPersona(切片 KB1 的真因)。取值与提示词/记忆同源(personaFor),
|
||||
// 因此工具看到的就是本轮说话的角色,不会出现两个角色。
|
||||
ctx = tool.WithTurnPersona(ctx, l.personaFor(ctx))
|
||||
ctx = withLoopSession(ctx, sessionID)
|
||||
|
||||
l.emitTurnBoundary(ctx, model.StreamKindTurnStart)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
)
|
||||
|
||||
// 本文件锁定切片 KB1 的 loop 侧一半:**本回合角色必须发布给工具**。
|
||||
//
|
||||
// 复现的形状:模型省略 knowledge 工具的可选参数 persona,工具此前把空角色透给
|
||||
// 记忆存储,后者以 ErrMissingPersona 硬失败 —— 模型既不知道该填哪个角色 id,
|
||||
// 参数说明还写着 optional,于是连撞同一堵墙。修复是循环在 Run 顶部把 personaFor
|
||||
// 解析出的角色经 tool.WithTurnPersona 发布到本轮 ctx 上,工具据此查到「以谁的
|
||||
// 名义」检索。
|
||||
//
|
||||
// 断言走真路径:真 Run → 真 tool.Dispatcher.Invoke → 真工具 Execute 读 ctx。
|
||||
// 工具看到的值必须与提示词/记忆同源(personaFor):会话环境有显式角色时取它,
|
||||
// 否则取循环配置的默认角色。
|
||||
|
||||
// personaProbeTool 记录 Execute 时在 ctx 上读到的本回合角色。
|
||||
type personaProbeTool struct {
|
||||
got []string
|
||||
}
|
||||
|
||||
func (p *personaProbeTool) Name() string { return "persona_probe" }
|
||||
func (p *personaProbeTool) Description() string { return "记录本回合角色" }
|
||||
func (p *personaProbeTool) Parameters() (map[string]any, error) {
|
||||
return map[string]any{"type": "object"}, nil
|
||||
}
|
||||
|
||||
func (p *personaProbeTool) Execute(ctx context.Context, _ any) (any, error) {
|
||||
p.got = append(p.got, tool.TurnPersonaFrom(ctx))
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
// runPersonaProbe 跑一个「一次工具调用 + 收尾」的回合,返回工具在 ctx 上读到的角色。
|
||||
func runPersonaProbe(t *testing.T, ctx context.Context, configured string) string {
|
||||
t.Helper()
|
||||
call := `{"tool_calls":[{"name":"persona_probe","args":{}}]}`
|
||||
ad := &mockAdapter{chat: []mgResp{
|
||||
{resp: model.Response{Content: call}},
|
||||
{resp: model.Response{Content: "收尾"}},
|
||||
}}
|
||||
probe := &personaProbeTool{}
|
||||
td := dispatcherWith(t, probe)
|
||||
a := newTestAgent(t, ad, td)
|
||||
l := New(Options{Agent: a, MaxRounds: 8, PersonaID: configured})
|
||||
if _, err := l.Run(ctx, "sess", []model.Message{model.TextMessage(model.RoleUser, "查一下")}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if len(probe.got) != 1 {
|
||||
t.Fatalf("工具执行次数 = %d, want 1", len(probe.got))
|
||||
}
|
||||
return probe.got[0]
|
||||
}
|
||||
|
||||
// TestRun_PublishesTurnPersonaToTools 断言:会话环境给了显式角色时,工具看到的是
|
||||
// 那个角色(而不是循环配置的默认角色)。
|
||||
func TestRun_PublishesTurnPersonaToTools(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := OwnerEnv()
|
||||
env.PersonaID = "persona-x"
|
||||
ctx := WithSessionEnv(context.Background(), env)
|
||||
if got := runPersonaProbe(t, ctx, "default-persona"); got != "persona-x" {
|
||||
t.Fatalf("工具读到的本回合角色 = %q, want persona-x(应与提示词/记忆同源)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_PublishesConfiguredPersonaWhenEnvEmpty 断言:会话环境没给角色
|
||||
// (channelbridge / backthink 等既有调用方)时,工具看到的是循环配置的默认角色 ——
|
||||
// 正是「空角色不许透给下游」的兜底。
|
||||
func TestRun_PublishesConfiguredPersonaWhenEnvEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := runPersonaProbe(t, context.Background(), "default-persona"); got != "default-persona" {
|
||||
t.Fatalf("工具读到的本回合角色 = %q, want default-persona", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package tool
|
||||
|
||||
import "context"
|
||||
|
||||
// 本文件承载「本回合角色」的 context 通道(切片 KB1)。
|
||||
//
|
||||
// 背景:一个 Run 里每轮要回答的**角色**由循环解析(loop 的 personaFor:
|
||||
// 会话环境的显式角色,否则循环配置的默认角色),提示词、记忆注入与情绪
|
||||
// 都按它取值。工具侧此前拿不到这个角色,于是 knowledge 工具把可选参数
|
||||
// `persona` 缺席时的空串直接透给记忆检索器,由后者以 ErrMissingPersona
|
||||
// 硬失败 —— 模型既不知道该填哪个角色 id(参数说明还写着 optional),
|
||||
// 也就只能对同一堵墙反复重试(安卓侧实测的连续两次
|
||||
// 「工具调用失败 · knowledge」)。
|
||||
//
|
||||
// 通道形态与 allowset.go 同族:循环在 Run 顶部播种一次,工具经
|
||||
// Dispatcher.Invoke 的 ctx(它由本轮 ctx 派生)读取。身份与权限**不**
|
||||
// 走这条通道 —— 它只是「本回合以谁的名义说话」这个作用域元数据,与
|
||||
// SessionEnv 的注释同一纪律(项目红线:身份由 actor 元数据 + 策略引擎
|
||||
// 决定,绝不从这里推断)。
|
||||
|
||||
// turnPersonaKey 是本回合角色的 context key。
|
||||
type turnPersonaKey struct{}
|
||||
|
||||
// WithTurnPersona 把本回合角色挂到 ctx 上(循环在 Run 顶部调用一次)。
|
||||
// 空角色是空操作:不挂载即「本回合没有解析出角色」,读取侧返回空串,
|
||||
// 让依赖角色作用域的工具据此给出可消化的拒绝,而不是把空角色透给
|
||||
// 下游存储换回一条无从纠正的错误。
|
||||
func WithTurnPersona(ctx context.Context, personaID string) context.Context {
|
||||
if ctx == nil || personaID == "" {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, turnPersonaKey{}, personaID)
|
||||
}
|
||||
|
||||
// TurnPersonaFrom 返回 ctx 上挂载的本回合角色;未挂载(或 ctx 为 nil)时
|
||||
// 返回空串。
|
||||
func TurnPersonaFrom(ctx context.Context) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
s, _ := ctx.Value(turnPersonaKey{}).(string)
|
||||
return s
|
||||
}
|
||||
@@ -369,7 +369,7 @@ func (o *Orchestrator) goTurn(sessionID string, gen uint64, messages []model.Mes
|
||||
// —— 见 session_document_status.go;语音回合的音频回复同样在此收尾(协同 C1,
|
||||
// 见 session_voice.go):只有这里知道这个回合真正产出了什么。
|
||||
func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Result, runErr error, deviceID, mode, personaID string) error {
|
||||
o.persistAssistantReply(sessionID, res, deviceID, mode, personaID)
|
||||
res = o.persistTurnReply(sessionID, gen, res, runErr, deviceID, mode, personaID)
|
||||
o.persistDocumentStatus(sessionID, gen, res)
|
||||
o.dispatchVoiceReply(sessionID, gen, res)
|
||||
o.mu.Lock()
|
||||
|
||||
@@ -124,6 +124,10 @@ func (o *Orchestrator) persistUserMessages(ctx context.Context, sessionID string
|
||||
// 在未接线 history 存储,或该回合没有产出文本
|
||||
// 时是空操作(被中断/出错的回合保持 res 为 nil)。该写入在一个全新的
|
||||
// 后台 context 下运行,因此被取消的回合 context 绝不可能中断它。
|
||||
//
|
||||
// 它是**裸写入**,判定属于 persistTurnReply(session_turn_reply.go):收尾路径
|
||||
// 经后者进入,由它保证「归本代次、且不是被取消的回合一定有一条可见消息」——
|
||||
// 空文本在这里仍然是空操作,因此本函数绝不自作主张编造内容。
|
||||
func (o *Orchestrator) persistAssistantReply(sessionID string, res *loop.Result, deviceID, mode, personaID string) {
|
||||
if o.history == nil || res == nil || res.Text == "" {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
)
|
||||
|
||||
// 本文件归属「回合收尾必须留下可见回复」这条不静默铁律(切片 KB1)。
|
||||
//
|
||||
// 缺陷形状(安卓侧实测 E2E-RETRY,纯文本回合):模型连续两次调用一个失败的
|
||||
// `knowledge` 工具,随后给出一段**空回复**(只有思维链、没有正文)—— 循环按
|
||||
// 「正常收尾」落定,Result.Text 为空,而助手回复的落库对空文本是静默空操作,
|
||||
// 于是历史里只剩那条用户消息:客户端两张「工具调用失败」卡片之后直接回空闲,
|
||||
// 既没有回复、也没有错误横幅。
|
||||
//
|
||||
// 同类静默出口还有两个,全都在这里收敛:
|
||||
// - 工具循环耗尽轮次预算(loop 返回 StopReasonMaxRounds 且 Text 为空);
|
||||
// - 回合内部报错(Run 返回 nil Result + error:模型 / 工具 / 编排报错)。
|
||||
//
|
||||
// 保证是:**归本代次、且不是被用户打断的回合,历史里一定有一条可见的 assistant
|
||||
// 消息** —— 没有真实文本时补一条按结束原因区分的诚实说明。落库仍走
|
||||
// persistAssistantReply(同一条写入 + message_persisted 广播路径),因此历史接口、
|
||||
// 跨端同步与客户端渲染都与真实回复完全一致,不需要任何新契约。
|
||||
//
|
||||
// 三类**不补**的情形(边界,与既有语义不冲突):
|
||||
// - 被 interrupt 取消的回合:用户意图就是「停下」,此刻补一条「我出问题了」
|
||||
// 反而违背那条指令;
|
||||
// - 被更新的回合取代的陈旧代次(st.gen 已前进):那次收尾不属于当前会话状态,
|
||||
// 补消息会落到别人的回合里;
|
||||
// - 静默内部流程(backthink、子代理):它们各自驱动 loop,从不经过本编排器,
|
||||
// 因而天然不受这条保证影响(它们的产出只进内部状态,不进聊天历史)。
|
||||
|
||||
// 静默收尾的诚实说明文案(全部中文,按结束原因区分)。它们是**用户可见**的聊天
|
||||
// 内容,因此只说明「没答上来 + 下一步能怎么办」,不写内部术语、不写堆栈。
|
||||
const (
|
||||
// turnFallbackEmptyReply 用于模型给出空回复的回合(没有正文、也没有工具调用)。
|
||||
turnFallbackEmptyReply = "抱歉,我这次没能组织出回复内容(模型返回了空回复)。你可以再说一次,或者换个说法。"
|
||||
// turnFallbackMaxRounds 用于工具循环耗尽轮次预算仍没得出结论的回合。
|
||||
turnFallbackMaxRounds = "抱歉,我这次的工具调用反复失败、把思考轮次用尽了,还没能得出结论。你可以再说一次,或者把要求拆小一点。"
|
||||
// turnFallbackToolLoop 用于关键工具循环安全网终止的回合(同一个工具调用跨轮重复)。
|
||||
turnFallbackToolLoop = "抱歉,我在同一个工具调用上反复碰壁,已经把这次回合停下了。你可以换个说法再说一次。"
|
||||
// turnFallbackError 用于回合因内部错误(模型 / 工具 / 编排)中断的回合。
|
||||
turnFallbackError = "抱歉,我这边出了点问题,这个回合没能给出回复。你可以稍后再试一次。"
|
||||
)
|
||||
|
||||
// turnReplyText 返回本回合**应当落库**的可见回复文本:真实文本逐字原样返回,
|
||||
// 没有可见文本、或只有关键工具循环安全网塞进来的内部英文文案时,换成按结束
|
||||
// 原因区分的诚实说明。
|
||||
func turnReplyText(res *loop.Result) string {
|
||||
if res == nil {
|
||||
return turnFallbackError
|
||||
}
|
||||
if strings.TrimSpace(res.Text) != "" && res.StopReason != loop.StopReasonToolLoopTerminated {
|
||||
return res.Text
|
||||
}
|
||||
switch res.StopReason {
|
||||
case loop.StopReasonToolLoopTerminated:
|
||||
return turnFallbackToolLoop
|
||||
case loop.StopReasonMaxRounds:
|
||||
return turnFallbackMaxRounds
|
||||
default:
|
||||
return turnFallbackEmptyReply
|
||||
}
|
||||
}
|
||||
|
||||
// turnIsCurrent 报告该代次是否仍是该会话的当前代次。陈旧代次(已被 interrupt
|
||||
// 或更新的顶层回合取代)意味着这次收尾属于**上一个**回合,不该再往历史里写。
|
||||
func (o *Orchestrator) turnIsCurrent(sessionID string, gen uint64) bool {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
st := o.states[sessionID]
|
||||
return st != nil && st.gen == gen
|
||||
}
|
||||
|
||||
// persistTurnReply 是本回合助手回复的**唯一落库入口**(completeTurn 的收尾第一
|
||||
// 步)。两条分支:
|
||||
//
|
||||
// - 有真实文本:逐字落库,行为与加入本保证之前完全一致(被取消/陈旧代次的
|
||||
// 回合也照旧落 —— 内容是真的,与「不静默」无关);
|
||||
// - 没有可见文本(空回复 / 轮次用尽 / 内部报错 / 只剩安全网的内部文案):只有
|
||||
// 「归本代次且不是被取消」才补一条诚实说明;其余情形返回原样、什么都不写。
|
||||
//
|
||||
// 补过文案时它就地改写 res.Text 并把它交还调用方,因此**续跑尾部**(st.tail)也
|
||||
// 带着这条说明 —— 队列里的下一条消息不会看到一个「什么都没说」的上下文。res 为
|
||||
// nil(回合报错)时它新建一个只带文案的 Result,调用方的 nil 语义不变(异步路径
|
||||
// 丢弃返回值,同步路径在此之前已按错误返回)。
|
||||
func (o *Orchestrator) persistTurnReply(sessionID string, gen uint64, res *loop.Result, runErr error, deviceID, mode, personaID string) *loop.Result {
|
||||
if o.history == nil {
|
||||
return res
|
||||
}
|
||||
text := turnReplyText(res)
|
||||
if res != nil && res.Text == text {
|
||||
// 真实文本:逐字落库,行为与加入本保证之前完全一致。
|
||||
o.persistAssistantReply(sessionID, res, deviceID, mode, personaID)
|
||||
return res
|
||||
}
|
||||
if errors.Is(runErr, context.Canceled) || !o.turnIsCurrent(sessionID, gen) {
|
||||
return res
|
||||
}
|
||||
if res == nil {
|
||||
res = &loop.Result{}
|
||||
}
|
||||
res.Text = text
|
||||
o.persistAssistantReply(sessionID, res, deviceID, mode, personaID)
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"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/model"
|
||||
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||||
)
|
||||
|
||||
// 本文件锁定切片 KB1 的网关侧一半:**回合收尾必须留下一条可见的 assistant 消息**
|
||||
// (见 session_turn_reply.go)。
|
||||
//
|
||||
// 复现场景逐条照抄安卓侧实测(会话 E2E-RETRY,纯文本回合):一条短文本 → 模型连续
|
||||
// 两次调用一个失败的 knowledge 工具 → 之后给出一段空回复 → 回合「正常」收尾。
|
||||
// 修复前的结果是历史里只有那条 user 消息,客户端两张「工具调用失败」卡片之后直接
|
||||
// 回空闲(没有回复、也没有错误横幅)。
|
||||
//
|
||||
// 断言走真路径:真 loop(真模型步、真工具批次)+ 真 knowledge 工具(真 retriever)
|
||||
// + 真 SQLite history + 真编排器。只有模型适配器是脚本化的。
|
||||
|
||||
// kbScriptAdapter 是脚本化的流式适配器:前 toolSteps 步请求一次 knowledge 工具调用
|
||||
// (参数默认**按步变化**,因此绝不会命中循环自身的「同调用去重」安全网,模型真的连撞
|
||||
// 同一堵墙),之后给出 final 正文(空串 = 复现里的空回复)。
|
||||
type kbScriptAdapter struct {
|
||||
mu sync.Mutex
|
||||
steps int
|
||||
toolSteps int
|
||||
final string
|
||||
// errAt 在指定步(1 起)返回模型错误;0 表示不报错。
|
||||
errAt int
|
||||
// identicalArgs 让每一步请求**完全相同**的调用(参数与步号无关),用来触发
|
||||
// 循环的关键工具循环安全网。
|
||||
identicalArgs bool
|
||||
reqs []model.Request
|
||||
}
|
||||
|
||||
func (a *kbScriptAdapter) Name() string { return "kb1-script" }
|
||||
|
||||
func (a *kbScriptAdapter) Chat(ctx context.Context, req model.Request, _ model.Options) (model.Response, error) {
|
||||
return a.ChatStream(ctx, req, nil)
|
||||
}
|
||||
|
||||
func (a *kbScriptAdapter) ChatStream(_ context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.steps++
|
||||
step := a.steps
|
||||
a.reqs = append(a.reqs, req)
|
||||
if a.errAt != 0 && step == a.errAt {
|
||||
return model.Response{}, errors.New("model: upstream 502")
|
||||
}
|
||||
if step <= a.toolSteps {
|
||||
query := fmt.Sprintf("q-%d", step)
|
||||
if a.identicalArgs {
|
||||
query = "q-same"
|
||||
}
|
||||
return model.Response{
|
||||
Content: fmt.Sprintf(`{"tool_calls":[{"name":"knowledge","args":{"query":%q}}]}`, query),
|
||||
}, nil
|
||||
}
|
||||
return model.Response{Content: a.final}, nil
|
||||
}
|
||||
|
||||
func (a *kbScriptAdapter) stepCount() int {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.steps
|
||||
}
|
||||
|
||||
// kbSawToolFailure 报告适配器的某个请求窗口里是否带上了该工具的**失败结果**
|
||||
// (RoleTool 消息里的 "tool <name> failed"):工具失败必须回灌给模型,吞掉它
|
||||
// 就等于让模型在错误的假设下继续(切片 KB1 的「不许静默」)。
|
||||
func kbSawToolFailure(a *kbScriptAdapter, name string) bool {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
for _, req := range a.reqs {
|
||||
for _, m := range req.Messages {
|
||||
if m.Role == model.RoleTool && strings.Contains(messageText(m), "tool "+name+" failed") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// kbFailingRetriever 让 knowledge 工具的检索每次都以执行错误失败 —— 形状与生产里
|
||||
// 「RAG / 向量后端不可用」一致,也就是客户端上那张「工具调用失败 · knowledge」卡片。
|
||||
type kbFailingRetriever struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
lastPersona string
|
||||
}
|
||||
|
||||
func (r *kbFailingRetriever) Retrieve(_ context.Context, personaID, query string, topK int) ([]memory.Result, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.calls++
|
||||
r.lastPersona = personaID
|
||||
return nil, fmt.Errorf("memory: retrieve list: backend unavailable (query=%s, topK=%d)", query, topK)
|
||||
}
|
||||
|
||||
func (r *kbFailingRetriever) callCount() int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.calls
|
||||
}
|
||||
|
||||
// kbTurnFixture 装配一条端到端回合链路的全部生产依赖。
|
||||
type kbTurnFixture struct {
|
||||
store *history.Store
|
||||
ad *kbScriptAdapter
|
||||
ret *kbFailingRetriever
|
||||
orch *Orchestrator
|
||||
|
||||
mu sync.Mutex
|
||||
persisted []history.Message
|
||||
}
|
||||
|
||||
// newKBTurnFixture 装配真 loop(含真 io 工具集与真知识库检索器)+ 真 SQLite history
|
||||
// + 真编排器。maxRounds 取 0 时用循环默认值(8 轮)。
|
||||
func newKBTurnFixture(t *testing.T, ad *kbScriptAdapter, maxRounds int) *kbTurnFixture {
|
||||
t.Helper()
|
||||
store := newHistoryStoreForGateway(t)
|
||||
ret := &kbFailingRetriever{}
|
||||
ioSet, err := iotools.New(&config.Config{}, iotools.WithKnowledgeRetriever(ret), iotools.WithLogger(gwTestLogger()))
|
||||
if err != nil {
|
||||
t.Fatalf("iotools.New: %v", err)
|
||||
}
|
||||
dispatcher := newSteerDispatcher(t, ioSet...)
|
||||
ag, err := agent.New("你是测试助手。", dispatcher, nil, agent.WithModel(ad, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("agent.New: %v", err)
|
||||
}
|
||||
// OnStream 非 nil ⇒ 走生产所用的流式步路径(安卓侧实测的路径),它正是
|
||||
// 「模型返回空正文 ⇒ 回合按最终答复落定」的那一条。PersonaID 是生产里必有的
|
||||
// 默认角色(cmd/cyrene 的 a.defaultPersonaID()):工具据此拿到角色作用域,
|
||||
// 而不是撞回 memory 的 missing persona。
|
||||
l := loop.New(loop.Options{
|
||||
Agent: ag, Tools: dispatcher, MaxRounds: maxRounds,
|
||||
PersonaID: kbPersonaID,
|
||||
OnStream: func(model.StreamEvent) {}, Log: gwTestLogger(),
|
||||
})
|
||||
f := &kbTurnFixture{store: store, ad: ad, ret: ret}
|
||||
f.orch = NewOrchestrator(OrchestratorOptions{
|
||||
Run: func(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (*loop.Result, error) {
|
||||
return l.Run(loop.WithSessionEnv(ctx, env), sessionID, messages)
|
||||
},
|
||||
Log: gwTestLogger(),
|
||||
History: store,
|
||||
OnPersisted: func(m history.Message) {
|
||||
f.mu.Lock()
|
||||
f.persisted = append(f.persisted, m)
|
||||
f.mu.Unlock()
|
||||
},
|
||||
})
|
||||
return f
|
||||
}
|
||||
|
||||
// submit 把一个纯文本回合交给编排器并跑完(REST 同步路径,确定性)。
|
||||
func (f *kbTurnFixture) submit(t *testing.T) (*loop.Result, error) {
|
||||
t.Helper()
|
||||
sr, res := f.orch.SubmitSync(context.Background(), kbSessionID, []model.Message{userMsg("W15-key-test")}, loop.OwnerEnv())
|
||||
if !sr.Accepted || !sr.Immediate {
|
||||
t.Fatalf("submit = %+v, want Accepted+Immediate", sr)
|
||||
}
|
||||
return res, sr.Err
|
||||
}
|
||||
|
||||
// kbSessionID 是本文件的会话 id。
|
||||
const kbSessionID = "sess-kb1"
|
||||
|
||||
// kbPersonaID 是测试 loop 配置的默认角色(生产里由组合根注入)。
|
||||
const kbPersonaID = "cyrene"
|
||||
|
||||
// assertUserAndSingleAssistant 断言历史里恰好一条 user + 恰好一条 assistant,
|
||||
// 并返回那条 assistant 消息。
|
||||
func (f *kbTurnFixture) assertUserAndSingleAssistant(t *testing.T) history.Message {
|
||||
t.Helper()
|
||||
users := countHistoryRows(t, f.store, kbSessionID, history.RoleUser)
|
||||
assistants := countHistoryRows(t, f.store, kbSessionID, history.RoleAssistant)
|
||||
if len(users) != 1 {
|
||||
t.Fatalf("user 消息 = %d 条, want 1", len(users))
|
||||
}
|
||||
if len(assistants) != 1 {
|
||||
t.Fatalf("assistant 消息 = %d 条, want 恰好 1(回合收尾必须留下可见回复)", len(assistants))
|
||||
}
|
||||
return assistants[0]
|
||||
}
|
||||
|
||||
// TestTurnEnd_EmptyReplyAfterFailedKnowledgeStillPersistsAssistant 是必测①:
|
||||
// 复现安卓侧的场景 —— 模型连续两次调用失败的 knowledge 工具,随后给出空回复 ——
|
||||
// 断言 user 消息 + **恰好一条** assistant 消息落库,且内容是看得懂的诚实说明。
|
||||
func TestTurnEnd_EmptyReplyAfterFailedKnowledgeStillPersistsAssistant(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := &kbScriptAdapter{toolSteps: 2, final: ""}
|
||||
f := newKBTurnFixture(t, ad, 0)
|
||||
|
||||
res, err := f.submit(t)
|
||||
if err != nil {
|
||||
t.Fatalf("回合不该报错(它按「正常收尾」落定): %v", err)
|
||||
}
|
||||
if res.Text == "" {
|
||||
t.Fatal("Result.Text 为空 ⇒ REST reply / 续跑尾部都会是空的")
|
||||
}
|
||||
assistant := f.assertUserAndSingleAssistant(t)
|
||||
if strings.TrimSpace(assistant.Content) == "" {
|
||||
t.Fatal("落库的 assistant 内容为空 —— 客户端会看到「什么都没发生」")
|
||||
}
|
||||
if assistant.Content != res.Text {
|
||||
t.Fatalf("落库内容 %q != Result.Text %q", assistant.Content, res.Text)
|
||||
}
|
||||
// 两次工具失败确实发生过(工具真的被执行了两次,而不是被去重跳过)。
|
||||
if got := f.ret.callCount(); got != 2 {
|
||||
t.Fatalf("knowledge 工具执行次数 = %d, want 2(复现里正是两次失败)", got)
|
||||
}
|
||||
// 角色经 loop 发布给工具:绝不把空角色透给记忆检索器(根因①)。
|
||||
if f.ret.lastPersona == "" {
|
||||
t.Fatal("工具收到的角色为空 —— 又会撞回 memory 的 missing persona")
|
||||
}
|
||||
// 失败**可见且可消化**:模型在下一次调用的窗口里看到了结构化失败结果,
|
||||
// 而不是被静默吞掉(软判/拦截路径同理,见 writeToolOutcome)。
|
||||
if !kbSawToolFailure(ad, "knowledge") {
|
||||
t.Fatal("模型没有看到 knowledge 的失败结果 —— 失败被吞掉了")
|
||||
}
|
||||
// 跨端同步(message_persisted)同样带上这条消息,客户端无需刷新即可看到。
|
||||
f.mu.Lock()
|
||||
persisted := append([]history.Message(nil), f.persisted...)
|
||||
f.mu.Unlock()
|
||||
if len(persisted) != 2 || persisted[1].Role != history.RoleAssistant {
|
||||
t.Fatalf("persisted = %+v, want [user assistant]", persisted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnEnd_MaxRoundsExhaustedStillPersistsAssistant 是必测④:工具循环耗尽
|
||||
// 8 轮上限时的收尾也落一条可见消息(loop 在该出口返回的空 Text 曾同样静默)。
|
||||
func TestTurnEnd_MaxRoundsExhaustedStillPersistsAssistant(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := &kbScriptAdapter{toolSteps: 1 << 10, final: ""} // 永远请求工具,永不收尾
|
||||
f := newKBTurnFixture(t, ad, loop.DefaultMaxRounds)
|
||||
|
||||
res, err := f.submit(t)
|
||||
if err != nil {
|
||||
t.Fatalf("轮次用尽不该报错: %v", err)
|
||||
}
|
||||
if res.StopReason != loop.StopReasonMaxRounds {
|
||||
t.Fatalf("StopReason = %q, want %q", res.StopReason, loop.StopReasonMaxRounds)
|
||||
}
|
||||
if ad.stepCount() != loop.DefaultMaxRounds {
|
||||
t.Fatalf("模型步数 = %d, want %d", ad.stepCount(), loop.DefaultMaxRounds)
|
||||
}
|
||||
assistant := f.assertUserAndSingleAssistant(t)
|
||||
if !strings.Contains(assistant.Content, "轮次") {
|
||||
t.Fatalf("轮次用尽的说明文案不对: %q", assistant.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnEnd_ToolLoopTerminatedTextIsHonestChinese 断言关键工具循环安全网终止的
|
||||
// 回合落的是**中文诚实说明**,而不是循环内部那句英文诊断文案(同一调用跨轮重复
|
||||
// 三次即触发,正是「连撞两次同墙」的下一个回合)。
|
||||
func TestTurnEnd_ToolLoopTerminatedTextIsHonestChinese(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := &kbScriptAdapter{toolSteps: 1 << 10, identicalArgs: true}
|
||||
f := newKBTurnFixture(t, ad, 0)
|
||||
res, err := f.submit(t)
|
||||
if err != nil {
|
||||
t.Fatalf("安全网终止不该报错: %v", err)
|
||||
}
|
||||
if res.StopReason != loop.StopReasonToolLoopTerminated {
|
||||
t.Fatalf("StopReason = %q, want %q", res.StopReason, loop.StopReasonToolLoopTerminated)
|
||||
}
|
||||
assistant := f.assertUserAndSingleAssistant(t)
|
||||
if !strings.Contains(assistant.Content, "工具调用") {
|
||||
t.Fatalf("落库内容应是中文诚实说明: %q", assistant.Content)
|
||||
}
|
||||
if strings.Contains(assistant.Content, "critical tool-loop") {
|
||||
t.Fatalf("内部英文诊断文案漏给了用户: %q", assistant.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnEnd_ModelErrorStillPersistsAssistant 断言回合因内部错误中断(Run 返回
|
||||
// nil Result + error)时,历史里仍留下一条诚实说明 —— 另一条曾经静默的出口。
|
||||
func TestTurnEnd_ModelErrorStillPersistsAssistant(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := &kbScriptAdapter{errAt: 1}
|
||||
f := newKBTurnFixture(t, ad, 0)
|
||||
|
||||
_, err := f.submit(t)
|
||||
if err == nil {
|
||||
t.Fatal("模型报错的回合应当把错误交回调用方")
|
||||
}
|
||||
assistant := f.assertUserAndSingleAssistant(t)
|
||||
if !strings.Contains(assistant.Content, "出了点问题") {
|
||||
t.Fatalf("内部错误的说明文案不对: %q", assistant.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnEnd_NormalReplyIsPersistedVerbatim 是必测③的回归项:正常回合(无工具
|
||||
// 失败)的落库行为逐字不变 —— 恰好一条 assistant,内容与模型正文完全相同。
|
||||
func TestTurnEnd_NormalReplyIsPersistedVerbatim(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := &kbScriptAdapter{toolSteps: 0, final: "你好,我在。"}
|
||||
f := newKBTurnFixture(t, ad, 0)
|
||||
|
||||
res, err := f.submit(t)
|
||||
if err != nil {
|
||||
t.Fatalf("正常回合不该报错: %v", err)
|
||||
}
|
||||
if res.Text != "你好,我在。" {
|
||||
t.Fatalf("Result.Text = %q", res.Text)
|
||||
}
|
||||
assistant := f.assertUserAndSingleAssistant(t)
|
||||
if assistant.Content != "你好,我在。" {
|
||||
t.Fatalf("落库内容 = %q, want 逐字等于模型正文", assistant.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnEnd_InterruptedTurnStaysSilent 是必测③的边界项:被 interrupt 取消的
|
||||
// 回合**不**补任何消息(用户意图就是停下)。这里用 current 口径,它刻意不递增
|
||||
// 代次,因此「不落库」只可能来自取消判定本身。
|
||||
func TestTurnEnd_InterruptedTurnStaysSilent(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newHistoryStoreForGateway(t)
|
||||
runner := newKBBlockingRunner()
|
||||
o := NewOrchestrator(OrchestratorOptions{Run: runner.Run, Log: gwTestLogger(), History: store})
|
||||
|
||||
sr := o.Submit(context.Background(), kbSessionID, []model.Message{userMsg("停下")}, loop.OwnerEnv())
|
||||
if !sr.Immediate {
|
||||
t.Fatalf("submit = %+v", sr)
|
||||
}
|
||||
runner.waitEntered(t)
|
||||
if dropped := o.InterruptScope(kbSessionID, InterruptScopeCurrent); dropped != 0 {
|
||||
t.Fatalf("current 口径不该丢弃待处理:%d", dropped)
|
||||
}
|
||||
runner.release()
|
||||
if !waitIdle(o, kbSessionID) {
|
||||
t.Fatal("会话没有回到空闲")
|
||||
}
|
||||
if n := len(countHistoryRows(t, store, kbSessionID, history.RoleAssistant)); n != 0 {
|
||||
t.Fatalf("被取消的回合落了 %d 条 assistant 消息, want 0", n)
|
||||
}
|
||||
if n := len(countHistoryRows(t, store, kbSessionID, history.RoleUser)); n != 1 {
|
||||
t.Fatalf("user 消息 = %d 条, want 1(取消不影响已落库的用户消息)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// kbBlockingRunner 是一个可取消的 TurnRunner:它阻塞到被释放,然后按 ctx 的取消
|
||||
// 状态返回 —— 模拟被 interrupt 打断的回合。
|
||||
type kbBlockingRunner struct {
|
||||
entered chan struct{}
|
||||
gate chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newKBBlockingRunner() *kbBlockingRunner {
|
||||
return &kbBlockingRunner{entered: make(chan struct{}), gate: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (r *kbBlockingRunner) Run(ctx context.Context, _ string, _ []model.Message, _ loop.SessionEnv) (*loop.Result, error) {
|
||||
r.once.Do(func() { close(r.entered) })
|
||||
select {
|
||||
case <-r.gate:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, fmt.Errorf("loop: run: %w", err)
|
||||
}
|
||||
return &loop.Result{Text: "放行后的回复", StopReason: "stop"}, nil
|
||||
}
|
||||
|
||||
func (r *kbBlockingRunner) waitEntered(t *testing.T) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-r.entered:
|
||||
case <-time.After(kbRunnerTimeout):
|
||||
t.Fatal("回合没有进入 runner")
|
||||
}
|
||||
}
|
||||
|
||||
func (r *kbBlockingRunner) release() { close(r.gate) }
|
||||
|
||||
// kbRunnerTimeout 是后台回合进入 runner 的等待上限(真 loop 的首步可能要先
|
||||
// 组装上下文,给足余量但绝不让测试无限挂住)。
|
||||
const kbRunnerTimeout = 3 * time.Second
|
||||
|
||||
// TestTurnEnd_StaleGenerationStaysSilent 是必测③的另一条边界:被更新的回合取代的
|
||||
// 陈旧代次**不**补消息(那次收尾不属于当前会话状态)。这里让回合在代次前进之后才
|
||||
// 返回一个空 Result —— 若补消息逻辑漏了代次判定,就会多出一条 assistant。
|
||||
func TestTurnEnd_StaleGenerationStaysSilent(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newHistoryStoreForGateway(t)
|
||||
runner := newKBStaleRunner()
|
||||
o := NewOrchestrator(OrchestratorOptions{Run: runner.Run, Log: gwTestLogger(), History: store})
|
||||
|
||||
o.Submit(context.Background(), kbSessionID, []model.Message{userMsg("旧回合")}, loop.OwnerEnv())
|
||||
runner.waitEntered(t)
|
||||
// all 口径:取消在飞回合并把代次推进(该回合的收尾从此是陈旧代次)。
|
||||
o.InterruptScope(kbSessionID, InterruptScopeAll)
|
||||
runner.release()
|
||||
if !waitIdle(o, kbSessionID) {
|
||||
t.Fatal("会话没有回到空闲")
|
||||
}
|
||||
if n := len(countHistoryRows(t, store, kbSessionID, history.RoleAssistant)); n != 0 {
|
||||
t.Fatalf("陈旧代次的回合落了 %d 条 assistant 消息, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// kbStaleRunner 返回一个**空 Result 且不报错**的回合:它给不出可见文本,但代次已经
|
||||
// 前进,因此不该被补上任何消息。
|
||||
type kbStaleRunner struct {
|
||||
entered chan struct{}
|
||||
gate chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newKBStaleRunner() *kbStaleRunner {
|
||||
return &kbStaleRunner{entered: make(chan struct{}), gate: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (r *kbStaleRunner) Run(context.Context, string, []model.Message, loop.SessionEnv) (*loop.Result, error) {
|
||||
r.once.Do(func() { close(r.entered) })
|
||||
<-r.gate
|
||||
return &loop.Result{Text: "", StopReason: agent.StopReasonStop}, nil
|
||||
}
|
||||
|
||||
func (r *kbStaleRunner) waitEntered(t *testing.T) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-r.entered:
|
||||
case <-time.After(kbRunnerTimeout):
|
||||
t.Fatal("回合没有进入 runner")
|
||||
}
|
||||
}
|
||||
|
||||
func (r *kbStaleRunner) release() { close(r.gate) }
|
||||
@@ -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