package skills import ( "strconv" "strings" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // 本文件实现 design/09 §6.3 的**清单投影**:模型在清单里只看到 name + // description(+when-to-use),**正文(prompt / 步骤渲染文本)不进清单**。正文只在 // 技能命中后才展开注入(注入点走 design/14 的唤醒式口径,落在 loop 片)。 // // 这里只做「投影 + 预算 + 三态」这一层纯计算,不读盘、不写上下文。 const ( // SkillListingDescriptionBudget 是单条 description(+when-to-use) 的字符上限 // (design/09 §6.3-2:照 Agent Skills listing 的 1536 字符上限)。 SkillListingDescriptionBudget = 1536 // SkillListingMaxEntries 是清单条数的全局上限。超出时**如实标 truncated**, // 绝不静默丢条目(design/09 §6.3-2)。 SkillListingMaxEntries = 64 // SkillListingTruncatedMarker 是超预算时追加的显式标记。 SkillListingTruncatedMarker = "…(truncated)" ) // ListingEntry 是清单里的一行。它只带名字与描述 —— 正文不在其中。 type ListingEntry struct { // Name 是技能名。 Name string // Description 是 description 与 when-to-use 拼成的一句说明(按预算截断); // invocation=model-only 时为空(该调用态下清单不带描述,省上下文)。 Description string // ModelInvocable 报告模型是否可以调用该技能(invocation=user-only 为 false)。 ModelInvocable bool // UserInvocable 报告人是否可以显式调用该技能(invocation=model-only 为 false)。 UserInvocable bool // Truncated 报告本行描述是否被预算截断。 Truncated bool } // Listing 是清单投影的结果。 type Listing struct { // Entries 是清单条目(保持输入顺序)。 Entries []ListingEntry // Truncated 报告条目数是否因 SkillListingMaxEntries 被截断。 Truncated bool // Note 是截断的人类可读说明(未截断时为空)。 Note string } // BuildListing 把技能列表投影成模型可见的清单:三态过滤 + 单条预算 + 条数上限。 // // 三态(design/09 §6.3-4,与 Agent Skills 的矩阵逐格对齐): // // - both ⇒ 清单含 description,人与模型都可调; // - model-only ⇒ 清单**不含** description,只有模型能调; // - user-only ⇒ 清单含 description,**模型不可调**。 // // nil 技能被跳过。**所有**技能都会出现在清单里(user-only 的技能只是模型不可调, // 但仍要让人在面板里看得到),因此条数上限对三类一视同仁。 func BuildListing(list []sdk.Skill) Listing { out := Listing{} limit := SkillListingMaxEntries for _, s := range list { if s == nil { continue } if len(out.Entries) >= limit { out.Truncated = true break } out.Entries = append(out.Entries, listingEntry(s)) } if out.Truncated { out.Note = "skill listing truncated to " + strconv.Itoa(limit) + " entries" } return out } // listingEntry 投影一个技能。 func listingEntry(s sdk.Skill) ListingEntry { invocation := sdk.SkillView(s).Invocation().OrDefault() entry := ListingEntry{ Name: s.Name(), ModelInvocable: invocation.ModelInvocable(), UserInvocable: invocation.UserInvocable(), } if !invocation.ListingHasDescription() { return entry } text := strings.TrimSpace(joinDescription(s.Description(), sdk.SkillView(s).WhenToUse())) entry.Description, entry.Truncated = truncateBudget(text, SkillListingDescriptionBudget) return entry } // joinDescription 把 description 与 when-to-use 合成清单里那一句(两者都空则为空)。 func joinDescription(description, whenToUse string) string { parts := make([]string, 0, 2) if trimmed := strings.TrimSpace(description); trimmed != "" { parts = append(parts, trimmed) } if trimmed := strings.TrimSpace(whenToUse); trimmed != "" { parts = append(parts, "Use when: "+trimmed) } return strings.Join(parts, " ") } // truncateBudget 按 rune 截断到 n 个字符以内,并报告是否真的截断了(截断标记也 // 计入预算,保证结果长度不超过 n)。 func truncateBudget(s string, n int) (string, bool) { if n <= 0 { return s, false } runes := []rune(s) if len(runes) <= n { return s, false } marker := []rune(SkillListingTruncatedMarker) if n <= len(marker) { return string(runes[:n]), true } return string(runes[:n-len(marker)]) + SkillListingTruncatedMarker, true }