fix(gateway,loop,tools): 失败回合必落可见回复 + knowledge 角色可信
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

根因(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:
AskaEth committed 2026-10-04 22:14:42 +08:00
1 parent 3be70f8232
commit 18381f65fd
9 files changed
+838 -11

No files matched your search

+47 -4
View File
@@ -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)
}