从"知道"到"能做"
前九篇讲了很多机制:Cordis 插件系统、工具注册、Agent 循环、Session 内存、System Prompt 组装、能力接缝、多 Agent 协作、可观测性......
知道每个零件能做什么,和把它们组合成一个能跑的东西,是两件事。
这篇文章做后者。
我们会完整地写一个插件,从需求描述到可以加入 Bundle 的代码。前九篇提到的知识点不会被列一遍------它们会以代码的形式自然出现。如果你在某段代码前觉得"这是什么",那里会有对应的解释和章节引用。
我们要做什么
插件名:workspace-context
功能定义:
- 把当前工作目录信息注入 system prompt(让模型"知道自己在哪里")
- 提供
get_workspace_info工具(让模型可以主动查询工作区文件) - 在危险工具调用(名称含
delete或rm)前暂停,等待用户确认 - 计量每次工具调用的执行时间(可观测性)
- 插件卸载时,所有注册项自动清理
这五个功能恰好覆盖了插件开发的五个主要关切点:提示词 + 工具 + 权限 + 观测 + 生命周期。
插件骨架
dsh 插件是一个 TypeScript 模块,遵循 Cordis 的插件协议:
typescript
// packages/workspace-context/src/index.ts
// 插件名:在 Cordis 依赖树里的唯一标识
export const name = 'workspace-context'
// 声明依赖的服务
// Cordis 会确保这些服务就绪后才调用 apply
// 如果服务不可用,插件自动挂起(不会报错,只是等待)
export const inject = ['tools', 'systemPrompt']
// 插件入口
// ctx 是这个插件的私有 Cordis 上下文
// 所有通过 ctx 做的注册(工具、提示词 Section、事件监听)
// 都与这个 ctx 的生命周期绑定------插件卸载时自动 dispose
export function apply(ctx: Context): void {
// 所有注册操作放在这里
}
这三行声明(name、inject、apply)是 dsh 插件的最小结构。
inject 不只是一个"导入列表"------它是一份契约,告诉 Cordis 运行时:"我依赖这些服务,如果它们还没就绪,不要急着调用我。"这是热插拔的基础(第 02 篇讲过的 Cordis 服务依赖机制)。
第一步:注入 System Prompt Section
让模型知道当前工作目录,最干净的方式是通过 System Prompt Section,而不是在每条用户消息里重复(第 06 篇:System Prompt 组装)。
typescript
// 向 system prompt 注入工作区信息
ctx.systemPrompt.section({
name: 'workspace-context:cwd',
// 排在 Agent 指令之后、用户提示词之前的上下文区域
order: ctx.systemPrompt.getSectionOrder('context:workspace'),
// 动态文本:每次 system prompt 组装时重新求值
// 这意味着如果工作目录中途变了,下一次请求会拿到最新的值
text: (context) => {
const cwd = context.agent?.session?.header?.cwd
if (!cwd) return '' // 没有 cwd 信息,不注入
return [
'## Workspace',
`Current working directory: ${cwd}`,
'All relative file paths are resolved from this directory.',
].join('\n')
},
})
注意 text 是一个函数,不是字符串。dsh 在每次组装 system prompt 时调用它------所以这段信息总是实时的,不是快照。
另一个细节:我们不需要保存 section() 的返回值(一个 disposer)。因为我们用了 ctx.systemPrompt.section(),Cordis 知道这个 Section 属于当前插件的上下文,插件卸载时会自动清理。
第二步:注册 get_workspace_info 工具
工具让模型可以主动"问"而不只是"被告知"(第 03 篇:工具系统)。
typescript
import { defineTool } from '@deepseek-ai/dsh-tools'
import { readdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
ctx.tools.register(defineTool({
name: 'get_workspace_info',
description: 'Get information about the current workspace: directory listing and file sizes.',
parameters: {
path: {
type: 'string',
description: 'Relative path within the workspace. Defaults to workspace root.',
},
},
output: {
schema: {
// 严格模式:additionalProperties: false 防止模型传入意外字段
type: 'object',
additionalProperties: false,
properties: {
path: { type: 'string' },
entries: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
name: { type: 'string' },
type: { type: 'string', enum: ['file', 'directory'] },
size: { type: 'number' },
},
},
},
},
},
// render 把结构化返回值转成模型可读的文本
// 分离"结构化数据"和"模型看到的文本"是 dsh 工具系统的设计原则
render: (_args, value) => {
const v = value as {
path: string
entries: Array<{ name: string; type: string; size: number }>
}
const lines = [`Directory: ${v.path}`, '']
for (const entry of v.entries) {
const sizeStr = entry.type === 'file' ? ` (${entry.size} bytes)` : '/'
lines.push(` ${entry.name}${sizeStr}`)
}
return [{ type: 'text', text: lines.join('\n') }]
},
},
// 只读操作,不修改任何状态,可以和其他工具并行执行
isConcurrencySafe: () => true,
async execute(args, exec) {
// 从 session header 里拿 cwd;如果没有则 fallback 到 process.cwd()
const cwd = exec.agent?.session?.header?.cwd ?? process.cwd()
const targetPath = args.path ? join(cwd, args.path) : cwd
const entries = await readdir(targetPath, { withFileTypes: true })
const result = await Promise.all(
entries.map(async (entry) => {
const fullPath = join(targetPath, entry.name)
// 只对文件调用 stat(获取大小),目录不需要
const stats = entry.isFile() ? await stat(fullPath) : null
return {
name: entry.name,
type: entry.isDirectory() ? 'directory' : 'file',
size: stats?.size ?? 0,
}
})
)
return {
path: targetPath,
// 目录排在文件前面,同类按名称排序
entries: result.sort((a, b) => {
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1
return a.name.localeCompare(b.name)
}),
}
},
}))
isConcurrencySafe: () => true 是一个性能信号。dsh 在 Agent 循环中可以并行执行多个标记了"并发安全"的工具调用,而不需要串行等待。对只读工具来说,这几乎总是应该设为 true。
第三步:权限拦截
危险操作(删除、覆盖)在执行前应该让人知道。dsh 通过 tools/pre-execute 事件实现拦截(第 03 篇):
typescript
// 拦截含 'delete' 或 'rm' 字样的工具调用
// 返回 { kind: 'ask' } 会暂停执行,等待用户确认
// 返回 { kind: 'deny' } 则直接拒绝,工具不会执行
ctx.on('tools/pre-execute', async (exec, next) => {
const isDangerous = exec.name.includes('delete') || exec.name.includes('rm')
// 不危险:放行,继续走正常执行链
if (!isDangerous) return next()
// 危险:要求确认
// 如果 Bundle 里没有配置 approval 服务,ask 会自动降级为 deny
return {
kind: 'ask',
reason: `About to run: ${exec.name}(${exec.arguments})`,
}
})
这是一个中间件模式 :next() 代表继续执行链,不调用 next() 而是返回其他值则会短路------工具不会被真正执行。
这段代码还有一个隐含的设计意图:它不依赖具体的工具名白名单,而是基于命名模式判断危险性。真实项目里你可能需要更精确的规则,但模式是一样的。
第四步:执行时间计量
生产环境里你需要知道哪些工具最慢、哪次调用异常耗时(第 09 篇:可观测性):
typescript
// 包装工具执行,记录耗时
// tools/execute 是一个中间件 hook,每次工具调用都经过这里
ctx.on('tools/execute', async (exec, next) => {
const start = performance.now()
// 调用 next() 执行实际的工具逻辑
const result = await next()
const elapsed = Math.round(performance.now() - start)
// result.isError 是标准化的错误标志
// 这里用 console.log,实际项目里可以发到 ctx.sessionTelemetry
console.log(`[${exec.name}] ${result.isError ? 'ERROR' : 'OK'} ${elapsed}ms`)
// 必须把 result 传回去,否则工具返回值会丢失
return result
})
这里有一个容易犯的错误:忘记 return result。tools/execute 是一个"包装"hook,你既可以在执行前后做事,也可以修改返回值,但必须把结果传回去。
第五步:Session 观测
在每个 Turn 结束时记录一个摘要,方便事后分析:
typescript
// 监听 session/event,在 Turn 结束时打印摘要
ctx.on('session/event', (session, event) => {
if (event.type !== 'turn/end') return
// reason.kind 是 Turn 结束的原因:
// 'complete' ------ 正常结束(模型认为任务完成)
// 'error' ------ 出错
// 'interrupted' ------ 用户中断
// 'max-turns' ------ 达到最大 Turn 数限制
const reason = event.data.reason.kind
const turnNum = event.data.turn
console.log(`[Turn ${turnNum}] ${reason}`)
})
这是最简化的版本。完整的生产实现会在这里发送遥测记录(ctx.sessionTelemetry.emit()),并结合 ctx.tokenMeter.measure(session) 打印 Token 消耗摘要。
完整插件:组合在一起
把以上所有部分合并成一个文件:
typescript
// packages/workspace-context/src/index.ts
// workspace-context 插件:工作区感知 + 工具 + 权限拦截 + 可观测性
import { defineTool } from '@deepseek-ai/dsh-tools'
import { readdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
// ── 插件元数据 ─────────────────────────────────────────────────
export const name = 'workspace-context'
// 声明服务依赖
// tools:工具注册中心
// systemPrompt:System Prompt 段落管理
export const inject = ['tools', 'systemPrompt']
// ── 插件入口 ───────────────────────────────────────────────────
export function apply(ctx: Context): void {
// ── 1. 注入 System Prompt Section ─────────────────────────────
ctx.systemPrompt.section({
name: 'workspace-context:cwd',
order: ctx.systemPrompt.getSectionOrder('context:workspace'),
text: (context) => {
const cwd = context.agent?.session?.header?.cwd
if (!cwd) return ''
return [
'## Workspace',
`Current working directory: ${cwd}`,
'All relative file paths are resolved from this directory.',
].join('\n')
},
})
// ── 2. 注册 get_workspace_info 工具 ───────────────────────────
ctx.tools.register(defineTool({
name: 'get_workspace_info',
description: 'Get information about the current workspace: directory listing and file sizes.',
parameters: {
path: {
type: 'string',
description: 'Relative path within the workspace. Defaults to workspace root.',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
path: { type: 'string' },
entries: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
name: { type: 'string' },
type: { type: 'string', enum: ['file', 'directory'] },
size: { type: 'number' },
},
},
},
},
},
render: (_args, value) => {
const v = value as {
path: string
entries: Array<{ name: string; type: string; size: number }>
}
const lines = [`Directory: ${v.path}`, '']
for (const entry of v.entries) {
const sizeStr = entry.type === 'file' ? ` (${entry.size} bytes)` : '/'
lines.push(` ${entry.name}${sizeStr}`)
}
return [{ type: 'text', text: lines.join('\n') }]
},
},
isConcurrencySafe: () => true,
async execute(args, exec) {
const cwd = exec.agent?.session?.header?.cwd ?? process.cwd()
const targetPath = args.path ? join(cwd, args.path) : cwd
const entries = await readdir(targetPath, { withFileTypes: true })
const result = await Promise.all(
entries.map(async (entry) => {
const fullPath = join(targetPath, entry.name)
const stats = entry.isFile() ? await stat(fullPath) : null
return {
name: entry.name,
type: entry.isDirectory() ? 'directory' : 'file',
size: stats?.size ?? 0,
}
})
)
return {
path: targetPath,
entries: result.sort((a, b) => {
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1
return a.name.localeCompare(b.name)
}),
}
},
}))
// ── 3. 权限拦截:危险工具调用要求确认 ─────────────────────────
ctx.on('tools/pre-execute', async (exec, next) => {
const isDangerous = exec.name.includes('delete') || exec.name.includes('rm')
if (!isDangerous) return next()
return {
kind: 'ask',
reason: `About to run: ${exec.name}(${exec.arguments})`,
}
})
// ── 4. 执行时间计量 ────────────────────────────────────────────
ctx.on('tools/execute', async (exec, next) => {
const start = performance.now()
const result = await next()
const elapsed = Math.round(performance.now() - start)
console.log(`[${exec.name}] ${result.isError ? 'ERROR' : 'OK'} ${elapsed}ms`)
return result
})
// ── 5. Session Turn 摘要观测 ───────────────────────────────────
ctx.on('session/event', (session, event) => {
if (event.type !== 'turn/end') return
const reason = event.data.reason.kind
const turnNum = event.data.turn
console.log(`[Turn ${turnNum}] ${reason}`)
})
// ── 注意 ──────────────────────────────────────────────────────
// 这里没有任何 cleanup 代码
// 所有通过 ctx 做的注册(tools.register、systemPrompt.section、ctx.on)
// 都由 Cordis 追踪,插件卸载时自动 dispose
}
整个插件 90 行左右,包含注释。五个关切点,每个 10-20 行。这大概是一个正常 dsh 插件的体量。
把插件加入 Bundle
有了插件代码,还需要把它接进 Bundle:
typescript
// bundle.ts(伪代码:Bundle 配置文件)
import workspaceContext from './packages/workspace-context/src/index.ts'
export default defineBundle([
// 核心服务必须在依赖它们的插件之前加载
// workspace-context 声明了 inject: ['tools', 'systemPrompt']
// 所以 tools 和 systemPrompt 的提供者必须排在它前面
coreToolsPlugin, // 提供 tools 服务
systemPromptPlugin, // 提供 systemPrompt 服务
workspaceContext, // 我们的插件
// 其他业务插件...
])
Cordis 会根据 inject 声明自动处理服务就绪顺序------即使顺序写错了,Cordis 也会等待依赖就绪后再激活插件,而不是直接报错崩溃。
系列回顾:这个插件用到了哪些章节
这个 90 行的插件,覆盖了系列里大部分核心机制:
| 插件功能 | 对应章节 |
|---|---|
ctx.tools.register() 注册工具 |
第 03 篇:工具系统 |
defineTool + isConcurrencySafe 并发安全 |
第 03 篇:工具系统 |
tools/pre-execute 权限拦截 |
第 03 篇:工具系统 |
tools/execute 包装执行 |
第 03 篇:工具系统 |
ctx.systemPrompt.section() 注入提示词段落 |
第 06 篇:System Prompt 组装 |
session/event Turn 结束观测 |
第 09 篇:可观测性 |
export const inject Cordis 服务依赖 |
第 02 篇:Cordis 插件系统 |
ctx 生命周期自动 dispose |
第 02 篇:Cordis 插件系统 |
session.header.cwd Session 上下文 |
第 05 篇:Session 内存 |
没有用到的部分:多 Agent 协作(第 08 篇)、Agent 能力接缝(第 07 篇)------它们是更高层的机制,通常在框架级别配置,不需要每个业务插件都碰。
dsh 的设计哲学
这个系列到这里可以画一个句号了。
dsh 的核心设计理念很简单:一切皆插件,一切有边界。
你不需要 fork 核心代码就能改变几乎所有行为------添加工具、修改提示词、拦截危险操作、接入监控系统,全部通过插件完成。边界由 Cordis 的 ctx 保证:插件只影响它自己注册的东西,卸载不留残留。
这套机制来自 Cordis,dsh 把它用在了 Agent 运行时上。
生产级 Agent 不只是"模型 + 工具调用"------它需要权限控制、可观测性、持久化、多 Agent 协作,需要在这些工程问题上有稳定的抓手。dsh 的架构就是为了把这些问题变成可组合、可替换、可测试的积木。
这是一个尚在早期的领域。很多最佳实践还在形成中,很多边界案例还没有标准答案。但有了这套架构基础,你至少知道在哪里加代码,在哪里找问题。
系列索引
如果你刚开始读,可以按顺序来:
- 第 01 篇:dsh 是什么,为什么要用它
- 第 02 篇:Cordis 插件系统------插件、服务、生命周期
- 第 03 篇:工具系统------注册、执行、拦截
- 第 04 篇:Agent 循环------一次对话怎么跑起来
- 第 05 篇:Session 内存------状态怎么存、怎么读
- 第 06 篇:System Prompt 组装------提示词怎么构建
- 第 07 篇:能力接缝------可替换的 Agent 能力
- 第 08 篇:多 Agent 协作------子 Agent、任务分发
- 第 09 篇:可观测性------Token 计量、遥测接入
- 第 10 篇:完整插件实战(本篇)
在 PrimeSkills 可以找到已在真实企业场景验证过的 AI Agent 技能和工作流,不是演示级的,是用在实际项目里的。
更多内容见我的个人主页