11. 二次开发指南:理解“一切皆插件”
11.1 “一切皆插件”到底是什么意思
它不是“系统没有核心”。核心仍定义不能破坏的不变量:Session 事件格式、Agent Turn/Step、Tool 策略顺序、LLM 流合同和 Client Slot 合同。所谓“一切皆插件”,是实际行为尽量由可装配、可作用域化、可撤销的注册贡献形成,而不是硬编码进主循环。
五种常见形态:
| 形态 | 作用 | 最小特征 |
|---|---|---|
| Cordis 函数插件 | 注册监听器或能力 | apply(ctx, config) |
| 服务插件 | 提供可替换能力 | Service Definition + Provider + Consumer |
| Profile/Bundle | 组合插件树 | dsh.profile.bundles / dsh.bundle.patch |
| Host 工具插件 | 给模型真实能力 | inject = ['tools'] + defineTool() |
| Browser 插件 | 给 Web 增加视图 | package.json.dsh.client + keyed Slot |
判断规则:普通功能优先插件;供应商差异优先 Adapter;只有公开语义或跨模块不变量变化才修改 core/*。
11.2 第一步:可由 --patch 加载的 Host 工具
在上游仓库的 scratch-plugin/ 中建立:
scratch-plugin/
├─ package.json
├─ cordis.yml
└─ src/
└─ index.ts接口固定为:
export type WorkspaceSummaryInput = {
path: string
maxFiles?: number
}
export type WorkspaceSummaryOutput = {
path: string
fileCount: number
byExtension: Record<string, number>
truncated: boolean
}src/index.ts:
import { lstat, readdir, realpath } from 'node:fs/promises'
import { extname, isAbsolute, relative, resolve, sep } from 'node:path'
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'workspace-summary'
export const inject = ['tools']
export interface Config {
workspaceRoot: string
}
type Output = {
path: string
fileCount: number
byExtension: Record<string, number>
truncated: boolean
}
function inside(root: string, candidate: string): boolean {
const rel = relative(root, candidate)
return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel))
}
async function summarize(rootInput: string, requested: string, maxFiles: number, signal: AbortSignal): Promise<Output> {
signal.throwIfAborted()
const root = await realpath(rootInput)
const target = resolve(root, requested)
if (!inside(root, target)) throw new Error('path must stay inside workspaceRoot')
const targetReal = await realpath(target)
if (!inside(root, targetReal)) throw new Error('resolved path escapes workspaceRoot')
const files: string[] = []
const walk = async (directory: string): Promise<void> => {
signal.throwIfAborted()
const entries = await readdir(directory, { withFileTypes: true })
entries.sort((a, b) => a.name.localeCompare(b.name))
for (const entry of entries) {
signal.throwIfAborted()
const absolute = resolve(directory, entry.name)
const stat = await lstat(absolute)
if (stat.isSymbolicLink()) continue
if (stat.isDirectory()) await walk(absolute)
else if (stat.isFile()) files.push(relative(root, absolute).split(sep).join('/'))
}
}
await walk(targetReal)
files.sort()
const selected = files.slice(0, maxFiles)
const byExtension: Record<string, number> = {}
for (const file of selected) {
const extension = extname(file).toLowerCase() || '[no extension]'
byExtension[extension] = (byExtension[extension] ?? 0) + 1
}
return {
path: relative(root, targetReal).split(sep).join('/') || '.',
fileCount: selected.length,
byExtension,
truncated: files.length > selected.length,
}
}
export function apply(ctx: Context, config: Config): void {
ctx.tools.register(defineTool({
name: 'workspace_summary',
description: '统计工作区内目录的文件数量与扩展名分布,不读取文件内容。',
parameters: {
path: { type: 'string', required: true, description: '工作区内的相对路径' },
maxFiles: { type: 'number', description: '排序后最多统计 1 到 500 个文件' },
},
output: {
schema: {
type: 'object', additionalProperties: false,
properties: {
path: { type: 'string', required: true },
fileCount: { type: 'number', required: true },
byExtension: { type: 'object', required: true, additionalProperties: true },
truncated: { type: 'boolean', required: true },
},
},
render: (_args, value) => [{
type: 'text',
text: `${value.path}: ${value.fileCount} files${value.truncated ? ' (truncated)' : ''}\n`
+ Object.entries(value.byExtension).map(([ext, count]) => `${ext}: ${count}`).join('\n'),
}],
presentationMeta: (_args, value) => ({
kind: 'workspace-summary', ...value,
}),
},
async execute(args, exec) {
if (args.path === '' || args.path.includes('\0')) throw new Error('path must be a non-empty relative path')
+ if (Object.keys(args).some(key => key !== 'path' && key !== 'maxFiles')) throw new Error('unknown argument')
const maxFiles = args.maxFiles ?? 100
if (!Number.isInteger(maxFiles) || maxFiles < 1 || maxFiles > 500) {
throw new Error('maxFiles must be an integer from 1 to 500')
}
return summarize(config.workspaceRoot, args.path, maxFiles, exec.signal)
},
}))
}这里的安全边界是:只读、不读文件内容、不跟随符号链接、realpath 后再次确认仍在工作区、稳定排序后截断,并在循环中响应 AbortSignal。output.render 面向模型;presentationMeta 持久化前端重放所需事实。当前 Value Schema 要求 additionalProperties 为布尔值,因此动态扩展名 Map 由实现构造为数字值,并由单测锁定该约束。
最小 Patch(实际 Loader 语法以 scratch-plugin 教程生成的插件入口为准):
- id: workspace-summary
plugin: ./src/index.ts
config:
workspaceRoot: /path/to/deepseek-harness【仓库声明但未执行】启动:
pnpm dsh web --patch ./scratch-plugin/cordis.yml撤销:停止进程并从临时 Patch 移除该 entry;不要修改默认 Bundle。
11.3 第二步:整理成 Host + Browser 双面包
packages/learning/workspace-summary/
├─ package.json
└─ src/
├─ index.ts # 上面的 Host 工具
└─ client/
├─ index.tsx # Browser apply
└─ locales.tspackage.json 的关键公开接口:
{
"name": "@example/dsh-workspace-summary",
"type": "module",
"main": "lib/index.js",
"exports": {
".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" },
"./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }
},
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-renderer",
"@deepseek-ai/dsh-client-ui-tool"
],
"platform": "web"
}
}
}若一个包只有 UI,Host src/index.ts 可以像 packages/client/ui-skill/src/index.ts 一样只导出空 apply(): void {},让 Loader 挂载后由 Client Module 系统发现 dsh.client,无需重建整个 Web 应用。当前示例同包还含 Host 工具,所以根入口使用上一节 apply()。
src/client/locales.ts:
export const NS = 'workspaceSummary'
export const zh = {
'title': '工作区摘要', 'pending': '正在统计', 'success': '统计完成',
'error': '统计失败', 'truncated': '结果已截断', 'malformed': '结果格式无法识别',
} satisfies Record<string, string>
export type Key = keyof typeof zh
export const en = {
title: 'Workspace summary', pending: 'Scanning', success: 'Complete',
error: 'Failed', truncated: 'Result truncated', malformed: 'Malformed result',
} satisfies Record<Key, string>src/client/index.tsx(刻意只依赖 wire block,不导入 Host 实现):
import type { Context } from '@deepseek-ai/cordis'
import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import { en, NS, zh } from './locales.js'
type SummaryMeta = {
kind: 'workspace-summary'; path: string; fileCount: number
byExtension: Record<string, number>; truncated: boolean
}
function parseMeta(value: unknown): SummaryMeta | null {
if (typeof value !== 'object' || value === null) return null
const v = value as Record<string, unknown>
if (v.kind !== 'workspace-summary' || typeof v.path !== 'string'
|| typeof v.fileCount !== 'number' || typeof v.truncated !== 'boolean'
|| typeof v.byExtension !== 'object' || v.byExtension === null) return null
if (!Object.values(v.byExtension as Record<string, unknown>).every(n => typeof n === 'number')) return null
return v as SummaryMeta
}
function WorkspaceSummaryRow({ block }: ToolCallViewProps) {
if (!('kind' in block)) return <div>工作区摘要:正在统计…</div>
if (block.isError) return <div role="alert">工作区摘要:统计失败</div>
const meta = parseMeta(block.meta)
if (meta === null) return <div>工作区摘要:结果格式无法识别</div>
return (
<section data-tool="workspace_summary">
<strong>{meta.path} · {meta.fileCount} 个文件</strong>
{meta.truncated ? <span>(结果已截断)</span> : null}
<ul>{Object.entries(meta.byExtension).map(([ext, count]) => <li key={ext}>{ext}: {count}</li>)}</ul>
</section>
)
}
export const inject = ['slots', 'locale']
export function apply(ctx: Context): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'workspace-summary: dictionaries')
ctx.slots.inject('tool.call.toolview', () => ctx.slots.register(
{ name: 'tool.call.toolview', key: 'workspace_summary', locale: NS },
WorkspaceSummaryRow,
))
}当前 wire 类型把持久结果元数据挂在 settled block 上;当前 ToolCallBlock 将持久结果元数据暴露为 meta。升级依赖时以该类型声明为准并同步测试,不能用 any 静默绕过。
11.4 测试:把安全和回放当一等公民
至少覆盖:
it.each([
['.', 10, false],
['src', 1, true],
])('returns a stable summary', async (path, maxFiles, mayTruncate) => { /* fixture + assertions */ })
it('rejects absolute, escaping paths and extra arguments', async () => { /* no traversal occurs */ })
it('does not follow symlinks', async () => { /* escaped target is absent */ })
it('stops when exec.signal aborts', async () => { /* rejects once */ })
it('renders pending, success, error and truncated states', () => { /* client component */ })
it('falls back for malformed metadata', () => { /* never crashes replay */ })【仓库声明但未执行】包内测试与类型检查:
pnpm exec vitest run packages/learning/workspace-summary/tests
pnpm --filter @example/dsh-workspace-summary bundle11.5 两侧不可跨越的边界
- Browser 插件不得导入 Host 的目录遍历或工具定义。
- Browser 不自行配对
tool/call与tool/result,也不重建调用树;ui-tool提供冻结 block。 - wire tool name
workspace_summary是 keyed Slot 的选择键。 tool/result与持久presentationMeta是实时展示和回放的共同合同。- malformed/旧日志必须回退到 generic 行,展示错误不能让会话回放崩溃。
11.6 提交前检查清单
- 是否优先使用正式扩展点而非修改 Agent Loop?
- 输入和 canonical output 是否都经过 Schema?
- 路径、权限、取消和并发是否在 Host 强制?
- Client 是否只依赖 wire contract,并覆盖五种状态?
- 注册是否随 Scope dispose?
- 是否补 package README、定向测试、类型检查和 Loader smoke?
- 是否保留 Session 事件兼容与可重建性?