如何為 DeepSeek Harness 撰寫 dsh 外掛
從空資料夾建立 dsh 外掛:掌握 package.json 必要欄位、掛載外掛的 patch 檔、實作真實工具,以及必需的兩個 hooks。
dsh 外掛的實際定義
dsh 外掛是 npm 套件。它會匯出一個 apply 函式,並附帶一個小型 YAML 檔案,告知 DeepSeek Harness 載入該外掛。無須先學習獨立的外掛 SDK。dsh 是 Cordis 應用程式,而「一切都是外掛」是字面上的意思:工具註冊表、代理程式迴圈、工作階段儲存區與 Web 伺服器,都是同一個外掛樹中的節點,而你的套件會加入這棵樹。
Cordis 是通用的組合框架,獨立開發,並多年來一直作為 Koishi 聊天機器人框架的基礎。它負責載入與卸載,也會解析外掛之間的相依性。它不了解代理程式。所有代理程式相關功能,都來自建構在其上的 harness 套件,因此下方的外掛結構看起來很小。你取得的大部分功能都是繼承而來。
外掛分成兩個部分。主機部分在 Node 中執行,註冊工具與事件監聽器,也可以提供自己的服務。瀏覽器部分在 Web UI 內執行,並註冊介面插槽。第一個外掛幾乎一定只需要主機部分,因此在有需要之前,可以將瀏覽器部分視為選用功能。
本指南是以 @deepseek-ai/dsh version 0.1.0-rc.7 為基準,使用的是 19 August 2026 的 npm latest tag。dsh 仍是 developer preview,其 README 也說明未來會有不相容變更。下方每個鍵名稱,都是根據該日期的上游文件與 repository 查閱所得。若要依賴這些名稱,請再次確認,因為 preview API 可能會在不同的 release candidate 之間重新命名欄位。若 harness 尚未執行,請先依照 在 VPS 上設定 DeepSeek Harness 與 設定 dsh API key 與 model 完成設定,再回到本節。
在打包前先載入單一測試檔案
先打包是較慢的學習方式。先載入單一檔案,確認執行環境會呼叫您的程式碼,再進行打包。
在 harness checkout 外建立資料夾,並在其中放入一個檔案。
import type { Context } from '@deepseek-ai/cordis'
export const name = 'hello-plugin'
export function apply(ctx: Context) {
console.log('[hello-plugin] plugin loaded')
}export const name 是用於在診斷資訊中標示 plugin 的中繼資料。apply 是完整的契約:Cordis 會呼叫它一次,並傳入作用域限於該 plugin 的 context。您在該 context 上註冊的所有項目,都會在 plugin 終止時由系統取消。
在該檔案旁建立 cordis.yml。
- insert:
- id: hello
name: '/absolute/path/to/scratch-plugin/hello.ts'現在使用該檔案覆蓋在最上層的方式啟動 profile。
dsh web --patch ./scratch-plugin/cordis.yml如果 dsh 不在您的 PATH 中,npx @deepseek-ai/dsh web --patch ./scratch-plugin/cordis.yml 可執行相同工作。這個 npx 路徑可能會提供快取中的較舊 release candidate,而不是本指南說明的版本。因此,如果 harness 直接拒絕已記載的 flag,請先依照dsh 安裝與版本錯誤的修正方法處理,再懷疑自己的檔案。您應該會在啟動 dsh 的終端機中看到 [hello-plugin] plugin loaded。如果沒有任何輸出,表示該列未成功解析。
name 欄位接受 npm package 名稱或檔案系統路徑,上游文件指出該路徑必須是絕對路徑。scratch plugin 沒有輸出時,首先應檢查相對的 ./hello.ts。其次是檔案副檔名。文件記載的迴圈會從 harness repository 的 clone 中以 pnpm dsh web --patch ... 執行,TypeScript 項目則透過 tsx 載入。如果您的 dsh 來自 npm,請將該列指向純 JavaScript 檔案,或先建置該檔案。
--patch 是 launcher flag,其 overlay 會在所有 bundle 以及您自己的 profile patch 之後最後套用。因此,scratch overlay 一定會覆蓋其他設定,這正是反覆修改時所需的行為。
撰寫最小但實用的工具
日誌列可證明外掛程式已載入。工具則可證明外掛程式已成為 agent 的一部分。
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'greet-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'greet',
description: 'Greet someone by name.',
parameters: {
name: { type: 'string', required: true, description: 'The name to greet' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
return `Hello, ${args.name}!`
},
}))
}export const inject = ['tools'] 是人們容易省略的那一行。Cordis 設定中的項目會並行啟動,因此項目在檔案中的位置無法保證載入順序。順序來自宣告的相依性。inject 會告訴 Cordis,在呼叫您的 apply 之前,先等待 ctx.tools 存在;若省略這一行,您的程式可能在 registry 尚未建立時執行,因而無法向其註冊。
其餘物件內容就是 model 看到的契約。parameters 是引數 schema,而 execute 會接收已依照該 schema 解析的引數。output.schema 描述 execute 傳回的值,render 則將該值轉換為 model 讀取的內容區塊。將兩者分開,介面就能顯示一種內容,而 model 讀取另一種內容。
啟動 profile,並要求 assistant 以姓名向某人問候。回覆會透過您的 execute 傳回。透過 ctx 進行註冊是可逆的,因此處置外掛程式時,工具會自動取消註冊。對於 Cordis 無法管理的資源,例如 socket 或 file handle,請呼叫 ctx.effect() 並將 disposer 傳給它。
第一個外掛實際會接觸的兩個擴充點
完整的接縫清單很長。以下兩個擴充點涵蓋幾乎所有第一個外掛的需求。
Conversation events 是持久化且具備日誌的事件流。名稱為 session/event、turn/start、turn/end、step/start、step/end、user/message、assistant/message、assistant/chunk、tool/call 和 tool/result。請掛載一般的 listener。
ctx.on('tool/call', (payload) => {
console.log('[my-plugin] tool/call', JSON.stringify(payload))
})先印出 payload 並讀取。不要從任何指南複製 payload 欄位名稱,包括本指南,因為 payload 結構是 preview API 中變動最頻繁的部分。
第二個擴充點是 waterfall。agent/pre-step、agent/request、agent/request-error 和 llm/stream,以及 tools/* 事件,都是 waterfall;waterfall listener 使用不同的簽章。它會接收 next callback,只有呼叫該 callback 後,鏈結才會繼續。
ctx.on('agent/request', async (payload, next) => {
const startedAt = Date.now()
const downstream = await next()
console.log('[my-plugin] model request took', Date.now() - startedAt, 'ms')
return downstream
})如果忘記 await next(),就不算新增 hook。你會以空操作取代 model call,agent 也會在此停止,因為對於刻意拒絕請求的 gateway plugin 而言,短路是預期行為。這個差異造成大多數第一個外掛的混淆。請先寫好 next() 呼叫,再撰寫其周圍的內容。
agent/request 封裝 model call 本身。其 payload 包含執行呼叫的 agent、目前開啟的 turn number、請求所屬的步驟,以及該 turn 的 abort signal,因此適合用於 request logger 或 rate limiter。tools/* waterfalls 在下一層使用相同的結構。tools/pre-execute 會在 dispatch 前允許、拒絕或要求核准。tools/execute 封裝 dispatch。tools/post-execute 可以取代或封鎖正規化後的結果。tools/result 只會觀察已凍結的結果。
將其封裝成其他人可安裝的套件
套件是 npm package,其 package.json 宣告會指向修補檔案的 dsh.bundle 欄位。這項宣告就是暫存檔案與可安裝套件之間的全部差異。
{
"name": "dsh-plugin-hello",
"version": "0.1.0",
"type": "module",
"main": "lib/index.js",
"files": ["lib", "cordis.patch.yml", "README.md", "LICENSE"],
"engines": { "node": "^22.19 || >=24", "dsh": ">=0.1.0-rc.6" },
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } },
"keywords": ["dsh-plugin", "deepseek-harness"],
"scripts": { "build": "tsdown", "prepare": "pnpm run build" },
"exports": {
".": { "types": "./lib/index.d.ts", "default": "./lib/index.js" },
"./cordis.patch.yml": "./cordis.patch.yml",
"./package.json": "./package.json"
}
}旁邊的 cordis.patch.yml 很簡短。
- insert:
- id: dsh-plugin-hello
name: dsh-plugin-helloname 列是套件名稱,因此這兩個字串必須相同。id 列是使用者覆寫組態時,後續層所指定的目標,因此請選擇穩定的值,且不要將它重新用於其他外掛程式。
files 必須列出 cordis.patch.yml。若省略此項,發布的 tarball 會包含指向未封裝檔案的 dsh.bundle.patch,因此套件雖然能安裝,卻不會對樹狀結構產生任何作用。
請從包含外掛程式資料夾的目錄,將它安裝到 profile。
dsh plugin --profile demo add ./dsh-plugin-hello
dsh --profile demo --dump-config
dsh --profile demodsh plugin --profile <name> 會將其餘引數轉送給該 profile 目錄中的 pnpm,因此 add 和 remove 的行為會與 pnpm 相同。使用 dsh plugin --profile demo remove dsh-plugin-hello 解除安裝。web 和 headless profile 會在首次使用時,從隨附的範本自行建立;其他 profile 名稱則必須透過 dsh plugin 建立。
為何你的列未出現在組合後的樹狀結構中
組合會從空的項目清單開始,依固定順序堆疊各層。先處理設定檔的 dsh.profile.bundles 中列出的每個 bundle,順序依清單為準。接著處理設定檔自己的 cordis.patch.yml,再處理 $DSH_HOME/cordis.patch.yml。最後處理命令列提供的任何 --patch overlay。後加入的層會依 id 取代先前的列。
設定檔位於 $DSH_HOME/profiles/<name> 下。設定檔目錄包含一個 package.json,其中有 dsh.profile manifest 及其依序排列的 bundles 清單,另外也包含使用者自己的修補檔。Bundle 名稱會先從 dsh 安裝內容解析,再從設定檔的 node_modules 解析;pnpm 會將 out of tree plugin 放在後者。
dsh --profile demo --dump-config 會在不啟動任何項目的情況下,列印完整的組合後樹狀結構。這份輸出是除錯時的判斷界線。如果找不到你的列 id,問題出在組合過程:名稱無法解析,或修補檔從未被打包。如果列存在但沒有任何作用,問題就在你的程式碼。先回答這個問題,就能省去大部分猜測。
載入錯誤實際出現的位置
在 apply 內拋出的錯誤會直接顯示。程序會因該例外結束,並提供指向您自己程式碼行的 stack trace。
解析失敗通常不會直接顯示。載入器會透過 Cordis logger 回報無法解析的模組,而不會使程序當機。上游教學也提醒,這些訊息可能在啟動時遺失,因為訊息產生時 console exporters 尚未附加。因此,路徑拼寫錯誤看起來會和已載入但未執行任何動作的外掛完全相同。這也是為何值得先執行上方的 --dump-config 檢查,再開始閱讀程式碼。
開發期間,請將 console.log 放在 apply 的第一個陳述式。若看不到該輸出,就能判斷問題發生在哪一半,而且之後刪除不會造成任何成本。在伺服器上反覆測試時,請以前景模式執行 harness,不要交給服務管理器,讓載入器輸出直接顯示在終端機,而不是寫入您還必須另外查看的 journal。
迭代時不必重新啟動所有服務
對目前的 host 部分而言,實際做法是重新啟動。Web application bundle 隨附的共用 hot module reload 功能目前已停用,檔案中也註明,待 reload lifecycle 測試完成後才會重新啟用。Client side reload chain 一直處於掛載狀態,但在 rebuild watcher 重寫 client bundles 前會維持閒置,因此對你的 Node 部分也不會產生作用。
與其追逐尚未提供的 reload,不如讓重新啟動的成本降低。將 plugin 維持在單一檔案中。使用 --patch 載入,而不是將其安裝到 profile,讓 edit 與 run 之間不必執行 build 或 pnpm。透過 ctx 註冊所有內容,避免重新啟動後留下重複的 tool 或過期的 listener。使用 ctx.effect() 配置的任何資源,都應搭配實際的 disposer,因為缺少 disposer 的常見症狀是第二次執行失敗,而第一次執行仍占用該 port。
如果你是對著伺服器上的 harness 開發,而不是在自己的 laptop 上開發,上述內容都不變,但 Web UI binding 確實有影響。port 3080 上的 loopback bind 說明頁面為何不會自行開啟,以及應如何處理。
瀏覽器端,以及應信任到什麼程度
只有在外掛程式需要自己的介面時才加入這項設定。它與 bundle 一樣,宣告在相同的 dsh 欄位中。
{
"dsh": {
"client": {
"platform": "web",
"inject": [],
"external": [],
"immediately": false
}
},
"exports": {
".": "./src/index.ts",
"./client": "./src/client/apply.ts",
"./package.json": "./package.json"
}
}"platform": "web" 為必要欄位。若套件沒有 ./client 匯出,掃描器就會擲出錯誤。因此,匯出對映是 manifest 的一部分,而不是可有可無的便利功能。用戶端進入點會接收 Cordis Context,並以用戶端執行階段型別擴充。所有註冊都必須在 apply 內透過 ctx.slots.register 完成。這裡不允許模組層級的副作用。
import type { Context } from 'cordis'
import type { DshClientContext } from '@deepseek-ai/dsh-client-runtime'
export async function apply(ctx: Context & DshClientContext) {
ctx.slots.register({ name: 'domain.entry.slot' }, MyComponent)
}開始前,請先了解兩項細節。用戶端 manifest 中的 inject 僅供文件說明使用,不負責排程。它記錄套件層級的相依性邊,而不會控制啟用順序。external 用於宣告基準設定以外的模組要求,讓這些模組在外掛程式提出要求前先完成具現化。這是 preview 中變動最快的部分。因此,請在撰寫程式碼當天閱讀 harness repository 中的 packages/client/AGENTS.md,不要等到閱讀相關指南的那一天才查看。
發布套件,並說明外掛會接觸哪些內容
將 dsh-plugin 主題加入 GitHub 儲存庫後,使用者搜尋外掛時就會在瀏覽清單中看到它。這代表你要求陌生使用者信任你的程式碼,也伴隨相應責任。這些責任正好對應我們的安裝 dsh 外掛前的審查指南要求讀者檢查的項目,因此按照這份清單撰寫,是最容易符合要求的方式。
- 鎖定相依套件版本。轉移相依套件使用 caret 版本範圍,可能讓上週仍安全的套件在本週執行不同的程式碼;這正是伺服器上的 npm 供應鏈攻擊所利用的機制。
- 讓 manifest 說明你會接觸哪些內容。你的
inject清單應誠實且可由機器讀取,摘要你使用的 harness 服務。審查者可在幾秒內讀完,並據此形成評價。 - 不要進行未說明的網路呼叫。如果工具會呼叫 API,請在 README 中列出主機名稱,並讓 endpoint 可設定。若外掛連線到從未說明的伺服器,負責審查這些內容的人員會將它從清單中移除。
- 保持
files精簡。發布整個工作目錄,可能讓遺漏的憑證檔案進入 registry。 - 為 git 安裝程式提供
preparescript,確保它不依賴僅供開發使用的設定;並在 README 中告知使用者,必須在其設定檔的pnpm-workspace.yaml中允許這項建置。 - 讓 README 的日期對應到你建置並測試所用的 release candidate。使用 preview API 的讀者需要知道你使用的是哪個版本。
若要了解完成的外掛從外部看起來是什麼樣子,請閱讀值得安裝的 dsh 外掛,並留意每份 README 在安裝前告訴你的內容。如果你曾為其他 agent 撰寫擴充功能,Claude Code 外掛的組成方式可作為有用的對照。harness 會提供即時物件圖與可逆的註冊機制。這比檔案 manifest 更強大,也因此需要承擔更多責任。
FAQ
是否需要發布到 npm 才能撰寫 dsh 外掛程式?
不需要。在 cordis.yml overlay 中提供檔案系統路徑,再透過 dsh web --patch ./scratch-plugin/cordis.yml 載入,即可在 harness 內執行自己的程式碼。路徑必須是絕對路徑。只有在其他人需要安裝外掛程式時,套件化才有必要。即使如此,也可以使用 dsh plugin --profile demo add ./my-plugin 安裝本機資料夾,測試套件化版本,而不必接觸 registry。
為什麼外掛程式已載入,但工具始終沒有出現?
先執行 dsh --profile demo --dump-config。如果輸出中沒有你的 row id,表示外掛程式根本沒有掛載,原因在組合設定,而不是程式碼。如果該 row 存在,請檢查 export const inject = ['tools']。Cordis 設定中的項目會同時啟動,因此檔案順序不會決定載入順序。若沒有這項宣告,Cordis 不會等待工具 registry,導致你的 apply 可能在 ctx.tools 尚未可用以進行註冊時執行。
cordis.yml 與 cordis.patch.yml 有何差異?
cordis.yml 是完整的項目清單。cordis.patch.yml 是套用在前者之上的層,依 id 指定 row,以插入新項目或取代既有設定。bundle 會透過 dsh.bundle.patch 指向自己的 patch 檔案,該設定位於 package.json。各層會依固定順序套用:先依 profile 列出的順序處理所有 bundle,再處理 profile 的 patch 檔案,接著是 $DSH_HOME/cordis.patch.yml,最後是任何 --patch overlay。後套用的層會覆寫先前的設定。
agent 執行時,可以熱重新載入 dsh 外掛程式嗎?
截至 0.1.0-rc.7,在 web profile 中,host 部分無法熱重新載入。該 bundle 會停用共用的 hot module reload row,檔案中並註明,待其重新載入生命週期完成測試後才會恢復。請改為設計快速重新啟動:使用單一檔案,透過 --patch 載入且不需要建置步驟;所有註冊都透過 ctx 完成,避免一次執行的內容洩漏到下一次執行。對於 Cordis 無法自行清理的資源,請使用 ctx.effect() 搭配 disposer。