DeepSeek Harness のプラグイン構成を読む

DeepSeek が dsh というエージェントハーネスを出した。README の一行目が “Everything is a Plugin” で、正直この手の宣言には身構える。プラグイン機構を名乗るものの大半は、特権的なコアがあってその周りにフックが生えているだけだからだ。

ドキュメントを一通り読んだ。コードはまだ読んでいないので、以下は docs/architecture.md と周辺ドキュメントから読み取れた範囲の話。身構えたのは半分外れた。モデルアダプタもツールレジストリもエージェントループ自体もプラグインで、config から差し替えられる。ただし代償も大きい。

Cordis

土台は Cordis という Cordiverse のフレームワークで、dsh は vendor/cordis に取り込んで使っている。dsh のために書き下ろされたものではない。リポジトリが作られたのは2022年5月で、Koishi というチャットボットフレームワークの中核を切り出したものらしい1。

枠組みの元になっているのが “A Programming Paradigm for Spatiotemporal Composability” という論文で、cordiverse/paper に置いてある。プレプリントで、自分が見たのは2026年8月13日版2。

論文の枠組みは、合成可能性を直交する2軸に割る。時間方向が、コンポーネントを外したときに共有環境への変更を完全に取り消せるか。空間方向が、コンポーネント同士の依存を宣言し、発見し、解決できるか。

この2つを effect と coeffect の対で扱う。effect が自分は何を変えたか、coeffect が自分は何を必要とするか。どちらも型システムの研究では古い言葉で、それをランタイムの機構に降ろしたのが売りらしい。context の変換それぞれに逆写像を持たせてランタイムが追い、依存のほうは満たされ具合が変わるとコンポーネントに通知が飛んで、活性化と非活性化が自動で動く。Epoch という仕組みで古い依存に噛みつかないようにしている、とある。

評価に使われたのも Koishi。四年で4000を超えるコミュニティプラグインが積み上がっていて、チャットのアダプタもデータベースドライバも管理コンソールも全部プラグインになっているらしい3。

この出自のほうが自分には効いた。プラグインが四年ぶん出たり入ったりした場所で削られてきた機構と、エージェントハーネスのために今から書く機構とでは、壊れ方の既知度が違う。

プリマーは押さえどころを5つに畳んでいて、節の名前がそのまま Cordis In Five Ideas。

  • プラグインは apply(ctx) を持つ関数か、Service のサブクラス
  • context はサービスの置き場で、各サービスは ctx.tools ctx.llm ctx.sessions のような安定したキーを取る
  • 依存は inject で宣言する。ブート順を手で並べるのではなく、必要なサービスが揃うまで待つ
  • イベントは型付きで、emit waterfall parallel serial の4通りで配る
  • 登録は可逆なエフェクトとして入れる。ctx.effect() か ctx.on() を通すので、reload と teardown で巻き戻る

5番目が肝だと思う。すべてがプラグインを名乗る系がだいたい失敗するのは、アンロードしたときに状態が残るから。プロンプトの断片、ツールスキーマ、アダプタ、リスナー、これを全部 effect 経由で入れる規約にして、disposer を返さない登録を許さない。プリマーにも “Every registration should have a disposer” と書いてある。ここを規約として押し切れているなら、config からの差し替えは実際に動く。

配り方4つのうち、拡張点として効くのは waterfall。プリマーが独立した1節を割いているのもここで、リスナーは引数と一緒に next を受け取る。next() を呼べば後続に委譲し、呼ばずに値を返せばそこで打ち切り、前後を挟めば包める。around middleware と同じ形。dsh の割り込み口は大半がこの形で開いている。

fiber と effect

ctx.effect() の実物はこう。チュートリアルの lifecycle.ts から。

import type { Context } from '@deepseek-ai/cordis'
 
export const name = 'lifecycle-demo'
 
function heartbeat(ctx: Context) {
  console.log('heartbeat plugin loading')
  ctx.effect(() => {
    const timer = setInterval(() => console.log('tick'), 200)
    return () => {
      clearInterval(timer)
      console.log('heartbeat cleaned up')
    }
  })
}
 
