DeepSeek Harness 系列(10):写一个完整的 dsh 插件——从需求到上线

从"知道"到"能做"

前九篇讲了很多机制:Cordis 插件系统、工具注册、Agent 循环、Session 内存、System Prompt 组装、能力接缝、多 Agent 协作、可观测性......

知道每个零件能做什么,和把它们组合成一个能跑的东西,是两件事。

这篇文章做后者。

我们会完整地写一个插件,从需求描述到可以加入 Bundle 的代码。前九篇提到的知识点不会被列一遍------它们会以代码的形式自然出现。如果你在某段代码前觉得"这是什么",那里会有对应的解释和章节引用。


我们要做什么

插件名:workspace-context

功能定义:

  1. 把当前工作目录信息注入 system prompt(让模型"知道自己在哪里")
  2. 提供 get_workspace_info 工具(让模型可以主动查询工作区文件)
  3. 在危险工具调用(名称含 deleterm)前暂停,等待用户确认
  4. 计量每次工具调用的执行时间(可观测性)
  5. 插件卸载时,所有注册项自动清理

这五个功能恰好覆盖了插件开发的五个主要关切点:提示词 + 工具 + 权限 + 观测 + 生命周期


插件骨架

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 {
  // 所有注册操作放在这里
}

这三行声明(nameinjectapply)是 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 resulttools/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 技能和工作流,不是演示级的,是用在实际项目里的。

更多内容见我的个人主页

相关推荐
边境悍匪1 小时前
蜗牛学苑 Java 智能体学习 Day44|Vue 前端项目搭建 思维导图复盘
java·开发语言·spring boot·学习·阿里云
云杂项1 小时前
Safety at Scale: A Comprehensive Survey of Large Model and Agent Safety(LLMs章节)
人工智能·安全
cxk18082721 小时前
2026 GEO 优化实战指南:精采信时代昆明 AI 营销服务商选型方法论
大数据·人工智能·机器学习·geo·昆明geo
小小龙学IT1 小时前
Boost.PFR 开源结构体字段反射库深度解析
c++·开源
揽秀亭长1 小时前
论文降AI率实测思路,如何降低模板化语言特征
人工智能·自然语言处理
IT古董1 小时前
《FDE前沿部署工程师实战教程》18 - Enterprise Agent Mesh:Agent-to-Agent、协作编排与企业级Agent网络
人工智能·fde
CAIE研习社1 小时前
新能源招聘中的电气+AI:五类岗位方向与能力补充
大数据·人工智能
Akamai中国1 小时前
Akamai Valkey 托管数据库:企业 AI 的实时内存解决方案
人工智能·云计算·云服务
Dovis(誓平步青云)1 小时前
突破 32 位瓶颈:64 位 XID 如何化解事务号回卷危机
运维·服务器·人工智能·docker·容器