dsh-tool-gateway
把 DSH 的工具目录收成三个元工具:find_tools 查、call_tool 调、call_tools 批量调。
其余工具不出现在模型可见的工具表里,但仍然可以被调用 —— 只是入口变成了这几个。
另外三件配套的事:硬禁用名单(某些工具在任何调用路径上都不许执行)、右侧栏的 工具箱面板(随时开关某个工具或整组工具 —— 关掉之后守卫拒绝、检索目录里也查不到它), 以及工具组(注册一批工具时声明「这批默认先关着」,用户再按需打开)。
它解决什么
工具一多,真正的挑战不是上下文长度,而是模型会不会只盯着被截断的那份工具列表将就。 表越长越容易挑错;表被截断,模型就会忘掉那些没露面的工具,转而用几个熟面孔凑合。
这个插件把工具目录收起来,逼出"每一次动手之前先查清楚有什么"这个动作:
那份长列表不在眼前了,模型只能先 find_tools 问、再 call_tool 调。
顺带解决的一件更贵的事:前缀缓存
工具定义在每次请求的最前部,所以工具表一变,整段前缀缓存就废了 —— 重新计费、首字延迟变长。 而工具表恰恰是最容易变的那一块:接一个 MCP server、插件热插拔、换一个 preset 组合,都会改它。 工具越多,这件事越贵。
网关把模型可见的工具表恒定成那三个元工具:
- 装插件、接 MCP、用面板关掉某个工具 —— 模型可见的工具表一个字节都不变。
变的只是
find_tools能检索到什么、以及守卫放不放行。 - 想真正改工具表,只有动这三个元工具本身(
call_tools在 PTC 开着时会自动不挂)。
这也是面板只用「拒绝」、不用 restrict() 的原因:后者会改模型可见工具表,那才会毁缓存。
三个元工具
| 工具 | 干什么 | 什么时候用 |
|---|---|---|
find_tools |
按名字、描述、中文或拼音检索,返回完整参数 schema | 不知道有什么工具、或不知道参数怎么写 |
call_tool |
调一个工具,参数 {tool_name, arguments} |
一次调一个 |
call_tools |
写一段程序批量调用工具 | 要连着调好几个,而中间结果不必进对话 |
call_tools 干活的是一段模型写的 TypeScript 程序(DSH 的 PTC 运行时负责跑它)。
程序里只有两个函数可调:tools.find_tools({query}) 与 tools.call_tool({tool_name, arguments})。
只有打印内容与程序结果会回到对话里 —— 异步函数体用 return,新版 REPL / script 用最后一个表达式。说明与参数会按运行时格式生成;支持持久变量的运行时按会话隔离,agent 或网关卸载时释放。
它的参数、输出与界面呈现都对齐 DSH 官方 PTC 模式的 run_code:description 必填
(那是卡片标题,也是审批弹窗里给人看的第一行字)、控制参数只在运行时真的支持时才出现、
输出是结构化的 {logs, result?, sandbox?}、失败是带 kind 的 CodeRunFailedError。
唯一刻意不一样的地方:官方 run_code 的 SDK 段落是遍历注册表生成的(把一百多个工具
的名字和参数全喂给模型),我们这里是手写的两条声明 —— 那正是这个插件要消掉的东西。
一次查多少:预算与分页
find_tools 的结果按 token 估算收缩,顺序是:完整详情 → 折叠尾部条目的详情(只留名字)
→ 截断尾部条目。预算默认 10000,可配(见下面的配置一节)。单个 schema 不截断 ——
半个 JSON 比没有更糟。
被截断时返回的 offset 可以接着查同一份结果(沿用相同的 query/names/detail/scope)。
查「关着的那些工具」
find_tools({scope: "disabled"}) 专门查这个会话里关掉的工具 —— 普通查询不会把它们
列出来,否则模型会照着一次注定被拒的调用去准备参数。
这条路径每次都要审批,而且在完全文件权限(danger-full-access)下直接拒绝:
那条路径上没有人可以拦一下,而它是一条能让模型看到「现在被收起来的都是什么」的通道。
审批服务报 never 时同样拒绝。
查询本身不改任何开关,也不会解锁任何业务授权 —— 它只是"看一眼现在关着的是什么"。 要真正用起来,得人在工具箱面板里打开它。
什么时候不挂 call_tools
- PTC 模式已经开着(
mode: 'ptc'或'both'):那时 DSH 自己的run_code就在工具表里, 再加一个功能重叠的只会让模型困惑该用哪个(而在mode: 'ptc'下它本来也调不到 —— 那个模式的执行守卫只放行run_code)。 - 部署里没挂 PTC 运行时(
ctx.ptcRuntime缺席):挂了也跑不了。
旧版通过 tools.schemas(agent) 判断呈现模式;新版的 run_code 属于 executor,按完整装配结果识别。新版官方 PTC 保留 run_code,SDK 在工具箱开启时只列 find_tools 与 call_tool,不再嵌套列出 call_tools。禁用规则仍在真实执行守卫上生效。
装
dsh plugin --profile web add github:leolee9086/dsh-tool-gateway#v0.3.1
dsh plugin --profile <name> <args...> 在 profile 目录里转发给 pnpm。装完它会依据本包
package.json 的 dsh.bundle.patch 声明,把本包追加进 dsh.profile.bundles ——
于是每次启动自动插入加载行,不需要手工编辑 profile 的 cordis.patch.yml。
本包的 lib/ 随仓库提交,所以从 git 安装不需要 pnpm 的构建授权(allowBuilds):
拉下来就是能直接加载的产物,包里没有 prepare 脚本,安装时不会在你机器上跑构建。
想锁得更死,把 #v0.3.1 换成具体的 commit sha。
重启 DSH 生效。不需要改任何 preset —— 它挂在 profile 层(根作用域)一次, 对所有 preset、所有会话生效。
(本地开发时也可以用 file:// 直接引用源码目录的 lib/host.js,
客户端半边仍然走 package.json 的 exports["./client"] 与 dsh.client 声明。)
会话级开关
会话标题栏上有一个 chip(工具箱 开 / 工具箱 关),点一下切换这个会话
要不要施加约束。默认是开 —— 装上这个插件就是要约束行为;关掉是你在某个会话里的显式选择。
- 状态持久在
$DSH_HOME/storages下的tool_gateway/sessions里,重启后仍然生效。 - 子代理会话跟随父会话:你只看得见父会话,而子代理的工具组合本来也是从父那里继承的。
- 关掉之后:模型看到当前已启用工具表、可以直接调用其中的工具、系统提示里也不再出现那段使用说明。单工具、工具组和部署禁用仍生效。 元工具仍然在(它们只是不再是唯一入口)。
- 生效时机:执行守卫立刻生效(下一次工具调用就放开或拦住); 工具表与提示段落从下一轮请求开始生效(装配发生在每一轮开始的时候)。
- 切换时会往会话里注入一条通知给模型看。 模型看不见"装配"这件事本身,没有这条通知,
它只能在"我明明刚用过 read、怎么现在调不了了"的困惑里自己猜。通知走
agent.inject(), 作为一条user消息进会话日志(所以模型可见的东西仍然可以从日志重建),不唤醒 driver: 点完开关再发一条消息,模型就在同一次请求里看到它。 通知说的是"刚刚变了什么"(事件),系统提示里那段说的是"现在该怎么用"(状态), 所以两者措辞不同、不会互相替代。状态没变时不写库也不通知 —— 连点两下或者页面重复提交不该在会话里堆两条"模式已关闭"。
没有 webServer / connection 服务的部署(比如 headless)不会挂这个界面,
网关本身照常工作,开关固定为默认的"开"。
工具箱面板
右侧栏底部的「工具」按钮打开一个面板,里面是这个会话能看到的全部工具 —— 包括被网关 收起来的那些。每个工具一行、一个开关;有工具组时上面多一排组筛选与整组开关。
- 关掉一个工具 → 它从
find_tools的结果里消失;模型就算照着历史去调也会被守卫拒绝, 拒绝理由里写清了「被关掉了、去哪儿打开」。同时会给这个会话注入一条通知,告诉模型刚刚 变了什么(同一条通知机制,见上一节)。 - 按会话记:关掉一个工具是「这次别用它」的决定,换一个会话该不该用它是另一个决定。
状态就在工具箱模式那份记录的
disabledTools/enabledTools字段里,同一张表、 同一条ownerSessionId归属规则(子代理会话里关掉的工具记在父会话名下, 通知发给正在看的那个会话)。 - 开关是三态的:没设置(跟随它所属组的默认值)、显式开、显式关。所以「打开一个工具」 写的是显式开,而不是把关闭记录删掉 —— 那个工具可能属于一个默认关着的组,删掉记录 等于把它交还给组的默认值,而你要的是打开它。单独动过的工具在面板上标着 「单独打开 / 单独关掉」。
- 有工具组时,上面那排可以只看某一组,并对整组做「整组打开 / 整组关掉」。 组开关写的是一个键,不会往单工具名单里塞几百个名字。
- 搜索与分页都在宿主侧做(见
src/route.js的filterTools)。面板里过滤的话, 用户搜到的只是当前这一页里的匹配项,而计数是全量数字 —— 两个数字对不上, 用户会以为自己要的工具丢了。 - 三个元工具不给关(关了就没有工具入口了);
config.ban.deny里的名字也不给点, 那是配置说了算。 - 会话 id 由页签 seat 直接交给正文,面板不需要问用户在哪个会话。
为什么加它不会拖慢前缀缓存:网关开着的时候,模型可见的工具表里只有那三个元工具。
开关一个被收起来的工具,改的是目录索引(find_tools 检索到什么)与守卫的判定,
两处都不在请求最前部那段工具定义里,所以整段前缀缓存不受影响。这也是面板只用「拒绝」、
不用 restrict() 的原因 —— 后者会改模型可见工具表。
工具组:让一批新工具默认关着
有的插件一口气注册几百个工具(思源的 HTTP 端点就是这个量级)。全都默认开着的话, 工具箱一打开就是几百行,而用户真正要用的往往只有几个。
工具组让注册方说清楚「这一批是后加的、默认先关着」:
const gateway = ctx.get('toolGateway') // 拿不到 = 这个部署里没装网关
if (gateway !== undefined) {
const group = gateway.registerGroup({
id: 'siyuan-http',
title: '思源 HTTP 端点',
origin: 'dsh-plugin-siyuan',
defaultEnabled: false, // 缺省就是关
})
ctx.effect(() => () => group.dispose(), 'siyuan: gateway group')
for (const tool of tools) {
ctx.tools.register(tool)
group.add(tool.name) // 注册一个就往组里放一个
}
}
- 注册冲突直接抛错:同一个 id 注册两次抛;一个工具名只能属于一个组,第二个组去认领 它同样抛。两条都是「让错误在发生的时刻爆出来」,而不是留一条「谁先注册谁赢」的隐规则。
- 组的默认值是关(
defaultEnabled: false是缺省值):注册一个组的典型理由就是 「先别放出来」,要默认开就得明写true。 - 组句柄上还有
remove(name)、setDefaultEnabled(value)、dispose()。 撤销组之后,组内工具回到「不属于任何组」的判定 —— 也就是开着: 插件卸载了,它当初要求的那份默认关闭也就没有主张的人了。 - 用户那边的三态判定,优先级从高到低:单个工具显式关 → 单个工具显式开 → 整组的显式开关 → 组自己声明的默认值 → 不属于任何组(开)。
- 网关自己不认识任何具体插件:它只提供
registerGroup/listGroups两个方法, 组的语义全在src/groups.js里。
注册方必须在网关缺席时照常工作:ctx.get('toolGateway') 返回 undefined 时
就按普通工具注册,只是没有「默认关着」这回事。网关是可选依赖,不是硬依赖。
硬禁用名单
一部分工具在任何会话、任何调用路径上都不许执行。它和「网关没让它露面」是两件事:
| 拦住模型直接调用 | 拦住 call_tool 的子分发 |
|
|---|---|---|
| 被网关收起来 | 拦住 | 不拦(那正是元工具的用途) |
| 被硬禁用 | 拦住 | 拦住 |
默认禁用的是 ask_user_question(选择题卡片那一类交互)。它由同一条守卫的第一段判断
实现 —— 排在 parent 那条判断之前,所以连 call_tool 也绕不过去。
这一块原本是独立插件 dsh-tool-ban,现在并进网关:两者用的是同一个 ctx.tools.guard,
分成两个包只会让「拒绝理由谁先谁后」变成两个包之间的隐式约定。它的可见性屏蔽(restrict)
那一半也一并搬来了 —— 网关被关掉的会话暴露完整工具表,那一半在那里才有用。
配置里写 ban: { deny: [] } 表示这次一个都不禁(显式写空数组才算数;完全不写 deny
才用默认名单)。
它怎么工作
两层,缺一不可:
- 可见性 —— 旧版过滤装配瀑布;新版瀑布不再携带工具,因而包装完整
SystemPrompt.assemble返回值,卸载时恢复原方法。过滤同时考虑网关、单工具和工具组开关,不修改注册表或宿主的逐步准入集合。 - 执行守卫 ——
ctx.tools.guard()拒绝模型对其它工具的直接调用。 少了这一层,模型只要从历史里记得某个工具名、或者猜中一个,直接调用就会成功 —— 注册表里那些工具一直都在。
放行条件是"子分发"(execution.parent !== undefined)而不是"名字在白名单里":
call_tool 内部发起的调用带着 parent,模型直接发起的没有。这与 DSH 自己 PTC 模式的
塌缩是同一种机制 —— 限制调用路径,不限制可见性。
旧版子调用走 ctx.tools.execute();新版从 tools 服务所属 Loader 导入同一宿主的公开 ToolExecutionPipeline,执行 prepare、dispatch、complete,继承真实 parent、toolSet、signal 和目标 schema。审批、守卫、沙箱、条件准入、上下文及终止回合信号均保留。插件不安装第二份 Harness,不合并全局目录绕过作用域。
文件
| 文件 | 职责 |
|---|---|
src/host.js |
入口:接线,不实现任何一件具体的事。keepFor(agent) 在这里(含 PTC 检测) |
src/catalog.js |
检索索引(jieba 切词 + 拼音 + minisearch 倒排) |
src/meta-tools.js |
find_tools / call_tool,以及三个元工具共用的 invokeTool |
src/code-tools.js |
call_tools:把一段程序交给 PTC 运行时,只绑两个函数 |
src/gateway.js |
可见性过滤、执行守卫(硬禁用先判)、提示段落,都按 keepFor(agent) / enabledFor(agent) 分支 |
src/groups.js |
工具组名册,以及「这个工具此刻能不能用」的三态判定(纯函数,不认识 Cordis) |
src/ban.js |
硬禁用名单:静态名单的判定 + 按 agent 抹掉模型可见工具表(原 dsh-tool-ban) |
src/deliver-context.js |
子调用带回来的非文本内容(图片等)怎么送到模型眼前 |
src/session-key.js |
一个 agent 的开关记在哪个会话名下(子代理跟随父) |
src/switch-state.js |
会话偏好的读写与持久化:总开关 + 三态工具名单 + 组覆盖(同一个会话的写入串行) |
src/route.js |
给浏览器用的 HTTP 接口:会话开关(chip)+ 工具清单的过滤分页、单工具 / 整组 / 批量开关(面板) |
src/producer-source.js |
本插件在 V4 会话里的消息源归属(写错会让整轮毫秒级静默失败) |
src/switch-notice.js |
开关变化时写给模型的那条通知(四种粒度:整个工具箱模式、单个工具、整组、批量) |
src/client.js |
浏览器半边:会话标题栏的 chip + 右侧栏的工具箱面板 |
检索
find_tools 支持中文、英文、拼音全拼、拼音首字母:
| 查询 | 命中 |
|---|---|
zhihu / 知乎 |
知乎相关工具 |
sousuo / zhanneisousuo |
描述里含"搜索"的工具 |
tupian |
图片相关工具 |
搜索 |
描述里含该词的工具 |
实现是 jieba 搜索引擎模式切词 + 按词分段的拼音 + minisearch 倒排索引。
索引按 agent 惰性构建(preset 注册的工具在 agent 自己的作用域层,全局视图看不到它们),
tools/change 时失效重建。
实测结论(包括踩过的坑)记在 docs/检索栈实测.md。
配置
- insert:
- id: tool-gateway
name: "dsh-tool-gateway"
config:
maxResults: 5 # find_tools 一次返回几条,默认 5
search:
resultTokenBudget: 10000 # find_tools 结果的 token 估算预算,默认 10000
ban:
deny: [ask_user_question] # 硬禁用名单;显式写 [] 表示一个都不禁
hide: true # 还要把被禁的工具从模型可见表里摘掉
# reason: "…" # 拒绝理由(会被模型读到),不写用内置那条
# maxHideAttempts: 5 # 可见性摘除的重试上限
边界
- 不动会话历史。 不改写、不压缩、不按历史分档、不扫会话事件判断状态。
已经跑过一段的会话里那些直接调用
read、bash的记录原样留着, 说明里讲清楚了"入口变了",模型会遵守。 - 新版非文本和附加上下文走
deferContext。 经执行结果逐层交回 AgentLoop,按结果顺序提交;旧版保留agent.inject兼容路径。子工具的concludeTurn同样逐层传递。 - 注入的消息用生产者自己的来源 kind。 往会话里追加消息时
source.kind必须是plugin:dsh-tool-gateway—— V4 起消息源归生产者所有,退役的{ kind: 'plugin', plugin }会被会话准入直接拒绝。症状很隐蔽:那一轮在毫秒级失败、错误码是UNKNOWN、 会话日志一个字节都不写,重试每次死在同一处。两个发出点(开关通知、工具结果的 非文本补充)共用src/producer-source.js,kind 只在那一处拼。 - 开关状态不写会话日志。 它不是"懒"而是"不能":
Session.append没有写ignorable标记的通道,而读者遇到不认识的、没有该标记的事件必须拒绝重建整个会话。 所以开关状态走 storage domain($DSH_HOME/storages),与会话日志无关。 通知是另一回事:它作为一条合成user/message进日志(模型可见的东西因此仍然可重建), 所以它的source.kind必须归生产者所有 —— 见上一条。 - 覆盖子代理。 子代理加入父方的组装、走同一条装配瀑布,全局监听器与根作用域守卫
都生效;开关也跟随父会话。父方通过
toolFilter限制掉的工具,子代理既查不到也调不到。 - 新版装配异常明确报错:元工具未能匹配时停止该次请求,不静默发送完整目录;旧版保留原有告警行为。
- 不处理非文本块的内容:
call_tool把图片等非文本块经deferContext作为独立 上下文送出,不做转码或裁剪。call_tools的子调用也一样(程序里调到的图片会附在那次结果之后)。 call_tools的程序里能调到的,就只有那两个函数。 绑定只给tools.find_tools与tools.call_tool—— 程序不能直接写tools.read({...})。 这不是限制调用(子调用走的还是注册表的公开执行入口,审批、守卫、沙箱一样不少), 而是不让工具目录再被喂回模型:官方 PTC 的 SDK 段落会列出全部工具的签名, 我们这段只有两行。- 程序结束就中止还在飞的子调用。 程序写了
tools.call_tool(...)却没await, 那个调用不会在结果返回之后继续跑下去 —— 它会随这次运行一起停掉,而且会等它 收完尾才把结果交出去(不等的话,它的会话日志会落在这次调用之后)。 - 新版官方 PTC 的 SDK 同样按开关过滤。 用宿主自身 SDK renderer 重绘声明;执行守卫对任何 nested 调用继续强制禁用。程序知道旧工具名也不能绕过禁用。
- 依赖三个包:
@node-rs/jieba(原生模块)、pinyin-pro、minisearch。前两个都是 预编译分发、无 install 脚本、零运行时依赖树。
开发
pnpm install
pnpm test # 现有纯逻辑及兼容回归
# PowerShell:$env:DSH_TEST_CHECKOUT='D:\dev\deepseek-harness'
pnpm test:integration # 真实 Loader + AgentLoop + HTTP + Node PTC 子进程
pnpm build # 校验后逐字节拷贝 src/ → lib/
lib/ 随仓库提交,安装方不需要在自己机器上跑构建。
客户端半边(src/client.js)是手写的 window.__ModuleLoader__.load 自注册脚本,
构建只做语法校验、不转换 —— 它由 web server 原样下发。
No comments yet. Be the first to write one.