export function apply(ctx: Context) {
  // Mount a child plugin and keep its fiber to dispose it later.
  const fiber = ctx.plugin(heartbeat)
  // The demo timer is itself an effect: if THIS plugin is unloaded first,
  // the pending callback is cancelled instead of firing on a dead app.
  ctx.effect(() => {
    const timer = setTimeout(async () => {
      await fiber.dispose()
      console.log('disposed')
      process.exit(0)
    }, 700)
    return () => clearTimeout(timer)
  })
}

Cordis が管理していない資源、タイマーや接続や watcher を ctx.effect() で包んで disposer を返す。effect の中身はロード時に走り、返した disposer はアンロード時に走る。プラグインの寿命に紐づく資源について、disposer を自分で呼ぶことはない。ctx.on() も ctx.plugin() もサービス登録も最初から effect なので、勝手に巻き戻る。

面白いのは、上のコードで setTimeout 自体も effect にしてあるところ。このプラグインが先にアンロードされたら、待機中のコールバックは死んだアプリの上で発火せず、キャンセルされる。コメントがわざわざそう書いてある。

ctx.plugin() が返すのが fiber で、ロードされたプラグイン1インスタンスのハンドル。状態は PENDING, LOADING, ACTIVE, UNLOADING, DISPOSED と遷移する。依存が満たされないと PENDING のまま止まる。

import type { Context } from '@deepseek-ai/cordis'
 
export const name = 'needs-timer'
export const inject = ['timer']
 
export function apply(ctx: Context) {
  console.log('needs-timer loaded')
}

timer サービスが現れるまで、この fiber は PENDING で待つ。エラーにはならないし、ブート順を書く場所もない。代わりに、なぜ動かないのかが分かりにくくなる。チュートリアルはレジストリを舐めて探せと言っている。

import { FiberState, type Context } from '@deepseek-ai/cordis'
 
export const name = 'diagnose'
 
export function apply(ctx: Context) {
  setTimeout(() => {
    for (const runtime of ctx.registry.values()) {
      for (const fiber of runtime.fibers) {
        if (fiber.state === FiberState.PENDING) {
          console.log(`${fiber.name} is PENDING — a required service is missing`)
        }
      }
    }
  }, 500)
}

デバッグ手段がこれ、というのは正直しんどい。宣言的な依存解決の定番の代償で、暗黙に待たれると原因が見えない。

cordis.yml と HMR

構成は yaml 1枚で書く。

- id: greeter          # stable identity for this entry
  name: './greeter.ts'
- id: consumer
  name: './consumer.ts'
  disabled: true       # keep the entry, skip mounting it

id が安定した識別子で、ローダーはこれで差分を取り、変わったところだけ mount, unmount, 再設定する。disabled: true はエントリを残したまま mount だけ飛ばす。dsh の profile と bundle の patch が id で行を狙うのは、この仕組みをそのまま上に持ち上げたものだ。

HMR はプラグイン単位の入れ替えを、アンロードとロードに分解して実現する。アンロードが effect を解放し、ロードが依存に従うので、それ以上の仕掛けが要らない。論文の言い方だと、fiber が effect を束ねているので、モジュール差し替えは古い fiber を捨てて新しいものを作るだけになる。HMR 自体もプラグインとして積む。

- id: logger
  name: '@deepseek-ai/cordis-plugin-logger-console'
- id: timer
  name: '@deepseek-ai/cordis-plugin-timer'
- id: hmr
  name: '@deepseek-ai/cordis-plugin-hmr'
  config:
    root: ['.']
- id: hello
  name: './hello.ts'

profile と bundle

起動時のツリーは、空のリストに順序付きのレイヤを重ねて作る。bundle が配布単位で、profile がその積み方。どちらも package.json の dsh フィールドで名乗る。第1層は必ず dsh-base で、モデルアダプタからテレメトリまでの土台がここに入る。dsh-web-app がブラウザアプリを、dsh-headless がサーバなしのワンショット実行を足す。

重なる順は bundle 群、profile の cordis.patch.yml、home の同名ファイル、最後に --patch。上の層は id で行を狙って config を丸ごと差し替えるか、行を挿す。cordis.yml の差分適用と同じ機構が、そのまま配布の階層に伸びている。

自分の環境で何が積まれたかは dsh --profile web --dump-config で出せて、出てきた行はどれも patch で置き換えられる、とある。

