Files
AskaEth f75e7a9c9b 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 的分阶段)。
2026-10-06 09:03:35 +08:00

125 lines
4.4 KiB
Go
Raw Permalink 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 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
}