Files
AskaEth 6475eb875d 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 启发式(只认显式挂载);多技能并集;收缩作用域=激活的那个回合。
2026-10-06 14:12:24 +08:00

371 lines
15 KiB
Go
Raw Permalink 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 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
}