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