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.
$ARGUMENTS2.2 关键字段语义
| 字段 | 类型 | 默认值 | 语义 | |
|---|---|---|---|---|
context | `"inline" \ | "fork"` | "inline" | 执行模式 |
allowed-tools | string[] | nil(无限制) | fork 模式下子 Agent 可用工具白名单 | |
model | string | 空(继承主 Agent) | 覆盖 fork 子 Agent 使用的模型 | |
effort | `string \ | int` | 空 | thinking 预算等级 |
user-invocable | bool | true | 是否出现在 /help 列表中 | |
disable-model-invocation | bool | false | 是否禁止通过 Skill Tool 调用 | |
paths | string[] | 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)
两阶段解析:
- 提取阶段:正则匹配
---\n...\n---,提取 YAML 块 - 预处理阶段:对含
{}(glob 语法)的值自动加引号,避免 YAML 解析错误 - 路径展开:
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 } ← 结果返回主 Agent3.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 版本结构对应,入口在 NewService(mainloop/loop.go):
// 1. 内置 skill 优先注册(低优先级),文件系统 skill 可覆盖
skillReg := skill.NewRegistry()
bundled.Init(skillReg) // 从 embed.FS 读取编译进二进制的 SKILL.md
// 2. 加载用户级 + 项目级 skill(高优先级覆盖同名内置)
skillReg.Load(workspaceRoot)bundled.Init 通过 //go:embed skills 将 bundled/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.ExpandInline 或 executor.RunFork,绕过 SkillTool。
4.3 动态 Skill(条件激活)
paths 字段不为空的 skill 在加载时进入 conditional 池,不进入 active 池,对 LLM 不可见。
触发时机:挂载在 file tool 内部
参考 claude-code 的实现(FileReadTool.ts:590、FileWriteTool.ts:245、FileEditTool.ts:422),激活逻辑不在 REPL 层轮询,而是在 file tool 每次成功执行后同步触发。这样无论是用户手动读写还是模型在 agentic run 中途决定操作某个文件,激活都能即时发生。
集成方式:fs.NewTools 接收 SkillActivator 和 SkillDiscoverer 两个 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 对比:
| 维度 | Inline | Fork |
|---|---|---|
| 执行方式 | 展开为用户消息,主 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 命令。该段告知模型:
/commit等 slash command 是调用 Skill tool 的简写- 可用 skill 列表在
<system-reminder>消息中,不是静态内置的 - 只应调用 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-vars | inline | 三个模板变量全覆盖 |
review | fork | allowed-tools + model + effort |
secret-internal | inline | user-invocable: false,model-invocable: false |
react-helper | inline | 动态 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-helper4e — 不匹配路径不激活
重启 agentd,发送:
用户:读一下 internal/skill/registry.go 的内容模型读取 .go 文件,activator 被调用但无匹配项,日志中无 conditional skill activated,/react-helper 仍报找不到。
4f — 重复触发不重复激活
已激活后再次让模型读取另一个 .tsx 文件(如 src/components/Modal.tsx),确认日志中不再出现 conditional skill activated(activated 集合防重入)。
场景 5:权限控制
5a — 用户不可调用
secret-internal 设置了 user-invocable: false:
/help列表中不应出现secret-internal/secret-internal test应报错(找不到或无权限)
5b — 模型不可调用
SkillListingTracker.BuildReminder() 生成的 skill listing 中不含 secret-internal(IsModelInvocable() 返回 false,被过滤),LLM 不会知道该 skill 存在。
即使 LLM 强行发送 {"skill":"secret-internal","args":"..."} 调用 SkillTool:
skill tool: model invocation disabled skill=secret-internal返回错误,不执行。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用。你还可以使用@来通知其他用户。