2. 三类插件形态,附最小完整示例
August 14, 2026 · View on GitHub
2.1 工具插件 — defineTool()
工具是 agent 调用的插件类型。声明参数 schema(自动校验、推导 args 类型)、规范化 JSON 返回值,
以及 execute 函数体:
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'spike-tool-time'
// The plugin only activates once the host's `tools` registry is ready.
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'spike_env_time',
description: 'Return the current time and process environment info.',
parameters: {
tz: {
type: 'string',
description: "IANA timezone name, e.g. 'Asia/Shanghai'. Defaults to the system local timezone.",
},
},
output: {
schema: {
type: 'object',
properties: {
iso: { type: 'string', description: 'ISO-8601 timestamp (UTC).' },
unixMs: { type: 'integer', description: 'Unix epoch milliseconds.' },
tz: { type: 'string', description: 'Timezone actually used.' },
nodeVersion: { type: 'string', description: 'process.version' },
platform: { type: 'string', description: 'process.platform' },
},
additionalProperties: false,
},
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
},
async execute(args) {
const tz = args.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone
const now = new Date()
return {
iso: now.toISOString(),
unixMs: now.getTime(),
tz,
nodeVersion: process.version,
platform: process.platform,
}
},
}))
// Self-check: prove the tool actually landed in the registry.
console.log(
`[spike-tool-time] registered "spike_env_time" — listed=${ctx.tools.get('spike_env_time') !== undefined}`,
)
}
截图:
(本文件渲染)。
要点:
- 结构化返回,而非散文。
output.schema声明规范化 JSON 值;render()把它投影成面向模型的内容块。 Code Mode(PTC)下 schema 自动变成await tools.spike_env_time(...)。 - 校验免费。
parameters在execute运行前校验;args由它推导类型。 - 可逆。
ctx.tools.register()返回 disposer 并自动挂到本插件 fiber——卸载插件即反注册工具。 - 对象 schema 必须显式声明
additionalProperties。 每个返回字段标required: true, 让value在render()/presentationMeta()里保持非可选。
2.2 事件 / 生命周期插件 — ctx.on + ctx.effect
这个插件零运行时依赖:只用宿主交来的 ctx。所有 import type 在编译期擦除。
import type { Context } from '@deepseek-ai/cordis'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
export const name = 'spike-lifecycle-logger'
export function apply(ctx: Context) {
let sessionEvents = 0
let toolChanges = 0
let toolPreExecutes = 0
// ① Durable session firehose (emit): fires whenever a session's log grows.
ctx.on('session/event', (session: Session, event: SessionEvent) => {
sessionEvents += 1
if (sessionEvents <= 5 || sessionEvents % 25 === 0) {
console.log(`[spike-lifecycle] session/event #${sessionEvents} type=${event.type} session=${String(session.id)}`)
}
})
// ② Live registry change (emit): fires when any tool is registered or unregistered.
ctx.on('tools/change', () => {
toolChanges += 1
console.log(`[spike-lifecycle] tools/change #${toolChanges}`)
})
// ③ Tool execution pipeline (waterfall): log, then delegate with next().
// NOT calling next() would short-circuit and block the tool call.
ctx.on('tools/pre-execute', (exec: ToolExecution, next: () => Promise<PreToolDecision>) => {
toolPreExecutes += 1
console.log(`[spike-lifecycle] tools/pre-execute #${toolPreExecutes} tool=${exec.name}`)
return next()
})
// ④ A non-Cordis resource (a timer) wrapped in ctx.effect().
// The returned disposer runs on unload — the reversible-cleanup proof.
ctx.effect(() => {
const timer = setInterval(() => {
console.log(`[spike-lifecycle] heartbeat sessionEvents=${sessionEvents} toolPreExecutes=${toolPreExecutes} toolChanges=${toolChanges}`)
}, 30_000)
return () => {
clearInterval(timer)
console.log('[spike-lifecycle] DISPOSED — listeners removed, timer cleared')
}
})
console.log('[spike-lifecycle] listeners registered: session/event + tools/change + tools/pre-execute')
}
事件 seam(docs/event-producer-consumer.md 是全量矩阵):
| 派发模式 | 等待? | 顺序 | 有返回值? |
|---|---|---|---|
emit | 否 | 注册顺序观察 | 否 |
waterfall | 否 | 注册顺序(around-middleware) | 是 |
parallel | 是 | 并行 | 否 |
serial | 是 | 注册顺序 | 是 |
工具执行管线是拦截工具调用的地方:
declare module '@deepseek-ai/cordis' {
interface Events {
'tools/pre-execute'(this, exec, next): Promise<PreToolDecision> // waterfall:允许/拒绝/询问
'tools/execute'(this, exec, next): Promise<ToolExecutionResult> // waterfall:超时/重试/指标
'tools/post-execute'(this, exec, result, next): Promise<PostToolDecision> // waterfall:替换/拦截
'tools/result'(this, exec, result): undefined // emit:观察冻结的最终结果
'tools/change'(): void // emit:工具集合变化
}
}
2.3 Web UI 扩展 — 工具卡片与面板
Web UI 有两个扩展点:工具卡片(每个工具的渲染意图)和面板(通过双半插件加一整块浏览器 UI)。
2.3.1 工具卡片 — presentCall / presentResult
工具可以把调用/结果渲染成卡片(card)而非纯文本。presentCall 在模型调用工具时显示一张"进行中"卡片;
presentResult 从持久化的 meta 重建"已完成"卡片。二者必须纯函数(live 流式 和 会话日志重放都要跑)。
import { writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'my-webui'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'my_note',
description: 'Write a short note to a file and show an inline diff card (Web UI extension demo).',
parameters: {
path: { type: 'string', required: true, description: 'Absolute path to write.' },
content: { type: 'string', required: true, description: 'Note content.' },
},
output: {
schema: {
type: 'object',
properties: {
path: { type: 'string', required: true, description: 'Absolute path written.' },
bytes: { type: 'integer', required: true, description: 'Bytes written.' },
},
additionalProperties: false,
},
render: (_args, value) => [{ type: 'text', text: `Wrote ${value.bytes} bytes to ${value.path}` }],
// Replayable card data: combine args + canonical value so the card can be
// rebuilt from the persisted tool/result event on replay.
presentationMeta: (args, value) => ({ path: value.path, content: args.content }),
},
// Pending card (a diff card — this call creates a file, so oldText is null).
presentCall: (args) => ({
card: 'diff',
title: `Write ${args.path}`,
diffs: [{ path: args.path, oldText: null, newText: args.content }],
locations: [{ path: args.path }],
}),
// Completed card: rebuild the applied hunk from the persisted meta.
presentResult: (_args, result) => {
const meta = result.meta as { path?: string; content?: string } | undefined
const path = meta?.path ?? ''
return {
card: 'diff',
title: `Wrote ${path}`,
diffs: [{ path, oldText: null, newText: meta?.content ?? '' }],
}
},
async execute(args) {
const abs = resolve(args.path)
await writeFile(abs, args.content, 'utf8')
return { path: abs, bytes: Buffer.byteLength(args.content, 'utf8') }
},
}))
}
2.3.2 面板 — 双半插件 + slot 注册
真正的浏览器面板是一个双半插件(dual-half plugin):一个 npm 包里既有宿主半(Node 进程,
exports["."])又有浏览器半(exports["./client"])。浏览器半是一个 Cordis 插件,用
ctx.slots.register(...) 把一个 React 组件注册进 UI 的 slot(插槽)。没有单独的「panel API」——
面板就是一次 slot 注册。
package.json 声明浏览器半:
{
"name": "panel-spike",
"version": "0.1.0",
"type": "module",
"main": "lib/index.js", // 宿主半
"exports": {
".": "./lib/index.js", // 宿主半(Node 进程)
"./client": "./lib/client.js", // 浏览器半(Web UI 进程)
"./package.json": "./package.json"
},
"dsh": {
"bundle": { "patch": "./cordis.patch.yml" },
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-slots"
],
"platform": "web"
}
}
}
浏览器半(lib/client.js)——一段自注册的 closure factory,无需构建步骤:
window.__ModuleLoader__.load({
id: 'panel-spike', // 必须等于 package.json 的 name
factory: (require) => {
const React = require('react') // react 是 shell 提供的模块表条目
return {
inject: ['slots'], // 注入 runtime 的 slots 服务
apply(ctx) {
ctx.slots.register(
{ name: 'shell.overlay', id: 'panel-spike', order: 0 },
() => React.createElement('div', {
style: {
position: 'fixed', top: '16px', right: '16px', zIndex: 9999,
background: '#0b1220', color: '#7ee787', border: '1px solid #30363d',
borderRadius: '8px', padding: '12px 16px', fontFamily: 'monospace',
fontSize: '14px', pointerEvents: 'auto',
},
}, 'panel-spike: DSH Web UI panel API OK ✓'),
)
},
}
},
})
宿主半(lib/index.js)——最小面板通常留空:
export const name = 'panel-spike'
export function apply() {}
面板的关键事实(来自对 npx @deepseek-ai/dsh web(端口 3080)的真实验证):
- 装进内置
webprofile,别建新 profile:dsh plugin --profile web add ./panel-spike。 新 profile 默认是 agent profile——不带 Web UI(@deepseek-ai/dsh-web*是网页搜索能力,不是 Web 界面)。 - 加面板用
list插槽:shell.overlay(浮动层)或sidebar.footer.action(侧栏动作)。single插槽(root/sidebar/conversation/details)是"整块替换",重复注册会 throw。 list插槽必须给id;客户端 bundle 的id必须等于包名。- 浏览器半只注册 factory——
apply在 factory 物化时才跑;不要在模块顶层做 DOM 操作。 - 宿主半负责数据(fs / git / HTTP 路由 / SSE);浏览器半通过
/xxx/*路由拿数据。 apply抛错会炸掉整个 web shell boot——把 DOM 接线包进 try/catch 或 error boundary。
截图:
(Web UI 主页)和
(
shell.overlay面板,右上角)。 完整面板参考:research/webui-panel-api.md。
← Prev: 插件模型 · Contents · Next: 15 条设计准则 →
(本文件渲染)。
(Web UI 主页)和
(