OpenCode Custom Tools 完全指南:让 AI 编程助手调用你自定义的工具函数

OpenCode Custom Tools 完全指南:让 AI 编程助手调用你自定义的工具函数

引言

OpenCode 内置了十几种基础工具——read 读取文件、edit 编辑代码、bash 执行命令、websearch 搜索网络……这些工具覆盖了日常开发的大部分场景。但总有一些情况,你需要 AI 做的事情超出了内置工具的边界:查询内部数据库、调用私有 API、运行自定义脚本、或者对某些操作施加企业级的约束规则。

Custom Tools 正是为解决这类需求而生。你可以用 TypeScript 或 JavaScript 定义一个工具描述,让 LLM 在工作过程中自动调用它——就像调用内置工具一样自然。最妙的是,工具的执行逻辑完全由你掌控,底层可以是任何语言:Python 脚本、Go 二进制、cURL 请求,甚至是远程服务的 API 调用。

本文将带你从零开始掌握 OpenCode Custom Tools,涵盖基本结构、参数定义、上下文获取、跨语言实践和安全配置,并附上可直接运行的代码示例。

Custom Tools vs MCP vs Commands:先厘清边界

在开始之前,简要区分三种易混淆的扩展机制:

| 机制 | 定位 | 何时使用 |
|------|------|----------|
| Custom Tools | 让 LLM 调用的可执行函数 | 需要 LLM 自动决定何时触发某个操作(如"写日志前先检查磁盘空间") |
| Commands | TUI 中用户手动触发的快捷命令 | 你主动输入 /deploy 执行部署流程 |
| MCP Servers | 外部工具生态的标准协议集成 | 接入第三方服务(数据库、API、文件系统) |

简单来说:Command 是"用户主动发起的快捷操作",Custom Tool 是"LLM 自主决策调用的能力",MCP 是"标准化的外部工具接入协议"。

创建第一个 Custom Tool

Custom Tools 定义在 .opencode/tools/ 目录下的 .ts.js 文件中。文件名即工具名。

创建一个最简单的问候工具:

// .opencode/tools/greet.ts
import { tool } from "@opencode-ai/plugin"

export default tool({
  description: "向指定的名字打招呼",
  args: {
    name: tool.schema.string().describe("要打招呼的名字"),
  },
  async execute(args) {
    return `你好,${args.name}!欢迎使用 OpenCode Custom Tools。`
  },
})

保存后,在 OpenCode 会话中对 AI 说:

用 greet 工具向 "张三" 打招呼

LLM 就会自动调用 greet 工具,传入 { name: "张三" },返回问候语。

这里有几个关键点:

  • description:这是 LLM 决定是否调用该工具的核心依据。写清楚工具做什么、什么时候用、输入输出是什么。
  • args:参数定义使用 Zod schema(tool.schema 就是 Zod 的引用),支持 .string().number().boolean().object() 等所有 Zod 类型。
  • execute:异步函数,接收参数并返回字符串结果。返回的内容会直接展示在 LLM 的输出中。

深入理解:Tool 结构与参数定义

使用 Zod 定义复杂参数

简单的 string/number 参数远不够用,实际项目中你可能需要定义复杂的输入结构:

// .opencode/tools/deploy.ts
import { tool } from "@opencode-ai/plugin"

export default tool({
  description: "将项目部署到指定环境",
  args: {
    environment: tool.schema
      .enum(["staging", "production"])
      .describe("目标部署环境"),
    version: tool.schema
      .string()
      .regex(/^\d+\.\d+\.\d+$/)
      .describe("语义化版本号,如 1.2.3"),
    config: tool.schema
      .object({
        replicas: tool.schema.number().min(1).max(10).default(2),
        memory: tool.schema.string().default("512Mi"),
      })
      .optional()
      .describe("部署配置(可选)"),
  },
  async execute(args) {
    const { environment, version, config } = args
    // 执行实际部署逻辑
    const cmd = `deploy --env=${environment} --version=${version} --replicas=${config?.replicas ?? 2}`
    return `执行部署命令: ${cmd}\n部署完成!`
  },
})

Zod 的类型系统非常强大。你可以用它做参数校验、默认值设置、正则匹配等。如果 LLM 传入的参数不符合 schema 定义,OpenCode 会自动拦截并返回错误信息,LLM 会尝试修正参数后重试。

不使用 tool() 辅助函数的写法

如果你不想引入 @opencode-ai/plugin,也可以用纯对象的方式定义工具:

// .opencode/tools/ping.ts
import { z } from "zod"

export default {
  description: "检测服务健康状态",
  args: {
    url: z.string().url().describe("要检测的服务 URL"),
  },
  async execute(args: { url: string }) {
    try {
      const res = await fetch(args.url)
      return `状态码: ${res.status}, 耗时: ${res.headers.get("x-response-time") ?? "未知"}`
    } catch {
      return "无法连接到该服务"
    }
  },
}

两种写法完全等价,tool() 辅助函数只是提供了更好的 TypeScript 类型推导。

一个文件,多个工具:命名规则

你可以在单个文件中导出多个工具。每个具名导出会生成一个独立的工具:

