这是《DeepSeek Harness 权威指南 》系列第 15 篇,二开线(B 线)收官。源码基线 deepseek-harness @ 47f9438;项目仓库 rexleimo/rex-dhs-core (公开,MIT)。

B1-B6 每一篇都在讲一个 seam。这篇把 B 线全部能力串成一个有落地场景的真实项目:需求 → 设计 → 架构 → 实现 → 真实运行 → 截图 → 分发。所有环节都有真实证据;唯一没做完的是"真实问答回合"——它需要你的 API key,文章末尾给了补跑步骤。

一、需求

一句话:让 agent 能在一个本地 markdown 文档库(只读)里搜索并阅读内容,从而基于真实资料回答知识库问题。

用户故事

  1. 作为团队成员,我提问"我们平台的限流策略是什么",agent 应搜索知识库并引用实际文档段落回答;
  2. 作为知识库维护者,我要求 agent 只能读配置的文档根目录,绝不能读到根目录之外的任意文件;
  3. 作为安全负责人,我要求每一次 kb 工具调用都经过可审计的策略层,并能在 UI 上看到结构化卡片。

非目标(明确不做):向量/嵌入索引、文件写入、web 抓取、外部知识源、企业级权限系统。

二、设计:复用 B 线,只写域逻辑

B 线能力复用点本项目落点
B1 工具注册defineTool + output.schema/renderkb_querykb_read
B3 权限门tools/pre-execute + ctx.tools.guard()KbPathGate 路径白名单 + owner 单调拒绝
B4 结果投影presentationMeta / presentResultkb_read 的 read 卡片(行号/总行数)
B5 事件流session/event 订阅调用自然进入 turn 日志(tool/call ↔ tool/result)
B6 打包named-export 契约 + manifest独立 package @rex-local/dsh-kb-qa

关键架构决策:域逻辑与装配分离path-policy / search-core / read-core 是纯函数(无 Context、无 I/O),可以零依赖单测;index.ts 只做接线。这来自官方 fs 三组件拆分的"能力 seam"思想(docs/cookbook/adding-a-package.zh.md §3)。

kb-qa-plugin 架构图

图:装配层注册工具并接线门与 guard;三个纯函数模块可独立单测;I/O 只读、路径收窄、输出有界。

三、实现

1. 路径策略(纯函数,src/path-policy.ts

export function resolveWithinRoot(root: string, candidate: string): PathVerdict {
  if (typeof candidate !== 'string' || candidate.length === 0) {
    return { ok: false, reason: 'empty path' }
  }
  if (candidate.includes('\0')) {
    return { ok: false, reason: 'path contains NUL byte' }
  }
  const abs = isAbsolute(candidate) ? candidate : resolve(root, candidate)
  const rel = relative(root, abs)
  if (rel === '') return { ok: false, reason: 'path resolves to the root itself' }
  if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
    return { ok: false, reason: `path escapes the knowledge base root: ${candidate}` }
  }
  return { ok: true, abs }
}

拒绝的每一类都在单元检查里断言过:.. 逃逸、根外绝对路径、空路径、NUL、指向 root 本身。kb_read 的 execute 里还做了第二道防线——realpath 复查,防符号链接逃逸:

const [rootReal, targetReal] = await Promise.all([
        fsp.realpath(config.root),
        fsp.realpath(abs).catch(() => null),
      ])
      if (targetReal === null) throw kbError(`file not found: ${verdict.rel}`, 'KB_FILE_NOT_FOUND')
      if (!targetReal.startsWith(rootReal + sep)) {
        throw kbError(`path escapes the knowledge base root: ${verdict.rel}`, 'KB_PATH_ESCAPE')
      }