ここが効いているのは、設定と拡張が同じものになっている点だと思う。たいていのツールは設定ファイルとプラグイン機構が別種で、設定でできることとプラグインでできることの間に段差がある。dsh は全部が config 行なので、ユーザが自分の patch で bundle の判断を上書きできて、そのために bundle を fork しなくていい。

代わりに、行の id が公開インタフェースになる。誰かが id を変えた瞬間、それを狙っていた patch は黙って効かなくなる。developer preview で破壊的変更を予告している対象には、この id 空間も入っている。

turn と step

拡張点はイベントで、どのドメインを選ぶかが変更の最初の判断になる、とドキュメントは書いている。骨だけ抜くとこう。

turn/start
  入力を claim → prompt sections + tool schemas を組む
  agent/pre-step            書き換え | 拒否
    step/start
    agent/request → llm/stream → assistant/*
    tool/call → tools/* → tool/result
    step/end
  agent/turn-stopping
turn/end

step はモデルへの1リクエストとそれが呼んだツール。turn は0個以上の step。

turn の切り方が少し変わっている。よくあるエージェントループはユーザ発話1つを turn 1つに対応させるが、dsh は入力の数で切らない。最初の入力が claim される前に開いて、何も owed でなくなった時点で閉じる。だから注入された context も、ツールが要求した追加リクエストも、turn を割らずに吸収できる。agent.inject() が新しい turn を作らずに次のリクエストへ載るのは、この定義があるから。

イベントは3ドメインに分かれる。永続する事実なら turn/* step/* user/message assistant/* tool/* のセッションイベント、動いているエージェントを覗くなら agent/*、ポリシーやアダプタを口に付けるなら fs/* tools/* telemetry/*。waterfall なのは agent/pre-step agent/request llm/stream と tools/* の3つで、あわせて6つ。agent/turn-stopping だけ serial で next() がない。

モデルに何を見せるかを決めるのは agent/pre-step。claim されたメッセージを書き換えても、まるごと拒否してもいい。

面白いのは拒否したときで、step を1つも消費しない turn がそれでもログに残る。ミドルウェアを噛ませる系はたいてい弾いた入力を捨てるので、後から見て何も起きなかったのか誰かが止めたのかが区別できない。dsh はそこを区別できる形にしてある。プラグインを積むほど、なぜ動かなかったのかが分からなくなるのが普通で、試みを残すのは地味だが効く。

ツール実行の関門

ツール呼び出しは、他人が登録できる関門を4つ通る。この後にツール自身が持つ finalizeContent が1度だけ走るが、そこは定義者のもので割り込み口ではない。

tools/pre-execute   waterfall   allow | deny | ask
ToolGuard           単調         deny のみ返せる
tools/execute       waterfall   around 包み
tools/post-execute  waterfall   結果の差し替え・拒否

tools/pre-execute が許可の判断、その後に ToolGuard が走る。ガードは理由を返せば拒否、undefined なら素通り。

ここに allow に相当する戻り値がない。だからリスナーの順序をどう並べても、一度出た拒否を許可に戻せない。ドキュメントも “listener ordering cannot turn a denial back into permission” と書いている。

この非対称が何を買っているかというと、ポリシープラグインの安全性が順序に依存しなくなる。素性の怪しいものを積んでも、最悪でも締めすぎるだけで、緩む方向には倒れない。権限まわりであってほしい性質が、型を片側に寄せただけで手に入っている。

tools/execute の wrapper はタイムアウトやリトライを巻けるが、差し替えられるのは exec.signal だけ。呼び出しの identity はフックを通しても変わらない。監査ログをミドルウェアが偽装できない、という形になっている。

モデルに見える面も明示的な allowlist で切ってある。output execute finalizeContent timeoutMs isConcurrencySafe presentCall presentResult はモデルのリクエストに出ない。除外を書き忘れて漏れる形ではなく、載せるものを列挙する形。ツール定義にホスト都合のフィールドを足しても、モデルの見え方が勝手に変わらない。

3つとも同じ方向を向いている。拒否は緩められない、identity は書き換えられない、モデルに出るものは列挙したものだけ。拡張点を大量に開けた系で秩序を保つのは、拡張点の数を絞ることではなく、開けた口から入れられないものを型で決めることだ、という判断に見える。

セッションログと不変条件

前節の型で縛る話の、いちばん大きい版がこれ。

セッションログがモデルの見るコンテキストの出どころで、deriveMessages() がそこからモデル履歴を射影する。生の assistant/chunk イベントも残していて、リプレイと UI 再現に使う。fork も resume もトランスクリプトもテレメトリも永続化も、全部この1本のストリームから導く。

そして “Model-visible means logged.” モデルのリクエストに到達するものは何であれログから再構成できなければならず、ランタイムの不変条件がそれを assert する。だからモデルに見える入力を新しく足すには、新しいセッションイベントが要る。SessionEventMap を拡張して、ログからレンダリングする。

プラグイン機構だけなら他にもある。差がつくのはこの制約のほうだと思っている。誰かがプラグインで勝手にプロンプトへ文字列を差し込んでも、ログに出ていないなら不変条件で落ちる。拡張点をたくさん開けた系が壊れるのは、状態がどこから来たか追えなくなるからで、そこに検証可能な線を1本引いてある。

反論はありうる。この不変条件のコストは、拡張のたびにイベント型を足す手間としてプラグイン作者に全部乗る。少しコンテキストを足したいだけの人には重い。実際 agent.inject() という逃げ道が用意されていて、これは次に admitted なリクエストに載る。

capability seam の3役

入ってくるものをログで縛ったうえで、差し替えの単位を決めているのが seam。dsh は差し替え可能な能力をそう呼び、3つの役で構成する。

Service Definition がインタフェースを宣言し、Service Provider が実装し、Consumer が使う。Consumer はたいていモデルから見えるツール。1つのパッケージが複数の役を兼ねてもいいが、役が1つだけなら seam ではない。能力を足すとは3役すべてを用意することだ、と言い切っている。

この言い切りが効く理由は、ctx.fs と ctx.subprocess の例で分かる。この2つのプロバイダは同じ実行世界を共有するので、両方をリモートサンドボックスに向けると Bash も PTY も LSP も一緒に移る。プロバイダをフォークしなくていい。実際 fs-local fs-sandbox fs-e2b、subprocess-local subprocess-e2b が並んでいる。

subagent の seam も同じ形で、subagent-spawn-in-process subagent-fork-in-process subagent-acp subagent-codex subagent-claude-code subagent-dsh-sdk が1つの ctx.subagents に相乗りしている。子エージェントを新しく立てるのと、別製品に turn を委譲するのが、同じインタフェースの裏に並ぶ。Codex と Claude Code をサブエージェントとして呼べるようにしているのは、なかなか厚かましくて好き。

ctx.web も分かりやすい。web-search-exa web-search-perplexity web-search-deepseek web-fetch-http が同じ seam に登録して、tool-web がモデルから見える名前を持つ。検索プロバイダを増やしてもツール名は動かない。

ツールを1つ足すだけなら、ctx.tools に register するだけで済む。

import { readFile } from 'node:fs/promises'
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
 
export const name = 'my-tool'
export const inject = ['tools']
 
export function apply(ctx: Context) {
  ctx.tools.register(defineTool({
    name: 'read_file',
    description: 'Read a file from disk.',          // what the model sees
    parameters: {
      path: { type: 'string', required: true, description: 'Absolute path' },
      limit: { type: 'number' },                     // optional by default
    },
    output: {
      schema: { type: 'string' },
      render: (_args, value) => [{ type: 'text', text: value }],
    },
    async execute(args, exec) {
      return readFile(args.path, { encoding: 'utf8', signal: exec.signal })
    },
  }))
}

inject = ['tools'] があるので、ctx.tools が生えるまでこのプラグインは待つ。ブート順を書く場所がない。

読んでいて自分が一番持ち帰りたかったのが、この3役の規約。インタフェースを切っただけでは seam と呼ばない、という線引きは厳しい。ただ、実装が1つのまま置かれたインタフェースが後になって素直に差し替わった例を、自分は見たことがない。

Bevy との違い

同じ形のものをもう1つ思い出した。ゲームエンジンの Bevy で、あれも全部プラグインで組む。App に add_plugins していくと機能が生える。並べてみたら、論文が立てた2軸の片方だけを極端に伸ばした例になっていた。

プラグインの本体は build(&self, app: &mut App) で、App を受け取って好きに書き換える。この後に ready finish cleanup が続く。cleanup は名前が disposer っぽいが違って、スケジュール開始前に1回だけ走り、build 時の一時リソースを捨てるためのもの。どれも一方向で、逆写像はない。

実行中に外す手段もない。プラグインは App を組み立てる段階で決まって、動き出したら変えられない。runtime での追加と削除を求める issue が #279 #4843 #11083 #15613 と並んでいて、どれも開いたまま。設計としても技術としても難しい、という扱いになっている。時間方向の合成は最初から諦めている。

空間方向は逆に、Cordis より強い。Bevy は依存を名前で宣言させない。システムの引数の型から読み取る。

fn move_players(
    time: Res<Time>,
    mut q: Query<(&mut Transform, &Velocity), With<Player>>,
) { /* ... */ }

