Go版本claude-code项目github地址

Phase 7 Skill 技能系统实现详解

对应设计方案:Phase 7 —— Skill 技能系统
涵盖:claude-code 源码深度分析、Frontmatter 规范、Go 实现方案、与现有代码的集成点

一、claude-code Skill 系统架构全貌

1.1 整体架构

┌──────────────────────────────────────────────────────────────────┐
│                       Skill 注册表(命令表)                        │
│                                                                   │
│  bundledSkills   +   fileSystemSkills   +   dynamicSkills        │
│  (内置,二进制内)      (~/.claude/skills)     (会话中动态发现)        │
└───────────────────────────────┬──────────────────────────────────┘
                                │  findCommand(name)
                                ▼
┌──────────────────────────────────────────────────────────────────┐
│                       Slash 命令路由器                             │
│                                                                   │
│  用户输入 /skill-name args                                         │
│       │                                                           │
│       ├── context: "inline" → 展开为用户消息 → 主 Agent 处理         │
│       └── context: "fork"   → 启动独立子 Agent → 返回结果           │
└──────────────────────────────────────────────────────────────────┘

1.2 技能加载的四个来源(按优先级)

优先级来源路径说明
最高项目级.claude/skills/<name>/SKILL.md当前 CWD 下项目特定技能
用户级~/.claude/skills/<name>/SKILL.md用户全局技能
内置打包编译进二进制(embed.FS)随程序分发
最低/动态动态发现工作目录树中的 .claude/skills/会话中按文件路径激活
实现细节:claude-code 用 realpath() 规范化路径后去重,同名 Skill 按优先级取第一个(高优先级覆盖低优先级)。

二、SKILL.md 文件格式规范

2.1 完整 Frontmatter 字段

来源:src/utils/frontmatterParser.ts + src/types/command.ts

---
# === 基础信息 ===
name: commit                          # 显示名称(覆盖目录名)
description: Create a git commit      # 用户可见描述(在 /help 中显示)
when_to_use: When you want to commit  # 向模型说明何时调用此 Skill
argument-hint: "optional: message"   # 参数提示(UI 显示)

# === 执行控制 ===
context: fork                         # "inline"(默认)或 "fork"
model: claude-sonnet-4-6              # 覆盖默认模型("opus"/"haiku" 等别名也可)
effort: high                          # 思考深度:"low"/"medium"/"high"/"max" 或整数

# === 权限控制 ===
allowed-tools:                        # 限制此 Skill 可用的工具子集
  - Bash
  - Read
  - Write

# === 调用控制 ===
user-invocable: "true"                # 用户可否通过 /skill-name 调用(默认 true)
disable-model-invocation: "false"     # 禁止模型通过 Skill 工具调用(默认 false)

# === 条件激活(动态 Skill)===
paths: "src/**/*.{ts,tsx}"           # 匹配文件路径时自动激活

# === 别名 ===
aliases:
  - git-commit
  - gc

# === Hooks ===
hooks:
  PreToolUse:
    - matcher: "bash"
      hooks:
        - command: "echo running skill"
---

# Commit

Review staged changes and create a conventional commit.

$ARGUMENTS

2.2 关键字段语义

字段类型默认值语义
context`"inline" \"fork"`"inline"执行模式
allowed-toolsstring[]nil(无限制)fork 模式下子 Agent 可用工具白名单
modelstring空(继承主 Agent)覆盖 fork 子 Agent 使用的模型
effort`string \int`thinking 预算等级
user-invocablebooltrue是否出现在 /help 列表中
disable-model-invocationboolfalse是否禁止通过 Skill Tool 调用
pathsstring[]nil条件激活的 gitignore 风格 glob

2.3 内容模板变量

变量替换内容
$ARGUMENTS用户输入的参数文本
${CLAUDE_SKILL_DIR}Skill 所在目录的绝对路径
${CLAUDE_SESSION_ID}当前会话 ID

三、claude-code 源码关键实现解析

