读完这篇你能:给模型注册一个它本来没有的工具——对它说一句「帮我在这堆笔记里找 XX」,它就会调你写的
note_search,拿到命中列表再回答你。前置:10-事件进阶。约 30 分钟。
示例代码:dsh-note-search
DSH 版本:0.2.0-rc.2
到目前为止,模型手里的工具全是 DSH 自带的。08-替换内置能力的提供方与消费者把 web_search 的源换成了我的笔记,模型确实能搜到笔记了——但那是「借别人的工具」:得等模型自己想去搜公网,它才会顺路查到笔记。我想要的更直接:对它说一句「帮我在这堆笔记里找 XX」,它就调一个专门干这件事的工具。
这样的工具 DSH 没有,得自己写。这篇就写它:一个 note_search,按关键词匹配笔记的文件名和正文,返回路径、行号、片段。
目标
- 一个 host 插件注册
note_search工具:参数{ query, limit? },返回命中列表。 - 笔记根目录可配(
Config的root字段,默认~/notes,06-用 Config schema 声明插件配置的机制);演示包自带三篇笔记,装上就能跑。 - 全部代码在
dsh_plugin/dsh-note-search/,装法与 08-替换内置能力的提供方与消费者/09-订阅 DSH 事件与五种分发模式同一个 test profile。
DSH 设计
工具是模型能在对话里点名调用的函数。那么「给模型加一个工具」,在架构里该落在哪?DSH 的架构文档里有一张「新行为的归属位置」表,添加面向模型的能力那一格写得很直接:往工具注册表(ctx.tools,集中保管全部工具定义的花名册)上登记,它的参数声明会进入提示词组装。驱动对话的循环不认识任何具体工具。每次组装模型请求,它向注册表要一份当前可见工具的清单;模型按清单决定调用,循环再向注册表分派这次调用。所以「加一个工具」不是给循环打补丁,而是往花名册登记一份定义。自带工具和第三方工具在注册表里完全同等待遇,配置、策略、事件、界面对两者不加区分。登记同时是一次可逆的 effect:插件被卸载或停用时,它的工具跟着从注册表消失,下一次模型请求的清单里就没有它了。
登记进去的一份定义,被刻意切成两个世界。面向模型的只有三件:名字、描述、参数声明。只属进程内的是其余几样:返回值的形状声明、真正干活的函数、超时预算、界面展示回调。组装模型请求时,注册表按一份写死的允许清单投影这份定义,只有那三样进请求,其余字段被明确列为「绝不能泄漏到模型请求中」。所以「模型只看得到三样」不是写作纪律,是投影的结构性结果。「这个工具什么时候用、返回什么」必须写进描述,因为那是模型唯一能读到的部分。
结果侧是同一切分的延伸。干活的函数返回的不是直接给模型看的文本,而是一个普通 JSON 值,结构由返回值的形状声明约束。注册表拿到它之后先校验、冻结,再交给一个纯函数投影成模型读的内容块。「程序用的结构」和「模型读的文本」因此天然是两份产出。边界在设计上划得很死:这个 JSON 值只在执行期间存在,会话记录只存内容块、错误和可选的结构化附件。想让某份结构化数据活到回放时刻,得显式声明一条「可回放投影」,让它随记录落盘。
抛错在这条管线上被设计成结果,不是事故。管线把工具抛出的异常规范化成结构化的失败结果:这次调用失败,但不终止当前这一轮对话。模型填的参数不过校验、函数返回的值不过形状校验,都走同一条常规错误路径,错误消息原文发给模型。所以「在干活的函数里抛错」是一种合法的返回方式。错误消息是发给模型的工作输入,它读得懂就能自己修正,不是插件崩了。
工具的界面卡片也遵循同一个切分思路:不绑定任何客户端。工具可以声明「这次调用在界面上长什么样」,展示回调返回的是带卡片标签的渲染意图,也就是一套与提供方无关的中性词汇:默认卡片、终端卡片、文件变更卡片、搜索卡片。进程内与浏览器两个运行时各自把它投影成自己的视图。这就是工具只声明意图、不 import 界面代码的原因。另外,几分钟级的长活在归属表里另有一格:后台任务注册表(ctx.jobs)配上给模型读和停的通用工具。「登记任务、返回句柄」是另一条设计好的路,不是把前台调用拉长。
自带工具与第三方工具走的是同一条登记、投影、执行、落盘的路——「登记进注册表」就是全部差别。
工具的最小形态
模型能调什么,取决于每次请求发给它的工具清单。DSH 里这张清单由工具注册表(ctx.tools,来自 @deepseek-ai/dsh-tools)维护:插件往注册表里登记工具定义,配置下一次模型请求时,注册表把每个可见工具的 name / description / parameters 投影进请求——模型能看到的只有这三样,你的实现代码它永远看不到。
登记用 defineTool 包一层,再交给 ctx.tools.register:
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'my-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}!`
},
}))
}
(这是 DSH 官方教程里的 greet 例子,本篇的 note_search 是它的放大版。)
一个工具定义有五件东西:
| 字段 | 给谁看 | 作用 |
|---|---|---|
name |
模型 | 工具名,惯例是蛇形(note_search、web_search) |
description |
模型 | 什么时候该用这个工具、它返回什么 |
parameters |
模型 | 参数 schema,模型照着填 |
output |
注册表 | 声明 execute 返回值的 schema,以及怎么把它渲染成模型读的文本 |
execute |
注册表 | 真正干活的函数 |
两个机制先交代清楚:
inject = ['tools'] 是必需依赖。 08-替换内置能力的提供方与消费者见过这种写法:工具注册表不在,插件整体不启动。headless 和 web profile 里都有它,所以同一个插件两边都能跑。
注册是 effect。 ctx.tools.register 和 08-替换内置能力的提供方与消费者的 registerSearchProvider 一样,注册落在调用方 fiber 上:插件被 patch 停用或卸载,工具跟着从注册表消失,下一次模型请求的清单里就没有它了。不需要自己写 ctx.effect 包一层,也不用维护注销账本。
完整代码
dsh-note-search/ ← 插件包(bundle)
├── src/
│ └── dsh-note-search.ts ← note_search 工具
├── notes/ ← 演示用笔记(root 默认指到这里,见「配置」)
│ ├── 缓存一致性.md
│ ├── 九月周会记录.md
│ └── 装修清单.md
├── package.json
├── patch.yaml
├── tsconfig.json
└── install.sh
src/dsh-note-search.ts 全文:
/**
* 给模型一个搜索本地笔记的工具:note_search。
*
* 08-替换内置能力的提供方与消费者是把已有工具(web_search)背后的能力换成自己的实现;这个插件做的
* 是另一件事——注册一个模型本来没有的工具。注册进 ctx.tools 之后,下一次
* 模型请求的工具清单里就有它,模型按 name / description / parameters 决定
* 什么时候调、怎么填参数。
*/
import { existsSync } from 'node:fs'
import { readFile, readdir } from 'node:fs/promises'
import { homedir } from 'node:os'
import { extname, join, relative } from 'node:path'
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'dsh-note-search'
/** 必需依赖:工具注册表不在,本插件就不启动。 */
export const inject = ['tools']
export interface Config {
/** 笔记根目录;开头是 `~` 时展开成本人主目录。 */
root: string
}
export const Config: Schema<Config> = Schema.object({
root: Schema.string().default('~/notes'),
})
const TEXT_EXTENSIONS = new Set(['.md', '.markdown', '.txt'])
/** 一次搜索最多看的文件数,给遍历一个上界。 */
const MAX_FILES = 2000
/** 一条命中:相对路径、1-based 行号(0 表示文件名命中)、那一行的内容。 */
interface Hit {
path: string
line: number
snippet: string
}
/** 展开路径开头的 `~`;配置里写绝对路径时原样返回。 */
function expandHome(path: string): string {
if (path === '~') return homedir()
if (path.startsWith('~/')) return join(homedir(), path.slice(2))
return path
}
/** 列出 root 下所有像笔记的文件,跳过点开头的目录(.git、.obsidian 之类)。 */
async function collectFiles(root: string): Promise<string[]> {
const found: string[] = []
const queue = [root]
while (queue.length > 0 && found.length < MAX_FILES) {
const dir = queue.shift() as string
let entries
try {
entries = await readdir(dir, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
if (entry.name.startsWith('.')) continue
const full = join(dir, entry.name)
if (entry.isDirectory()) queue.push(full)
else if (TEXT_EXTENSIONS.has(extname(entry.name).toLowerCase())) found.push(full)
}
}
return found
}
/** 在 root 里找所有包含 query 的行(大小写不敏感),文件名本身也算一个可命中的单位。 */
async function search(root: string, query: string, signal: AbortSignal): Promise<Hit[]> {
const hits: Hit[] = []
const needle = query.toLowerCase()
for (const path of await collectFiles(root)) {
// 用户点停止时 signal 会被中止:每读一篇查一次,别把已经没人要的结果做完
signal.throwIfAborted()
const relativePath = relative(root, path)
if (relativePath.toLowerCase().includes(needle)) {
hits.push({ path: relativePath, line: 0, snippet: relativePath })
}
const lines = (await readFile(path, 'utf8')).split('\n')
lines.forEach((text, index) => {
if (text.toLowerCase().includes(needle)) {
hits.push({ path: relativePath, line: index + 1, snippet: text.trim() })
}
})
}
return hits
}
/** 把规范值排成模型读的文本:一行一条命中,结尾交代总数与截断。 */
function formatHits(value: { hits: Hit[]; total: number }): string {
if (value.total === 0) return '笔记里没有命中。'
const lines = value.hits.map(hit => hit.line === 0
? `- ${hit.path}(文件名命中)`
: `- ${hit.path}:${hit.line} — ${hit.snippet}`)
const suffix = value.total > value.hits.length
? `\n\n(共 ${value.total} 条命中,只显示了前 ${value.hits.length} 条。调大 limit 或换个更精确的关键词可以看到更多。)`
: ''
return `${lines.join('\n')}${suffix}`
}
export function apply(ctx: Context, config: Config): void {
const root = expandHome(config.root)
ctx.tools.register(defineTool({
name: 'note_search',
description: '在本地的 Markdown / 纯文本笔记里按关键词检索,文件名和正文都会匹配。返回命中行的路径、行号和内容片段。',
parameters: {
query: { type: 'string', required: true, description: '要检索的关键词,大小写不敏感。' },
limit: { type: 'integer', description: '最多返回多少条命中,默认 5。' },
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
hits: {
type: 'array',
required: true,
items: {
type: 'object',
additionalProperties: false,
properties: {
path: { type: 'string', required: true },
line: { type: 'integer', required: true },
snippet: { type: 'string', required: true },
},
},
},
total: { type: 'integer', required: true },
},
},
render: (_args, value) => [{ type: 'text', text: formatHits(value) }],
// 把命中列表投影进 tool/result 的 meta 落盘:结果卡片(16-把工具结果渲染成卡片)从 meta 取回它。
presentationMeta: (_args, value) => ({ hits: value.hits }),
},
// 只读检索,不改任何共享状态:允许和同一批里的其他工具调用并行。
isConcurrencySafe: () => true,
async execute(args, exec) {
// schema DSL 表达不了的约束(非空、正数)在函数体里自己查;抛出的
// Error 会变成这次调用的 isError 结果,消息原文发给模型。
const query = args.query.trim()
if (query.length === 0) throw new Error('query 不能为空字符串')
const limit = args.limit ?? 5
if (limit < 1) throw new Error('limit 必须是正整数')
if (!existsSync(root)) throw new Error(`笔记根目录不存在:${root}`)
const hits = await search(root, query, exec.signal)
return { hits: hits.slice(0, limit), total: hits.length }
},
presentCall: args => ({ card: 'generic', title: args.query, kind: 'search' }),
}))
console.log(`[${name}] note_search 已注册(root=${root})`)
}
遍历文件的 collectFiles 和 08-替换内置能力的提供方与消费者那个是同一份写法;Config 是 06-用 Config schema 声明插件配置的机制,root 没配就用 ~/notes。新的部分只有工具定义本身,下面分三节讲:参数、结果、以及 presentCall 那一行。
参数与描述
parameters 用的是 DSH 自己的一套 schema 声明写法(不是裸 JSON Schema,但子集对应):每个属性一个节点,type 可以是 string / number / integer / boolean / array / object 等,必填是逐属性标 required: true——没标的 limit 就是可选参数。
模型怎么用这份声明,决定了怎么写它:
description 是模型选择的唯一依据。 模型决定「要不要调 note_search」时,读的是工具列表里的名字和描述;决定「query 填什么」时,读的是参数的 description。所以描述要回答两个问题:什么时候用(「在本地的 Markdown / 纯文本笔记里按关键词检索」)、返回什么(「返回命中行的路径、行号和内容片段」)。只写「搜索笔记」,模型分不清它和 web_search 该用哪个——尤其 08-替换内置能力的提供方与消费者之后你的 web_search 也能查笔记,两个工具的描述更要把分工说开。
校验是 defineTool 做的,不用自己写。 模型填的参数在进 execute 之前就按 schema 验过了:类型不对、缺了必填键,这次调用直接以 INVALID_ARGS 失败,错误文本发回模型,它自己会修正了重试。所以 execute 里的 args 类型是从 schema 推出来的(query: string、limit?: number),不是 unknown。
schema 的 default 只是注解,不会真的填值。 这套写法里 default 不参与校验也不参与填参,模型没给 limit,args.limit 就是 undefined。所以默认值在 execute 里兜底:args.limit ?? 5。「默认 5」这件事同时写在参数的 description 里,模型想多要时知道可以传。
DSL 表达不了的约束,在函数体里查了抛错。 「非空字符串」「正整数」这类约束 schema 说不出来,就在 execute 开头自己判断,不满足就 throw new Error(...)。抛错不是插件崩了——它是工具结果的一种正常形态,下一节讲。
结果的形态
execute 返回的不是直接给模型看的文本,而是一个规范值(canonical value):一个普通 JSON 对象,结构由 output.schema 声明。注册表拿到它之后做三件事:按 output.schema 校验、冻结、然后交给 output.render(args, value) 投影成模型读的内容块([{ type: 'text', text: ... }])。
这么拆的原因是「程序用的结构」和「模型读的文本」是两种东西。规范值里 hits 是数组、line 是数字,字段各归各;模型读的则是一段排好版的文本。render 就是那个从结构到文本的纯函数,note_search 里是 formatHits:一行一条命中,被 limit 截断时在结尾交代总数。
加上失败,一次调用有三种结局:
| 结局 | 怎么发生 | 模型看到什么 |
|---|---|---|
| 成功 | execute 返回的值通过 output.schema 校验 |
render 投影出的文本 |
| 参数失败 | 模型填的参数不过 parameters 校验(defineTool 代查) |
INVALID_ARGS 错误文本 |
| 执行失败 | execute 抛错,或返回值不过 output.schema |
isError 结果,带上 Error.message 原文 |
第三条值得展开:在 execute 里抛错是合法的返回方式。 注册表会把异常兜成这次调用的 isError 结果,消息原文发给模型——模型读得懂「笔记根目录不存在:/xxx」,下一轮它会换个问法或者告诉你环境有问题。所以错误消息要写成给人读的句子,不要只抛一个码。反过来,「没搜到」不是失败:那是成功的领域结果,hits 为空、total 为 0,render 输出「笔记里没有命中。」——把正常空结果做成报错,模型会以为自己调错了。
还有一个不在表里的事实:规范值不落盘。 会话记录(09-订阅 DSH 事件与五种分发模式的 tool/result)里存的是 render 后的 content、失败时的 error,以及可选的 meta(见「界面上的样子」);value 只在执行期间存在。所以别让模型依赖「下次还能拿到这个对象」——它下次看到的是文本。
最后说 exec。execute(args, exec) 的第二个参数带着这次调用的身份和取消信号 exec.signal:用户在界面上点了停止,这个 signal 就会被中止。search() 每读一篇笔记查一次 signal.throwIfAborted(),被停就抛出去,剩下的文件不再读——和 08-替换内置能力的提供方与消费者 provider 里的约定是同一条:信号要一路传到底,别做完已经没人要的活。
长任务的后台执行
note_search 是有界的快操作(最多 2000 个文件,内存里过一遍),前台跑完就行。但不是所有工具都这样:跑一个构建、处理一批文件,可能要几分钟。前台调用一直占着这一轮对话,超时还会被杀——这种活 DSH 的规矩是走后台任务。
机制在 ctx.jobs(@deepseek-ai/dsh-jobs,任务注册表)和 @deepseek-ai/dsh-tool-jobs(模型侧的控制工具)两个包里。生产方(你的工具)在 execute 里不干活,而是把活登记成一个任务:
// 骨架,不是本篇 demo 的一部分
const jobId = ctx.jobs.start({
kind: 'note-index', // 任务种类,也是 id 前缀(note-index-1、note-index-2…)
label: `索引 ${root}`, // 一行给人看的说明
...exec.agent ? { owner: exec.agent.id } : {}, // 归属会话;它的 agent 销毁时任务跟着取消
run: (job) => {
// 在这里真正开始干活;job.append() 往输出里写,job.updateProgress() 更新进度行
const controller = new AbortController()
const done = doWork(controller.signal, job) // 返回 Promise<JobOutcome>,不允许 reject
return { cancel: () => controller.abort(), done }
},
})
return { kind: 'background', jobId } // 规范值:一个句柄,不是结果
start 一返回,execute 就带着 { kind: 'background', jobId } 结束,这一轮对话立刻继续。之后模型用 dsh-tool-jobs 提供的三个通用工具跟任务打交道:job_output 读输出(可以等它跑完)、job_list 列任务、job_kill 终止。任务跑完时,完成通知会被送回归属的 agent——模型不用轮询也知道活干完了。
两条硬性约定:
- 后台分支要由配置门控。
tool-bash的做法是enableRunInBackground配置(默认开):部署方可以关掉它,关掉后模型连run_in_background参数都看不到,填了也会被拒绝。门控的原因是后台任务会脱离这一轮对话存活,部署方要有权不许它存在。 start成功之后,取消就归任务管了。 前台活的命拴在exec.signal上;后台任务一旦登记,外层调用被取消只是「不等了」,不会杀任务——杀它是job_kill、归属 agent 销毁、或服务关停的事。所以run里要用自己的AbortController,别再挂exec.signal。
tool-bash 是这套机制的完整范本(它的前台调用其实也登记成任务,只是原地等它结束),真要给工具加后台分支时去读 packages/shell/tool-bash/src/。
界面上的样子
工具结果在对话里是一张卡片。这张卡片长什么样,工具可以在定义里声明——就是 presentCall 和 presentResult 两个可选方法,它们返回一个带 card 标签的「渲染意图」,由界面去兑现:
card |
用在哪 | 例子 |
|---|---|---|
generic |
默认卡片:标题 + 内容 | 大多数工具 |
terminal |
这次调用就是一条命令 | bash |
diff |
这次调用创建/修改了文件 | write / edit |
read |
结果是一个文件窗口 | read |
search |
结果是按文件分组的命中或路径列表 | grep / glob |
web |
结果是一次网页检索/抓取 | web_search / web_fetch |
presentCall(args) 管进行中的卡片,presentResult(args, result) 管完成后的卡片;都不写,界面就退回通用卡片(标题是工具名,内容是原始参数和结果文本)。note_search 只声明了 presentCall:调用进行中显示一张 generic 卡片,标题是关键词,kind: 'search' 让界面配一个搜索图标。完成后的卡片没声明——render 出的文本直接进通用卡片,对这个工具够用了。
output 里还有一行 presentationMeta:它把命中列表从规范值投影成一份可回放的 JSON,随 tool/result 落盘。现在没有消费者,留着是因为16-把工具结果渲染成卡片要给这个结果做真正的卡片,卡片的数据就从这份 meta 来。
两条规矩:
- 这两个方法必须是纯函数。 它们在直播和会话回放时都会跑,只能依赖传进来的
args和result——不能读盘、读时钟、读会话状态。想在卡片上展示结果时刻才知道的数据(比如「这次命中的结构化列表」),走output.presentationMeta(args, value):它从规范值投影出一份可回放的 JSON,随tool/result落盘,presentResult从result.meta里取回来。 - 声明归声明,渲染归渲染。 这里返回的只是「我想要一张 search 卡片」这个意图,不 import 任何界面代码。Web 客户端里真正把它画成 React 卡片是另一套注册(按工具名认领),那是16-把工具结果渲染成卡片的事。
配置
package.json 比 09-订阅 DSH 事件与五种分发模式少几个依赖——只用 import type 的包不用列,这里真正在运行时 import 的是 defineTool 和 Schema:
{
"name": "dsh-note-search",
"version": "0.1.0",
"private": true,
"type": "module",
"dsh": { "bundle": { "patch": "./patch.yaml" } },
"dependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-tools": "^0.2.0-rc.2",
"@deepseek-ai/schemastery": "^3.18.2"
}
}
patch.yaml 插一行(插件路径写绝对路径,install.sh 会按本机位置重新生成这个文件):
- insert:
- id: dsh-note-search
name: '/绝对路径/dsh-note-search/src/dsh-note-search.ts'
root 的配置写在 profile 覆盖层里。install.sh 会往 ~/.dsh/profiles/test/cordis.patch.yml(和 headless 的同名文件)追加下面这块——已有同 id 的块就跳过,文件里 08-替换内置能力的提供方与消费者留下的换源配置原样不动:
# dsh-note-search:演示用笔记目录(11-为模型注册自定义工具)
- id: dsh-note-search
config:
root: '/绝对路径/dsh-note-search/notes'
指向演示包自带的 notes/,装上就有三篇笔记可搜;真要用起来,把这一处改成你的笔记目录(或者干脆删掉这块,用默认的 ~/notes)。装:
cd dsh_plugin/dsh-note-search && ./install.sh
验证
配置层:
dsh --profile test --dump-config | grep -A3 'id: dsh-note-search'
# == dsh-note-search
- id: dsh-note-search
name: >-
file:///绝对路径/dsh-note-search/src/dsh-note-search.ts
不看界面、直接让模型跑一句(headless profile 里也装了同一个插件):
dsh --profile headless "用 note_search 在我的笔记里找「缓存」,告诉我当时定的结论是什么"
模型会调一次 note_search,参数大概是 {"query": "缓存"}。它收到的工具结果就是 formatHits 排出来的那段文本(对演示包自带的三篇笔记,内容是这个样子):
- 缓存一致性.md(文件名命中)
- 缓存一致性.md:1 — # 缓存一致性笔记
- 缓存一致性.md:3 — 九月那次会定的结论:缓存一律走「写穿透 + 短 TTL」,不做主动失效。
- 九月周会记录.md:3 — - 缓存方案定稿,细节见《缓存一致性笔记》
然后它用这段话回答你。09-订阅 DSH 事件与五种分发模式的统计插件如果还装着,终端里会多一行 [dsh-event-stats] note_search 成功 …ms——tools/result 对我们注册的工具一样触发,统计插件根本不认识 note_search,却已经在数它了。这就是「注册进注册表」的含义:从配置、策略、事件到 UI,整条管线对自带工具和第三方工具不加区分。
web profile 里验证的是另一头:启动 dsh web,在界面上说同一句话,调用进行中能看到标题为「缓存」的卡片(presentCall 声明的那张),完成后卡片里是上面那段文本。
再试一次失败路径,确认错误消息真的到了模型手里:
dsh --profile headless "用 note_search 找「缓存」,limit 传 0"
模型第一次调用会收到 limit 必须是正整数 的 isError 结果——然后它多半会自己改成 5 重试一次。你什么都没做,纠错发生在模型那一侧,因为错误消息写得能读懂。
注意事项
- 模型只看得到
name/description/parameters。execute、output、各种 presenter 永远不会进模型请求。所以「这个工具什么时候用、返回什么」必须在这三样里说完;代码注释写得再好,模型也不知道。 - 工具名全局唯一。 和同一层里已有的工具重名,注册直接失败。起名前可以先翻
docs/tool-catalog.md里已有的名字。 execute里别返回内容块。 返回值必须过output.schema——想过不了校验的结构,结果就是isError(INVALID_TOOL_OUTPUT)。给模型看的排版全部放在render里。- 空结果不是错误。 「没命中」走成功路径(
hits: []),让render说一句「没有命中」;只有「这次调用本身不成立」(参数非法、根目录不存在)才抛错。模型对两种结局的应对完全不同:前者换个关键词,后者怀疑自己用错了工具。 - 注册随插件卸载消失。 在
cordis.patch.yml里把dsh-note-search那行disabled: true,保存后下一轮模型请求的清单里就没有note_search了——模型会立刻退回「没有工具可用」的行为,不会报一个「工具失踪」的错。排查「模型怎么不用我的工具了」时先想这一步。 - presenter 必须是纯函数。
presentCall/presentResult在直播和回放时都跑,读盘、读时钟、读会话状态都会让回放和直播不一致。要结果时刻的数据,用presentationMeta落盘后再从result.meta取。 - 改工具源码要重启。 配置热重载管不到
.ts源码;07-配置与源码的热重载的源码热重载(hmr行)可以管,默认是关的。