DeepSeek Harness 実践:差し替え可能な能力を作成する

前の章では三役割の概念を説明しました。この記事では公式チュートリアルに従ってそれをコードに書きます。

myCap という能力を実装します:テキストを入力すると、すべて大文字で出力します。一目で理解できるほど小さい一方、Definition、Provider、Consumer の3つのパッケージを完全にカバーします。

3つのステップ:Service Definition(抽象クラス + 型)→ Service Provider(実装サブクラス)→ Consumer(defineTool)。

最後に cordis.yml で Provider と Consumer を組み合わせてロードします。


ステップ1:Service Definition を書く

Service Definition は能力自体を宣言します:サービスが何と呼ばれるか、どう呼び出すか、リクエストと結果の型が何かを。

実装ロジックは一切含まず、抽象メソッド1つとインターフェース2つだけを持ちます。

抽象クラス MyCapService は Service を継承し、次の方法でsuper(ctx, 'myCap')名前付きサービスとして登録します。

declare module の宣言マージにより ctx.myCap に型が与えられます。このテクニックは第14回で説明しました。

例

// ファイルパス:packages/my-cap/my-cap/src/index.ts
import { Service, type Context } from '@deepseek-ai/cordis'

// 宣言マージ:ctx.myCap に TypeScript での型ヒントを持たせる
declare module '@deepseek-ai/cordis' {
  interface Context {
    myCap: MyCapService
  }
}

// 抽象クラス:Definition パッケージは契約のみを宣言し、実装は書かない
export abstract class MyCapService extends Service {
  constructor(ctx: Context) {
    super(ctx, 'myCap') // 名前付きサービス ctx.myCap として登録
  }

  /** Execute the capability. */
  abstract execute(request: MyCapRequest): Promise<MyCapResult>
}

// リクエスト型:呼び出し側は input を提供する必要がある
export interface MyCapRequest {
  input: string
}

// 結果型:能力は output を返す
export interface MyCapResult {
  output: string
}

abstract execute が唯一の抽象メソッドであり、Provider はそれを実装する必要があります。

MyCapRequest は input フィールドを持ち、MyCapResult は output フィールドを持ちます。

リクエストと結果の型は Definition パッケージが所有し、Provider と Consumer はどちらもそこからインポートします。


ステップ2:Service Provider を書く

Service Provider は抽象クラスを継承し、実際の振る舞いを実装します。

MyCapLocal は input を大文字に変換して返します。

export const name でプラグイン名を宣言し、apply 内で ctx.plugin(MyCapLocal) によってサービス実装を登録します。

例

// ファイルパス:packages/my-cap/my-cap-local/src/index.ts
import type { Context } from '@deepseek-ai/cordis'
import { MyCapService, type MyCapRequest, type MyCapResult } from '@deepseek-ai/dsh-my-cap'

// 実装クラス:Definition パッケージのみに依存
class MyCapLocal extends MyCapService {
  async execute(request: MyCapRequest): Promise<MyCapResult> {
    // Local provider behavior.
    return { output: request.input.toUpperCase() }
  }
}

export const name = 'my-cap-local'

export function apply(ctx: Context) {
  // 実装クラスをプラグインとしてロードし、ctx.myCap の実際のサービスとして登録
  ctx.plugin(MyCapLocal)
}

Provider は Definition パッケージ内の抽象クラスと型のみに依存します。

モデルがどう呼び出すかも、ツールの形も気にしません。

将来、実装を変えたい場合、例えばリモートサーバー上で実行する場合、Provider のサブクラスをもう1つ書くだけですみます。


ステップ3:消費側 Consumer を書く

Consumer は能力をモデルが呼び出せるツールとしてラップします。

inject でツールと myCap への依存を宣言し、この2つのサービスが準備できてから apply を実行することを保証します。

defineTool はツールのスキーマを宣言します。モデルは my_cap ツールを見ると、parameters に従って引数を渡します。

execute 内で ctx.myCap.execute を呼び出し、結果をツール出力として返します。

例

// ファイルパス:packages/my-cap/tool-my-cap/src/index.ts
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'