3.1 Skill 加载器(loadSkillsDir.ts

核心函数调用链

getSkillDirCommands(cwd)                     ← 主入口,按 cwd 记忆化
  ├── loadSkillsFromSkillsDir(basePath)       ← 扫描 skills/ 目录
  │     └── 遍历子目录,读取 SKILL.md
  │           └── parseSkillFrontmatterFields() ← 解析 frontmatter
  │                 └── createSkillCommand()    ← 构造 Command 对象
  └── loadSkillsFromCommandsDir(cwd)          ← 兼容旧版 commands/

去重策略(核心逻辑):

// 1. 并行预计算所有 Skill 的 realpath
const realpaths = await Promise.all(skills.map(s => realpath(s.path)))
// 2. 同步去重(顺序决定优先级,前面的赢)
const seen = new Set<string>()
const deduped = skills.filter((s, i) => {
  if (seen.has(realpaths[i])) return false
  seen.add(realpaths[i])
  return true
})

3.2 Frontmatter 解析器(frontmatterParser.ts

两阶段解析

  1. 提取阶段:正则匹配 ---\n...\n---,提取 YAML 块
  2. 预处理阶段:对含 {}(glob 语法)的值自动加引号,避免 YAML 解析错误
  3. 路径展开paths 字段支持花括号展开,如 **/*.{ts,tsx}["**/*.ts", "**/*.tsx"]

3.3 Skill 执行引擎(SkillTool.ts

Inline 执行流程

SkillTool.call({ skill: "commit", args: "feat: xxx" })
  → findCommand("commit")                    ← 在命令表中查找
  → skill.getPromptForCommand(args, ctx)     ← 展开模板,替换变量
  → 追加到当前 messages 列表                  ← 作为用户消息注入
  → 返回 { newMessages, contextModifier }   ← 主 Agent 继续处理
  
  副作用:
    - 如果 skill 有 allowedTools → 更新当前会话工具白名单
    - 如果 skill 有 model → 更新当前会话使用模型
    - 如果 skill 有 effort → 更新 thinking 预算

Fork 执行流程

SkillTool.call({ skill: "verify", args: "" })
  → findCommand("verify")
  → skill.getPromptForCommand(args, ctx)     ← 展开模板
  → runAgent({                               ← 启动独立子 Agent
      model: skill.model || defaultModel,
      allowedTools: skill.allowedTools,      ← 工具白名单过滤
      effort: skill.effort,
      prompt: expandedContent
    })
  → await result                             ← 同步等待
  → return { success: true, result: text }  ← 结果返回主 Agent

3.4 内置 Skill 注册(bundledSkills.ts

// 在 main.tsx 启动时调用
initBundledSkills()                         ← 所有内置 Skill 注册
  ├── registerUpdateConfigSkill()
  ├── registerSimplifySkill()
  ├── registerLoopSkill()                   ← 特性开关保护
  └── registerClaudeApiSkill()             ← 特性开关保护

// 每个内置 Skill 的注册模式:
registerBundledSkill({
  name: "simplify",
  description: "Review changed code...",
  context: "fork",
  getPromptForCommand: async (args, ctx) => [{ type: "text", text: "..." }],
})

3.5 Skill Listing 注入(attachments.ts + query.ts

claude-code 通过 getSkillListingAttachments() 将 skill 列表注入为 <system-reminder> 用户消息,并维护 sentSkillNames Map<agentId, Set<skillName>> 做 delta 追踪:

// query.ts:1580 — 每次 tool 执行后调用,注入新增的 skill listing
const attachments = getSkillListingAttachments(skills, agentId)
if (attachments.length > 0) {
  messages.push(...attachments)
}

// attachments.ts — 只发送还未发过的 skill(delta)
function getSkillListingAttachments(skills, agentId): Message[] {
  const sent = sentSkillNames.get(agentId) ?? new Set()
  const newSkills = skills.filter(s => !sent.has(s.name) && s.isModelInvocable())
  if (newSkills.length === 0) return []
  newSkills.forEach(s => sent.add(s.name))
  sentSkillNames.set(agentId, sent)
  return [{ role: "user", content: formatSkillListing(newSkills) }]
}

格式规则:每条 - name: description - whenToUse,总预算 8000 字符,每条 ≤ 250 字符,bundled skill 优先完整描述。

SkillTool 描述:claude-code 的 SkillTool getPrompt() 返回静态文本,明确说明 "Available skills are listed in system-reminder messages in the conversation",不在 tool schema 里枚举。

3.5 条件激活(动态 Skill)

触发时机:用户操作文件(如保存 .tsx 文件)时调用 activateConditionalSkillsForPaths(filePaths, cwd)

激活算法

for (const skill of conditionalSkills.values()) {
  const matcher = ignore().add(skill.paths)
  for (const filePath of filePaths) {
    const rel = relative(cwd, filePath)
    if (matcher.ignores(rel)) {
      // 从 conditionalSkills 移到 dynamicSkills
      dynamicSkills.set(skill.name, skill)
      break
    }
  }
}
// 触发 skillsLoaded 信号,通知 UI 刷新

四、Go 实现方案

4.1 Skill 加载流程

Go 实现与 TypeScript 版本结构对应,入口在 NewServicemainloop/loop.go):

// 1. 内置 skill 优先注册(低优先级),文件系统 skill 可覆盖
skillReg := skill.NewRegistry()
bundled.Init(skillReg)          // 从 embed.FS 读取编译进二进制的 SKILL.md

// 2. 加载用户级 + 项目级 skill(高优先级覆盖同名内置)
skillReg.Load(workspaceRoot)

bundled.Init 通过 //go:embed skillsbundled/skills/ 目录编译进二进制,启动时无需磁盘读取:

//go:embed skills
var skillsFS embed.FS

func Init(registry *skill.Registry) {
    entries, _ := skillsFS.ReadDir("skills")
    for _, entry := range entries {
        data, _ := fs.ReadFile(skillsFS, "skills/"+entry.Name()+"/SKILL.md")
        fm, body, _ := skill.ParseSkillFile(string(data))
        registry.RegisterBundled(&skill.Skill{Frontmatter: fm, Content: body, Source: skill.SourceBundled})
    }
}

Registry.Load 按优先级扫描两个目录,同名 skill 后加载的覆盖先加载的:

func (r *Registry) Load(cwd string) error {
    homeDir, _ := os.UserHomeDir()
    r.loadFromDir(filepath.Join(homeDir, ".claude", "skills"), SourceUser)    // 先加载(低优先级)
    r.loadFromDir(filepath.Join(cwd, ".claude", "skills"), SourceProject)     // 后加载(高优先级覆盖)
    return nil
}

ParseSkillFile 两阶段解析 SKILL.md,同时展开 paths 中的花括号语法:

func ParseSkillFile(content string) (SkillFrontmatter, string, error) {
    // 1. 定位 --- 分隔符,分离 YAML 块与 Markdown body
    endIdx := strings.Index(rest, "\n---")
    yamlBlock, body := rest[:endIdx], rest[endIdx+4:]

    // 2. YAML 反序列化
    yaml.Unmarshal([]byte(yamlBlock), &fm)

    // 3. 展开 paths 花括号:**/*.{ts,tsx} → ["**/*.ts", "**/*.tsx"]
    fm.Paths = expandBracePatterns(fm.Paths)
    return fm, body, nil
}

4.2 Skill 注入主循环

Skill 系统通过 SkillTool 以标准 Eino Tool 的形式接入主 ReAct Agent,无需修改 Agent 核心。

NewService
  │
  ├── skillTracker := NewSkillListingTracker()  ← 追踪已通知的 skill,支持 delta
  ├── BuildBaseTools(workspaceRoot)             ← 工作区基础工具集
  ├── skill.NewExecutor(model, baseTools)       ← executor 持有工具列表,供 fork 过滤
  ├── NewSkillTool(skillReg, skillExec)         ← Skill tool(LLM 可调用)
  │
  └── Build(ctx, model, ..., skillReg, skillTracker, activator, extraTools=[skillTool])
        └── ReAct Agent(工具列表 = 工作区工具 + Skill tool)
              └── buildGenModelInput: 每次 Run 注入 skill listing <system-reminder>
SkillTool 描述(静态)

SkillTool.Info() 使用静态描述常量skillToolDesc),不再枚举 registry。Skill 列表通过 <system-reminder> 用户消息动态注入,对齐 claude-code 的做法:

const skillToolDesc = `Execute a skill within the main conversation

When users ask you to perform tasks, check if any of the available skills match.

Important:
- Available skills are listed in system-reminder messages in the conversation
- When a skill matches the user's request, this is a BLOCKING REQUIREMENT: invoke
  the relevant Skill tool BEFORE generating any other response about the task
- NEVER mention a skill without actually calling this tool
- Do not use this tool for built-in CLI commands (like /clear, /compact, etc.)`

func (t *SkillTool) Info(_ context.Context) (*schema.ToolInfo, error) {
    return &schema.ToolInfo{Name: "Skill", Desc: skillToolDesc, ...}, nil
}
Skill Listing 注入机制(SkillListingTracker)

对齐 claude-code 的 sentSkillNames delta 追踪,ai-coding 在 internal/mainloop/skill_listing.go 中实现 SkillListingTracker

type SkillListingTracker struct {
    mu   sync.Mutex
    sent map[string]bool
}

// BuildReminder: 返回当前轮次新增 skill 的列表文本(不含 <system-reminder> 标签)
// 用于 buildGenModelInput 在每次 Run() 的用户消息前注入
func (t *SkillListingTracker) BuildReminder(registry *skill.Registry) string

// BuildReminderForSkills: 返回指定 skill 的完整 <system-reminder> 字符串
// 用于条件激活时从 fs tool 结果中追加通知
func (t *SkillListingTracker) BuildReminderForSkills(registry *skill.Registry, names []string) string

