在上一章中,我们成功写出了第一个 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'] 时:
框架会先加载 tools 服务(如果它还没加载的话)。
确认 tools 服务成功进入 ACTIVE 状态后,才会加载你的插件。
在你的 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!。
重要提示:
请先在 设置(Harness Web UI 界面右下角)→ 模型面板中,添加提供方。
充值。
如果一切顺利,你会看到模型使用工具并给出了回答。恭喜你——你的第一个“有动手能力”的插件已经成功运行了!
工具开发的进阶方向
至此,你已经掌握了工具开发的核心模式。但在实际项目中,工具往往比 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() 注册的工具,在插件卸载时自动注销 |
你还学到了一个重要理念:工具是模型与外部世界之间的桥梁。模型负责思考和规划,工具负责执行和反馈。一个好的工具定义——清晰的名称、准确的描述、合理的参数结构——能让模型更准确地理解何时调用、如何调用。
下一章,我们将学习如何为插件添加可配置的参数,让问候语不再是硬编码,而是可以由用户自由定制。