この署名だけで、スケジューラは Time を読むこと、Transform を書くこと、Velocity を読むことを知る。衝突しないシステム同士は勝手に並列に走る。順序が要るところだけ .before() .after() .chain() や SystemSet で足す。しかも ScheduleBuildSettings の ambiguity_detection を上げると、同じデータを触っているのに順序を書いていない組を報告してくれる。

ここは Cordis より素直に良いと思う。Cordis の inject は必要なサービス名を並べた配列で、揃うまで待つための情報でしかない。宣言と実際に触るものが一致しているかを検査する仕組みは見当たらないし、書き忘れても起動順が運良く合えば動いてしまう。Bevy は書き忘れようがない。宣言が引数そのものだから。

ただしこの推論が成り立つのは、アクセスの語彙が閉じているから。Bevy の世界にあるのは component と resource と event で、全部1つの World の中に型付きで並んでいる。Cordis の ctx に載るのは任意のメソッドを持つ TypeScript のサービスで、そこから読み取れるものがない。dsh が Bevy 方式を採るには、ctx.tools も ctx.sessions も型付きのデータ表に作り替える必要があって、たぶん割に合わない。推論は均一なデータモデルと引き換えに手に入る。

プラグインの束ね方は似ている。DefaultPlugins や MinimalPlugins が curated な既定セットで、DefaultPlugins.build().disable::<LogPlugin>() で一部だけ抜ける。fork せずに引き算できるという点で、dsh の profile に patch を重ねる形と同じ狙い。違うのは解決される時点で、Bevy はビルド時のビルダ操作、dsh は起動時の yaml。

