Files
Cyrene_v2/plugins/tools/io/knowledge.go
T
AskaEth 6deab2e836
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(io): knowledge 工具不再接受「以他角色身份检索」(红线越权读)
design/03 §7.1 写死:跨角色读只有一条通路(把 B 当子代理派出去查);
「自己的检索能拿到 B 的角色日常记忆 = 已经越权(不是灰区)」。

缺陷:resolvePersona 让显式 persona 参数优先且零校验 ⇒ 模型(或提示词注入)
传 persona=B 即走 ListByPersona(B),读到 B 的角色日常记忆。

- 角色改为只能来自当前回合(tool.TurnPersonaFrom(ctx));显式 persona 非空
  且不等于本回合角色 ⇒ 明确拒绝(可消化:原因 + 改道 + 不许重复调用)
- 选「严格校验」而非「删参数」:删参数会让模型静默改用本回合角色,把 A 的
  记忆当成 B 的(归属错认)且毫无信号;删键亦属对外可见的 schema 变化
- 工具 schema 键集合与必填项不变(只改 description 文案)⇒ 契约零变化
- 子代理通路未破(子代理回合的 TurnPersonaFrom 本来就是 B,已加用例钉住)
- 顺带核对:FilterSharedVisible 仍在,受限环境过滤未因地塌陷

点名改掉两条「依赖缺陷行为」的既有用例:
- TestKnowledge_ExplicitPersonaWins(原断言把「模型点名即换角色」写成规格)
- TestKnowledge_ReturnsResults(机械依赖,只改调用形状,断言一字未动)
2026-10-07 19:17:07 +08:00

