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 }