どちらに投資するかは、プロセスが死んでいいかどうかで決まると思う。ゲームは落として立ち上げ直せる。ホットリロードは開発中の便利機能で、出荷後の要件ではない。Koishi はそうはいかない。4000個のプラグインを抱えて動き続けるチャットボットは、その場で載せ替えるしかない。dsh は Koishi 側に立っていて、セッションを切らずにモデルアダプタを差し替えたい。そこで時間方向に金を払う判断になる。

Extism との違い

Bevy が空間方向だけを伸ばした例なら、Extism は時間方向だけが勝手に片付いている例。こちらも並べた。同じ言葉で呼ばれているだけで、解いている問題が直交していた。

Extism はプラグインを WebAssembly のモジュールとして扱う。ホストは各言語の Host SDK で読み込み、プラグイン作者は PDK でビルドして .wasm を吐く。両者は線形メモリで隔てられていて、やりとりはシリアライズしたバイト列。プラグインから呼べるのは、ホストが Host Function として明示的に注入した関数だけ。引数の型は I32 I64 F32 F64 V128 で、実際にはメモリのオフセットを渡し合う。マニフェストに allowed_hosts allowed_paths memory.max_pages を書けば、HTTP の宛先、見えるファイル、使えるメモリを絞れる。

Cordis は隔離しない。プラグインは同じプロセスで同じ TypeScript の型を共有し、ctx.tools のようなキーで生のオブジェクトを渡し合う。権限の機構はフレームワークに存在しない。dsh はそれを Cordis の外、ctx.sandbox と ctx.approval という別の seam で解いている。

ExtismCordis
境界WASM の線形メモリなし。同一プロセス
受け渡しシリアライズしたバイト列オブジェクト参照
言語PDK のある言語なら何でもTypeScript
権限manifest で宣言機構なし
拡張点ホストが決めた関数ctx に生やすサービス
依存解決なしinject と fiber の PENDING
取り外しインスタンスごと破棄effect の逆写像