// Reset: 清空已发送记录,下次 BuildReminder 重新全量发送
// 在 /clear 时调用,对齐 claude-code 的 resetSentSkillNames()
func (t *SkillListingTracker) Reset()

注入位置buildGenModelInput 闭包,build.go):

// 消息顺序:[system] [history...] [skill listing system-reminder] [current user message]
msgs = append(msgs, HistoryFromContext(ctx)...)
if skillTracker != nil && skillReg != nil {
    if content := skillTracker.BuildReminder(skillReg); content != "" {
        reminder := "<system-reminder>\n" + content + "\n</system-reminder>"
        msgs = append(msgs, schema.UserMessage(reminder))
    }
}
msgs = append(msgs, input.Messages...)

注入格式(对齐 claude-code formatCommandsWithinBudget):

  • 每条:- <name>: <description> - <whenToUse>(whenToUse 存在时)
  • 每条描述上限 250 个字符(MAX_LISTING_DESC_CHARS
  • 总预算 8000 个字符(DEFAULT_CHAR_BUDGET
  • Bundled skill 始终获得完整描述;非 bundled 按剩余预算等比截断

Delta 行为

  • 第一次 Run():全量发送所有 active model-invocable skill
  • 后续 Run():仅发送新增 skill(delta),已发送的跳过
  • /clear 后:skillTracker.Reset() 清空已发送记录,下次全量重发

LLM 通过 {"skill": "commit", "args": "feat: xxx"} 调用 Skill tool;tool 内部对 inline/fork 均走相应执行路径:

func (t *SkillTool) InvokableRun(ctx context.Context, argsJSON string, ...) (string, error) {
    s, _ := t.registry.Find(input.Skill)
    if s.Frontmatter.Context == "inline" {
        return s.ExpandContent(input.Args, ""), nil  // inline: 直接返回展开内容
    }
    return t.executor.RunFork(ctx, s, input.Args)    // fork: 启动子 agent
}

用户通过 /skill-name args 触发时,REPL slash 路由器直接调用 executor.ExpandInlineexecutor.RunFork,绕过 SkillTool。


4.3 动态 Skill(条件激活)

paths 字段不为空的 skill 在加载时进入 conditional 池,不进入 active 池,对 LLM 不可见。

触发时机:挂载在 file tool 内部

参考 claude-code 的实现(FileReadTool.ts:590FileWriteTool.ts:245FileEditTool.ts:422),激活逻辑不在 REPL 层轮询,而是在 file tool 每次成功执行后同步触发。这样无论是用户手动读写还是模型在 agentic run 中途决定操作某个文件,激活都能即时发生。

集成方式fs.NewTools 接收 SkillActivatorSkillDiscoverer 两个 callback,构造时注入到 read/write/edit 三个 tool 的闭包中:

// internal/tools/fs/tools.go

// SkillActivator 在文件操作成功后被调用,激活匹配 paths 的条件 skill,
// 并返回新激活 skill 的 <system-reminder> 通知字符串(空字符串表示无新激活)。
type SkillActivator func(filePaths []string, cwd string) string

// SkillDiscoverer 在文件操作后检查目录树中是否有未加载的 .claude/skills/ 目录,
// 返回新发现的 skill 名称列表。
type SkillDiscoverer func(filePaths []string, cwd string) []string

每个 file tool 在成功返回后调用 notifySkillHooks,并将通知追加到 tool 返回值末尾:

// read_file_tool.go(write/edit 同理)
func newReadFileTool(root string, activator SkillActivator, discoverer SkillDiscoverer) (tool.InvokableTool, error) {
    return utils.InferTool("read_file", "...", func(ctx context.Context, in readFileRequest) (string, error) {
        result, err := handleReadFile(root, in)
        if err != nil {
            return "", err
        }
        full, _ := safeJoinAbsolute(root, in.FilePath)
        if notification := notifySkillHooks(full, root, discoverer, activator); notification != "" {
            result += "\n" + notification   // ← 追加到 tool 结果,LLM 在本轮 tool call 即可看到
        }
        return result, nil
    })
}

// notifySkillHooks: 先调用 discoverer(发现新 skill),再调用 activator(激活并返回通知)
func notifySkillHooks(filePath, cwd string, discoverer SkillDiscoverer, activator SkillActivator) string {
    fp := []string{filePath}
    if discoverer != nil {
        discoverer(fp, cwd)
    }
    if activator != nil {
        return activator(fp, cwd)
    }
    return ""
}

mid-run 通知机制SkillActivator 返回的是完整 <system-reminder>...</system-reminder> 字符串(由 skillTracker.BuildReminderForSkills() 生成),直接追加在 file tool 的 JSON 返回值末尾。LLM 在同一个 tool round 就能看到新激活的 skill,而不必等到下一次 Run()buildGenModelInput 只在 Run 开始时调用一次)。这是 ai-coding 对齐 claude-code query.ts:1580 每轮 tool 执行后注入 skill listing 的等效实现。

activator 的注入链

NewService
  │
  ├── skillReg := skill.NewRegistry()              ← 先初始化 registry
  ├── skillTracker := NewSkillListingTracker()     ← 注意:必须在 activator 闭包之前初始化
  ├── skillReg.Load(workspaceRoot)
  │
  ├── activator := SkillActivator(func(...) {      ← 闭包捕获 skillReg 和 skillTracker
  │       names := skillReg.ActivateConditionalSkills(filePaths, cwd)
  │       if len(names) == 0 { return "" }
  │       return skillTracker.BuildReminderForSkills(skillReg, names)  ← 返回通知字符串
  │   })
  │
  ├── discoverer := SkillDiscoverer(func(...) {    ← 发现子目录中的新 skill
  │       return skillReg.DiscoverAndLoad(filePaths, cwd)
  │   })
  │
  ├── BuildBaseTools(workspaceRoot, cfg, nil, nil)  ← skill executor 用,不需要 callback
  │                                                   (子 agent 不共享父 registry)
  └── Build(ctx, ..., skillReg, skillTracker, activator, discoverer, extraTools)
        └── BuildBaseTools(workspaceRoot, cfg, activator, discoverer)
              └── fs.NewTools(root, cfg, activator, discoverer)
为什么 skill executor 的 baseTools 不注入 activator:fork 子 agent 运行在独立上下文中,不共享父 registry,在子 agent 内触发激活没有意义。
激活算法
func (r *Registry) ActivateConditionalSkills(filePaths []string, cwd string) []string {
    var activated []string
    for name, s := range r.conditional {
        if r.activated[name] { continue }
        if matchesPaths(s.Frontmatter.Paths, filePaths, cwd) {
            r.active[name] = s        // 从 conditional 移入 active
            r.activated[name] = true  // 防止重复激活
            delete(r.conditional, name)
            activated = append(activated, name)
        }
    }
    return activated  // 调用方可用于刷新 UI 提示
}

路径匹配使用 doublestar 库支持 ** 语法,路径统一转为相对 cwd 的形式:

func matchesPaths(patterns, filePaths []string, cwd string) bool {
    for _, fp := range filePaths {
        rel, _ := filepath.Rel(cwd, fp)
        for _, pattern := range patterns {
            if matched, _ := doublestar.Match(pattern, rel); matched {
                return true
            }
        }
    }
    return false
}

典型场景react-helper skill 声明 paths: ["src/**/*.{tsx,jsx}"],初始不可见;模型在 agentic run 中调用 read_file 读取 src/components/Button.tsx 时,activator 在 tool 返回后立即被调用,react-helper 从下一个 tool call 起对 LLM 可见。


4.4 Fork 子 Agent 执行

Fork 执行在 Executor.RunFork 中实现(skill/executor.go),核心是启动一个完全隔离的 Eino ReAct sub-agent:

func (e *Executor) RunFork(ctx context.Context, s *Skill, args string) (string, error) {
    prompt := s.ExpandContent(args, e.sessionID)

    // 1. 按 AllowedTools 白名单过滤工具(空列表 = 不限制)
    subTools := filterTools(e.allTools, s.Frontmatter.AllowedTools)

    // 2. 构造独立 sub-agent(独立 token 预算、独立历史)
    agentCfg := &adk.ChatModelAgentConfig{
        Name:          "SkillFork:" + s.Name(),
        Model:         e.mainModel,   // 未来可替换为 s.Frontmatter.Model
        MaxIterations: 24,
        ToolsConfig:   adk.ToolsConfig{ToolsNodeConfig: compose.ToolsNodeConfig{Tools: subTools}},
    }
    subAgent, _ := adk.NewChatModelAgent(ctx, agentCfg)

    // 3. 用独立 checkpoint store 运行,完全隔离主 agent 状态
    runner := adk.NewRunner(ctx, adk.RunnerConfig{
        Agent:           subAgent,
        CheckPointStore: checkpoints.NewInMemoryStore(),
    })
    iter := runner.Run(ctx, []adk.Message{schema.UserMessage(prompt)}, ...)

    // 4. 收集最终 assistant 文本返回给调用方
    var sb strings.Builder
    for ev, ok := iter.Next(); ok; ev, ok = iter.Next() {
        if ev.Output?.MessageOutput != nil {
            sb.WriteString(msg.Content)
        }
    }
    return sb.String(), nil
}

Inline vs Fork 对比

维度InlineFork
执行方式展开为用户消息,主 Agent 继续独立 sub-agent 运行到结束
Token 预算共享主会话独立(不影响主会话)
工具访问继承主 Agent 全部工具allowed-tools 过滤
结果注入Skill 内容追加到对话返回文本结果,主 Agent 决定如何使用
模型调用时REPL slash 路由直接展开SkillTool 始终走 fork

filterTools 按 skill 的 allowed-tools 白名单过滤,保证 fork sub-agent 的最小权限原则:

func filterTools(all []tool.BaseTool, allowed []string) []tool.BaseTool {
    if len(allowed) == 0 { return all }  // 空 = 不限制
    set := make(map[string]bool)
    for _, a := range allowed { set[strings.ToLower(a)] = true }
    var out []tool.BaseTool
    for _, t := range all {
        info, _ := t.Info(context.Background())
        if set[strings.ToLower(info.Name)] { out = append(out, t) }
    }
    return out
}

4.5 System Prompt 的 Skill 引导段

internal/mainloop/prompts/system_prompt.md 包含专门的 # Skill 系统 章节,对齐 claude-code constants/prompts.ts 的相应段落:

# Skill 系统

/<skill-name>(如 /commit)是用户调用 user-invocable skill 的简写。执行时,skill 内容会被
展开为完整提示词。使用 Skill 工具来执行它们。重要:只对 system-reminder 消息中列出的 skill
使用 Skill 工具——不要猜测或使用内置 CLI 命令。

该段告知模型:

  1. /commit 等 slash command 是调用 Skill tool 的简写
  2. 可用 skill 列表在 <system-reminder> 消息中,不是静态内置的
  3. 只应调用 system-reminder 中列出的 skill,不要猜测

4.6 会话重置(/clear)

Service.ClearHistory() 同时重置 SkillListingTracker,对齐 claude-code 的 resetSentSkillNames()

func (s *Service) ClearHistory() {
    s.history = nil
    if s.skillTracker != nil {
        s.skillTracker.Reset()   // 清空 sentSkillNames,下次 Run 重新全量注入 skill listing
    }
}

这确保 /clear 后第一条消息会重新收到完整的 skill listing,与 claude-code 行为一致。


五、手动验证场景

前提:以 debug 日志级别启动 agentd(LOG_LEVEL=debug 或对应配置),便于观察 slog.Debug 输出。

当前项目 Skill 清单

Skill模式特性
greet(别名:hello / hi)inline基础 inline,别名解析
with-varsinline三个模板变量全覆盖
reviewforkallowed-tools + model + effort
secret-internalinlineuser-invocable: false,model-invocable: false
react-helperinline动态 skill(paths: src/*/.{tsx,jsx})
bundled/skills/ 目录当前为空,无编译进二进制的内置 skill。

场景 0:Skill Listing 注入验证

0a — 首次消息全量注入

启动 agentd(debug 日志),发送第一条消息。观察注入的 <system-reminder> 出现在 system message 之后、用户消息之前,包含所有 active model-invocable skill:

<system-reminder>
The following skills are available for use with the Skill tool:

- commit: Review staged changes and create a conventional git commit - When you want to commit staged changes
- simplify: Review changed code for reuse, quality, and efficiency - After writing or modifying code
...
</system-reminder>

0b — 后续消息 delta 追踪

发送第二条消息(不触发任何文件操作),观察日志中不再注入 skill listing(所有 skill 已在第一轮发送过)。

0c — /clear 后重新全量注入

执行 /clear 后发送消息,skillTracker.Reset() 已清空记录,skill listing 应重新全量注入(与 0a 相同格式)。

0d — 条件激活 skill 的 mid-run 通知

模型在 agentic run 中调用 read_file 读取 .tsx 文件,read_file 返回值末尾追加:

<system-reminder>
- react-helper: React component helper - When editing React component files
</system-reminder>

LLM 在同一 tool round 就能看到该通知,不必等到下一次 Run()


场景 1:加载验证

1a — 正常加载

启动 agentd,检查日志输出顺序和数量:

skill: loading skills from filesystem  project_dir=<cwd>/.claude/skills
skill: loaded from filesystem  name=greet        source=project  conditional=false
skill: loaded from filesystem  name=with-vars    source=project  conditional=false
skill: loaded from filesystem  name=review       source=project  conditional=false
skill: loaded from filesystem  name=secret-internal  source=project  conditional=false
skill: loaded from filesystem  name=react-helper source=project  conditional=true
skill: registered as conditional  name=react-helper  paths=[src/**/*.tsx src/**/*.jsx components/**/*.tsx]
skill: registry loaded  active=4  conditional=1

预期active=4(greet/with-vars/review/secret-internal),conditional=1(react-helper)。


1b — 优先级覆盖

~/.claude/skills/greet/SKILL.md 创建同名 user-level skill(内容与项目版不同),重启,观察日志:

skill: loaded from filesystem  name=greet  source=user     ← 先加载
skill: loaded from filesystem  name=greet  source=project  ← 后加载,覆盖

验证 /greet 执行的是项目版本内容。


1c — 解析失败不阻塞

.claude/skills/broken/SKILL.md 写入缺少闭合 --- 的内容,重启:

skill: failed to parse SKILL.md  dir=...broken...  error=unclosed front-matter block
skill: registry loaded  active=4  conditional=1   ← 其余 skill 正常

预期:broken skill 被跳过,其余 skill 全部正常加载。


场景 2:Inline 执行

2a — 基础调用

/greet Alice

日志:skill: expanding inline name=greet args=Alice

展开结果注入对话:Hello, Alice! Welcome to ai-coding.


2b — 别名解析

/hello Bob
/hi Carol

两者效果等同于 /greet,日志中 name=greet(别名解析到主名)。


2c — 模板变量展开

/with-vars test-arg-123

预期展开:

Args: test-arg-123
Dir: /path/to/.claude/skills/with-vars     ← ${CLAUDE_SKILL_DIR} 替换为实际路径
Session: <当前会话 ID>                      ← ${CLAUDE_SESSION_ID} 替换

三个变量均被替换,无原始占位符($ARGUMENTS${...})残留。


2d — 无参数调用

/greet

$ARGUMENTS 替换为空字符串,输出 Hello, ! Welcome to ai-coding.,不报错。


场景 3:Fork 执行

3a — 用户触发 fork

/review HEAD~1..HEAD

观察日志:

skill: starting fork  name=review  allowed_tools=[bash read_file git_diff]  model_override=claude-sonnet-4-6
skill: fork tools filtered  name=review  total_tools=N  sub_tools=3
skill: fork complete  name=review  result_len=<非零>

验证点:

  • sub_tools=3,主 agent 的其他工具(write_file、web_search 等)未传入子 agent
  • review 结果作为文本回复出现在主对话,主 agent 的对话历史未膨胀

3b — 模型触发 fork(LLM 调用 SkillTool)

用户:"请帮我 review 最近的代码改动"

主 Agent 应识别到 review skill,通过 {"skill":"review","args":"..."} 调用 SkillTool。

日志中应出现:

skill tool: invoking via fork  skill=review  args=...

(说明 LLM 调用路径走的是 SkillTool → RunFork,而非 REPL slash 路由)


3c — inline skill 被模型调用时也强制 fork

用户:"帮我问候一下 Alice"

若主 Agent 决定调用 {"skill":"greet","args":"Alice"},greet 是 inline skill,但 SkillTool 内部依然走 fork:

skill tool: invoking via fork  skill=greet   ← 注意:不是 expanding inline

验证 SkillTool 不区分 inline/fork,统一走 RunFork。


场景 4:动态 Skill(条件激活)

激活由 file tool 内部 callback 驱动,无需外部触发。以下场景均通过向 agentd 发送自然语言 prompt 来让模型执行文件操作。

4a — 激活前不可调用

启动后不做任何文件操作,直接:

/react-helper add a button

预期报错skill "react-helper" not found(尚未激活,不在 active 池)。

/skills 列表中也不出现 react-helper。


4b — 模型读取 tsx 文件时自动激活

用户:读一下 src/components/Button.tsx 的内容

模型调用 read_file 工具读取该文件,tool 成功返回后 activator 同步触发。

日志:

skill: conditional skill activated  name=react-helper  paths=[src/**/*.tsx src/**/*.jsx ...]  trigger_files=[.../src/components/Button.tsx]
skill: conditional activation complete  activated=[react-helper]

激活发生在 read_file 返回时,read_file 的返回值末尾会追加 <system-reminder> 通知(由 BuildReminderForSkills 生成),同一轮 agentic run 的后续 tool call 中 LLM 即可看到 react-helper。


4c — 模型写入 tsx 文件时自动激活

重启 agentd(清空激活状态),发送:

用户:在 src/components/ 下新建一个 Icon.tsx,内容为空的函数组件

模型调用 write_file 写入 src/components/Icon.tsx,tool 成功后 activator 触发,日志同 4b。


4d — 激活后同一 run 内即可调用 Skill

接续 4b 或 4c,在同一对话继续:

/react-helper add a delete button

展开内容包含:You are helping with a React component file.Current task: add a delete button

也可验证:模型在 agentic run 中读完 tsx 文件后,若下一步 LLM 决定调用 SkillTool 执行 react-helper,日志应出现:

skill tool: invoking via fork  skill=react-helper

4e — 不匹配路径不激活

重启 agentd,发送:

用户:读一下 internal/skill/registry.go 的内容

模型读取 .go 文件,activator 被调用但无匹配项,日志中无 conditional skill activated/react-helper 仍报找不到。


4f — 重复触发不重复激活

已激活后再次让模型读取另一个 .tsx 文件(如 src/components/Modal.tsx),确认日志中不再出现 conditional skill activatedactivated 集合防重入)。


场景 5:权限控制

5a — 用户不可调用

secret-internal 设置了 user-invocable: false

  • /help 列表中不应出现 secret-internal
  • /secret-internal test 应报错(找不到或无权限)

5b — 模型不可调用

SkillListingTracker.BuildReminder() 生成的 skill listing 中不含 secret-internalIsModelInvocable() 返回 false,被过滤),LLM 不会知道该 skill 存在。

即使 LLM 强行发送 {"skill":"secret-internal","args":"..."} 调用 SkillTool:

skill tool: model invocation disabled  skill=secret-internal

返回错误,不执行。


李烁
162 声望99 粉丝