2. 有界扫描(纯函数,src/search-core.ts

export function searchMarkdown(query: string, files: SearchInput[], options: SearchOptions): SearchOutcome {
  let re: RegExp
  try {
    re = new RegExp(query, 'iu')
  } catch (error) {
    throw new KbSearchError(`invalid query pattern: ${query}`, 'KB_INVALID_QUERY')
  }
  const matches: MatchLine[] = []
  let truncated = false
  for (const file of files) {
    if (matches.length >= options.maxResults) { truncated = true; break }
    let perFile = 0
    const lines = file.content.split(LINE_SPLIT)
    for (let i = 0; i < lines.length; i++) {
      if (perFile >= options.maxPerFile) { truncated = true; break }
      const line = lines[i] ?? ''
      if (Buffer.byteLength(line, 'utf8') > options.maxLineBytes) continue
      re.lastIndex = 0
      if (re.test(line)) {
        matches.push({ file: file.file, lineNumber: i + 1, line })
        perFile++
      }
    }
  }
  if (matches.length >= options.maxResults) truncated = true
  return { matches: matches.slice(0, options.maxResults), totalFiles: files.length, truncated }
}

有界性三重:总结果上限、单文件上限、行长上限(超长行跳过并置截断标志)。canonical value 永远是 { matches, totalFiles, truncated }——模型拿到的每个字段都是确定性的。

3. 装配(src/index.ts):两个工具 + 门 + guard

节选// ... 行为作者省略,其余逐字;完整文件见 kb-qa-plugin/src/index.ts

export function apply(ctx: Context, config: Config): void {
  // ---- owner-level final guard (B3 guard seam): monotonic deny only ----
  ctx.tools.guard((exec) => {
    if (exec.name !== 'kb_query' && exec.name !== 'kb_read') return undefined
    if (config.deniedToolNames.includes(exec.name)) {
      return `kb-qa owner guard denies "${exec.name}"`
    }
    return undefined
  })

  // ---- pre-execute gate: any kb_* path argument must stay inside root ----
  ctx.on('tools/pre-execute', async (exec, next): Promise<{ kind: 'deny'; reason: string } | undefined> => {
    if (exec.name === 'kb_query') {
      const prefix = (exec.arguments as { pathPrefix?: unknown }).pathPrefix
      if (prefix !== undefined) {
        const verdict = toRootRelative(config.root, String(prefix))
        if (!verdict.ok) return { kind: 'deny', reason: `kb_query pathPrefix rejected: ${verdict.reason}` }
      }
    }
    if (exec.name === 'kb_read') {
      const path = (exec.arguments as { path?: unknown }).path
      if (typeof path !== 'string') return { kind: 'deny', reason: 'kb_read requires a string path' }
      const verdict = toRootRelative(config.root, path)
      if (!verdict.ok) return { kind: 'deny', reason: `kb_read path rejected: ${verdict.reason}` }
      if (config.askOnRead) return { kind: 'ask', reason: 'kb_read requires approval in this deployment' }
    }
    return next()
  })
  // ... ctx.tools.register(defineTool({ name: 'kb_query', ... }))
  // ... ctx.tools.register(defineTool({ name: 'kb_read', ... presentResult: read card }))
}

开发中踩到的真实坑(写进文章以儆效尤):ctx.tools.guard() 的签名是单个 ToolGuard 函数(exec) => string | undefined),不是 (name, guard)——第一次按错误签名写,运行时报 guard is not a function。B3 文章只展示了用法,这次把签名也钉死。

四、验证:三层证据

1. 单元层(零依赖)

node --import tsx tests/unit-checks.ts
PASS  path-policy: inside / absolute-inside / .. escape / absolute escape / empty / root itself
PASS  search-core: hits / per-call cap / long-line skip / invalid pattern code
PASS  read-core: window / clamp / cap / beyond-end
KB_QA_UNIT_CHECKS=ALL_PASS

read-core 的语义修正也如实记录:末尾换行不是空行;请求区间超出文件必须标 truncated 而不是静默。)

2. 集成层:真实 ctx 管线六 case

node --import tsx demo/integration-demo.ts

真实输出(与 demo/integration-demo-output.txt 逐字一致):

=== A: kb_query success ===
{"isError":false,"value":{"matches":[{"file":"limits.md","lineNumber":3,"line":"本平台对 DeepSeek API 请求实施**速率限制(rate limit)**:"}],"totalFiles":2,"truncated":false}}
=== B: kb_read success ===
{"isError":false,"value":{"path":"limits.md","lines":["# 平台限流策略","","本平台对 DeepSeek API 请求实施**速率限制(rate limit)**:","","- 免费额度:每分钟 10 次请求(RPM);"],"startLine":1,"endLine":5,"totalLines":20,"truncated":false}}
=== C: kb_read path escape ===
{"isError":true,"error":{"message":"kb_read path rejected: path escapes the knowledge base root: ../secret.md"}}
=== D: kb_read outside-root absolute ===
{"isError":true,"error":{"message":"kb_read path rejected: path escapes the knowledge base root: C:/Windows/system32/drivers/etc/hosts"}}
=== F: kb_query invalid regex ===
{"isError":true,"error":{"message":"invalid query pattern: ("}}
=== E: kb_read askOnRead without approval service ===
{"isError":true,"error":{"message":"kb_read requires approval in this deployment"}}

A/B 成功路径走完整 registry 管线;C/D 被 pre-execute 门在 body 前拒绝(B3 的 deny 语义);F 的非法正则变成规范错误(B4 的错误投影);E 证明 askOnRead 在没有 approval service 时 fail-closed(B3 的 serviceAsk 分支 ①)。

3. 真实运行:dsh web profile 装载(本项目最大突破)

B3 以来"本机无法真实启动 pnpm dsh web"的障碍,在 B7 用 npx [email protected] 绕过错坏的 Corepack shim 后解除。真实启动:

npx --yes [email protected] dsh web --patch E:/coding/rex-dhs-core/kb-qa-plugin/cordis.yml

证据 1:dump-config 显示 patch 行完整组合进 profiledemo/dump-config-output.txt 503 行,节选):

