Files
Cyrene_v2/internal/runtime/loop/assemble_skill_render.go
T
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

180 lines
5.8 KiB
Go
Raw 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 (
"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)
}