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,179 @@
package loop
import (
"hash/fnv"
"strconv"
"strings"
"unicode/utf8"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/skills"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件是技能面的**渲染与预算**(DevDocs/design/09-插件生态.md §6.3-1/§6.3-2/
// §6.3-5):清单投影怎么渲染成模型可见的一行行说明、正文怎么渲染成一段可注入
// 文本、以及正文的字节预算怎么施加(技能包的 1536 字符清单预算由 BuildListing
// 自己施加)。渲染都是纯函数,缓存策略见 assemble_skill.go 的 bodyOf。
// renderSkillListing 把清单投影渲染成模型可见的一段文本:逐行 `- 名字: 说明`。
//
// 三态在这里逐格落地(design/09 §6.3-4):`model-only` 的行**没有说明**
// (BuildListing 已按该态省略 Description),`user-only` 的行照常带说明。
// 条数超上限时如实标注(BuildListing 置的 Truncated)—— 绝不静默丢条目;
// 因会话展开上限未注入正文的技能同样如实标注。
func renderSkillListing(listing skills.Listing, dropped int) string {
if len(listing.Entries) == 0 && dropped == 0 {
return ""
}
var b strings.Builder
b.WriteString(skillListingHeader)
for _, e := range listing.Entries {
b.WriteString("\n- ")
b.WriteString(e.Name)
if e.Description != "" {
b.WriteString(": ")
b.WriteString(e.Description)
}
}
if listing.Truncated {
b.WriteString("\n[skill-listing ")
b.WriteString(SkillBodyTruncatedMarker)
b.WriteString("] 清单只列到第 ")
b.WriteString(strconv.Itoa(len(listing.Entries)))
b.WriteString(" 条(全局上限),其余条目未列出")
}
if dropped > 0 {
b.WriteString("\n[skill-listing ")
b.WriteString(SkillBodyTruncatedMarker)
b.WriteString("] 另有 ")
b.WriteString(strconv.Itoa(dropped))
b.WriteString(" 个已激活技能的正文未展开(会话上限 ")
b.WriteString(strconv.Itoa(SkillActiveLimit))
b.WriteString(")")
}
return b.String()
}
// bodyOf 返回该技能的注入正文:按「技能名 + 内容指纹」缓存,同一份定义
// **只渲染一次**,后续回合与后续轮次都直接复用(design/09 §6.3-3:注入后不再
// 重复读盘)。定义被热装覆盖时指纹变化,自然重新渲染。
func (f *SkillFace) bodyOf(s sdk.Skill) string {
key := bodyCacheKey(s)
f.mu.Lock()
if body, ok := f.bodies[key]; ok {
f.stats.BodyCacheHits++
f.mu.Unlock()
return body
}
f.mu.Unlock()
body, truncated := renderSkillBody(s, f.bodyBytes)
f.mu.Lock()
f.bodies[key] = body
f.stats.BodyRenders++
if truncated {
f.stats.BodyTruncations++
}
f.mu.Unlock()
if truncated {
f.log.Warn("skill body truncated to the inject budget",
"skill", s.Name(), "budget_bytes", f.bodyBytes)
}
return body
}
// renderSkillBody 渲染一个技能的注入正文:名字 + 说明 + 基础提示词 + 步骤渲染
// 文本 + 约束(design/09 §6.3-1 把正文定义为「prompt / steps 渲染文本」;约束
// 一并给出,使模型能按 guardrail 自控,而强制点仍由执行侧把守)。渲染结果按
// 字节预算截断,截断时追加 SkillBodyTruncatedMarker(标记计入预算,保证结果
// 不超过上限 —— 与清单的 1536 字符预算同一口径)。
func renderSkillBody(s sdk.Skill, budget int) (string, bool) {
var b strings.Builder
b.WriteString("技能正文:")
b.WriteString(s.Name())
if d := strings.TrimSpace(s.Description()); d != "" {
b.WriteString("\n说明:")
b.WriteString(d)
}
if p := strings.TrimSpace(s.Prompt()); p != "" {
b.WriteString("\n\n")
b.WriteString(p)
}
if steps := s.Steps(); len(steps) > 0 {
b.WriteString("\n\n步骤:")
for i, st := range steps {
b.WriteString("\n")
b.WriteString(strconv.Itoa(i + 1))
b.WriteString(". ")
b.WriteString(strings.TrimSpace(st.Name))
if p := strings.TrimSpace(st.Prompt); p != "" {
b.WriteString(" — ")
b.WriteString(p)
}
if len(st.Depends) > 0 {
b.WriteString("(依赖:")
b.WriteString(strings.Join(st.Depends, ", "))
b.WriteString(")")
}
}
}
if gs := s.Guardrails(); len(gs) > 0 {
b.WriteString("\n\n约束:")
for _, g := range gs {
b.WriteString("\n- ")
b.WriteString(g.ID)
if g.Enforced {
b.WriteString("(强制)")
}
b.WriteString(":")
b.WriteString(strings.TrimSpace(g.Message))
}
}
return truncateUTF8Bytes(b.String(), budget)
}
// truncateUTF8Bytes 把文本截到不超过 n 字节,且不切断 UTF-8 字符;被截断时末尾
// 追加 SkillBodyTruncatedMarker(标记计入 n,因此结果绝不超过上限)。
func truncateUTF8Bytes(s string, n int) (string, bool) {
if n <= 0 || len(s) <= n {
return s, false
}
if n <= len(SkillBodyTruncatedMarker) {
return cutUTF8Prefix(s, n), true
}
return cutUTF8Prefix(s, n-len(SkillBodyTruncatedMarker)) + SkillBodyTruncatedMarker, true
}
// cutUTF8Prefix 取不超过 n 字节的前缀,且不落在一个 UTF-8 字符中间
// (最多回退 3 字节)。
func cutUTF8Prefix(s string, n int) string {
if n >= len(s) {
return s
}
cut := n
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut]
}
// bodyCacheKey 拼出正文缓存的键:技能名 + 定义内容指纹。指纹覆盖正文渲染真正
// 读到的字段,使「同名但内容变了」(热装刷新)不会被旧缓存顶掉。
func bodyCacheKey(s sdk.Skill) string {
h := fnv.New64a()
writeHash := func(parts ...string) {
for _, p := range parts {
_, _ = h.Write([]byte(p))
_, _ = h.Write([]byte{0})
}
}
writeHash(s.Name(), s.Description(), s.Prompt())
for _, st := range s.Steps() {
writeHash(st.Name, st.Prompt, strings.Join(st.Depends, ","))
}
for _, g := range s.Guardrails() {
writeHash(g.ID, g.Message, strconv.FormatBool(g.Enforced))
}
return s.Name() + "\x00" + strconv.FormatUint(h.Sum64(), 16)
}