- 清单只给 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 启发式(只认显式挂载);多技能并集;收缩作用域=激活的那个回合。
371 lines
15 KiB
Go
371 lines
15 KiB
Go
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
|
||
}
|