209 lines
10 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 只作形状兼容:它必须**等于**本回合的角色,其他值一律被拒绝
// (design/03 §7.1:检索他角色的 scope=persona 无路径)。说明里写死这
// 一点,使合规模型根本不填它,而不是把它当成一个可自由选择的角色旋钮。
"persona": map[string]any{"type": "string", "description": "Must equal the persona of the current turn; any other value is refused. Memory retrieval always runs as the persona of the current turn, so omit this field."},
"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
}
// 群聊/陌生人(受限环境)是只读的(design/03 §5「能查不能写」):检索
// 时关掉衰减退化的物化回写(memory.WithoutDecayWriteback),并在返回前
// 复用循环注入路径上的同一隔离判据(loop.FilterSharedVisible),绝不另造
// 第二套 —— 否则一旦管理面板把本工具放行给群聊/陌生人,就会返回角色日常
// (ScopePersona,主人私有)与 L4 意图记录(主人逐句原话),触碰 design/03
// §5「不泄露主人敏感信息」(审计 2026-10-05,P0 红线)。
restricted := loop.SessionEnvFrom(ctx).Restricted()
rctx := ctx
if restricted {
rctx = memory.WithoutDecayWriteback(ctx)
}
results, err := k.retriever.Retrieve(rctx, persona, query, topK)
if err != nil {
return nil, fmt.Errorf("io: knowledge: retrieve: %w", err)
}
if restricted {
results = loop.FilterSharedVisible(results)
}
out := make([]map[string]any, 0, len(results))
for _, r := range results {
entry := map[string]any{
"id": r.Item.ID,
"kind": string(r.Item.Kind),
"scope": string(r.Item.Scope),
"content": r.Item.Content,
"score": r.Score,
}
// 项目作用域记忆链接到项目索引层级(design/03 §3:项目索引 →
// 项目描述 → 章节内容)。此前工具丢掉了 Result.Project,模型因此
// 只看得到一条句子,够不着它所属的项目(审计 2026-10-05 §4「模型
// 不可达章节」)。这里把链接的项目条目带出(id/title/desc),使模型
// 能沿「项目索引 → 项目描述」继续深入,而不是盲取一条悬空记忆。
if r.Project != nil {
entry["project"] = map[string]any{
"id": r.Project.ID,
"title": r.Project.Title,
"desc": r.Project.Desc,
}
}
out = append(out, entry)
}
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 解析本次检索的角色作用域:**只认本回合角色**
// (tool.TurnPersonaFrom;循环在 Run 顶部经 tool.WithTurnPersona 发布,见
// internal/runtime/tool/turncontext.go)。显式参数 `persona` 只作形状兼容 ——
// 它**必须等于**本回合角色,不等即拒绝。
//
// 为什么角色不许由参数指定:检索按角色作用域隔离(design/03 §3/§4),而
// 「直接检索他角色的 scope=persona(角色日常)」**没有路径**(design/03 §7.1
// 的判据 + §7.6 R4 + retrieve.go 的 visibleCandidates:候选域 = ListByPersona
// ∪ ScopeUser ∪ ScopeProject)。本回合角色由编排层盖章、模型影响不到;`persona`
// 参数则来自模型输出(可被提示词注入操纵)—— 一旦认它,模型只要传 persona=B,
// 这条链就会以 **B 的角色**去检索,把 B 的角色日常读出来,直接越过 §7.1 的红线。
// 跨角色读的**唯一**通路是把 B 当子代理派出去查(design/03 §7.2):那条通路上
// B 本就是子代理回合的角色(orchestration 的 withPersonaEnv → personaFor →
// WithTurnPersona),TurnPersonaFrom(ctx) 天然是 B,因此这里不必也不许开任何口子。
//
// 为什么是「校验 + 拒绝」而不是「直接删掉参数」:删掉参数后,模型传 persona=B
// 时会**静默**改用本回合角色 A —— 结果集虽不越权(A 只拿到 A 的可见域),但它
// 会把 A 的记忆当成 B 的(归属错认),并且没有任何信号让它停止换角色重试。
// 校验则给出可消化的拒绝(见 personaScopeError):写明不一致、要求去掉参数重试。
//
// 为什么拿不到角色也要拒绝:记忆检索按角色作用域隔离(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) {
persona := tool.TurnPersonaFrom(ctx)
if requested := strings.TrimSpace(argString(m, "persona")); requested != "" && requested != persona {
return "", personaScopeError(requested, persona)
}
if persona == "" {
return "", unavailableKnowledgeError("本次会话没有解析出可用角色")
}
return persona, nil
}
// unavailableKnowledgeError 构造「本工具不可用」这一类失败的可消化拒绝:
// 原因 + 明确要求改道。错误包住 ErrKnowledgeUnavailable,调用方据此以
// errors.Is 分类,文案本身则是给模型(以及工具失败卡片)看的。
func unavailableKnowledgeError(reason string) error {
return fmt.Errorf("io: knowledge: %w(%s):重试不会成功,请改用其他工具或直接作答,不要重复调用本工具。",
ErrKnowledgeUnavailable, reason)
}
// personaScopeError 构造「点名了别的角色」这一类**越权尝试**的拒绝。它与
// unavailableKnowledgeError 同一口径(同样包住 ErrKnowledgeUnavailable,同样
// 是「原因 + 明确要求改道 + 不许重复调用」),只是改道动作不同:本回合的角色
// 仍然可用,模型去掉 persona 参数即可按本回合角色检索,不必放弃本工具。
//
// 拒绝而不是静默改用本回合角色,以及为什么这类调用必须无处可去,见
// resolvePersona 的注释与 design/03 §7.1/§7.6 R4。
func personaScopeError(requested, turn string) error {
if turn == "" {
return fmt.Errorf("io: knowledge: %w(本回合没有解析出可用角色,不能以 persona=%q 的身份检索,记忆检索只以本回合角色的身份进行):去掉 persona 参数后重试,或改用其他工具;不要以其他角色重复调用本工具。",
ErrKnowledgeUnavailable, requested)
}
return fmt.Errorf("io: knowledge: %w(本回合的角色是 %q,persona=%q 与它不一致,记忆检索只以本回合角色的身份进行,不能以其他角色的身份检索):去掉 persona 参数后重试;不要以其他角色重复调用本工具。",
ErrKnowledgeUnavailable, turn, requested)
}