// .opencode/tools/math.ts
import { tool } from "@opencode-ai/plugin"

export const add = tool({
  description: "对两个数进行加法运算",
  args: {
    a: tool.schema.number().describe("第一个数"),
    b: tool.schema.number().describe("第二个数"),
  },
  async execute(args) {
    return (args.a + args.b).toString()
  },
})

export const multiply = tool({
  description: "对两个数进行乘法运算",
  args: {
    a: tool.schema.number().describe("第一个数"),
    b: tool.schema.number().describe("第二个数"),
  },
  async execute(args) {
    return (args.a * args.b).toString()
  },
})

这会生成两个工具:math_addmath_multiply。命名规则是 <文件名>_<导出名>,中间的连接符为下划线。

注意:default 导出是一个特例——它生成的文件名不带后缀。上面的 greet.ts 导出 default,所以工具名就是 greet;而 math.ts 中没有 default 导出,所以每个具名导出都会加上 math_ 前缀。

覆盖内置工具:接管 LLM 行为

Custom Tools 的一个高级用法是覆盖内置工具。如果自定义工具的名字与内置工具重名,自定义工具会优先使用。

一个经典场景是:你想限制 LLM 执行 shell 命令的范围,创建一个包装版的 bash

// .opencode/tools/bash.ts
import { tool } from "@opencode-ai/plugin"
import { $ } from "bun"

const ALLOWED_COMMANDS = [
  "npm",
  "node",
  "git",
  "ls",
  "mkdir",
  "rm",
  "cat",
]

export default tool({
  description: "受限的 bash 命令执行器",
  args: {
    command: tool.schema.string().describe("要执行的命令"),
  },
  async execute(args) {
    const baseCmd = args.command.split(" ")[0]
    if (!ALLOWED_COMMANDS.includes(baseCmd)) {
      return `命令 "${baseCmd}" 不在允许列表中。允许的命令: ${ALLOWED_COMMANDS.join(", ")}`
    }
    const result = await $`${{ raw: args.command }}`.text()
    return result
  },
})

不过,更推荐的做法是使用权限系统直接禁止某些工具,而不是通过覆盖来迂回实现。覆盖内置工具主要用于"在原有功能基础上增强"的场景——例如在执行每个 bash 命令前自动记录审计日志。

Context:获取会话上下文信息

Tool 的 execute 函数可以接收第二个参数 context,其中包含当前会话的关键信息:

// .opencode/tools/project-info.ts
import { tool } from "@opencode-ai/plugin"

export default tool({
  description: "获取当前项目信息,包括工作目录和 Git 根目录",
  args: {},
  async execute(_args, context) {
    const { agent, sessionID, messageID, directory, worktree } = context
    return [
      `当前 Agent: ${agent}`,
      `会话 ID: ${sessionID}`,
      `消息 ID: ${messageID}`,
      `工作目录: ${directory}`,
      `Git 根目录: ${worktree}`,
    ].join("\n")
  },
})

Context 对象的属性说明:

| 属性 | 类型 | 说明 |
|------|------|------|
| agent | string | 当前使用的 agent 名称 |
| sessionID | string | 当前会话的唯一标识 |
| messageID | string | 当前消息的唯一标识 |
| directory | string | 会话的工作目录 |
| worktree | string | Git 仓库的根目录 |

这些信息在你需要定位文件路径、处理项目结构相关的逻辑时非常有用。例如,你可以用 context.worktree 拼接出脚本的绝对路径,避免相对路径在不同工作目录下出错。

跨语言实践:用 Python 写工具执行逻辑

TypeScript/JavaScript 只是工具定义的壳,实际执行逻辑完全可以用任何语言编写。下面是一个完整的跨语言工具案例:

首先,写一个 Python 脚本处理 JSON 数据:

# .opencode/tools/analyze_logs.py
import sys
import json

log_file = sys.argv[1]
level_filter = sys.argv[2] if len(sys.argv) > 2 else "ERROR"

with open(log_file, "r") as f:
    lines = f.readlines()

results = []
for line in lines:
    if level_filter in line:
        results.append(line.strip())

output = {
    "total_lines": len(lines),
    "matched_lines": len(results),
    "filter": level_filter,
    "matches": results[:10],
}
print(json.dumps(output, ensure_ascii=False, indent=2))

然后创建一个 TypeScript 工具定义来调用它:

// .opencode/tools/log-analyzer.ts
import { tool } from "@opencode-ai/plugin"
import path from "path"
import { $ } from "bun"

export default tool({
  description: "分析日志文件,按日志级别过滤并返回统计信息",
  args: {
    file: tool.schema
      .string()
      .describe("日志文件的路径(相对于项目根目录)"),
    level: tool.schema
      .enum(["DEBUG", "INFO", "WARN", "ERROR", "FATAL"])
      .default("ERROR")
      .describe("过滤的日志级别,默认为 ERROR"),
  },
  async execute(args, context) {
    const script = path.join(
      context.worktree,
      ".opencode/tools/analyze_logs.py"
    )
    const absFile = path.join(context.worktree, args.file)
    const result =
      await $`python ${script} ${absFile} ${args.level}`.text()
    return result
  },
})

