Files
Cyrene_v2/plugins/tools/io/knowledge.go
T
AskaEth 3e3d37a824
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(memory): 堵住 knowledge 工具绕过群聊/陌生人过滤的 P0 / owner 召回 hits 如实上报 / 静默吞点补来源日志
2026-10-05 11:30:42 +08:00

154 lines
6.1 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"
"fmt"
"log/slog"
"strings"
"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/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.
type KnowledgeRetriever interface {
Retrieve(ctx context.Context, personaID, query string, topK int) ([]memory.Result, error)
}
// Compile-time assertion that knowledgeTool implements sdk.Tool.
var _ sdk.Tool = (*knowledgeTool)(nil)
// knowledgeTool answers a question from the configured knowledge base via a
// memory retriever, returning ranked results or a clear message when absent.
type knowledgeTool struct {
retriever KnowledgeRetriever
maxResults int
log *slog.Logger
}
func newKnowledgeTool(t *tools) *knowledgeTool {
return &knowledgeTool{
retriever: t.retriever,
maxResults: t.maxResults,
log: t.log,
}
}
// Name implements sdk.Tool.
func (k *knowledgeTool) Name() string { return "knowledge" }
// Description implements sdk.Tool.
func (k *knowledgeTool) Description() string {
return "Query the knowledge base and long-term memory store for context relevant to a question."
}
// Parameters implements sdk.Tool.
func (k *knowledgeTool) Parameters() (map[string]any, error) {
return map[string]any{
"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; 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. 它按「角色作用域 + query」检索记忆,并在工具
// 本身不可用时(未接知识库 / 本回合没有角色)返回一条**可消化的拒绝**:写明
// 原因并要求模型改道,而不是透出一个它无从纠正的底层错误。
func (k *knowledgeTool) Execute(ctx context.Context, args any) (any, error) {
if k.retriever == nil {
return nil, unavailableKnowledgeError("没有配置知识库")
}
m, ok := args.(map[string]any)
if !ok {
return nil, fmt.Errorf("io: knowledge: args must be an object")
}
query := argString(m, "query")
if strings.TrimSpace(query) == "" {
return nil, fmt.Errorf("io: knowledge: query is required")
}
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
}
results, err := k.retriever.Retrieve(ctx, persona, query, topK)
if err != nil {
return nil, fmt.Errorf("io: knowledge: retrieve: %w", err)
}
// 群聊/陌生人(受限环境)只读非敏感的共享域记忆:复用循环注入路径上的
// 同一隔离判据(loop.FilterSharedVisible),绝不另造第二套 —— 否则一旦
// 管理面板把本工具放行给群聊/陌生人,就会返回角色日常(ScopePersona,
// 主人私有)与 L4 意图记录(主人逐句原话),触碰 design/03 §5
// 「不泄露主人敏感信息」(审计 2026-10-05,P0 红线)。
if loop.SessionEnvFrom(ctx).Restricted() {
results = loop.FilterSharedVisible(results)
}
out := make([]map[string]any, 0, len(results))
for _, r := range results {
out = append(out, map[string]any{
"id": r.Item.ID,
"kind": string(r.Item.Kind),
"scope": string(r.Item.Scope),
"content": r.Item.Content,
"score": r.Score,
})
}
if len(out) == 0 {
return map[string]any{
"query": query,
"count": 0,
"note": "no relevant knowledge found",
"results": []map[string]any{},
}, nil
}
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)
}