並べて気づいたことがある。Extism が時間方向の合成をほとんど気にしなくていいのは、隔離しているからだ。プラグインはホストの状態に触れないので、インスタンスを捨てれば線形メモリごと消え、取り消すべきものが残らない。隔離すれば取り消しは自明になる。

Cordis は共有を選んだ。だから取り消しが問題として立ち上がり、ランタイムで解く必要が出た。effect の逆写像も fiber の状態機械も、共有を選んだ代金だと思っている。論文が時間と空間を直交する2軸として立てたのは、この代金を払うと決めた側からしか見えない構図で、Extism は最初から時間軸を潰している。

どちらが良いかは、何を差し替えたいかで決まる。dsh がやりたいのはエージェントループ自体やモデルアダプタの差し替えで、これは WASM 越しのバイト列だと無理がある。ストリーミングのチャンクを跨ぐし、AsyncIterable も async 関数も AbortSignal も渡す。ツール定義の execute(args, exec) が exec.signal を受け取るあたりが典型で、境界をシリアライズで切るとキャンセルの伝播だけで一仕事になる。

逆に、素性の分からない第三者のプラグインをマーケットから入れる方向に dsh が舵を切るなら、Cordis だけでは足りない。プラグインは同じプロセスで process.env を読めるし、ctx に生えている他人のサービスも触れる。README は dsh-plugin トピックを付けて配布しろと言っているが、いま入れるものはコードを読んでからにする。

気になったところ

docs/capability-seams.md のテーブルを数えたら、ctx.* のキーが56あった。ctx.spillStore ctx.tokenMeter ctx.sessionProjectionCache ctx.typertGateway あたりになると、名前から役割が推測できない。どこを触れば何が変わるかを掴むまでの距離が長い。

docs/architecture.md 自身が冒頭でこう書いている。

We recommend using an agent to explore the codebase and understand its architecture.

エージェントハーネスのドキュメントが、読むのにエージェントを使えと言っている。自己言及として面白いが、人間が読み切れる規模を超えたと認めているようにも取れる。

あと developer preview で、README が大文字で “THERE WILL BE COMPATIBILITY-BREAKING CHANGES.” と書いている。プラグイン作者が掴む面は、56個のサービスキーとイベント名と config 行の id という3層あって、いまはどれも動く。1層ずつ固まっていくならまだしも、同時に動かれると自分の patch が効かなくなった原因を探すところから始まる。いま本気でプラグインを書くのは微妙で、しばらくは読んで学ぶ対象だと思う。

持ち帰り

自分がハーネスを書くなら真似したいのは2つ。

seam の3役を揃えないと能力追加とみなさない、という規約。インタフェースだけ切って実装が1つしかない状態を seam と呼ばないのは厳しいが、後から差し替えるときに効く。

もう1つはログを唯一の入力にする不変条件。モデルに何が渡ったか分からなくなる問題は、拡張点を増やすほど悪化する。assert で縛るのはコストに見合うと思う。

逆に真似しないのは、サービスキーを増やす方向。56個のキーは dsh の規模だから成立している。

その前に決めることが1つある。隔離するのか共有するのか。Extism を選べば権限と多言語が手に入って取り消しは自明になり、Cordis を選べば型と参照をそのまま渡せる代わりに逆写像を全登録で払う。後から乗り換えられる種類の選択ではない。

そしてその判断の前に、プロセスを落としていいかを決めておく。落としていいなら Bevy のように時間方向を捨てて、空間方向の推論に全部注ぎ込むほうが得だと思う。逆写像は落とせない側だけが払う税金で、払う理由がないのに払っている実装をときどき見る。

参考

Footnotes

  1. Koishi の作者いわく、Cordis はラテン語の心臓から来ていて、Koishi の全部がそこから始まる、とのこと。ちなみに作者は Shigma、本名 Yifan Shi、プロジェクト名 Koishi と、し の音で揃えているそうだ。 ↩

  2. DeepSeek と大学の共著だという話をいくつかのニュースサイトが書いているが、cordiverse/paper の README に著者名も所属も出ていない。一次情報で裏が取れなかったので保留にしておく。 ↩

  3. 論文の PDF は読んでおらず、この数字は解説記事からの孫引き。 ↩