feat(skills): 渐进披露接线(L2)—— 清单投影 + 命中才展开 + 工具面只收不放

- 清单只给 name+description(+when-to-use),单条 ≤1536 字符 + 全局条数上限,超限如实标 truncated
- 命中才把正文作为一条消息注入:跨回合保留、按内容指纹缓存不重复读盘、注入 ≤16 KiB 标 .truncated
- invocation 三态与清单逐格对齐(model-only 清单不含 description;user-only 清单有但模型不可调)
- 激活轮的工具面 = allowed-tools ∩ 安全闸 ∩ 注册表 再减 disallowed-tools(owner 也看不见未允许的工具)
- 激活状态进程内按会话(不落库);注入量已界化(压缩器估值未含,超我文件面)

裁定(DECISIONS #3/#4/#5):删掉 paths 启发式(只认显式挂载);多技能并集;收缩作用域=激活的那个回合。
This commit is contained in:
AskaEth committed 2026-10-06 14:12:24 +08:00
1 parent 2e1c3ed3fd
commit 6475eb875d
10 files changed
+1448 -3

No files matched your search

@@ -0,0 +1,106 @@
package loop
import (
"context"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/skills"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件把技能包已交付的能力收缩求值(skills.ResolveToolSurface)接进**请求
// 装配**(DevDocs/design/09-插件生态.md §6.4-3):
//
// 技能轮次可用工具 = allowed-tools(若声明)∩ 当前安全闸 ∩ 全局注册表
// − disallowed-tools
//
// 🔴 铁律:**任何情况下都不得因为技能而放宽**。因此这里只从「本轮已经过
// capacity 允许集 + design/11 §3 安全门 + 注册表」的工具面里**删**名字:
// 技能声明的 allowed-tools 里若含未注册/未授权的工具,它照样拿不到 —— 闸就是
// 入参名字集合本身(fail-closed),绝不是「全放行」的闸。
//
// 生效范围:**技能轮次**。正文跨回合保留,但这个工具面收缩只在技能被激活的
// 那个回合内生效(对齐 Agent Skills 的「正文进上下文后跨回合保留,但权限不
// 保留」),否则一次命中会让该会话此后所有回合都只剩技能声明的那几个工具。
// narrowToolSchemas 在已过滤的本轮工具面之上再做技能收缩,并把收缩后的面重新
// 发布到本 Run 的允许集(design/14 §4 的软判通道),使 Invoke 的越界判定与模型
// 真正看到的工具面同源:模型凭记忆调用被技能收窄掉的工具时,会拿到 not_allowed
// 软提示(调用照常执行 —— 状态不是能力边界)。
//
// 未接技能面、本轮没有激活技能、或收缩后名单没变时都是空操作。
func (l *Loop) narrowToolSchemas(ctx context.Context, schemas []model.ToolSpec) []model.ToolSpec {
if l == nil || l.skillFace == nil || len(schemas) == 0 {
return schemas
}
out, active, changed := l.skillFace.narrow(ctx, schemas)
if !changed {
return schemas
}
names := make([]string, 0, len(out))
for _, s := range out {
names = append(names, s.Name)
}
tool.PublishRoundAllowSet(ctx, names, "skill:"+strings.Join(active, "+"))
l.log.Info("skill tool surface narrowed",
"session", loopSession(ctx), "skills", strings.Join(active, ","),
"tools_before", len(schemas), "tools_after", len(out))
return out
}
// narrow 返回本回合激活技能收缩后的工具面、参与收缩的技能名,以及「是否真的
// 收敛了」(收缩前后条数不同)。
//
// 入参 schemas 是 dispatcher 给出的、已经过 capacity 与安全门的本轮工具面,
// 也就是说「安全闸 ∩ 注册表」已经在入参里;技能只能在这个交集里再删。
//
// 多个技能同时激活时取**并集**:每个技能的 allowed-tools 是它自己的上限,并集
// 仍然落在入参之内(安全性不受影响);取交集则会让两个技能互相封掉对方的工具,
// 且「技能 A 声明 [x]、技能 B 不声明」时会直接变成空集 —— 那不是任何设计意图。
func (f *SkillFace) narrow(ctx context.Context, schemas []model.ToolSpec) ([]model.ToolSpec, []string, bool) {
active := f.turnSkills(ctx)
if len(active) == 0 {
return schemas, nil, false
}
registered := make([]string, 0, len(schemas))
for _, s := range schemas {
registered = append(registered, s.Name)
}
gate := skills.NewNameSet(registered)
keep := make(map[string]struct{}, len(registered))
for _, sk := range active {
spec := sdk.SkillView(sk)
for _, name := range skills.ResolveToolSurface(registered, spec.AllowedTools(), spec.DisallowedTools(), gate) {
keep[name] = struct{}{}
}
}
out := make([]model.ToolSpec, 0, len(keep))
for _, s := range schemas {
if _, ok := keep[s.Name]; ok {
out = append(out, s)
}
}
names := make([]string, 0, len(active))
for _, sk := range active {
names = append(names, sk.Name())
}
return out, names, len(out) != len(schemas)
}
// turnSkills 返回该会话**本回合**激活的技能(按激活顺序);无会话标识或本回合
// 没有激活时为空 —— 于是工具面完全不动。
func (f *SkillFace) turnSkills(ctx context.Context) []sdk.Skill {
session := loopSession(ctx)
if session == "" {
return nil
}
f.mu.Lock()
defer f.mu.Unlock()
st := f.sessions[session]
if st == nil || len(st.turn) == 0 {
return nil
}
return orderedSkills(st, st.turn)
}