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) }