在上一章中,我们成功写出了第一个 Harness 插件。不过,那个插件除了在控制台打印一行日志之外,什么实际的事情都没做——它没有和用户交互,也没有被大语言模型感知到。

这一章,我们将迈出关键的一步:为插件添加一个工具(Tool)。

工具是 Harness 中最核心的概念之一。简单来说,工具就是大语言模型可以“调用”的函数。当模型需要执行某个操作——比如查询天气、读取文件、搜索网络——它不会自己去实现这些功能,而是会调用你定义好的工具来完成。

打个比方:模型就像一个聪明的“大脑”,擅长思考和规划;而工具就是它的“手”和“脚”,负责执行具体的动作。没有工具的大脑只能空谈,有了工具的大脑才能真正“动手”做事。

在本章中,我们将创建一个 greet 工具——它虽然简单,但包含了工具开发的所有核心要素。模型调用它时,它会根据传入的名字返回一句问候语。

准备工作

本章将在上一章第一个插件的基础上继续开发。请确保你仍保留 scratch-plugin 目录及其中的 cordis.yml 配置文件。

如果你是从本章开始学习的,建议先完成上一章的内容,或者至少按照上一章的步骤创建好 scratch-plugin 项目结构和 cordis.yml 补丁文件。

替换插件代码

打开 scratch-plugin/src/my-plugin.ts,将之前的内容全部替换为以下代码:

import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'

export const name = 'greet-tool'
export const inject = ['tools']

export function apply(ctx: Context) {
    ctx.tools.register(
        defineTool({
            name: 'greet',
            description: 'Greet someone by name.',
            parameters: {
                name: {
                    type: 'string',
                    required: true,
                    description: 'The name to greet',
                },
            },
            output: {
                schema: { type: 'string' },
                render: (_args, value) => [{ type: 'text', text: value }],
            },
            async execute(args) {
                return `Hello, ${args.name}!`
            },
        })
    )
}

这段代码比上一章复杂了不少,但别担心——我们接下来会逐行拆解,让你彻底理解每一部分的作用。

逐行拆解:工具插件是如何工作的

导入依赖
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'

第一行和之前一样,导入 Context 类型。第二行从 @deepseek-ai/dsh-tools 包中导入 defineTool 函数——这是定义工具的核心 API。

给初中级开发者的提示:@deepseek-ai/dsh-tools 是 Harness 的工具核心包。它提供了工具的定义、注册、校验和调用等所有基础设施。你不需要关心它内部怎么实现的,只需要知道用 defineTool 来定义工具就行。

声明插件元数据
export const name = 'greet-tool'
export const inject = ['tools']

name 和上一章一样,是插件的唯一标识名。

  • type:参数的类型,支持 string、number、boolean、object、array 等。

  • required:是否为必填参数(默认为 false,即可选)。

  • description:参数的描述,帮助模型理解该填什么值。

新增的 inject 数组是关键变化。inject = ['tools'] 的意思是:这个插件依赖 tools 服务。框架会确保 tools 服务已经就绪之后,才会加载你的插件。

为什么需要这个依赖?因为我们要调用 ctx.tools.register() 来注册工具——如果 tools 服务还没准备好,调用就会失败。通过声明 inject,我们让框架帮我们处理好加载顺序,不需要自己操心。

给初中级开发者的提示:inject 是插件之间的“契约”。你声明需要什么,框架保证在你开始工作之前,这些东西都已经准备好了。这种设计让插件之间的协作变得清晰可靠。

注册工具
export function apply(ctx: Context) {
    ctx.tools.register(
        defineTool({
            // ... 工具定义
        })
    )
}

在 apply 函数中,我们通过 ctx.tools.register() 来注册工具。register 方法接收一个由 defineTool 创建的工具定义对象。

这里要注意:一个插件可以注册多个工具。你可以在同一个 apply 函数中多次调用 ctx.tools.register(),每个调用注册一个工具。这样,一个插件就能提供一整套相关的能力。

工具的核心结构:defineTool

defineTool 接收一个配置对象,这个对象描述了工具的方方面面。我们来逐一看看每个字段:

name —— 工具的名字

name: 'greet',

这是模型调用工具时使用的名称。模型会在对话中通过这个名字来引用工具,比如“使用 greet 工具来问候 Ada”。

命名建议:使用小写字母和下划线,保持简洁且语义清晰。比如 get_weather、search_files、send_email。

description —— 工具的“说明书”

description: 'Greet someone by name.',

这是给模型看的描述。模型会根据这个描述来判断“在什么情况下应该调用这个工具”。描述写得越清晰,模型就越能在恰当的时机使用你的工具。

举个反例:如果描述是“一个工具”,模型根本不知道它有什么用。好的描述应该说明工具的功能和使用场景,比如“根据城市名查询当前天气温度”。

parameters —— 工具的参数

parameters: {
    name: {
        type: 'string',
        required: true,
        description: 'The name to greet',
    },
},

parameters 定义了工具需要接收哪些参数。这里的语法和 JSON Schema 类似:

  • type:参数的类型,支持 string、number、boolean、object、array 等。

  • required:是否为必填参数(默认为 false,即可选)。

  • description:参数的描述,帮助模型理解该填什么值。

这个 greet 工具只需要一个参数:name,类型是字符串,必填。

