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}`,
  )
}

截图:02-plugin-code(本文件渲染)。

要点:

  • 结构化返回,而非散文。 output.schema 声明规范化 JSON 值;render() 把它投影成面向模型的内容块。 Code Mode(PTC)下 schema 自动变成 await tools.spike_env_time(...)
  • 校验免费。 parametersexecute 运行前校验;args 由它推导类型。
  • 可逆。 ctx.tools.register() 返回 disposer 并自动挂到本插件 fiber——卸载插件即反注册工具。
  • 对象 schema 必须显式声明 additionalProperties 每个返回字段标 required: true, 让 valuerender()/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)的真实验证):

  • 装进内置 web profile,别建新 profiledsh 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。

截图:04-web-ui-home(Web UI 主页)和 05-panel-spikeshell.overlay 面板,右上角)。 完整面板参考:research/webui-panel-api.md


Prev: 插件模型 · Contents · Next: 15 条设计准则