open-code 源码解析· №3
OpenCode Agent 核心实现:工具系统与 MCP 协议深度解析 | 深度解析(三)
深入剖析 OpenCode 的 Agent 工具系统:BaseTool 接口设计、内置工具集(bash/edit/glob/grep 等)、MCP 协议集成、子 Agent 机制。
约 9 分钟阅读2,598 字42 次阅读博主

open-code 源码解析· №3
深入剖析 OpenCode 的 Agent 工具系统:BaseTool 接口设计、内置工具集(bash/edit/glob/grep 等)、MCP 协议集成、子 Agent 机制。

📚 本系列目录:《OpenCode 深度解析》 当前第 4/11 篇 · 上一篇:OpenCode 技术架构深度剖析:Go 语言打造的 AI Agent 基础设施 | 深度解析(二) · 下一篇:OpenCode 数据库与会话管理:SQLite 存储设计与 Auto-Compact 机制 | 深度解析(四)
《OpenCode 深度解析》共 11 篇,本篇是第 4 篇。
← 上一篇:OpenCode 技术架构深度剖析:Go 语言打造的 AI Agent 基础设施 | 深度解析(二)
下一篇:OpenCode 数据库与会话管理:SQLite 存储设计与 Auto-Compact 机制 | 深度解析(四) →
在上一篇文章中,我们深入剖析了 OpenCode 的技术架构,理解了其模块化设计和 Provider 模式的多模型支持机制。本文将聚焦于 OpenCode 最重要的核心能力——Agent 工具系统,探讨它如何实现一个真正能够"动手编程"的 AI Agent。
工具系统是 AI 编程助手与传统对话式 AI 的本质区别。一个强大的工具系统让 AI 不仅能够回答问题,还能够执行命令、读写文件、搜索代码、甚至启动子 Agent 来完成复杂任务。
OpenCode 所有工具都实现了统一的 BaseTool 接口:
// internal/llm/tools/tools.go
type BaseTool interface {
Info() ToolInfo
Run(ctx context.Context, params ToolCall) (ToolResponse, error)
}
这个设计遵循了面向接口编程的原则:
Info() 方法返回工具的元信息(名称、描述、参数),用于向 LLM 描述工具能力Run() 方法执行具体的工具逻辑type ToolInfo struct {
Name string // 工具名称,LLM 调用时使用
Description string // 工具描述,帮助 LLM 理解何时使用
Parameters map[string]any // JSON Schema 格式的参数定义
Required []string // 必填参数列表
}
type ToolCall struct {
ID string // 工具调用 ID,用于匹配响应
Name string // 工具名称
Input string // JSON 格式的参数
}
type ToolResponse struct {
Type toolResponseType // 响应类型:text 或 image
Content string // 响应内容
Metadata string // 额外的元数据(JSON 格式)
IsError bool // 是否为错误响应
}
工具执行时需要访问当前会话的上下文信息,OpenCode 使用 Go 的 context 机制传递:
// 上下文 key 定义
const (
SessionIDContextKey sessionIDContextKey = "session_id"
MessageIDContextKey messageIDContextKey = "message_id"
)
// 从 context 中提取会话和消息 ID
func GetContextValues(ctx context.Context) (string, string) {
sessionID := ctx.Value(SessionIDContextKey)
messageID := ctx.Value(MessageIDContextKey)
if sessionID == nil {
return "", ""
}
if messageID == nil {
return sessionID.(string), ""
}
return sessionID.(string), messageID.(string)
}
这种设计的巧妙之处在于:
OpenCode 的 Coder Agent 配备了 11 个内置工具:
// internal/llm/agent/tools.go
func CoderAgentTools(...) []tools.BaseTool {
return append(
[]tools.BaseTool{
tools.NewBashTool(permissions), // 执行 shell 命令
tools.NewEditTool(lspClients, ...), // 编辑文件
tools.NewFetchTool(permissions), // 获取 URL 内容
tools.NewGlobTool(), // 文件模式匹配
tools.NewGrepTool(), // 文本搜索
tools.NewLsTool(), // 目录列表
tools.NewSourcegraphTool(), // 公共代码搜索
tools.NewViewTool(lspClients), // 查看文件
tools.NewPatchTool(lspClients, ...), // 应用补丁
tools.NewWriteTool(lspClients, ...), // 写文件
NewAgentTool(sessions, messages, lspClients), // 子 Agent
},
GetMcpTools(ctx, permissions)..., // MCP 扩展工具
)
}
BashTool 是最强大但也最危险的工具。OpenCode 实现了多层安全机制:
// internal/llm/tools/bash.go
var bannedCommands = []string{
"alias", "curl", "curlie", "wget", "axel", "aria2c",
"nc", "telnet", "lynx", "w3m", "links", "httpie", "xh",
"http-prompt", "chrome", "firefox", "safari",
}
禁止的命令主要包括:
var safeReadOnlyCommands = []string{
"ls", "echo", "pwd", "date", "cal", "uptime", "whoami", "id", "groups",
"env", "printenv", "set", "unset", "which", "type", "whereis",
"whatis", "uname", "hostname", "df", "du", "free", "top", "ps", "kill",
// Git 命令
"git status", "git log", "git diff", "git show", "git branch",
"git tag", "git remote", "git ls-files", "git ls-remote",
"git rev-parse", "git config --get", "git config --list",
// Go 命令
"go version", "go help", "go list", "go env", "go doc",
"go vet", "go fmt", "go mod", "go test", "go build", "go run",
}
const (
DefaultTimeout = 1 * 60 * 1000 // 默认 1 分钟
MaxTimeout = 10 * 60 * 1000 // 最大 10 分钟
)
type BashParams struct {
Command string `json:"command"`
Timeout int `json:"timeout"` // 毫秒
}
const MaxOutputLength = 30000 // 最大 30000 字符
BashTool 使用持久化的 shell 会话,这意味着:
// 好的实践
pytest /foo/bar/tests
// 不好的实践
cd /foo/bar && pytest tests // 应该避免 cd 命令
OpenCode 提供了一组互补的文件操作工具:
| 工具 | 用途 | 特点 |
|---|---|---|
glob | 模式匹配文件 | 支持 **/*.go 这样的通配符 |
grep | 文本搜索 | 支持正则表达式和文件过滤 |
ls | 目录列表 | 支持忽略模式 |
view | 查看文件 | 支持分页(offset/limit) |
write | 写入文件 | 完整覆盖 |
edit | 编辑文件 | 替换指定字符串 |
patch | 应用补丁 | 使用 unified diff 格式 |
ViewTool 不仅仅是读取文件,还能通过 LSP 获取额外信息:
// internal/llm/tools/view.go
// 集成 LSP 获取:
// - 语法高亮信息
// - 符号定义位置
// - 悬停文档
// internal/llm/tools/fetch.go
type FetchParams struct {
URL string `json:"url"`
Format string `json:"format"` // "text" 或 "json"
Timeout int `json:"timeout"`
}
特点:
// 用于在公共代码仓库中搜索
// 支持搜索代码模式、符号等
这是最强大的工具,允许 Agent 启动子 Agent 来完成任务:
// internal/llm/agent/agent-tool.go
type agentTool struct {
sessions session.Service
messages message.Service
lspClients map[string]*lsp.Client
}
type AgentParams struct {
Prompt string `json:"prompt"`
}
func (b *agentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolResponse, error) {
// 创建新的 Task Agent
agent, err := NewAgent(config.AgentTask, b.sessions, b.messages, TaskAgentTools(b.lspClients))
// 创建任务会话
session, err := b.sessions.CreateTaskSession(ctx, call.ID, sessionID, "New Agent Session")
// 运行 Agent
done, err := agent.Run(ctx, session.ID, params.Prompt)
result := <-done
// 成本累加到父会话
parentSession.Cost += updatedSession.Cost
return tools.NewTextResponse(response.Content().String()), nil
}
文档建议在以下场景使用 AgentTool:
- 搜索关键词如 "config" 或 "logger"
- 询问"哪个文件做了 X"
- 不确定第一次就能找到匹配项的情况
但要注意:
子 Agent 不能使用 Bash、Replace、Edit,所以不能修改文件。如果需要修改文件,应该直接使用相应工具。
回顾 Agent 的核心处理循环:
// internal/llm/agent/agent.go
func (a *agent) processGeneration(ctx context.Context, sessionID, content string, ...) AgentEvent {
for {
// 1. 流式处理 LLM 事件
agentMessage, toolResults, err := a.streamAndHandleEvents(ctx, sessionID, msgHistory)
// 2. 如果需要工具调用
if (agentMessage.FinishReason() == message.FinishReasonToolUse) && toolResults != nil {
// 3. 将工具结果添加到历史
msgHistory = append(msgHistory, agentMessage, *toolResults)
// 4. 继续循环
continue
}
// 5. 否则返回最终响应
return AgentEvent{
Type: AgentEventTypeResponse,
Message: agentMessage,
Done: true,
}
}
}
// internal/llm/agent/agent.go - processEvent
func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg *message.Message, event provider.ProviderEvent) error {
switch event.Type {
case provider.EventThinkingDelta:
// Claude 的扩展思考内容
assistantMsg.AppendReasoningContent(event.Content)
case provider.EventContentDelta:
// 普通文本内容
assistantMsg.AppendContent(event.Content)
case provider.EventToolUseStart:
// 开始工具调用
assistantMsg.AddToolCall(*event.ToolCall)
case provider.EventToolUseStop:
// 工具调用完成
assistantMsg.FinishToolCall(event.ToolCall.ID)
case provider.EventComplete:
// 生成完成
assistantMsg.SetToolCalls(event.Response.ToolCalls)
assistantMsg.AddFinish(event.Response.FinishReason)
}
return nil
}
// internal/llm/agent/agent.go - streamAndHandleEvents
toolCalls := assistantMsg.ToolCalls()
for i, toolCall := range toolCalls {
// 1. 查找工具
var tool tools.BaseTool
for _, availableTool := range a.tools {
if availableTool.Info().Name == toolCall.Name {
tool = availableTool
break
}
}
// 2. 工具未找到
if tool == nil {
toolResults[i] = message.ToolResult{
ToolCallID: toolCall.ID,
Content: fmt.Sprintf("Tool not found: %s", toolCall.Name),
IsError: true,
}
continue
}
// 3. 执行工具
toolResult, toolErr := tool.Run(ctx, tools.ToolCall{
ID: toolCall.ID,
Name: toolCall.Name,
Input: toolCall.Input,
})
// 4. 处理结果
toolResults[i] = message.ToolResult{
ToolCallID: toolCall.ID,
Content: toolResult.Content,
IsError: toolErr != nil || toolResult.IsError,
}
}
敏感操作需要权限确认:
// internal/permission/permission.go
type Permission string
const (
PermissionBash Permission = "bash"
PermissionEdit Permission = "edit"
PermissionWrite Permission = "write"
PermissionRead Permission = "read"
PermissionFetch Permission = "fetch"
)
// 权限服务接口
type Service interface {
Check(p Permission) error // 检查是否有权限
Grant(p Permission) // 单次授予
GrantPersistant(p Permission) // 永久授予
Deny(p Permission) // 拒绝
IsGranted(p Permission) bool // 是否已授权
IsPersistant(p Permission) bool // 是否永久授权
}
当权限被拒绝时:
if errors.Is(toolErr, permission.ErrorPermissionDenied) {
toolResults[i] = message.ToolResult{
ToolCallID: toolCall.ID,
Content: "Permission denied",
IsError: true,
}
// 取消后续所有工具调用
for j := i + 1; j < len(toolCalls); j++ {
toolResults[j] = message.ToolResult{
ToolCallID: toolCalls[j].ID,
Content: "Tool execution canceled by user",
IsError: true,
}
}
a.finishMessage(ctx, &assistantMsg, message.FinishReasonPermissionDenied)
break
}
Model Context Protocol (MCP) 是一种开放协议,允许 AI 模型与外部工具服务器通信。OpenCode 通过 mcp-go 库实现了 MCP 客户端。
// internal/llm/agent/mcp-tools.go
type mcpTool struct {
mcpName string // MCP 服务器名称
tool mcp.Tool // MCP 工具定义
mcpConfig config.MCPServer // MCP 服务器配置
permissions permission.Service
}
func (b *mcpTool) Info() tools.ToolInfo {
required := b.tool.InputSchema.Required
if required == nil {
required = make([]string, 0)
}
return tools.ToolInfo{
Name: fmt.Sprintf("%s_%s", b.mcpName, b.tool.Name),
Description: b.tool.Description,
Parameters: b.tool.InputSchema.Properties,
Required: required,
}
}
命名规范:MCP 工具的名称格式为 {mcpName}_{toolName},例如 filesystem_read 或 github_get_issue。
func runTool(ctx context.Context, c MCPClient, toolName string, input string) (tools.ToolResponse, error) {
defer c.Close()
// 1. 初始化 MCP 会话
initRequest := mcp.InitializeRequest{}
initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
initRequest.Params.ClientInfo = mcp.Implementation{
Name: "OpenCode",
Version: version.Version,
}
_, err := c.Initialize(ctx, initRequest)
if err != nil {
return tools.NewTextErrorResponse(err.Error()), nil
}
// 2. 调用工具
toolRequest := mcp.CallToolRequest{}
toolRequest.Params.Name = toolName
var args map[string]any
if err = json.Unmarshal([]byte(input), &args); err != nil {
return tools.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
}
toolRequest.Params.Arguments = args
result, err := c.CallTool(ctx, toolRequest)
if err != nil {
return tools.NewTextErrorResponse(err.Error()), nil
}
// 3. 处理结果
output := ""
for _, v := range result.Content {
if v, ok := v.(mcp.TextContent); ok {
output = v.Text
} else {
output = fmt.Sprintf("%v", v)
}
}
return tools.NewTextResponse(output), nil
}
// internal/config/config.go
type MCPServer struct {
Command string `json:"command"` // stdio 模式命令
Env []string `json:"env"` // 环境变量
Args []string `json:"args"` // 命令参数
Type MCPType `json:"type"` // stdio 或 sse
URL string `json:"url"` // SSE 模式 URL
Headers map[string]string `json:"headers"` // HTTP 头
}
{
"mcpServers": {
"filesystem": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed"]
},
"github": {
"type": "sse",
"url": "https://api.example.com/mcp",
"headers": {
"Authorization": "Bearer token"
}
}
}
}
适用于本地命令启动的 MCP 服务器:
// 通过 stdio 通信
type stdioClient struct {
cmd *exec.Cmd
stdin io.WriteCloser
stdout io.ReadCloser
}
适用于远程 MCP 服务器:
// 通过 Server-Sent Events 通信
type sseClient struct {
url string
headers map[string]string
}
BashTool 的描述非常详细,包含使用指南和最佳实践:
func bashDescription() string {
return fmt.Sprintf(`Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
Before executing the command, please follow these steps:
1. Directory Verification:
- If the command will create new directories or files, first use the LS tool to verify the parent directory exists and is the correct location
- For example, before running "mkdir foo/bar", first use LS to check that "foo" exists and is the intended parent directory
2. Security Check:
- For security and to limit the threat of a prompt injection attack, some commands are limited or banned. If you use a disallowed command, you will receive an error message explaining the restriction. Explain the error to the User.
- Verify that the command is not one of the banned commands: %s.
3. Command Execution:
- After ensuring proper quoting, execute the command.
- Capture the output of the command.
4. Output Processing:
- If the output exceeds %d characters, output will be truncated before being returned to you.
- Prepare the output for display to the user.
5. Return Result:
- Provide the processed output of the command.
- If any errors occurred during execution, include those in the output.
Usage notes:
- The command argument is required.
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 30 minutes.
- VERY IMPORTANT: You MUST avoid using search commands like 'find' and 'grep'. Instead use Grep, Glob, or Agent tools to search.
- When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines.
- IMPORTANT: All commands share the same shell session. Shell state persists between commands.`, bannedCommandsStr, MaxOutputLength)
}
工具的参数使用 JSON Schema 定义:
type BashParams struct {
Command string `json:"command"`
Timeout int `json:"timeout"`
}
// 对应的 ToolInfo.Parameters
Parameters: map[string]any{
"command": map[string]any{
"type": "string",
"description": "The bash command to execute",
},
"timeout": map[string]any{
"type": "integer",
"description": "Timeout in milliseconds",
},
}
Required: []string{"command"}
每个工具只做一件事,且做好:
BashTool 只执行命令GlobTool 只做文件模式匹配GrepTool 只做文本搜索这种设计让工具更容易理解和维护。
复杂的操作通过组合工具实现:
# 创建目录并进入
mkdir new_project && cd new_project
# 编译并运行
go build ./... && go test ./...
工具执行可能失败,OpenCode 统一处理:
type ToolResponse struct {
Content string `json:"content"`
IsError bool `json:"is_error"`
}
// 使用 NewTextErrorResponse 创建错误响应
func NewTextErrorResponse(content string) ToolResponse {
return ToolResponse{
Content: content,
IsError: true,
}
}
默认情况下,敏感操作需要用户确认:
// TUI 层处理权限对话框
case pubsub.Event[permission.PermissionRequest]:
a.showPermissions = true
return a, a.permissions.SetPermissions(msg.Payload)
用户可以选择:
LLM 根据工具描述决定使用哪个工具:
If you are searching for a keyword like "config" or "logger", or for questions like "which file does X?", the Agent tool is strongly recommended.
If you want to read a specific file path, use the View or GlobTool tool instead of the Agent tool.
for {
// 1. LLM 决定调用工具
agentMessage, toolResults, err := a.streamAndHandleEvents(ctx, sessionID, msgHistory)
// 2. 如果 LLM 请求工具调用
if (agentMessage.FinishReason() == message.FinishReasonToolUse) && toolResults != nil {
// 3. 执行工具并获取结果
msgHistory = append(msgHistory, agentMessage, *toolResults)
// 4. 继续,让 LLM 处理结果
continue
}
// 5. 否则返回最终响应
return AgentEvent{...}
}
对于 Claude 模型,OpenCode 支持扩展思考模式:
// internal/llm/provider/anthropic.go
if messageContent != "" && a.options.shouldThink != nil && a.options.shouldThink(messageContent) {
thinkingParam = anthropic.ThinkingConfigParamOfEnabled(int64(float64(a.providerOptions.maxTokens) * 0.8))
temperature = anthropic.Float(1)
}
思考过程通过 EventThinkingDelta 事件传递:
case provider.EventThinkingDelta:
assistantMsg.AppendReasoningContent(event.Content)
User: Create a new file called hello.go with a main function that prints "Hello, World!"
LLM calls: write(file_path="hello.go", content="package main
import "fmt"
func main() {
fmt.Println("Hello, World!")
}")
User: Find all TODO comments in the project and add a timestamp
LLM calls:
1. grep(pattern="TODO", include="*.go")
2. edit(file_path="file1.go", old_string="TODO", new_string="TODO - 2024-01-15")
3. edit(file_path="file2.go", old_string="TODO", new_string="TODO - 2024-01-15")
User: Find all files that import the "database/sql" package
LLM calls:
agent(prompt="Search all Go files in the project that import "database/sql". Return a list of file paths.")
User: Run the test suite and fix any failing tests
LLM calls:
1. bash(command="go test ./... 2>&1")
2. view(file_path="package_test.go", offset=10, limit=50)
3. edit(file_path="package_test.go", ...)
4. bash(command="go test ./... -run TestName")
OpenCode 的工具系统是其作为 AI 编程助手的核心能力。通过:
BaseTool 接口这套工具系统让 OpenCode 不仅仅是一个问答系统,而是一个真正能够动手编程的 AI Agent。
本系列下一篇文章将深入探讨 OpenCode 的数据库与会话管理,剖析 SQLite 存储设计、自动摘要压缩机制,以及如何实现长期记忆能力。
Conversation
0 条