2026-07-24 · Cat-Code Team
目标
Index 01 的智能体会说话,但不会做事。你让它"看看 UserService.kt 有什么问题",它只能凭记忆猜——因为它读不到文件,更改不了代码。
Index 02 的目标是让智能体长出双手。具体来说,我们要做四件事:
- 定义 Tool 接口和 ToolRegistry 注册表,让工具可以像插件一样注册和调度
- 实现三个内置工具——读文件、写文件、执行 Shell 命令——覆盖 80% 的编程场景
- 改造 AgentLoop,从"调一次 LLM 就返回"变成"LLM 请求工具 → 执行 → 回传结果 → 继续推理"的循环
- 实现智能并发,当 LLM 同时请求读两个文件时,并行执行而非串行等待
完成后的效果——智能体现在可以真正操作文件系统:
$ ./gradlew run
🐱 Cat-Code — Index 02: Tool Use
Type /help for commands, /exit to quit.
> 帮我看看 src/main/kotlin/com/sepcai/code/agent/AgentLoop.kt 有多少行
[AgentLoop 调用 read_file 工具读取文件,LLM 分析后回复]
那个文件有 203 行。核心逻辑在 run() 方法中,它管理对话历史
并调用 LLM。还有一个 sanitizeHistory() 方法用于清理异常状态。
> 在第 5 行后面加一个作者注释
[AgentLoop 调用 write_file 工具写入修改]从用户视角看,对话体验没有变化——还是输入 prompt、得到回复。但智能体现在可以主动使用工具来获取信息和执行操作了。
架构:三层扩展
Index 01 建立了五层架构(配置 → 入口 → 终端 → 智能体 → API)。Index 02 在这三层上做了扩展:
┌──────────────────────────────┐
│ tool/ │
│ Tool.kt 接口 │
│ ToolCall.kt LLM 请求 │
│ ToolResult.kt 执行结果 │
│ ToolRegistry.kt 注册表 │
│ ├─ ReadFileTool │
│ ├─ WriteFileTool │
│ └─ BashTool │
└──────────┬───────────────────┘
│
┌──────────────┐ ┌──────────▼──────────┐ ┌─────────────────────┐
│ AgentLoop │───▶│ ToolRegistry │ │ LLMProvider │
│ + 工具循环 │ │ .get() .register() │ │ chat( │
│ + 并发执行 │ └─────────────────────┘ │ messages, │
│ maxIters=10 │ │ tools, │
└──────┬───────┘ │ toolResults │
│ │ ): ChatResponse │
│ └──────────┬───────────┘
│ │
│ ┌──────────▼───────────┐
└───────────────────────────────────────▶ AnthropicProvider │
│ convertToAnthropic │
│ buildToolResult │
│ parseResponse→tool │
└──────────────────────┘| 层 | Index 01 状态 | Index 02 变更 | 行数变化 |
|---|---|---|---|
| 数据模型 | Message, ChatResponse 纯文本 | 新增 toolCalls 字段, ToolCall, ToolResult | +45 行 |
| 工具 | 无 | 新增 tool/ 包: 接口 + 注册表 + 3 个内置工具 | +250 行 |
| 智能体 | 单次 LLM 调用 | for 循环: TOOL_USE → 执行 → 回传 → 继续 | 111→203 行 |
| API | 纯文本请求/响应 | 工具定义传递 + tool_use 解析 + tool_result 组装 | 207→287 行 |
第一层:数据模型扩展
Index 02 不改动 Index 01 的核心类型——它在现有类型上增加字段,并引入三个新类型。
Message:存储工具调用的位置
// Index 01
data class Message(val role: Role, val content: String)
// Index 02
data class Message(
val role: Role,
val content: String,
val toolCalls: List<ToolCall> = emptyList() // 新增
)toolCalls 只对 Role.ASSISTANT 消息有意义——当 LLM 返回 stop_reason: "tool_use" 时,它的回复里既有一段文本("Let me check that file"),又有工具调用请求({name: "read_file", input: {...}})。文本放在 content,工具调用放在 toolCalls。
一个重要的设计选择:tool_result(工具执行结果)不进 Message。它作为 LLMProvider.chat() 的独立参数传入,由 Provider 内部组装成 API 要求的格式。原因很简单——Anthropic API 的 tool_result 是一个 {"type": "tool_result", "tool_use_id": "...", "content": "..."} 的 content block,而 OpenAI 的格式完全不同。让 Message 只描述对话角色(谁说的),工具结果的序列化留给各 Provider 自己处理。
ToolCall 和 ToolResult:工具调用的请求-响应对
data class ToolCall(
val id: String, // Anthropic tool_use id, 如 "toolu_001"
val name: String, // 工具名, 如 "read_file"
val input: JsonObject // 参数 JSON, LLM 直接生成的
)
data class ToolResult(
val toolCallId: String, // 对应 ToolCall.id, API 要求必须匹配
val content: String, // 返回给 LLM 的文本
val isError: Boolean = false
)ToolCall.id 和 ToolResult.toolCallId 的对应关系至关重要:Anthropic API 要求 tool_result.tool_use_id 必须匹配之前的 tool_use.id,否则返回 400。我们的工具实现(ReadFileTool 等)不需要自己设置 toolCallId——它们只返回 ToolResult("", content)(空 id),由 AgentLoop 的 executeOneTool() 统一调用 result.copy(toolCallId = toolCall.id) 填入。
LLMProvider 接口:两个新参数
// Index 01
suspend fun chat(messages: List<Message>, options: ChatOptions): ChatResponse
// Index 02
suspend fun chat(
messages: List<Message>,
options: ChatOptions,
tools: List<JsonObject> = emptyList(), // 工具 JSON Schema 列表
toolResults: List<ToolResult> = emptyList() // 上一轮工具执行结果
): ChatResponse两个新参数都有默认值 emptyList(),所以 Index 01 的所有代码无需修改即可编译通过。ChatResponse 也新增了 toolCalls: List<ToolCall> = emptyList() 字段,同样向后兼容。
第二层:Tool 接口与注册表
五个属性的接口
interface Tool {
val name: String // "read_file"
val description: String // 一句话描述, 日志和帮助
val isReadOnly: Boolean // 驱动并发策略的核心属性
val jsonSchema: JsonObject // 完整的 JSON Schema, 原样传给 LLM
suspend fun execute(input: JsonObject): ToolResult
}设计上刻意保持极简。没有泛型 <T: Any>,没有 ToolContext 参数,没有 validate() 方法。所有参数用 JsonObject 传递——Anthropic API 返回的 tool_use input 本身就是 JsonObject,我们原样转发,零序列化开销。
isReadOnly 值得单独说明。它不是"这个操作有没有副作用"的哲学问题——它是一个并发策略声明。isReadOnly = true 的工具会被 AgentLoop 并行执行,isReadOnly = false 的会串行执行。三个内置工具中,ReadFileTool 标记为 true,WriteFileTool 和 BashTool 标记为 false。未来添加 grep 或 glob 工具时,只需设置 isReadOnly = true 即可自动获得并行能力。
jsonSchema 是完整的 JSON Schema 对象(含 name、description、input_schema),不是只有 input_schema 部分。这样 ToolRegistry.getDefinitions() 直接返回 tools.values.map { it.jsonSchema },无需再包装一层。
ToolRegistry:一个单例 Map
object ToolRegistry {
private val tools = mutableMapOf<String, Tool>()
fun register(tool: Tool) { tools[tool.name] = tool }
fun get(name: String): Tool = tools[name] ?: throw UnknownToolException(name)
fun getAll(): Collection<Tool> = tools.values
fun getDefinitions(): List<JsonObject> = tools.values.map { it.jsonSchema }
fun clear() { tools.clear() }
}为什么用 object 而非 class?Index 02-05 阶段只有一个全局注册表,DI 容器在这个阶段是过度工程。当 Index 06 Subagent 需要每个子智能体有独立的工具集时,再把 object 改成 class 并注入。
clear() 方法不在设计文档里——它是在实现测试时发现的必要补充。ToolRegistry 是单例,register() 在测试间会累积状态,导致 getDefinitions() 测试看到前一个测试注册的工具。clear() 配合 Kotest 的 afterTest { } 钩子解决隔离问题。这是 TDD 过程中的一个典型修正:设计时没考虑测试隔离,测试写出来后自然暴露了问题。
UnknownToolException 被 AgentLoop 的 executeOneTool() 捕获并转化为 ToolResult(isError=true),不会让整个循环崩溃。LLM 有时会编造工具名(hallucination),这个容错设计让智能体可以从错误中恢复——LLM 收到 "Unknown tool: xxx" 的错误结果后,通常会换一个正确的工具重试。
第三层:三个内置工具
每个工具约 50-90 行代码,结构一致:定义 JSON Schema(告诉 LLM 怎么调用),实现 execute()(执行实际操作)。所有工具的 JSON Schema 都缺少 required 数组——这是一个已知的简化。在 Index 02 阶段,我们假设 LLM 能从 description 中理解哪些参数是必须的。
ReadFileTool — 读文件,带行号
class ReadFileTool : Tool {
companion object { private const val MAX_LINES = 2000 }
override val name = "read_file"
override val isReadOnly = true
override suspend fun execute(input: JsonObject): ToolResult {
val filePath = input["file_path"]?.jsonPrimitive?.content
?: return ToolResult("", "Error: file_path is required", isError = true)
val offset = input["offset"]?.jsonPrimitive?.content?.toIntOrNull() ?: 0
val limit = input["limit"]?.jsonPrimitive?.content?.toIntOrNull() ?: MAX_LINES
val file = File(filePath)
if (!file.exists()) return ToolResult("", "Error: File not found: $filePath", isError = true)
val allLines = file.readLines()
val selected = allLines.drop(offset).take(limit)
val numbered = selected.mapIndexed { i, line -> "${offset + i + 1}\t$line" }
val content = if (allLines.size > offset + limit)
numbered.joinToString("\n") + "\n[File truncated: ${allLines.size} total lines]"
else numbered.joinToString("\n")
return ToolResult("", content)
}
}返回内容带行号(1\tpackage com.sepcai...),方便 LLM 在后续对话中引用具体位置("第 42 行的 sanitizeHistory 方法...")。超过 2000 行截断,防止一个 read_file("/etc/passwd") 撑爆上下文窗口。
WriteFileTool — 创建或覆盖文件
class WriteFileTool : Tool {
override val name = "write_file"
override val isReadOnly = false
override suspend fun execute(input: JsonObject): ToolResult {
val filePath = input["file_path"]?.jsonPrimitive?.content
?: return ToolResult("", "Error: file_path is required", isError = true)
val content = input["content"]?.jsonPrimitive?.content
?: return ToolResult("", "Error: content is required", isError = true)
val file = File(filePath)
file.parentFile?.mkdirs() // 自动创建父目录
file.writeText(content)
return ToolResult("", "Success: File written to $filePath (${content.length} bytes)")
}
}mkdirs() 是唯一值得强调的细节。LLM 经常会要求"写一个新文件到 src/main/kotlin/foo/Bar.kt",如果 foo/ 目录不存在就失败了。自动创建父目录消除了这个摩擦。
BashTool — 执行 Shell 命令
class BashTool : Tool {
companion object { private const val TIMEOUT_SECONDS = 120L }
override val name = "bash"
override val isReadOnly = false
override suspend fun execute(input: JsonObject): ToolResult {
val command = input["command"]?.jsonPrimitive?.content
?: return ToolResult("", "Error: command parameter is required", isError = true)
val process = ProcessBuilder()
.command("sh", "-c", command)
.redirectErrorStream(true) // stderr 合并到 stdout
.start()
val completed = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS)
if (!completed) {
process.destroyForcibly()
return ToolResult("", "Error: Command timed out after ${TIMEOUT_SECONDS}s", isError = true)
}
val output = process.inputStream.bufferedReader().readText().trimEnd()
val exitCode = process.exitValue()
return if (exitCode == 0) ToolResult("", output)
else ToolResult("", "Command exit code: $exitCode\n$output", isError = true)
}
}三个细节值得注意。(1)使用 sh -c 而非直接调用可执行文件——这让命令可以包含管道、重定向等 shell 语法,LLM 习惯写这种命令。(2)redirectErrorStream(true) 将 stderr 合并到 stdout,避免因为读取顺序问题导致进程挂起。(3)超时后调用 destroyForcibly() 确保子进程不会变成孤儿进程。120 秒是硬编码的——不做可配置,因为 Index 02 不需要这个灵活性。
这三个工具覆盖了编程助手的核心场景:LLM 读代码 → 分析 → 写修改 → 运行测试。Index 03 的 Permission 管线会在 BashTool 执行前插入安全确认。
第四层:AgentLoop 工具循环
这是 Index 02 最核心的变更。Index 01 的 run() 方法是 15 行的一次性调用——调一次 LLM,返回文本。Index 02 把它变成了一个 55 行的状态机:
suspend fun run(userInput: String): String {
sanitizeHistory()
messagesHistory.add(Message(Role.USER, userInput))
val toolDefinitions = ToolRegistry.getDefinitions()
var toolResults: List<ToolResult> = emptyList()
for (iteration in 1..config.maxIterations) {
val response = llmProvider.chat(
messages = buildRequestMessages(),
options = ChatOptions(model, maxTokens, temperature),
tools = toolDefinitions,
toolResults = toolResults
)
when (response.stopReason) {
StopReason.END_TURN -> {
messagesHistory.add(Message(Role.ASSISTANT, response.content))
return response.content
}
StopReason.TOOL_USE -> {
messagesHistory.add(
Message(Role.ASSISTANT, response.content, toolCalls = response.toolCalls)
)
toolResults = executeTools(response.toolCalls)
continue // ← 关键:回到循环开头,把结果传给 LLM
}
StopReason.ERROR, StopReason.MAX_TOKENS, StopReason.STOP_SEQUENCE -> {
if (response.content.isNotBlank())
messagesHistory.add(Message(Role.ASSISTANT, response.content))
return response.content
}
}
}
return "Max iterations (${config.maxIterations}) reached."
}核心逻辑在 TOOL_USE 分支:把 Assistant 消息(含 tool_use 块)追加到历史 → 执行工具 → 把结果赋值给 toolResults → continue 回到循环开头。下一轮调用 llmProvider.chat() 时,toolResults 作为参数传入,AnthropicProvider 会把它组装成 tool_result 消息。
这里有一个 Index 01 行为的变化:ERROR 响应的处理。Index 01 中 ERROR 回复从不追加到历史(因为内容可能是 "Connection refused",不应该是对话的一部分)。Index 02 改为"非空白才追加"——如果 API 返回了一个有意义的错误消息,LLM 后续可能需要引用它。空白的 ERROR(如网络超时前没有收到任何内容)仍然不追加。
maxIterations 从 Index 01 的 1 改为 10。这不是拍脑袋的数字——典型的工具调用链是 "read_file → write_file → bash test",正好三轮 LLM 调用。10 给足了余量又不至于在死循环时等太久。
智能并发:读并行、写串行
当 LLM 同时请求读取三个文件时,顺序执行需要 time(A) + time(B) + time(C)。并行执行只需要 max(time(A), time(B), time(C))。实现方式是在一个 coroutineScope 内用 async 启动并发任务:
private suspend fun executeTools(toolCalls: List<ToolCall>): List<ToolResult> {
if (toolCalls.size == 1) return listOf(executeOneTool(toolCalls[0]))
// 按 isReadOnly 分组
val (reads, writes) = toolCalls.partition { tc ->
try { ToolRegistry.get(tc.name).isReadOnly }
catch (_: UnknownToolException) { false } // 未知工具当写操作, 安全第一
}
// 读操作并行
val readResults = if (reads.isNotEmpty()) {
coroutineScope { reads.map { tc -> async { executeOneTool(tc) } }.awaitAll() }
} else emptyList()
// 写操作串行
val writeResults = writes.map { tc -> executeOneTool(tc) }
// 按原始顺序合并结果
val allResults = readResults + writeResults
return toolCalls.map { tc ->
allResults.firstOrNull { it.toolCallId == tc.id }
?: ToolResult(tc.id, "Error: tool result lost", isError = true)
}
}结果的合并顺序很重要。并行执行的读操作完成顺序是不确定的——A 文件可能比 B 文件大、IO 慢、后完成。但回传给 LLM 的 tool_result 序列必须和 LLM 请求的 tool_use 序列一一对应(靠 tool_use_id 匹配)。所以最后一步是 toolCalls.map { tc -> allResults.firstOrNull { it.toolCallId == tc.id } }——按 LLM 的请求顺序重建结果列表。
catch (_: UnknownToolException) { false } 表示未知工具被当作写操作(串行、安全处理)。这是防御性的——如果 LLM 编造了一个不存在的工具名,我们宁愿它慢一点执行也不要引入并发问题。
第五层:AnthropicProvider 的内容多态
这是 Index 02 中最微妙的技术挑战。Anthropic Messages API 中,一条消息的 content 字段可以是两种类型:
- 纯文本:
"content": "Hello"—— 字符串 - 多块内容:
"content": [{"type": "text", "text": "..."}, {"type": "tool_use", ...}]—— 数组
Index 01 中 AnthropicMessage.content 是 String。Index 02 需要支持数组格式,用于两种场景:
- Assistant 消息含 tool_use 块:当 LLM 请求工具时,回复包含
text + tool_use的 content block 数组 - User 消息含 tool_result 块:将工具执行结果回传给 LLM 时,消息格式是
[{"type": "tool_result", "tool_use_id": "...", "content": "..."}]
解决方案是将 content 类型从 String 改为 JsonElement:
// Index 01
data class AnthropicMessage(val role: String, val content: String)
// Index 02
data class AnthropicMessage(val role: String, val content: JsonElement)JsonElement 是 kotlinx.serialization 的多态 JSON 节点——JsonPrimitive("hello") 当字符串用,JsonArray([...]) 当数组用,kotlinx.serialization 原生支持两者的序列化和反序列化。
发送方向:construct → convert
chat() 方法中,消息的转换现在只需要一步——convertToAnthropicMessage() 同时处理了 tool_use 和 tool_result 两种多态格式:
// 转换消息:tool_use 和 tool_result 块由 convertToAnthropicMessage() 统一处理
val apiMessages = messages.filter { it.role != Role.SYSTEM }
.map { msg -> convertToAnthropicMessage(msg) }convertToAnthropicMessage() 根据消息角色和字段自动选择序列化方式:
- 普通消息 →
JsonPrimitive(content) - ASSISTANT 消息含
toolCalls→JsonArray([{text...}, {tool_use...}]) - USER 消息含
toolResults→JsonArray([{tool_result...}])
工具结果不再是 chat() 的瞬态参数——它们作为 USER 消息持久化在历史中。这解决了跨对话轮次的 tool_use/tool_result 配对问题(详见踩坑记录第 5 条)。
接收方向:解析 tool_use 块
响应解析在 Index 01 的基础上增加两行:
// Index 01: 只提取文本
val text = response.content.filter { it.type == "text" }.joinToString("") { it.text }
// Index 02: 同时提取工具调用
val text = response.content.filter { it.type == "text" }.joinToString("") { it.text }
val toolCalls = response.content.filter { it.type == "tool_use" }
.map { ToolCall(id = it.id, name = it.name, input = it.input) }AnthropicContent 的 id、name、input 字段在纯文本块中都是默认值(空字符串/空 JsonObject),只有 type == "tool_use" 时才会有实际值。这种"一个类表示多种内容块"的设计牺牲了一点类型安全,换来了代码的简洁——不需要 sealed class 和自定义 serializer。
踩坑记录
1. Kotlin override 不能省略默认参数
修改 LLMProvider.chat() 签名(增加 tools 和 toolResults 两个默认参数)后,FakeLLMProvider 编译失败。Kotlin 的规则是:接口中定义了默认值的参数,实现类的 override 方法必须声明全部参数,不能因为有默认值就省略。这导致 AgentLoopTest.kt 中的 FakeLLMProvider 也必须在 Task 6 中同步更新签名——即使它不需要使用新参数。
2. AnthropicMessage.content 类型变更的连锁反应
AnthropicMessage.content 从 String 改为 JsonElement 后,所有构造 AnthropicMessage 的地方都需要修改:纯文本消息从 AnthropicMessage(role, content) 改为 AnthropicMessage(role, JsonPrimitive(content))。这个变更触及了 AnthropicProvider.chat() 中的三处代码和测试文件中的请求体验证(测试中的 capturedBody 断言从 "content":"Hello" 变成 "content":"Hello"——虽然 JSON 序列化结果相同,但代码逻辑走了不同的路径)。
3. FakeLLMProvider 的多轮响应支持
工具循环测试需要在第一轮返回 TOOL_USE、第二轮返回 END_TURN。Index 01 的 FakeLLMProvider 只支持单个 ChatResponse。我们改为接受 List<ChatResponse> 并依次消费:
class FakeLLMProvider(private val responses: List<ChatResponse>) : LLMProvider {
constructor(response: ChatResponse) : this(listOf(response)) // 向后兼容
private var callIndex = 0
override suspend fun chat(...): ChatResponse {
val response = if (callIndex < responses.size) responses[callIndex]
else responses.last() // 超出序列时重复最后一个
callIndex++
return response
}
}辅助构造函数 constructor(response: ChatResponse) 保持向后兼容——Index 01 的 7 个旧测试不需要修改。
4. tool_use id 必须回传
Anthropic API 要求 tool_result.tool_use_id 匹配 tool_use.id,否则返回 400。我们的工具实现不需要知道自己的调用 ID——它们返回 ToolResult("", content)(空 toolCallId)。AgentLoop 的 executeOneTool() 负责填入:result.copy(toolCallId = toolCall.id)。如果忘记这一步,API 会拒绝请求,错误信息是 "tool_use_id field is required for tool_result blocks"。
5. tool_use 和 tool_result 必须成对出现在历史中
这是一个在 DeepSeek API 上才暴露的隐蔽 bug。Anthropic API(以及 DeepSeek 的兼容端点)要求消息序列中每个 tool_use 块必须在紧随其后的消息中有对应的 tool_result 块。
最初的设计中,toolCalls 存储在 Message 历史中(永久),但 toolResults 只作为 LLMProvider.chat() 的 transient 参数传递——每次工具循环中,结果作为参数传给下一轮 LLM 调用,但从不写入历史。这在同一轮对话内工作正常(工具循环中 toolResults 参数始终被传递),但下一轮用户输入时:
messages: [
...,
{role: "assistant", content: [{text: "Let me check"}, {tool_use: {id: "call_xxx"}}]},
{role: "user", content: "新的用户输入"} // ← 缺少 tool_result!
]API 返回错误:tool_use ids were found without tool_result blocks immediately after。
修复方案:Message 新增 toolResults: List<ToolResult> 字段,AgentLoop 在工具执行完成后立即将结果作为 USER 消息写入历史:
// AgentLoop.run() 中 TOOL_USE 分支
messagesHistory.add(Message(Role.ASSISTANT, response.content, toolCalls = response.toolCalls))
val toolResults = executeTools(response.toolCalls)
messagesHistory.add(Message(Role.USER, "", toolResults = toolResults)) // 持久化这样 convertToAnthropicMessage() 统一从历史消息中读取 toolCalls 和 toolResults,不再依赖 transient 参数。跨轮次的历史完整性由 Message 模型保证。
6. JSON Schema 的 required 字段类型
JSON Schema 规范要求 required 是字符串数组(["file_path"]),但初期实现中 ReadFileTool 的 required 被写成了空对象 {},WriteFileTool 和 BashTool 甚至完全缺失该字段。Anthropic 官方 API 对此校验宽松(忽略无效字段),但 DeepSeek 的兼容端点严格按照规范验证,直接返回 Invalid schema for function 'read_file': {} is not of type "array"。
修复方法:使用 buildJsonArray { add(JsonPrimitive("file_path")) } 生成正确的数组格式。所有三个工具的 JSON Schema 在 Index 02 结束时都符合规范。
测试:66 个用例,两种策略
Index 02 新增 29 个测试,总用例数从 37 增长到 66。测试策略分两类:
对于纯逻辑(ToolRegistry、AgentLoop 工具循环):手写 Fake。FakeEchoTool 是一个只有 8 行的 Tool 实现,FakeLLMProvider 支持多轮响应序列。不需要 mock 框架——两个手写的假实现加起来不到 50 行。
对于 I/O 操作(三个工具):真实文件系统。ReadFileTool 和 WriteFileTool 的测试创建临时文件和目录,执行完后由 JVM 自动清理。BashTool 的测试在真实 sh 进程中执行——echo hello、exit 1、echo error >&2——验证 stdout、stderr 合并和退出码处理。
对于 API 适配(AnthropicProvider):MockEngine。新增 3 个测试验证 tool_use 解析(从 JSON 响应中提取 ToolCall)、tools 参数传递(请求体中包含 "tools" 字段)和 tool_result 消息组装(验证 tool_use_id 和 content 正确序列化)。
| 被测组件 | 测试方式 | Index 01 | Index 02 | 合计 |
|---|---|---|---|---|
Message | Kotest | 5 | — | 5 |
EnvFile | 临时文件 | 8 | — | 8 |
ConfigResolver | Kotest | 10 | — | 10 |
AnthropicProvider | MockEngine | 7 | +3 | 10 |
AgentLoop | FakeLLMProvider | 7 | +5 | 12 |
ToolRegistry | FakeEchoTool | — | 5 | 5 |
ReadFileTool | 临时文件 | — | 5 | 5 |
WriteFileTool | 临时目录 | — | 5 | 5 |
BashTool | ProcessBuilder | — | 5 | 5 |
| 总计 | 37 | +29 | 66 |
文件清单
src/main/kotlin/com/sepcai/code/
├── tool/ # [新建] 工具包
│ ├── Tool.kt # Tool 接口 (37行)
│ ├── ToolCall.kt # 工具调用 data class
│ ├── ToolResult.kt # 工具结果 data class
│ ├── ToolRegistry.kt # 工具注册表单例 (54行)
│ └── tools/
│ ├── ReadFileTool.kt # 读文件, 带行号+截断 (89行)
│ ├── WriteFileTool.kt # 写文件, 自动创建目录 (65行)
│ └── BashTool.kt # Shell 执行, 120s超时 (88行)
├── agent/
│ ├── AgentLoop.kt # [修改] 111→203行, +工具循环+并发
│ └── AgentConfig.kt # [修改] maxIterations: 1→10
├── llm/
│ ├── Message.kt # [修改] +toolCalls 字段
│ ├── LLMProvider.kt # [修改] chat() +tools/+toolResults
│ ├── AnthropicModels.kt # [修改] content: String→JsonElement, +tool_use字段
│ └── AnthropicProvider.kt # [修改] 207→287行, +工具序列化/反序列化
└── repl/
└── ReplLoop.kt # [修改] 启动时注册 3 个内置工具
src/test/kotlin/com/sepcai/code/
├── agent/ AgentLoopTest.kt # [修改] +5个工具循环测试 (319行)
├── llm/ AnthropicProviderTest.kt # [修改] +3个工具解析测试
└── tool/
├── ToolRegistryTest.kt # [新建] 5 tests
├── ReadFileToolTest.kt # [新建] 5 tests
├── WriteFileToolTest.kt # [新建] 5 tests
└── BashToolTest.kt # [新建] 5 tests17 个源文件(8 新建 + 9 修改)· 66 个测试 · 全部通过。
下一站
Index 02 的工具体系已经就绪,但任何工具现在都是无条件执行的——LLM 说 bash rm -rf /,AgentLoop 就会执行。
Index 03: Permission 将引入审批管线来拦截这种情况:
PermissionRule接口:evaluate(toolCall: ToolCall): PermissionResult { ALLOW, DENY, ASK }PermissionPipeline:责任链模式,串联多个规则(BashTool 危险命令检测、文件路径白名单等)ApprovalStore:记住用户的 "always allow" 选择,避免重复询问


