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) }