From 18381f65fdf2311cfcbff810b30312150a869391 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 22:14:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(gateway,loop,tools):=20=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E5=9B=9E=E5=90=88=E5=BF=85=E8=90=BD=E5=8F=AF=E8=A7=81=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=20+=20knowledge=20=E8=A7=92=E8=89=B2=E5=8F=AF?= =?UTF-8?q?=E4=BF=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因(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」红 --- internal/runtime/loop/run.go | 6 + internal/runtime/loop/turn_persona_test.go | 80 ++++ internal/runtime/tool/turncontext.go | 43 ++ .../services/gateway/session_orchestrator.go | 2 +- internal/services/gateway/session_persist.go | 4 + .../services/gateway/session_turn_reply.go | 109 +++++ .../gateway/session_turn_reply_test.go | 445 ++++++++++++++++++ plugins/tools/io/knowledge.go | 51 +- plugins/tools/io/knowledge_test.go | 109 ++++- 9 files changed, 838 insertions(+), 11 deletions(-) create mode 100644 internal/runtime/loop/turn_persona_test.go create mode 100644 internal/runtime/tool/turncontext.go create mode 100644 internal/services/gateway/session_turn_reply.go create mode 100644 internal/services/gateway/session_turn_reply_test.go diff --git a/internal/runtime/loop/run.go b/internal/runtime/loop/run.go index a8536ee..f5ed33d 100644 --- a/internal/runtime/loop/run.go +++ b/internal/runtime/loop/run.go @@ -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) diff --git a/internal/runtime/loop/turn_persona_test.go b/internal/runtime/loop/turn_persona_test.go new file mode 100644 index 0000000..a49fafb --- /dev/null +++ b/internal/runtime/loop/turn_persona_test.go @@ -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) + } +} diff --git a/internal/runtime/tool/turncontext.go b/internal/runtime/tool/turncontext.go new file mode 100644 index 0000000..45f5ab2 --- /dev/null +++ b/internal/runtime/tool/turncontext.go @@ -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 +} diff --git a/internal/services/gateway/session_orchestrator.go b/internal/services/gateway/session_orchestrator.go index 560dd1b..812709c 100644 --- a/internal/services/gateway/session_orchestrator.go +++ b/internal/services/gateway/session_orchestrator.go @@ -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() diff --git a/internal/services/gateway/session_persist.go b/internal/services/gateway/session_persist.go index f0027b1..5d7e54f 100644 --- a/internal/services/gateway/session_persist.go +++ b/internal/services/gateway/session_persist.go @@ -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 diff --git a/internal/services/gateway/session_turn_reply.go b/internal/services/gateway/session_turn_reply.go new file mode 100644 index 0000000..650c14b --- /dev/null +++ b/internal/services/gateway/session_turn_reply.go @@ -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 +} diff --git a/internal/services/gateway/session_turn_reply_test.go b/internal/services/gateway/session_turn_reply_test.go new file mode 100644 index 0000000..41a3f71 --- /dev/null +++ b/internal/services/gateway/session_turn_reply_test.go @@ -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 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) } diff --git a/plugins/tools/io/knowledge.go b/plugins/tools/io/knowledge.go index c2c38f3..620804e 100644 --- a/plugins/tools/io/knowledge.go +++ b/plugins/tools/io/knowledge.go @@ -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) +} diff --git a/plugins/tools/io/knowledge_test.go b/plugins/tools/io/knowledge_test.go index 23c075a..5793112 100644 --- a/plugins/tools/io/knowledge_test.go +++ b/plugins/tools/io/knowledge_test.go @@ -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) }