export const name = 'tool-my-cap'
// 依存宣言:tools は登録エントリを提供し、myCap はサービス実装を提供する
export const inject = ['tools', 'myCap']

export function apply(ctx: Context) {
  ctx.tools.register(defineTool({
    name: 'my_cap', // モデル向けのツール名
    description: 'Execute my capability.',
    // パラメータスキーマ:モデルはこれに従ってパラメータを生成する
    parameters: {
      input: { type: 'string', required: true },
    },
    // 出力スキーマとレンダリング方法
    output: {
      schema: { type: 'string' },
      render: (_args, value) => [{ type: 'text', text: value }],
    },
    // 実際の実行:能力サービスを呼び出す
    async execute(args) {
      const result = await ctx.myCap.execute({ input: args.input })
      return result.output
    },
  }))
}

モデルは ctx.myCap を直接認識せず、my_cap ツールのみを認識します。

Consumer は両者の橋渡しであり、結果をモデルが見えるテキストにレンダリングする役割も担います。


myCap の三役割構造とデータフロー

3つのパッケージを組み合わせると、データはモデルから出発し、Consumer、Definition を経由して Provider に到達します。

myCap 三角色结构与数据流图

モデルは最初に my_cap ツールを呼び出します。このステップは Consumer 層でのみ発生します。

Consumer はパラメータを MyCapRequest にラップし、ctx.myCap.execute(request) に渡します。

Definition は呼び出しを現在ロードされている Provider 実装に委譲します。

Provider は結果を計算した後、元の経路で MyCapResult を Consumer に返します。

Consumer は output をテキストにレンダリングし、ツール結果としてモデルに渡します。

一連のチェーンの中で Provider が置き換えられても、Consumer とモデルはどちらも感知しません。


cordis.yml で組み合わせる

3つのパッケージを書き終えただけでは部品にすぎません。cordis.yml で Provider と Consumer を一緒にロードする必要があります。

Provider をロードして初めて ctx.myCap に実装ができ、Consumer をロードして初めてモデルが my_cap ツールを使えるようになります。

例

# ファイルパス:cordis.yml
# 最初に Provider をロードし、ctx.myCap に実装を持たせる
- name
: '@deepseek-ai/dsh-my-cap-local'

# 次に Consumer をロードし、モデルが my_cap ツールを使えるようにする
- name
: '@deepseek-ai/dsh-tool-my-cap'

ロード順序について、Cordis は依存関係に基づいて自動的に並べ替えるため、Provider と Consumer のどちらが先でも重要ではありません。

別の Provider に変更したい場合は、最初の行だけを変更し、Consumer はそのままにします。


設計のポイント

公式ドキュメントには3つの設計ポイントがあります。能力を書く前に覚えておきましょう。

1つ目、予防的な分割をしない。

役割が独立して進化する必要がある場合にのみ、それらを異なるパッケージに入れます。

単純なツールプラグインは分割する必要がなく、1つのパッケージが複数の役割を担うことは完全に妥当です。

2つ目、Service Definition が Request/Result 型を所有する。

Provider と Consumer は Definition パッケージのみに依存するため、リクエストと結果の型は Definition が定義しなければなりません。

3つ目、暗黙的よりも明示的。

実装は既定値を明示的な resolve(request) ステップで処理すべきであり、run() 内で ?? default を隠すべきではありません。

Bash seam がその例です:ShellExecRequest の workdir と timeoutMs は省略可能であり、ツール層は最初に ctx.shell.resolve(request) を呼び出して、すべてのフィールドが必須の ShellExecSpec を取得してから、それを run() に渡します。

run() 内で静かに既定値を補完してはいけません。最初に resolve してから実行することで、境界が明確になります。


まとめと自己テスト

一言でまとめると:差し替え可能な能力を書く = Definition 抽象クラス + Provider サブクラス + Consumer ツール + cordis.yml 1つ。

自己テスト問題1:MyCapRequest と MyCapResult はどのパッケージに定義すべきでしょうか?

自測問題二:Provider と Consumer の間に依存関係はありますか?

自測問題三:なぜシンプルなツールプラグインは予防的な分割を推奨されないのか?

その他の拡張機能