目标
s01 到 s06 让智能体具备了工具调用、权限管控、Hook 扩展、TodoWrite 规划、子智能体并发能力。但它仍然"不知道自己能做什么"——所有行为都靠 LLM 从系统提示中猜。
如果你想让它支持新场景(如 "Figma 设计稿转代码"),唯一办法是改 Main.kt 写死 systemPrompt。每加一个场景就要重新编译、重新发布——这违背了"扩展不修改"的开闭原则。
Index 07 的目标是引入按需注入式技能系统:
- 扫描式加载——启动时扫描
./.cat-code/skills与~/.cat-code/skills下的SKILL.md文件(Claude Code 规范) - PRE_LLM_REQUEST hook——新增生命周期事件,让外部扩展点能在 LLM 请求发出前追加消息
- SkillInjectionHook——把所有已加载 skill 的
name + description拼成 SYSTEM 消息追加到请求末尾 load_skill工具——让 LLM 通过工具调用主动获取某个 skill 的完整 body(按需加载,token 经济)- ReplCommand 注册表——把 ReplLoop 硬编码的
when块重构为命令模式,新增/skill list|reload|show - 零依赖 frontmatter 解析器——只解析
name和description两个标量字段,不引入 snakeyaml
完成后的效果——用户可以在项目级或用户级目录下放一个 SKILL.md:
.cat-code/skills/figma-to-code/SKILL.md
---
name: figma-to-code
description: Translate Figma designs into production code. Use when the user references a Figma URL or node ID.
---
# Figma to Code Workflow
1. Fetch design via the Figma MCP server
2. Extract design tokens...启动 REPL 时会看到:
🐱 Cat-Code — Index 07: Skill Loading (1 skills loaded)
Type /help for commands, /exit to quit.每次 LLM 请求末尾都会被追加一条 SYSTEM 消息告诉它"现在你能做这些事,调用 load_skill 获取完整指令":
# Available Skills
The following skills are available. Call the `load_skill` tool with the skill's name to retrieve its full instructions, then follow them.
## figma-to-code
Translate Figma designs into production code. Use when the user references a Figma URL or node ID.LLM 看到这条消息后会根据用户输入判断是否触发该 skill。触发时调用 load_skill(name="figma-to-code") 工具拿到 body 中的完整步骤,再按步骤执行——未使用的 skill body 不占 context。
架构:按需注入式技能系统
Index 07 在 s06 的基础上增加了一个"PRE_LLM_REQUEST 扩展点"和"扫描式 skill 加载"。它不修改 AgentLoop 的核心循环逻辑,只增加一个 hook 注入点:
用户输入 "把这个 Figma 链接转成代码"
│
▼
┌──────────────────┐
│ 主 AgentLoop │
│ messagesHistory │
└──┬─────────────┘
│ buildRequestMessages() → [SYSTEM, USER, ...]
▼
┌──────────────────────────────────┐
│ s07: onPreLlmRequest hook │
│ │
│ HookManager.firePreLlmRequest │
│ ↓ 遍历 PRE_LLM_REQUEST handlers │
│ SkillInjectionHook │
│ → 读 SkillStore.getAll() │
│ → 拼 "# Available Skills..." │
│ → AppendMessages([SYSTEM(msg)])│
└──────────────────────────────────┘
│
▼ requestMessages = baseMessages + appended
│
▼ LLM 看到 skill metadata,判断该不该用 figma-to-code
│
▼ (s08 才注入 body;s07 LLM 只看到 name+description)关键洞察:SkillInjectionHook 与其他 hook 一样是普通 HookHandler,注册到同一个 HookManager。它不修改 AgentLoop 的核心循环,只在 buildRequestMessages() 之后追加一条 SYSTEM 消息。
| 层 | Index 06 状态 | Index 07 变更 |
|---|---|---|
| Hook | PRE/POST_TOOL_USE 两类事件 | +PRE_LLM_REQUEST 事件 + AppendMessages 结果 |
| Agent | AgentLoopHooks 3 字段 | +onPreLlmRequest 字段 |
| 配置 | AgentConfig 5 字段 | +skillPaths 字段(默认两条路径) |
| REPL | 硬编码 when 处理 /exit /help /clear | 重构为 ReplCommandRegistry,新增 /skill |
| Skill | — | 新建 skill/ 包,5 个主类 + 1 个 hook |
| REPL 命令 | 命令是 ReplLoop 私有常量 | 新建 repl.commands/ 包,4 个 ReplCommand 实现 |
第一层:PRE_LLM_REQUEST hook 扩展点
为什么需要新事件类型?
s04 的 PRE_TOOL_USE / POST_TOOL_USE 只覆盖工具调用。但很多扩展点需要在"LLM 请求发出前"介入:
- 注入 skill 元数据——让 LLM 知道有哪些 skill 可用
- 注入上下文压缩摘要(s08 计划)——历史消息超过 token 上限时替换为压缩摘要
- 注入会话级用户偏好——如用户的代码风格、语言偏好
这些都需要"在请求消息构造完成后、发出前"的扩展点。我们新增 PRE_LLM_REQUEST。
HookEvent 扩展(向后兼容)
data class HookEvent(
val type: HookEvent.Type,
val toolCall: ToolCall = ToolCall(id = "", name = "", input = buildJsonObject {}),
val result: ToolResult? = null,
val messages: List<Message> = emptyList() // 仅 PRE_LLM_REQUEST 时非空
) {
enum class Type {
PRE_TOOL_USE,
POST_TOOL_USE,
PRE_LLM_REQUEST
}
}三个字段都加了默认值:旧代码 HookEvent(Type.PRE_TOOL_USE, toolCall) 仍可编译,新增的 messages 字段默认空 list。Kotlin data class 的默认参数会自动生成多构造器。
HookResult 新增 AppendMessages
sealed class HookResult {
object Continue : HookResult()
data class Block(val reason: String) : HookResult()
data class Replace(val result: ToolResult) : HookResult()
data class AppendMessages(val messages: List<Message>) : HookResult() // 新增
}每种 HookResult 语义只对特定事件类型合法——这避免了误用:
| HookResult | PRE_TOOL_USE | POST_TOOL_USE | PRE_LLM_REQUEST |
|---|---|---|---|
| Continue | ✓ 放行 | ✓ 放行 | ✓ 不追加 |
| Block | ✓ 阻止工具 | ✗ warn 忽略 | ✗ warn 忽略 |
| Replace | ✗ warn 忽略 | ✓ 替换结果 | ✗ warn 忽略 |
| AppendMessages | ✗ warn 忽略 | ✗ warn 忽略 | ✓ 追加消息 |
Kotlin 编译器会强制 exhaustive when (HookResult) 报错,所有 when 都必须补齐 AppendMessages 分支——编译期发现遗漏。
HookManager.firePreLlmRequest 聚合规则
suspend fun firePreLlmRequest(messages: List<Message>): List<Message> {
val event = HookEvent(type = HookEvent.Type.PRE_LLM_REQUEST, messages = messages)
val results = fire(event)
val appended = mutableListOf<Message>()
for (r in results) {
when (r) {
HookResult.Continue -> { /* no-op */ }
is HookResult.Block -> logger.warn { "PreLlmRequest handler returned Block, ignoring" }
is HookResult.Replace -> logger.warn { "PreLlmRequest handler returned Replace, ignoring" }
is HookResult.AppendMessages -> appended.addAll(r.messages)
}
}
return appended
}聚合规则与 firePreToolUse / firePostToolUse 一致:
- 所有
AppendMessages按注册顺序合并追加(s07 MVP 通常只有一个 handler——SkillInjectionHook) Block/Replace在 PreLlmRequest 中语义不合法,记 warn 并忽略(与firePreToolUse忽略 Replace、firePostToolUse忽略 Block 一致)Continue等同空追加
AgentLoop 注入点
for (iteration in 1..config.maxIterations) {
val baseMessages = buildRequestMessages()
// s07: PRE_LLM_REQUEST hook —— 让外部扩展点追加消息(skill 元数据等)
val appended = hooks.onPreLlmRequest?.invoke(baseMessages) ?: emptyList()
val requestMessages = if (appended.isEmpty()) baseMessages else baseMessages + appended
logger.debug { "Iteration $iteration/${config.maxIterations}: sending ${requestMessages.size} messages to LLM" }
val response = llmProvider.chat(requestMessages, options, tools)
// ...
}注入时机在 buildRequestMessages() 之后——保留 SYSTEM + 历史 USER/ASSISTANT 顺序,把 skill metadata 追加到末尾。LLM 会把最近上下文优先注意,这正是我们想要的(让 skill 触发判断更敏感)。
为什么用 nullable + 默认 null 而不是空 lambda?
data class AgentLoopHooks(
val onBeforeToolExecute: (suspend (ToolCall) -> Boolean)? = null,
val onPreToolUse: (suspend (ToolCall) -> String?)? = null,
val onPostToolUse: (suspend (ToolCall, ToolResult) -> ToolResult)? = null,
val onPreLlmRequest: (suspend (List<Message>) -> List<Message>)? = null // 新增
)null 时直接 ?: emptyList() 跳过调用,零开销。空 lambda 会触发一次无用调用,虽然语义无害但违背"扩展不增加默认成本"原则。
第二层:SkillManifest 与零依赖 frontmatter 解析器
SkillManifest——不可变值对象
data class SkillManifest(
val name: String, // frontmatter.name,全局唯一,用作 SkillStore 的 key
val description: String, // frontmatter.description
val body: String, // frontmatter 之后的 markdown 原文,由 load_skill 工具返回给 LLM
val sourcePath: String // SKILL.md 的绝对路径,用于 /skill show 调试
)body 不会自动注入到 LLM 请求——否则几十个 skill 会撑爆 context。s07 通过 load_skill 工具实现"按需加载":LLM 看到某 skill 的 description 决定使用时,主动调用 load_skill(name) 拿到完整 body,再按 body 中的步骤执行。这是 token 经济性与功能完整性的折中。
为什么不引入 snakeyaml?
Claude Code 的 SKILL.md spec 明确 frontmatter 只有 name 和 description 两个标量字段:
---
name: skill-name
description: A short description of when to use this skill.
---
# Markdown body引入 snakeyaml 会增加 200KB+ 依赖、增加攻击面(YAML deserialization gadget)、增加心智负担(anchor/alias/tag 等概念)。项目内已建立"手动解析简单文本格式"先例——参考 EnvFile.kt:91-111 解析 .env 文件的方式,约 40 行就能实现:
object SkillFrontmatter {
const val FRONTMATTER_DELIMITER = "---"
fun parse(content: String, sourcePath: String): SkillManifest {
// 1. 校验首行 == "---"
// 2. 找到第二个 "---" 行作为 frontmatter 结束
// 3. 中间行用 parseLine 解析为 Map<String, String>
// 4. name 必需(非空),description 可选(默认空串)
// 5. body = 第二个 "---" 之后的内容(trimLeading)
}
internal fun parseLine(line: String): Pair<String, String>? {
// 支持 `key: value`、`key: "value"`、`key: 'value'`、trim 空格
// 不支持嵌套对象/列表/锚点(s07 不需要)
}
}支持三种值格式:
name: simple-value
description: "quoted value with: colon"
description: 'single quoted'未知字段被忽略——向前兼容(s08 可能新增 triggers 字段,老解析器直接跳过)。
SkillLoadException——明确的错误语义
class SkillLoadException(
val sourcePath: String?,
message: String
) : Exception(message)sourcePath 让 SkillLoader 聚合失败信息时能直接拿到路径,不需要在 catch 块里再拼接。
第三层:SkillLoader 多层级解析
镜像 EnvFile 的多层级模式
class SkillLoader(
private val skillPaths: List<String>
) {
fun load(): LoadResult {
val skills = mutableMapOf<String, SkillManifest>() // name → manifest,后加载覆盖
val failures = mutableListOf<LoadFailure>()
for (rawPath in skillPaths) {
val path = expandHome(rawPath)
if (!Files.exists(path)) continue // 路径不存在不算错误
if (!Files.isDirectory(path)) continue
Files.list(path).use { dirs ->
dirs.filter { Files.isDirectory(it) && !Files.isHidden(it) }.forEach { skillDir ->
val skillFile = skillDir.resolve(SKILL_FILE)
if (!Files.exists(skillFile)) return@forEach
try {
val content = Files.readString(skillFile)
val manifest = SkillFrontmatter.parse(content, skillFile.toString())
skills[manifest.name] = manifest // 后加载覆盖
} catch (e: SkillLoadException) {
failures.add(LoadFailure(...))
} catch (e: Exception) {
failures.add(LoadFailure(..., "IO error: ${e.message}"))
}
}
}
}
return LoadResult(skills = skills.values.toList(), failures = failures)
}
}设计要点:
- 路径优先级:
skillPaths按顺序加载,后加载的同名 skill 覆盖先加载的。默认["./.cat-code/skills", "~/.cat-code/skills"],用户级覆盖项目级——团队共享默认配置,个人覆盖个性化 - 错误隔离:单个
SKILL.md解析失败不阻塞其他 skill 加载,失败聚合到LoadResult.failures中供/skill reload命令展示 ~展开:路径以~/开头时展开为System.getProperty("user.home")- 子目录扫描:每个含
SKILL.md的子目录被识别为一个 skill(不递归扫描,避免深层嵌套)
LoadResult——成功与失败分离
data class LoadResult(
val skills: List<SkillManifest>,
val failures: List<LoadFailure>
) {
val successCount: Int get() = skills.size
val failureCount: Int get() = failures.size
}/skill reload 命令展示时可以分别报告"3 loaded, 1 failed",并打印失败列表帮助用户定位问题。
第四层:SkillStore
class SkillStore {
private val skills: CopyOnWriteArrayList<SkillManifest> = CopyOnWriteArrayList()
fun reload(skills: List<SkillManifest>) {
this.skills.clear()
this.skills.addAll(skills)
}
fun getAll(): List<SkillManifest> = skills.toList()
fun get(name: String): SkillManifest? = skills.firstOrNull { it.name == name }
fun size(): Int = skills.size
fun clear() = skills.clear()
}与 TodoStore / SubagentStore 完全一致的存储模式:
class(非object)——构造注入,支持未来子智能体隔离- CopyOnWriteArrayList——保证并发读安全(
/skill reload可能与主 AgentLoop 异步读getAll()并发) getAll()返回快照副本——避免外部修改内部状态
为什么 reload 是 clear + addAll 而不是直接替换 list?
CopyOnWriteArrayList 的引用不可变,但内容可变。clear + addAll 在两次操作之间会短暂出现空列表状态——但这是可接受的:reload 是 CLI 命令,用户主动触发,且只持续几毫秒。下一轮 LLM 请求读到空列表只是不注入 skill metadata,不会出错。
SkillStore 在 /clear 中不清空
class ClearCommand : ReplCommand {
override suspend fun execute(args: String, ctx: ReplContext): ReplCommandResult {
ctx.agentLoop.messagesHistory.clear()
ctx.approvalStore.clear()
ctx.todoStore.clear()
ctx.subagentStore.clear()
// 不清空 ctx.skillStore —— skill 是项目/用户级配置,不是会话状态
return ReplCommandResult.Continue
}
}/clear 是会话级重置——清空对话历史、权限记忆、待办、子任务。但 skill 是项目/用户级配置,跨会话保留——清空 skill 反而违背用户预期。
第五层:SkillInjectionHook
class SkillInjectionHook(
private val store: SkillStore
) {
fun registerTo(manager: HookManager) {
val handler: HookHandler = handler@{ event ->
if (event.type != HookEvent.Type.PRE_LLM_REQUEST) {
return@handler HookResult.Continue
}
val skills = store.getAll()
if (skills.isEmpty()) {
return@handler HookResult.Continue // 空 store 不浪费 token
}
val text = buildSkillsMetadataText(skills)
HookResult.AppendMessages(listOf(Message(Role.SYSTEM, text)))
}
manager.on(HookEvent.Type.PRE_LLM_REQUEST, handler)
}
internal fun buildSkillsMetadataText(skills: List<SkillManifest>): String = buildString {
append("# Available Skills\n\n")
append("The following skills are available. Call the `load_skill` tool with the skill's name to retrieve its full instructions, then follow them.\n\n")
for (skill in skills) {
append("## ${skill.name}\n")
append("${skill.description}\n\n")
}
}.trimEnd()
}s07 行为:始终注入所有 skill 的 name + description(不注入 body)。LLM 根据这些 metadata 判断是否调用某 skill,触发时调用 load_skill 工具按需加载 body(见下一节)。
注入位置:为什么放在请求末尾?
注入位置在 messagesHistory 之后,LLM 会作为最近上下文优先注意。这是有意的——skill metadata 是"提示触发"信息,应该比系统提示更靠后、更敏感。
为什么放在 skill 包而非 hooks/builtins?
依赖方向:skill → hooks → llm 单向合法(skill 是消费方)。若放在 hooks/builtins,则 hooks 包需要反向依赖 skill 包,破坏单向依赖。这与 TodoDisplayHook 放在 todo/ 包而非 hooks/builtins 的取舍完全一致。
第六层:SkillLoaderTool(load_skill 工具)
为什么需要这个工具?
只注入 metadata 不够——LLM 看到 "code-review skill 能审查代码" 后,要怎么审查?审查步骤是什么?这些都在 body 里。如果直接把所有 skill body 全注入,几十个 skill 会撑爆 context;如果完全不注入,metadata 就是空头支票。
SkillLoaderTool 是最简形式的"渐进式加载":
- metadata 始终注入(轻量,所有 skill 的 name+description)
- body 按需加载(LLM 显式调用
load_skill(name)才拿到某 skill 的完整指令)
class SkillLoaderTool(
private val store: SkillStore
) : Tool {
override val name = "load_skill"
override val description = "Load the full content (body) of a skill by name. " +
"Use this when you've decided to apply a skill from the available skills list."
override val isReadOnly = true // 只读 SkillStore,无副作用
override suspend fun execute(input: JsonObject): ToolResult {
val skillName = input["name"]?.jsonPrimitive?.content
?: return ToolResult("", "Error: name is required", isError = true)
val skill = store.get(skillName)
?: return ToolResult("", "Error: skill not found: $skillName", isError = true)
val content = buildString {
append("# Skill: ${skill.name}\n\n")
append("Description: ${skill.description}\n\n")
append("## Instructions\n\n")
append(skill.body)
}
return ToolResult("", content)
}
}设计要点
- isReadOnly = true:只读 SkillStore 无副作用,可与读工具并发
- 包路径
skill/:与 SkillInjectionHook 一致,保持skill → tool单向依赖(若放tool.tools会引入反向依赖) - 空 body 友好处理:返回明确提示"has no body content",不当作错误
- store reload 后立即生效:
/skill reload后下一轮 LLM 调用就能看到新 skill,load_skill也能加载——SkillStore 是 CopyOnWriteArrayList,并发读安全 - JSON Schema 明确描述何时使用:description 字段写"Use this when you've decided to apply a skill",让 LLM 知道调用时机
完整触发链路
用户: "帮我 review SkillStore.kt"
↓
LLM 收到请求(末尾带 # Available Skills SYSTEM 消息)
↓
LLM 看到 "## code-review / 审查代码变更..."
↓
LLM 调用 load_skill(name="code-review")
↓
SkillLoaderTool 返回完整 body(4 步审查流程)
↓
LLM 按 body 中的步骤执行:git diff → 检查 5 个维度 → 输出报告未使用的 skill 的 body 不会占用任何 context。
第七层:ReplCommand 注册表
为什么重构 ReplLoop?
s06 的 ReplLoop 用硬编码 when 块处理 /exit /help /clear:
private fun handleCommand(input: String): Boolean {
val cmd = input.split(" ", limit = 2)[0]
return when (cmd) {
CMD_EXIT, CMD_QUIT, CMD_Q -> { println("Exiting..."); false }
CMD_HELP, CMD_H -> { printHelp(); true }
CMD_CLEAR, CMD_C -> { /* clear 4 stores */ true }
else -> { println("Unknown command: $cmd"); true }
}
}这违背了"扩展不修改"原则——每加一个命令都要改 ReplLoop。s07 要加 /skill,正好顺手重构。
ReplCommand 接口
interface ReplCommand {
val name: String // "/exit"、"/skill"
val description: String // 用于 /help
suspend fun execute(args: String, ctx: ReplContext): ReplCommandResult
}
sealed class ReplCommandResult {
object Continue : ReplCommandResult()
object Exit : ReplCommandResult()
}
data class ReplContext(
val agentLoop: AgentLoop,
val approvalStore: ApprovalStore,
val todoStore: TodoStore,
val subagentStore: SubagentStore,
val skillStore: SkillStore,
val skillLoader: SkillLoader
)关键设计:
- 子命令解析在命令内部完成——
/skill list由SkillCommand自己解析args = "list",保持ReplLoop.handleCommand简单 - ReplContext 聚合所有可变状态——避免每个命令都通过 ReplLoop 引用,也便于测试时构造 mock context
suspend execute——为未来异步命令预留(虽然 s07 的命令都是同步的)
ReplCommandRegistry——主名 + 别名
class ReplCommandRegistry {
private val commands: MutableMap<String, ReplCommand> = mutableMapOf()
private val primaryNames: MutableSet<String> = mutableSetOf()
fun register(command: ReplCommand, vararg aliases: String) {
commands[command.name] = command
primaryNames.add(command.name)
for (alias in aliases) {
commands[alias] = command
}
}
fun find(name: String): ReplCommand? = commands[name]
fun all(): List<ReplCommand> =
primaryNames.map { commands[it]!! }
}all() 按主名去重——别名不重复出现,避免 /help 列出 /exit /quit /q 三个条目。
ReplLoop 委派
private fun handleCommand(input: String): Boolean = runBlocking {
val parts = input.split(" ", limit = 2)
val cmdName = parts[0]
val args = parts.getOrNull(1)?.trim() ?: ""
val command = commandRegistry.find(cmdName)
if (command == null) {
println("Unknown command: $cmdName. Type /help for available commands.")
return@runBlocking true
}
val ctx = ReplContext(
agentLoop = agentLoop,
approvalStore = approvalStore,
todoStore = todoStore,
subagentStore = subagentStore,
skillStore = skillStore,
skillLoader = skillLoader
)
when (command.execute(args, ctx)) {
ReplCommandResult.Continue -> true
ReplCommandResult.Exit -> false
}
}沿用 handleChat 的 runBlocking 模式(REPL 主线程本来就阻塞读 stdin,无死锁风险)。
四个 ReplCommand 实现
// ExitCommand: /exit /quit /q
class ExitCommand : ReplCommand {
override val name = "/exit"
override val description = "Exit Cat-Code"
override suspend fun execute(args: String, ctx: ReplContext) = run {
println("Exiting...")
ReplCommandResult.Exit
}
}
// HelpCommand: /help /h
class HelpCommand(private val registry: ReplCommandRegistry) : ReplCommand {
override val name = "/help"
override val description = "Show available commands"
override suspend fun execute(args: String, ctx: ReplContext) = run {
// 从 registry.all() 聚合命令列表,按 name 排序
println(buildHelpText(registry.all()))
ReplCommandResult.Continue
}
}
// ClearCommand: /clear /c
class ClearCommand : ReplCommand {
override val name = "/clear"
override val description = "Clear conversation history and stores"
override suspend fun execute(args: String, ctx: ReplContext) = run {
ctx.agentLoop.messagesHistory.clear()
ctx.approvalStore.clear()
ctx.todoStore.clear()
ctx.subagentStore.clear()
// 不清空 ctx.skillStore —— skill 是配置,不是会话状态
ReplCommandResult.Continue
}
}
// SkillCommand: /skill list|reload|show <name>
class SkillCommand : ReplCommand {
override val name = "/skill"
override val description = "Manage skills (list, reload, show)"
override suspend fun execute(args: String, ctx: ReplContext): ReplCommandResult {
// 子命令解析:list / reload / show <name>
}
}HelpCommand 持有 registry 引用——这是运行时注入,注册时构造:register(HelpCommand(this), "/h")。
第八层:ReplLoop 集成
fun start() {
subagentScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
todoStore = TodoStore()
subagentStore = SubagentStore()
// ... 工具注册 ...
val hookManager = HookManager()
LoggingHook().registerTo(hookManager)
TimingHook().registerTo(hookManager)
TodoDisplayHook().registerTo(hookManager)
// s07: 加载 skills 并注册注入 hook
skillStore = SkillStore()
skillLoader = SkillLoader(config.skillPaths)
val loadResult = skillLoader.load()
skillStore.reload(loadResult.skills)
logger.info { "Skills loaded: ${loadResult.successCount} ok, ${loadResult.failureCount} failed" }
SkillInjectionHook(skillStore).registerTo(hookManager)
// s07: 让 LLM 能通过 load_skill(name) 主动加载 skill body
toolRegistry.register(SkillLoaderTool(skillStore))
agentLoop = AgentLoop(
llmProvider = llmProvider,
systemPrompt = systemPrompt,
config = config,
hooks = AgentLoopHooks(
onBeforeToolExecute = { tc -> pipeline.approve(tc) },
onPreToolUse = { tc -> hookManager.firePreToolUse(tc) },
onPostToolUse = { tc, result -> hookManager.firePostToolUse(tc, result) },
onPreLlmRequest = { msgs -> hookManager.firePreLlmRequest(msgs) } // 新增
),
toolRegistry = toolRegistry
)
// s07: 命令注册表
commandRegistry = ReplCommandRegistry().apply {
val exitCmd = ExitCommand()
register(exitCmd, "/quit", "/q")
register(HelpCommand(this), "/h")
register(ClearCommand(), "/c")
register(SkillCommand())
}
printWelcome()
// ... REPL 主循环 ...
}
private fun printWelcome() {
val skillCount = skillStore.size()
val skillNote = if (skillCount > 0) " ($skillCount skills loaded)" else ""
println("🐱 Cat-Code — Index 07: Skill Loading$skillNote")
println("Type /help for commands, /exit to quit.")
println()
}欢迎语会动态显示已加载的 skill 数:
🐱 Cat-Code — Index 07: Skill Loading (3 skills loaded)关键设计取舍汇总
| 决策点 | 选择 | 理由 |
|---|---|---|
| YAML 解析依赖 | 零依赖手写 | Claude Code spec 只有 name/description 两字段;参考 EnvFile 先例 |
| 注入策略 | MVP 始终注入所有 metadata | s07 不做触发关键词过滤,s08/s10 再优化 |
| body 注入方式 | load_skill 工具按需加载 | metadata 始终注入(轻量);LLM 显式调用工具才拿 body;token 经济 |
| 子智能体加载 skill | 不加载 | 子智能体任务 prompt 已显式给出,无需 skill metadata |
| SkillStore 在 /clear 中 | 不清空 | skill 是项目/用户级配置,不是会话状态 |
| HookEvent 字段扩展 | 加默认值 | 旧代码 HookEvent(Type.PRE_TOOL_USE, toolCall) 仍可编译 |
| onPreLlmRequest 类型 | nullable lambda | null 时跳过调用,零开销 |
| 注入位置 | 请求末尾 | LLM 优先注意最近上下文,skill 触发判断更敏感 |
| SkillInjectionHook 包路径 | skill/ 包 | 避免 hooks → skill 反向依赖,保持 skill → hooks → llm 单向 |
| ReplLoop 重构 | 命令注册表 | 扩展不修改;新增命令只 register,不改 ReplLoop |
| 子命令解析 | 命令内部完成 | 保持 ReplLoop.handleCommand 简单 |
| ReplContext | 聚合所有 store | 便于测试构造 mock;避免每个命令引用 ReplLoop |
| 别名支持 | register 时变长参数 | /exit /quit /q 一次注册,find 时任一别名都能命中 |
| all() 去重 | 按主名 | 避免 /help 列出别名重复 |
| 多路径优先级 | 后加载覆盖先加载 | 用户级覆盖项目级(与 EnvFile 一致) |
| SkillLoader 错误处理 | 聚合不抛 | 单个 skill 失败不阻塞其他;失败信息供 /skill reload 展示 |
~ 展开 | System.getProperty("user.home") | 跨平台兼容,与 EnvFile 一致 |
测试策略
测试隔离原则
- 所有 skill 测试用
Files.createTempDirectory创建临时目录(参考 EnvFileTest 模式) - SkillStore / SkillLoader 测试每用例 new 局部实例
- 不修改全局状态
关键测试用例
SkillFrontmatterTest(8 用例):
- 正常解析 name + description + body
- 缺 name 抛 SkillLoadException
- 缺 frontmatter 抛 SkillLoadException
- 带引号 description(含冒号)
- 裸字符串 description
- 空 body(只有 frontmatter)
- 未知字段被忽略(向前兼容)
- frontmatter 后无空行
SkillLoaderTest(8 用例,临时目录):
- 单路径加载单个 skill
- 多路径加载,后加载覆盖先加载(同名 skill)
- 单个 SKILL.md 解析失败不阻塞其他
- 空目录返回空列表
- 路径不存在返回空列表(不抛异常)
- 展开
~/路径 - SKILL.md 必须在子目录下(不递归扫描)
SkillStoreTest(6 用例):
- reload 整体替换
- getAll 返回快照副本
- get 按 name 查找
- clear 清空
- size 返回当前数量
- 并发场景下 CopyOnWriteArrayList 线程安全
SkillInjectionHookTest(5 用例):
- 空 skillStore 返回空 list
- 有 skill 时返回 1 条 SYSTEM 消息
- 消息内容包含 name 和 description
- store reload 后下次 fire 反映新状态
- 非 PRE_LLM_REQUEST 事件忽略
ReplCommandRegistryTest(4 用例):
- register + find 按主名查找
- 别名查找(/quit /q 找到 ExitCommand)
- all() 去重(别名不重复出现)
- 未注册命令 find 返回 null
SkillCommandTest(8 用例):
- list 空时打印 "No skills loaded"
- list 非空时打印所有 skill name + description
- reload 调用 skillLoader.load 并更新 skillStore
- show 找到 skill 打印 body
- show 未找到打印 "Skill not found"
- show 无参数打印 usage
- 无子命令打印 usage
- 未知子命令打印 usage with warning
HookManagerTest(+4 用例):
- firePreLlmRequest 无 handler 返回空 list
- 聚合多个 AppendMessages 按注册顺序
- Block/Replace 被忽略,只保留 AppendMessages
- 把 messages 快照传给 handler
AgentLoopTest(+3 用例):
- onPreLlmRequest 返回的消息出现在 fakeLLM.lastMessages 末尾
- 无 onPreLlmRequest 时向后兼容(消息数不变)
- onPreLlmRequest 返回空 list 时不修改请求
文件清单
src/main/kotlin/com/sepcai/code/
├── skill/ # [新建] 技能领域包
│ ├── SkillManifest.kt # data class(name/description/body/sourcePath)
│ ├── SkillFrontmatter.kt # 零依赖 frontmatter 解析器 + SkillLoadException
│ ├── SkillLoader.kt # 多路径扫描 + LoadResult 聚合
│ ├── SkillStore.kt # CopyOnWriteArrayList 内存存储
│ ├── SkillInjectionHook.kt # PRE_LLM_REQUEST handler(注入 metadata)
│ └── SkillLoaderTool.kt # load_skill 工具(按需返回 body 给 LLM)
├── hooks/
│ ├── HookEvent.kt # [修改] +PRE_LLM_REQUEST +messages 字段
│ ├── HookResult.kt # [修改] +AppendMessages 子类
│ └── HookManager.kt # [修改] +firePreLlmRequest +两处 when 补分支
├── agent/
│ ├── AgentConfig.kt # [修改] +skillPaths 字段 + DEFAULT_SKILL_PATHS
│ ├── AgentLoop.kt # [修改] buildRequestMessages 后注入 hook
│ └── AgentLoopHooks.kt # [修改] +onPreLlmRequest 字段
├── repl/
│ ├── ReplCommand.kt # [新建] 接口 + ReplCommandResult + ReplContext
│ ├── ReplCommandRegistry.kt # [新建] Map + 别名支持
│ ├── ReplLoop.kt # [修改] 集成 skill + commandRegistry
│ └── commands/ # [新建]
│ ├── ExitCommand.kt # /exit /quit /q
│ ├── HelpCommand.kt # /help /h(从 registry 聚合)
│ ├── ClearCommand.kt # /clear /c
│ └── SkillCommand.kt # /skill list|reload|show
└── tool/tools/ # 不变
src/test/kotlin/com/sepcai/code/
├── skill/ # [新建]
│ ├── SkillFrontmatterTest.kt # 8 用例
│ ├── SkillLoaderTest.kt # 8 用例
│ ├── SkillStoreTest.kt # 6 用例
│ ├── SkillInjectionHookTest.kt # 5 用例
│ └── SkillLoaderToolTest.kt # 7 用例
├── repl/
│ ├── ReplCommandRegistryTest.kt # [新建] 4 用例
│ └── commands/ # [新建]
│ └── SkillCommandTest.kt # 8 用例
├── hooks/
│ └── HookManagerTest.kt # [修改] +4 个 firePreLlmRequest 用例
└── agent/
└── AgentLoopTest.kt # [修改] +3 个 PRE_LLM_REQUEST 集成用例12 个新源文件 + 7 个修改源文件 · 7 个新测试文件 + 2 个测试修改 · 全部通过。
下一站
s07 完成了阶段三的第一个能力——技能加载。智能体现在能:
- 启动时扫描项目级和用户级目录下的
SKILL.md - 每次向 LLM 发请求时自动注入所有 skill 的 name + description
- 通过
load_skill工具按需获取 skill body,未使用的 skill 不占 context - 通过
/skill list|reload|show命令管理技能
但 s07 的 metadata 注入是"全部注入"——不管用户问什么,每次都把所有 skill 的 name+description 塞进请求。当 skill 数量增加到几十上百个时,token 占用会成为问题。s08/s10 会引入:
- 按上下文相关性选择性注入——根据用户输入关键词匹配 skill description,只注入相关的
- ContextCompact——历史消息超过 token 上限时替换为压缩摘要(通过 PRE_LLM_REQUEST hook 实现,与 SkillInjectionHook 共存)
下一站 s08 将优先解决上下文压缩——因为当对话变长时,没有压缩就无法测试大量 skill 注入的效果。


