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(机械依赖,只改调用形状,断言一字未动)
This commit is contained in:
1 parent
811e463cea
commit
6deab2e836
2 files changed
+287
-23
No files matched your search
@@ -60,8 +60,11 @@ 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)."},
|
||||
"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"},
|
||||
@@ -143,24 +146,41 @@ 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)。
|
||||
// resolvePersona 解析本次检索的角色作用域:**只认本回合角色**
|
||||
// (tool.TurnPersonaFrom;循环在 Run 顶部经 tool.WithTurnPersona 发布,见
|
||||
// internal/runtime/tool/turncontext.go)。显式参数 `persona` 只作形状兼容 ——
|
||||
// 它**必须等于**本回合角色,不等即拒绝。
|
||||
//
|
||||
// 为什么必须解析:记忆检索按角色作用域隔离(design/03 §3/§4),拿不到角色就
|
||||
// 取不到正确的候选集。把空角色透给记忆存储只会换来它自己的 ErrMissingPersona
|
||||
// 为什么角色不许由参数指定:检索按角色作用域隔离(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,于是只能对同一堵墙反复重试。两者都拿不到时返回可消化的
|
||||
// 说明还写着 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
|
||||
persona := tool.TurnPersonaFrom(ctx)
|
||||
if requested := strings.TrimSpace(argString(m, "persona")); requested != "" && requested != persona {
|
||||
return "", personaScopeError(requested, persona)
|
||||
}
|
||||
if persona := tool.TurnPersonaFrom(ctx); persona != "" {
|
||||
return persona, nil
|
||||
if persona == "" {
|
||||
return "", unavailableKnowledgeError("本次会话没有解析出可用角色")
|
||||
}
|
||||
return "", unavailableKnowledgeError("本次会话没有解析出可用角色")
|
||||
return persona, nil
|
||||
}
|
||||
|
||||
// unavailableKnowledgeError 构造「本工具不可用」这一类失败的可消化拒绝:
|
||||
@@ -170,3 +190,19 @@ 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)
|
||||
}
|
||||
Reference in new issue
Block a user