154 lines
6.1 KiB
Go
154 lines
6.1 KiB
Go
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)
|
||
}
|