feat(skills): 技能加载/校验/真执行第一片(design/09 §6 定稿落地)

- 加载器/校验:目录式 configs/skills/<id>/skill.yaml(兼容旧单文件 <id>.yaml,先目录后文件);
  新字段 version/when-to-use/invocation(三态)/triggers/paths/allowed-tools/disallowed-tools/
  context(ine|fork)/agent/model/effort/metadata;**依赖环 ⇒ 加载期拒**(运行期绝不挂死);
  depends 指向不存在步骤 ⇒ 拒;同一工具同时在 allowed 与 disallowed ⇒ 拒;错误可分类(哨兵 + %w)
- Runner.Run 真执行(替换 ErrRunPending):步骤拓扑(无依赖可并行、上限 2)+ guardrail 强制点
  (enforced 违反 ⇒ 中止并如实报「哪条、哪一步」;否则 WARN 继续)+ 失败不静默跳过 + ctx 取消传播
- 🔴 能力收缩只收不放:该轮可用工具 = allowed-tools ∩ 安全闸 ∩ 注册表,再减 disallowed-tools
- 另有渐进披露相关的 listing/surface/activation(清单投影与激活判定)

⚠️ 未接线:本片是库与执行器,尚未把技能清单接进模型面(见 report 的分阶段)。
This commit is contained in:
AskaEth committed 2026-10-06 09:03:35 +08:00
1 parent 29181245ce
commit f75e7a9c9b
17 files changed
+3067 -164

No files matched your search

+124
View File
@@ -0,0 +1,124 @@
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
}