这里用到了 Bun.$ 来执行 shell 命令。如果你用的是 Node.js 项目,可以换成 child_process.execSyncexeca

现在,当 LLM 需要分析日志时,会自动调用 log-analyzer 工具,传入文件路径和日志级别,得到结构化的 JSON 分析结果。

实战案例:数据库查询工具

让我们做一个更实用的案例——让 LLM 能够查询 MySQL 数据库:

// .opencode/tools/db-query.ts
import { tool } from "@opencode-ai/plugin"
import mysql from "mysql2/promise"

const pool = mysql.createPool({
  host: process.env.DB_HOST ?? "localhost",
  port: Number(process.env.DB_PORT) || 3306,
  user: process.env.DB_USER ?? "root",
  password: process.env.DB_PASS ?? "",
  database: process.env.DB_NAME ?? "myapp",
})

export default tool({
  description:
    "执行只读 SQL 查询(SELECT)。仅用于数据查询分析,不会修改数据。",
  args: {
    query: tool.schema
      .string()
      .startsWith("SELECT", "仅允许 SELECT 查询")
      .describe("要执行的 SQL SELECT 语句"),
    limit: tool.schema
      .number()
      .min(1)
      .max(100)
      .default(20)
      .describe("返回结果的最大行数"),
  },
  async execute(args) {
    try {
      const [rows] = await pool.query(`${args.query} LIMIT ${args.limit}`)
      return JSON.stringify(rows, null, 2)
    } catch (err) {
      return `查询失败: ${err instanceof Error ? err.message : String(err)}`
    }
  },
})

注意事项:

  • 用 Zod 的 .startsWith("SELECT") 限制只允许查询操作,防止 LLM 执行 DELETE/DROP 等危险命令。
  • 使用连接池而非单次连接,避免频繁创建连接的开销。
  • 错误信息要清晰返回,让 LLM 能据此调整查询策略。

使用时,你可以对 LLM 说:

用 db-query 工具查询最近 30 天注册的用户数

LLM 会生成类似 SELECT COUNT(*) FROM users WHERE created_at > ... 的查询并返回结果。

安全与权限配置

Custom Tools 在默认情况下是直接可用的,不需要额外审批。但如果你在安全敏感的环境中工作,可以通过权限系统加以控制。

在 opencode.json 中配置权限

{
  "$schema": "https://opencode.ai/config.json",
  "permission": {
    "db-query": "ask",
    "deploy": "ask",
    "log-analyzer": "allow",
    "bash": "deny"
  }
}

权限级别:

  • "allow":直接执行,不询问
  • "ask":每次调用前弹窗确认
  • "deny":禁止使用

工具放置策略

| 位置 | 作用域 | 适用场景 |
|------|--------|----------|
| .opencode/tools/ | 项目级 | 该项目特有的工具(如项目专用部署脚本) |
| ~/.config/opencode/tools/ | 全局 | 跨项目共用的通用工具(如通用日志分析器) |

建议将敏感操作(数据库写入、生产环境部署、费用相关的 API 调用)放在项目级并配置 "ask" 权限,将只读辅助工具放在全局级。

调试技巧

由于 Custom Tools 运行在 OpenCode 的运行时环境中,调试并不像普通应用那么直观。以下是一些实用技巧:

1. 用 console.log 输出调试信息

async execute(args, context) {
  console.log("Tool called with args:", JSON.stringify(args))
  console.log("Session ID:", context.sessionID)
  // ...
}

日志会出现在 OpenCode 的服务端输出中。

2. 返回结构化的错误信息

不要只返回 "error",应该返回足够的上下文让 LLM 理解问题:

async execute(args) {
  try {
    return await doSomething(args)
  } catch (err) {
    return `工具执行失败 [${err.code}]: ${err.message}\n建议: 检查参数或重试`
  }
}

3. 测试时直接用 Bash 工具模拟调用

在 OpenCode 会话中直接让 LLM 调用你的工具并观察返回结果。如果参数 schema 有问题,LLM 会得到验证错误并自动调整。

总结

Custom Tools 是 OpenCode 扩展体系中最灵活的一环。它不像 MCP 那样需要一套完整的协议服务,也不像 Commands 那样依赖用户手动触发——Custom Tools 让 LLM 自主决策何时调用,而你只需要用几行 TypeScript 定义工具的签名和行为。

回顾本文覆盖的要点:

  • 工具定义文件放在 .opencode/tools/ 目录,文件名即工具名
  • 使用 tool() 辅助函数或纯对象定义,参数类型由 Zod 提供
  • 一个文件可导出多个工具,命名格式为 <文件名>_<导出名>
  • context 参数可获取会话上下文(工作目录、Git 根目录等)
  • 执行逻辑可以用任何语言编写,TypeScript 只负责工具定义和进程调度
  • 通过权限系统可以精细控制每个工具的调用是否需要确认

真正强大的 AI 编程助手,不是内置工具最多,而是能无缝接入你的技术栈。Custom Tools 正是这座桥梁。