猫咖
首页博客工具
搜索
语言
切换网站风格
选择主题颜色
点击特效
主题

猫咖 · 持续更新中,源码见 GitHub。

Index 02: Tool Use — 让智能体动手做事

2026年7月27日
AI后端

系列

使用Kotlin从0开发一个ClaudeCode

系列

使用Kotlin从0开发一个ClaudeCode

进度 2 / 21

使用Kotlin从0开发一个ClaudeCode

上一篇

Index 01: Agent Loop — 让智能体开口说话

下一篇

Index 03: Permission — 让智能体做事前先问一声

2026-07-24 · Cat-Code Team

目标

Index 01 的智能体会说话,但不会做事。你让它"看看 UserService.kt 有什么问题",它只能凭记忆猜——因为它读不到文件,更改不了代码。

Index 02 的目标是让智能体长出双手。具体来说,我们要做四件事:

  1. 定义 Tool 接口和 ToolRegistry 注册表,让工具可以像插件一样注册和调度
  2. 实现三个内置工具——读文件、写文件、执行 Shell 命令——覆盖 80% 的编程场景
  3. 改造 AgentLoop,从"调一次 LLM 就返回"变成"LLM 请求工具 → 执行 → 回传结果 → 继续推理"的循环
  4. 实现智能并发,当 LLM 同时请求读两个文件时,并行执行而非串行等待

完成后的效果——智能体现在可以真正操作文件系统:

BASH
$ ./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 在这三层上做了扩展:

TEXT
                        ┌──────────────────────────────┐
                        │  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:存储工具调用的位置

KOTLIN
// 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:工具调用的请求-响应对

KOTLIN
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 接口:两个新参数

KOTLIN
// 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 接口与注册表

五个属性的接口

KOTLIN
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

KOTLIN
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 — 读文件,带行号

KOTLIN
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 — 创建或覆盖文件

KOTLIN
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 命令

KOTLIN
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 行的状态机:

KOTLIN
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 启动并发任务:

KOTLIN
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 需要支持数组格式,用于两种场景:

  1. Assistant 消息含 tool_use 块:当 LLM 请求工具时,回复包含 text + tool_use 的 content block 数组
  2. User 消息含 tool_result 块:将工具执行结果回传给 LLM 时,消息格式是 [{"type": "tool_result", "tool_use_id": "...", "content": "..."}]

解决方案是将 content 类型从 String 改为 JsonElement:

KOTLIN
// 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 两种多态格式:

KOTLIN
// 转换消息: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 的基础上增加两行:

KOTLIN
// 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> 并依次消费:

KOTLIN
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 参数始终被传递),但下一轮用户输入时:

TEXT
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 消息写入历史:

KOTLIN
// 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 01Index 02合计
MessageKotest5—5
EnvFile临时文件8—8
ConfigResolverKotest10—10
AnthropicProviderMockEngine7+310
AgentLoopFakeLLMProvider7+512
ToolRegistryFakeEchoTool—55
ReadFileTool临时文件—55
WriteFileTool临时目录—55
BashToolProcessBuilder—55
总计37+2966

文件清单

TEXT
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 tests

17 个源文件(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" 选择,避免重复询问

目录

当前章节:目标

  • 1. 目标
  • 2. 架构:三层扩展
  • 3. 第一层:数据模型扩展
  • 4. Message:存储工具调用的位置
  • 5. ToolCall 和 ToolResult:工具调用的请求-响应对
  • 6. LLMProvider 接口:两个新参数
  • 7. 第二层:Tool 接口与注册表
  • 8. 五个属性的接口
  • 9. ToolRegistry:一个单例 Map
  • 10. 第三层:三个内置工具
  • 11. ReadFileTool — 读文件,带行号
  • 12. WriteFileTool — 创建或覆盖文件
  • 13. BashTool — 执行 Shell 命令
  • 14. 第四层:AgentLoop 工具循环
  • 15. 智能并发:读并行、写串行
  • 16. 第五层:AnthropicProvider 的内容多态
  • 17. 发送方向:construct → convert
  • 18. 接收方向:解析 tooluse 块
  • 19. 踩坑记录
  • 20. 1. Kotlin override 不能省略默认参数
  • 21. 2. AnthropicMessage.content 类型变更的连锁反应
  • 22. 3. FakeLLMProvider 的多轮响应支持
  • 23. 4. tooluse id 必须回传
  • 24. 5. tooluse 和 toolresult 必须成对出现在历史中
  • 25. 6. JSON Schema 的 required 字段类型
  • 26. 测试:66 个用例,两种策略
  • 27. 文件清单
  • 28. 下一站
回到顶部

相关推荐

查看全部文章
Index 21: Terminal UX —— Claude Code 风格终端交互

Index 21: Terminal UX —— Claude Code 风格终端交互

2026年8月21日

Index 21 将 cat-code 终端交互升级为 Claude Code 风格,解决旧 REPL 黑箱、无中断及输入体验差的问题。通过 JLine3 与 Mordant 实现历史补全、实时工具可见性、Spinner 状态及 Esc 中断。核心采用 UI 与 AgentLoop 解耦的事件流架构,支持内联权限菜单与优雅降级。同时配置 logback 收敛控制台日志,确保 TUI 清爽且功能无损,显著提升可用性。

Index 20: Comprehensive Agent —— 全机制集成(收口)

Index 20: Comprehensive Agent —— 全机制集成(收口)

2026年8月13日

作为收官之作,把 s01-s19 的二十个核心机制整合为一个全面智能体:统一的状态查询与运行周期、自治认领与工作区隔离的贯通,以及 /status 命令的全局可视化。全机制协同运转,标志着 Cat-Code 从零完整复刻 Claude Code 核心能力的收官。

Index 19: MCP Plugin —— 多传输 / 通道路由 / 工具池组装

Index 19: MCP Plugin —— 多传输 / 通道路由 / 工具池组装

2026年8月13日

引入 MCP(Model Context Protocol)插件机制,通过多传输适配与通道路由,把外部 MCP 服务器的工具动态接入智能体的工具池。工具注册从静态编译期扩展为运行时动态组装,让智能体能力随外部服务即插即用。