# == E:\coding\rex-dhs-core\kb-qa-plugin\cordis.yml
- id: kb-qa
  name: file:///E:/coding/rex-dhs-core/kb-qa-plugin/src/index.ts
  config:
    root: E:/coding/rex-dhs-core/kb-qa-plugin/fixtures/docs
    maxQueryLength: 200
    maxMatches: 10
    maxLineBytes: 500
    maxLines: 60
    maxFiles: 200
    askOnRead: false
    deniedToolNames: []

证据 2:web UI 插件列表——搜索 kb-qa 命中唯一条目(作者本机截图,见下方):

kb-qa 插件在 web profile 中已挂载已启用

file:///E:/coding/rex-dhs-core/kb-qa-plugin/src/index.ts, 已挂载, 已启用
  id: include:kb-qa
  配置状态: 已启用     Cordis 状态: 已挂载

证据 3:真实问答回合——需要 DEEPSEEK_API_KEY(本机未配置,如实标注)。补跑步骤:

# 在 deepseek-harness 根目录
npx --yes [email protected] dsh web --patch E:/coding/rex-dhs-core/kb-qa-plugin/cordis.yml
# 浏览器打开 http://127.0.0.1:3080,在 API Key 弹窗粘贴你的 key
# 新建会话提问:"我们的限流策略是什么?"
# 期望:agent 调用 kb_query(rate\s+limit)→ kb_read limits.md → 引用段落回答

五、事件流:一次 kb_query 从头到尾

一次 kb_query 的完整事件流

图:门在 body 前决定去留;guard 施加最终单调拒绝;成功结果走 B4 投影链进入日志、transcript 与 UI。

对照 B5 的 turn 结构:模型请求 → assistant/message: tool-calltool/call(arguments 原文)→ registry 流水线(门 → guard → body)→ tool/result(message + meta,无 value)→ 同 turn step 2 回填模型。kb-qa 的贡献只在这条链的门与 body:路径策略决定"能不能读",有界扫描决定"读多少"。

六、分发

项目已推送到公开仓库 rexleimo/rex-dhs-core(commit 1bc0cbe,紧随 B0-B6 demo 集 6529fdd):

rex-dhs-core/
├── README.md                  # 仓库总览(目录对照 / 运行前置 / 边界声明)
├── docs/b7-kb-qa-plan.md      # 本项目的设计文档
└── kb-qa-plugin/
    ├── package.json           # B6 不变式(workspace:^ 依赖镜像)
    ├── src/{index,path-policy,search-core,read-core}.ts
    ├── tests/unit-checks.ts   # 零依赖单元检查
    ├── fixtures/docs/         # 示例知识库(limits.md / deploy.md)
    ├── demo/integration-demo.ts + outputs
    └── cordis.yml             # dsh web --patch 入口

给读者的装载路径(三选一):

# ① 临时叠加(作者本机验证的方式)
npx --yes [email protected] dsh web --patch <你的路径>/kb-qa-plugin/cordis.yml

# ② 用户 profile 持久化:把 cordis.yml 的内容合并进 $DSH_HOME/cordis.patch.yml

# ③ 正式包:按 B6 规范发布 @rex-local/dsh-kb-qa 后,在 cordis.yml 里用包名装载

七、常见错误写法

过强或错误的说法更准确的表述
“kb-qa 是安全的权限系统。”它是 per-call 策略示例:只读 + 路径收窄 + 有界,不是 IAM。
“kb_query 是全文检索。”它是逐行正则扫描,无索引;大知识库性能未优化(有界是刻意选择)。
“demo 证明了真实问答。”集成 demo 证明工具管线;真实问答回合需 API key,已给出补跑步骤。
“guard(name, fn) 是正确签名。”实测签名是 ctx.tools.guard(fn),按 exec.name 自行分流。
“read-core 的截断标志只在超长时出现。”请求区间超出文件末尾也会标 truncated(语义修正已测)。

八、总结:B 线至此闭环

B1 教会你注册工具,B2 接模型,B3 上策略,B4 投影结果,B5 看事件流,B6 打包——B7 把它们合成一个能真实装载进产品 profile 的插件。回顾整条线,二开 dsh 插件的通用配方是:

  1. 域逻辑写成纯函数(可单测、可推理);
  2. 能力收窄是卖点(只读、有界、root 白名单),不是功能缺失;
  3. 策略走门与 guard,不要塞进工具 body;
  4. canonical value 一次成型,投影交给 render/presentationMeta/presentResult;
  5. 真实装载用 npx pnpm dsh web --patch,截图与 dump-config 就是证据。

下一篇预告:系列将进入收尾——全量审核、分批发布与 IndexNow 上线(见栏目计划阶段 6)。B 线的新案例(MCP 集成、headless agent、Python SDK 等计划中的主题)会以同样标准陆续补充。


一个端到端项目最难的不是写代码,而是每一层都有证据:单元检查证明纯函数、集成 demo 证明管线、dump-config 与 UI 截图证明真实装载。没有 API key 就诚实标注缺一环,并给出补跑步骤——这就是这个系列一贯的标准。