如果需要多个参数,就在 parameters 对象中添加更多字段。比如一个“发送邮件”的工具可能需要 to、subject、body 三个参数。

output: {
    schema: { type: 'string' },
    render: (_args, value) => [{ type: 'text', text: value }],
},

  • schema:声明返回值的结构。这里是 { type: 'string' },表示返回一个字符串。

  • render:将返回值转换为模型可见的内容。它接收两个参数——_args(原始调用参数)和 value(execute 返回的值),返回一个内容块数组。

这里的 render 函数将 value(即 Hello, Ada!)包装成一个文本块返回给模型。模型会看到这段文本,并据此继续对话。

execute —— 工具的执行逻辑

async execute(args) {
    return `Hello, ${args.name}!`
},

这是工具真正“干活”的地方。execute 函数接收 args 参数——它已经被 defineTool 根据 parameters 定义校验过了,所以你可以放心地使用 args.name,它一定存在且是字符串类型。

execute 可以是一个异步函数(用 async 关键字),这意味着你可以在里面执行耗时的操作——比如调用 API、读写文件、查询数据库——而不会阻塞整个系统。

重要提示:execute 返回的值必须符合 output.schema 中声明的结构。这里 output.schema 声明返回 string,所以 execute 返回一个字符串是合法的。如果返回了不符合 schema 的值,框架会报错。

关于 inject 的深入理解

我们在代码中写了 export const inject = ['tools'],这到底意味着什么?

在 Cordis 框架中,插件可以声明自己依赖哪些"服务"(Service)。tools 就是这样一个服务——它负责管理所有已注册的工具。

当你声明 inject = ['tools'] 时:

  1. 框架会先加载 tools 服务(如果它还没加载的话)。

  2. 确认 tools 服务成功进入 ACTIVE 状态后,才会加载你的插件。

  3. 在你的 apply 函数执行时,ctx.tools 已经可用。

这种依赖机制让插件之间的协作变得可靠。如果 tools 服务因为某种原因加载失败了,你的插件也不会被加载——这避免了“运行时突然发现依赖不存在”的尴尬情况。

自动清理:工具也不例外

还记得上一章提到的“自动清理”机制吗?对于工具来说,这个机制同样生效。

当你通过 ctx.tools.register() 注册了一个工具,这个注册行为会被框架记录下来。当插件被卸载时——无论是主动卸载、热更新还是异常退出——框架会自动调用 ctx.tools 来注销这个工具。

你不需要手动编写任何清理代码。框架就像一个尽职的管家,你离开时会帮你把所有东西收拾干净。

运行并测试

代码准备好了,现在来验证它是否真的能工作。

如果之前的开发命令还在运行,先按 Ctrl+C 停止它。然后重新启动:

pnpm dsh web --patch ./scratch-plugin/cordis.yml

打开浏览器访问 http://127.0.0.1:3080,你会看到 Harness 的 Web UI 界面。

在对话框中输入以下内容:

Use the greet tool to greet Ada.

模型会识别出你的意图,调用 greet 工具,并返回 Hello, Ada!。

重要提示:

  1. 请先在 设置(Harness Web UI 界面右下角)→ 模型面板中,添加提供方。

  2. 充值。

如果一切顺利,你会看到模型使用工具并给出了回答。恭喜你——你的第一个“有动手能力”的插件已经成功运行了!

工具开发的进阶方向

至此,你已经掌握了工具开发的核心模式。但在实际项目中,工具往往比 greet 复杂得多。以下是一些值得深入探索的方向:

嵌套参数与复杂 Schema

如果你的工具需要接收对象或数组类型的参数,可以在 parameters 中定义嵌套结构。比如一个“批量查询用户”的工具,可能需要接收一个用户 ID 数组。

后台任务

如果工具的执行时间较长(比如几分钟),可以配置 run_in_background 让它在后台运行,避免阻塞模型响应。

执行策略与权限控制

通过 tools/pre-execute 和 tools/execute 等扩展点,可以为工具添加权限门禁、超时控制、重试机制等。

UI 卡片

工具的 output 不仅可以返回文本,还可以通过 presentCall 和 presentResult 方法在 UI 中展示丰富的卡片——比如终端输出卡片、文件 Diff 卡片、搜索结果卡片等。

我们会在后续的章节中逐步涉及这些高级主题。

总结

本章我们完成了一个重要跨越——从“什么都不会做的插件”到“拥有工具能力的插件”。核心收获如下:

概念

说明

defineTool

定义工具的核心 API,接收工具的配置对象

parameters

声明工具的参数结构,模型会根据它来生成调用参数

execute

工具的执行逻辑,在这里实现具体功能

output.schema

声明返回值的结构,框架会据此校验返回值

output.render

将返回值转换为模型可见的内容

inject= ['tools']

声明对 tools 服务的依赖,确保服务就绪后再加载插件

自动清理

通过 ctx.tools.register() 注册的工具,在插件卸载时自动注销

你还学到了一个重要理念:工具是模型与外部世界之间的桥梁。模型负责思考和规划,工具负责执行和反馈。一个好的工具定义——清晰的名称、准确的描述、合理的参数结构——能让模型更准确地理解何时调用、如何调用。

下一章,我们将学习如何为插件添加可配置的参数,让问候语不再是硬编码,而是可以由用户自由定制。