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

+370
View File
@@ -0,0 +1,370 @@
package loop
import (
"context"
"log/slog"
"sort"
"strings"
"sync"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/skills"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件是 SK-5「渐进披露接线」的**模型面装配**(DevDocs/design/09-插件生态.md
// §6.3,注入口径与 design/14 的唤醒式一次请求视图同源):
//
// ① 清单投影:模型可见的只有 name + description(+when-to-use),正文不进清单;
// ② 命中才展开:激活时把正文**作为一条消息**注入,跨回合保留、不重复读盘;
// ③ invocation 三态与清单逐格对齐(model-only 不含 description;user-only
// 清单有但模型不可调 ⇒ 不参与自动激活);
// ⑤ 注入有字节上限,超限截断并标 .truncated。
//
// ④(工具面收缩 = allowed-tools ∩ 安全闸 ∩ 注册表 − disallowed-tools)落在
// 同包的 assemble_skill_surface.go:它是**请求装配**那一侧(Request.Tools)。
//
// 本文件不读盘:技能对象由 SkillSource 从**能力注册表**的 KindSkill 条目里取
// (进程内、每轮实时读),因此运行中热装的技能下一轮即出现在清单里;正文渲染
// 结果按「技能名 + 内容指纹」缓存,同一技能只渲染一次(design/09 §6.3-3 的
// 「does not re-read the skill file on later turns」在注入侧由此成立)。
const (
// SkillBodyInjectBytes 是**单个技能正文**注入模型可见上下文的字节上限
// (design/09 §6.3-5:正文注入有上限,超限截断并标 .truncated)。
//
// 取值依据:清单已经是「每条 ≤1536 字符」的轻投影,正文却是整段工作流
// (基础提示词 + 步骤渲染文本),不封顶的话一个巨型 skill.yaml 就能吃掉整个
// 上下文预算,而压缩器只能事后补救、还会顺手把对话一起压掉。16 KiB 足以容纳
// 一份十几步的常规技能,又对齐 Agent Skills 对正文「约 5k words / 500 行」
// 的建议量级(中文 1 字 ≈ 1 token 上下,16 KiB ≈ 5.5K 汉字 ≈ 5K token)。
SkillBodyInjectBytes = 16 << 10
// SkillActiveLimit 是**单个会话**同时保持展开的技能正文条数上限。
// 正文激活后跨回合保留(design/09 §6.3-3),不设上限的话,一次长会话里反复
// 命中会把正文越堆越多、把对话挤出去。超限的部分**不静默丢**:清单块里如实
// 写明还有几个已激活技能的正文未展开(与 listing 的 truncated 同一口径)。
//
// 取值依据(最坏情形可算):4 × 16 KiB ≈ 64 KiB ≈ 2 万 token 量级的注入上界,
// 在 128K 窗口里占比可接受;再高就可能把对话本身挤掉 —— 技能正文是「工作流
// 说明」,不该比对话还重。(清单最坏情形见 design/09 §6.3-2 的两个上限:
// 64 条 × 1536 字符,实际技能库通常个位数条目、描述一两句。)
SkillActiveLimit = 4
// SkillBodyTruncatedMarker 是正文(或清单)超预算时追加的显式标记
// (design/09 §6.3-5 要求「标 .truncated」)。它字面出现在注入文本里,
// 便于人与日志直接 grep,也让模型知道内容被截过而不是本来就短。
SkillBodyTruncatedMarker = ".truncated"
// skillListingHeader 是清单块首行:写明「清单只有名字与说明,正文按需展开」,
// 使模型知道眼前这些不是技能的全部内容。
skillListingHeader = "技能清单(只有名字与说明;命中相关技能时其正文会被展开)"
)
// SkillSource 返回当前可用的技能全集。实现必须**进程内**给出(例如读能力注册表
// 的 KindSkill 条目),装配每轮都会调用它一次:技能是可在运行中热装的能力
// (design/09 §2.1),清单因此每轮重取,不能是构造期快照。返回 nil/空即
// 「本进程没有技能」,整条技能面自然不出现。
type SkillSource func() []sdk.Skill
// SkillFaceOptions 配置技能面。
type SkillFaceOptions struct {
// Source 提供技能全集。为 nil 时技能面是关闭态(装配与收缩都是空操作),
// 调用方因此可以先无条件接上、再按配置决定是否给 Source。
Source SkillSource
// Log 是基础 logger,为 nil 时替换为丢弃 logger;输出一律带
// source=runtime/loop 的来源标签。
Log *slog.Logger
// BodyBytes 是单个技能正文的注入字节上限;非正取 SkillBodyInjectBytes。
BodyBytes int
}
// SkillFaceStats 是技能面的进程内计数(不参与任何判定,供观测与测试)。
type SkillFaceStats struct {
// Assembles 是装配调用次数(≈ 装配轮数)。
Assembles int
// Listed 是最近一次清单的条目数。
Listed int
// Activations 是累计自动激活次数(同一技能只记第一次)。
Activations int
// BodyRenders 是正文渲染次数 —— 也就是「读盘/渲染」次数,缓存命中不计入,
// 因此它正是「不重复读盘」这条验收判据的可观测证据。
BodyRenders int
// BodyCacheHits 是正文命中缓存的次数。
BodyCacheHits int
// BodyTruncations 是正文被字节预算截断的次数。
BodyTruncations int
// ListingTruncations 是清单因条数上限被截断的次数(按装配轮次累计,不是
// 被丢掉的条目数 —— 被丢条数由 renderSkillListing 的文案如实写在上下文里)。
ListingTruncations int
}
// SkillFace 承载技能面的模型侧状态:每会话的激活集合 + 正文缓存。
//
// 并发契约:Loop 会被并发会话复用(每个 channel/群聊/陌生人消息都解析出自己的
// 边界),因此状态按**会话**分桶并全部在 mu 之下读写;Loop 结构体上只挂一个
// 指针,绝不把会话状态放进 Loop 的字段(那会造成数据竞争,与 SessionEnv 的
// 做法同因)。
type SkillFace struct {
src SkillSource
bodyBytes int
log *slog.Logger
mu sync.Mutex
sessions map[string]*skillSession
bodies map[string]string
stats SkillFaceStats
}
// skillSession 是一个会话的技能面状态(全部在 SkillFace.mu 之下访问)。
type skillSession struct {
// retained 是已展开过的技能 —— 正文跨回合保留的载体(design/09 §6.3-3)。
retained map[string]sdk.Skill
// turn 是**本回合**激活的技能:工具面收缩只在技能轮次内生效(对齐 Agent
// Skills 的「正文进上下文后跨回合保留,但权限不保留」),故每回合清空。
turn map[string]sdk.Skill
// order 是激活顺序,使注入块与工具面的次序都确定(map 遍历序随机会让同一
// 轮的工具面漂移)。
order []string
}
// activationResult 是一次装配的激活结论。
type activationResult struct {
// active 是本轮要注入正文的技能(按激活顺序,已按 SkillActiveLimit 截取)。
active []sdk.Skill
// dropped 是因会话上限**未展开**的已激活技能数(如实上报,不静默丢)。
dropped int
// newly 是本轮新激活的技能名(用于带来源的日志;已激活的老技能不重复上报)。
newly []string
}
// NewSkillFace 构造技能面。Source 为 nil 时返回的是关闭态:所有方法都是空操作。
func NewSkillFace(opts SkillFaceOptions) *SkillFace {
f := &SkillFace{
src: opts.Source,
bodyBytes: opts.BodyBytes,
log: sourcedLog(opts.Log),
sessions: make(map[string]*skillSession),
bodies: make(map[string]string),
}
if f.bodyBytes <= 0 {
f.bodyBytes = SkillBodyInjectBytes
}
return f
}
// SetSkillFace 把技能面接到循环的模型面装配上(design/09 §6.3)。
//
// 必须在任何 Run 之前调用一次(组合根的装配期)。留成 setter 而不是 Options
// 字段,是因为组合根里两件事的次序是反的 —— 循环在 wireRuntime 里已建好,技能
// 注册表要到 wireAuxiliary 才填上(`wireSkills`),因此这里不做构造期强绑定。
// 传 nil 即关闭技能面,模型面与接线前逐字节一致。
func (l *Loop) SetSkillFace(f *SkillFace) {
if l == nil {
return
}
l.skillFace = f
}
// assembleSkillFace 是 nil 安全的装配入口:未接技能面时原样返回入参。
func (l *Loop) assembleSkillFace(ctx context.Context, messages []model.Message, round int) []model.Message {
if l == nil || l.skillFace == nil {
return messages
}
return l.skillFace.Assemble(ctx, messages, round)
}
// Assemble 把技能面投影进一份模型可见的消息列表(design/09 §6.3):先给清单块,
// 再给每个已激活/新命中技能的正文块(**一个技能一条消息**)。
//
// 它由 assembleContext 在 transformContext 之前调用,因此拿到的是本轮完整视图;
// 注入块插在**开头那段 system 消息之后**(人格知识、时间、记忆、角色声明之后),
// 既让前导块次序保持稳定(首部缓存不被后续回合的激活打乱),又让技能正文
// 紧贴它要影响的对话。
//
// 群聊 / 陌生人会话整块跳过(design/09 §6.5:技能是 owner 能力,注册走零值
// Policy ⇒ owner-only),与「人格知识」同一口径:技能正文既不进它们的上下文,
// 也不随请求发给模型供应商。
func (f *SkillFace) Assemble(ctx context.Context, messages []model.Message, round int) []model.Message {
if f == nil || f.src == nil {
return messages
}
if sessionEnvFrom(ctx).Restricted() {
return messages
}
list := f.src()
if len(list) == 0 {
return messages
}
res := f.activate(loopSession(ctx), list, round, lastUserText(messages), turnWorkFiles(ctx, messages))
listing := skills.BuildListing(list)
f.mu.Lock()
f.stats.Assembles++
f.stats.Listed = len(listing.Entries)
if listing.Truncated {
f.stats.ListingTruncations++
}
f.mu.Unlock()
if len(res.newly) > 0 {
f.log.Info("skill activated",
"session", loopSession(ctx), "round", round, "skills", strings.Join(res.newly, ","))
}
blocks := f.faceBlocks(listing, res)
if len(blocks) == 0 {
return messages
}
at := leadingSystemCount(messages)
out := make([]model.Message, 0, len(messages)+len(blocks))
out = append(out, messages[:at]...)
out = append(out, blocks...)
return append(out, messages[at:]...)
}
// activate 更新该会话的技能面状态,并给出本轮要注入正文的技能。
//
// 命中判定复用技能包已交付的纯函数 skills.AutoActivates(触发词命中**或**工作
// 文件命中 paths),只对**模型可调**的技能求值:user-only 的技能清单里照常列出
// (人看得见),但模型不可调 ⇒ 不参与自动激活(design/09 §6.3-4)。
//
// round<=1 视为**新回合**的开始(Run 内 step 从 1 起算,崩溃恢复从中间续接),
// 于此处清空「本回合激活」而保留跨回合的正文集合。
func (f *SkillFace) activate(session string, list []sdk.Skill, round int, turnText string, workFiles []string) activationResult {
if session == "" {
// 没有会话标识就没有「跨回合保留」的载体。此时只投影清单,绝不把激活
// 状态挂到一个空键上 —— 那会让互不相干的运行(子代理、后台思考、
// 未接会话的调用方)互相看见对方的技能面。
return activationResult{}
}
f.mu.Lock()
defer f.mu.Unlock()
st := f.sessionLocked(session)
if round <= 1 {
st.turn = make(map[string]sdk.Skill, len(st.turn))
}
current := make(map[string]sdk.Skill, len(list))
for _, s := range list {
if s == nil {
continue
}
if name := strings.TrimSpace(s.Name()); name != "" {
current[name] = s
}
}
// 定义可能被热装覆盖(admin 安装同名技能 = 刷新):已激活条目跟着换成当前
// 定义,正文缓存按内容指纹重算,绝不把旧正文当成新定义继续注入。
for name := range st.retained {
s, ok := current[name]
if !ok {
continue
}
st.retained[name] = s
if _, active := st.turn[name]; active {
st.turn[name] = s
}
}
var res activationResult
for _, name := range orderedNames(current) {
s := current[name]
if !sdk.SkillView(s).Invocation().ModelInvocable() {
continue
}
hit := skills.AutoActivates(s, turnText, workFiles)
if _, retained := st.retained[name]; retained {
// 已激活的技能:正文早已在上下文里,不重复上报激活。但它在本回合**再次
// 命中**(触发词/工作文件又出现)时,本回合的工具面收缩要重新生效 ——
// 否则「同一技能第二次用起来」会拿不到它声明的工具上限。
if hit {
st.turn[name] = s
}
continue
}
if !hit {
continue
}
st.retained[name] = s
st.turn[name] = s
st.order = append(st.order, name)
f.stats.Activations++
res.newly = append(res.newly, name)
}
all := orderedSkills(st, st.retained)
if len(all) > SkillActiveLimit {
res.dropped = len(all) - SkillActiveLimit
all = all[:SkillActiveLimit]
}
res.active = all
return res
}
// faceBlocks 生成注入块:清单一条 + 每个已激活技能正文一条(design/09 §6.3-3
// 「激活时把技能正文作为一条消息注入」)。没有任何技能、也没有已激活正文时
// 返回空,调用方据此保持消息列表逐字节不变。
func (f *SkillFace) faceBlocks(listing skills.Listing, res activationResult) []model.Message {
blocks := make([]model.Message, 0, len(res.active)+1)
if text := renderSkillListing(listing, res.dropped); text != "" {
blocks = append(blocks, model.TextMessage(model.RoleSystem, text))
}
for _, s := range res.active {
blocks = append(blocks, model.TextMessage(model.RoleSystem, f.bodyOf(s)))
}
return blocks
}
// sessionLocked 取(必要时新建)该会话的技能面状态。调用方必须持有 f.mu。
func (f *SkillFace) sessionLocked(session string) *skillSession {
st, ok := f.sessions[session]
if !ok {
st = &skillSession{
retained: make(map[string]sdk.Skill),
turn: make(map[string]sdk.Skill),
}
f.sessions[session] = st
}
return st
}
// orderedNames 按名字升序返回技能名,使「本回合新激活」的遍历序确定
// (源列表来自注册表,其 List 遍历的是 map)。
func orderedNames(current map[string]sdk.Skill) []string {
out := make([]string, 0, len(current))
for name := range current {
out = append(out, name)
}
sort.Strings(out)
return out
}
// orderedSkills 按该会话的激活顺序输出集合里的技能。map 遍历序随机,直接遍历会
// 让注入块与工具面在同一轮内漂移,因此统一按激活顺序取。
func orderedSkills(st *skillSession, set map[string]sdk.Skill) []sdk.Skill {
out := make([]sdk.Skill, 0, len(set))
for _, name := range st.order {
if s, ok := set[name]; ok {
out = append(out, s)
}
}
return out
}
// Stats 返回技能面的计数快照(nil 接收者返回零值)。
func (f *SkillFace) Stats() SkillFaceStats {
if f == nil {
return SkillFaceStats{}
}
f.mu.Lock()
defer f.mu.Unlock()
return f.stats
}
// leadingSystemCount 返回消息列表**开头连续**的 system 消息条数:注入块插在
// 这一段之后,使前导块(人格知识 / 时间 / 工作记忆 / 记忆 / 角色声明)的次序
// 与接线前逐字一致,而技能块紧贴对话。
func leadingSystemCount(messages []model.Message) int {
n := 0
for n < len(messages) && messages[n].Role == model.RoleSystem {
n++
}
return n
}