DeepSeek Harness(以下 dsh と略す)で Agent 開発を行う際、「モデルが何をできるか」を本当に決めるのは、プロンプトをどれだけ凝ったものにするかではなく、ツールをきちんと定義できているかどうかである。ツール定義の中で最も過小評価されがちな三つの要素は、パラメータ schema、承認判断、そしてツールパイプラインである。パラメータ schema はモデルが呼び出しパラメータを正しく生成できるかどうかを決め、承認はこの呼び出しを通過させるべきか遮断すべきかを決め、パイプラインは「モデルが一度呼び出しを発行してから」「結果がモデルに戻るまで」にどのような関門を通り、各関門で誰が決定権を持つのかを決める。本記事は前後二部構成のうちの第 1 部として、まず土台を固める。defineTool を使ってゼロから最初の実行可能なツール greet を書き、name、description、parameters、output.schema、output.render、execute の責務境界をフィールドごとに分解する。その後 dsh のツール実行パイプラインに入り、tools/pre-execute → 単調ガード → tools/execute → tools/post-execute → finalizeContent → tools/result という六段の固定順序を一度に解説し、waterfall 型のイベントディスパッチにおける next() と短絡リターンの違いを説明する。読み終えれば、あなたは具体的な問いに答えられるはずだ。モデルが一度ツール呼び出しを発行した後、その中間で一体何が起きているのか、そして自分はこのチェーンのどの環に介入できるのか。
ツールとは関数である:defineTool の name/description/parameters 三点セットはどのようにモデルに消費されるか
まず最も素朴で最も正確な認識を確立しよう。ツールとは、明確に記述された関数である。それには名称があり、説明があり、パラメータがあり、出力形式がある。この言葉は無意味に聞こえるかもしれないが、ツールの位置づけを明確にしている——ツールは「フレームワークに見せるプラグインメタ情報」ではなく、「モデルに見せる関数シグネチャ」である。dsh では、モデルが返答を生成する際にあなたが登録したツール定義を読み、その情報に基づいて呼び出すかどうか、どれを呼び出すか、どんなパラメータを渡すかを決める。したがってツール定義の読者は実は二組いる。一組はランタイムであり、定義を使って検証とディスパッチを行う。もう一組はモデルであり、定義を使って意思決定を行う。defineTool の設計目標は、同一の定義でこの二組の読者の要求を同時に満たすことにある。
defineTool はオブジェクトを受け取り、このオブジェクトがツールの全情報を記述する。最も核心的な三つのフィールドは name、description、parameters である。それらは一見ありふれているが、それぞれが担う情報量と失敗モードは全く異なり、一つずつ分解する価値がある。
name は呼び出しの一意な識別子であり、モデルが呼び出しを発行する際に使用する文字列でもある。その情報量は最小だが、一意性と可読性への要求は最も高い。エンジニアリング上、三点に注意すべきである。第一に、名称は同一レジストリ内で一意でなければならず、重複はディスパッチロジックを直接破壊する。なぜならモデルが返す呼び出しには名前しかなく、他の身元情報がないからである。第二に、名称は可読な動詞目的語構造またはドメイン用語であるべきで、内部略語であってはならない。例えば greet は g1 より良く、fs_write は w より良い。モデルはツールを選択する際に名前による意味的マッチングに依存するため、曖昧な名前は誤呼び出し率を著しく高める。第三に、名前の大文字小文字と区切りスタイルは全体で統一すべきであり、greet、Greet、greet_tool といったスタイルを混用すると、ツール数が増えた後にモデルの選択行動が不安定になる。
description は「このツールをいつ使うか」を自然言語で説明するもので、三種の神器の中で情報密度が最も高く、最も時間をかけて磨くべきフィールドです。素材の例には 'Greet someone by name.' と書かれており、一文で動作と対象を明確に説明しています。しかし実際のプロジェクトでは、description はさらに多くの役割を担うべきです。このツールが何をするか、何をしないか、どのような場面で優先的に選ぶべきか、そしてどのようなエッジケースを担当しないかを説明します。その理由は、モデルのツール選択がほぼ完全に name + description のセマンティックマッチングに基づいて行われるためであり、parameters は「このツールを選ぶかどうか」よりも「どうパラメータを埋めるか」に影響します。よくある落とし穴は、description が曖昧すぎることです。例えば「データを照会する」とだけ書くと、モデルが3つの異なるクエリツールの間で何度も行き来してしまいます。解決策は、各 description に「X の場面で使用し、Y の場面では使用しない」といった区別的な文言を明確に書き、ツール間の境界を明示化することです。
parameters は入力パラメータの schema であり、defineTool はこれに基づいて args を推論し検証します。これは三種の神器の中で唯一、モデルとランタイムの両方に同時に向けたフィールドです。モデルはこれを見て各パラメータの型と意味を理解し、ランタイムはこれを見てパラメータ検証と型推論を行います。その設計要件は「型付き定義」、つまり各パラメータに独立した型と説明を与えることであり、自由形式のオブジェクトを投げるのではありません。次のセクションでは parameters の詳細を専門的に展開します。
3つのフィールドをまとめて見ると、それらの役割分担は1つの表にまとめられます:
| フィールド | 主な読者 | 担う情報量 | よくある失敗モード |
|---|---|---|---|
| name | ランタイム + モデル | 一意の識別子、呼び出しエントリ名 | 名前の重複衝突、命名の曖昧さによる誤選択 |
| description | モデルが主 | 適用場面、能力の境界、優先度 | 曖昧すぎて、複数のツール間で区別できない |
| parameters | ランタイム + モデル | パラメータの型、必須性、パラメータの意味 | 自由形式のオブジェクトで、検証が形骸化している |
この表は何度も見返す価値があります。「モデルがツールを乱用する」という多くの問題の根本原因は、モデルの能力ではなく、これら3つのフィールドの情報量の供給不足にあります。ツール定義は本質的にモデルに向けて書かれた API ドキュメントであり、ドキュメントの出来が悪ければ、呼び出しが乱れるのは当然です。
ctx.tools.register と inject: ['tools']:greet をツールレジストリに登録する最小前提
定義が書けたら、次のステップはそれを dsh のツールレジストリに登録することです。dsh では、ツールは ctx.tools.register を通じて登録されます。ただしここには前提条件があります:あなたの Plugin が tools サービスへの依存を宣言していなければなりません。そうでなければ ctx.tools という名前空間がそもそも存在せず、register の呼び出しは直接失敗します。
素材での書き方は次のとおりです:
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'greet-tool'
// 需要 tools 服务:注册工具的前提
export const inject = ['tools']
export function apply(ctx: Context) {
// 注册一个名为 greet 的工具
ctx.tools.register(defineTool({
// ...
}))
}このコードには必ず理解すべき 3 つの仕組みがあります。
1 つ目は inject です。export const inject = ['tools'] は、この Plugin が tools という名前のサービスに依存していることを宣言しています。Cordis のような Plugin フレームワークの依存性注入は明示的です。inject を宣言してはじめて、フレームワークは apply が呼び出される前に tools サービスを準備して ctx に紐付けます。宣言がなければ、ctx.tools は undefined になります。これにはエンジニアリング上の直接的な帰結があります——依存を宣言していない Plugin の中でこっそりツールを登録することは不可能であり、インフラストラクチャのレベルでこの経路は塞がれています。Plugin を公開する際に inject を書き忘れると、最もよくあるエラーは ctx.tools が空であることによる TypeError で、修正方法はこの 1 行の宣言を補うことです。
2 つ目は apply(ctx) です。export function apply(ctx: Context) は Plugin が有効化されたときのエントリーポイントです。登録処理をモジュールのトップレベルではなく apply の内部に置く理由は、トップレベルの副作用はフレームワークがサービスの組み立てを完了するよりも早いタイミングで実行され、その時点では ctx にまだ tools サービスがなく、登録は必ず失敗するからです。ctx に依存するすべての操作を apply の中に入れることは、この種のフレームワークにおいて最も基本的かつ最も重要な規律です。登録位置が登録タイミングを決め、登録タイミングがサービスがすでに準備できているかどうかを決めます。
3 つ目は 登録の戻り値のセマンティクスです。ctx.tools.register は、defineTool によってすでにラップされたツール定義を受け取り、それをレジストリに登録します。呼び出しの形は ctx.tools.register(defineTool({ ... })) であることに注意してください——defineTool はラップ/構築であり、register は登録です。この 2 層構造の利点は、defineTool が生成するオブジェクトが純粋で再利用可能な定義であり、登録動作そのものは別の事柄であり、両者が分離されていることです。エンジニアリング上は、定義を単独で 1 つのファイルに抽出してエクスポートし、複数の apply で条件に応じて登録したり、テストで同じ定義に対して重複登録の境界テストを行ったりできます。
もう一点注意すべきことがある:export const name = 'greet-tool' の name はプラグイン名であり、ツール名ではない。ツール名は defineTool オブジェクト内の name: 'greet' である。両者の名前空間はまったく異なり、初心者はプラグイン名とツール名を混同しやすく、デバッグ時にログに現れる greet-tool と greet という2つの文字列を前に呆然とすることになる。プラグイン名はフレームワークがこのプラグインを識別・管理するために使われ、ツール名はモデルが呼び出しを発起するために使われる。命名時には両者のセマンティクスを明確に保ち、互いに衝突しないようにしなければならない。
parameters は入力パラメータの schema:defineTool はこれに基づいて args をどのように推論・検証するか
parameters は defineTool の中で最も「重い」フィールドである。なぜならそれは型付き定義であり、defineTool はこれに基づいて args の型を推論し、実行時に検証を行うからだ。素材の例は次のとおり:
parameters: {
name: { type: 'string', required: true, description: 'The name to greet' },
}フィールドごとに見ていこう:type はパラメータの型であり、ここでは string と宣言されているので、このパラメータ値は文字列でなければならない;required は必須を表す。true の場合、モデルはこのパラメータを必ず提供しなければならず、そうでなければ呼び出しは検証を通過できない;description はパラメータの説明であり、その読者は主にモデルである——モデルはこの一文によってこのパラメータに何を入れるべきかを理解する。この3つが組み合わさることで、完全なパラメータ契約が構成される:型は検証を通過できるかどうかを決め、必須性は省略が合法かどうかを決め、説明はモデルが何を埋めるかを決める。
ここで最も重要な一文は:「defineTool は args を推論し検証する」である。この一文には2つの意味が含まれており、必ず区別しなければならない。
第一の層は推論であり、開発期に発生する(TypeScript にとっては型レベルである)。defineTool は parameters の宣言から execute 内の args の静的型を推論するので、args.name を書くときにエディタが補完を提示し、フィールド名を間違えると即座に赤く表示される。これはパラメータ定義を単一の真実の源(single source of truth)として扱う典型的な手法である:parameters を一度書くだけで、型はそこから推論され、interface をもう一度手書きして両者がずれないことを祈る必要はない。多くのチームはツール定義において「手書き interface + 手書き検証関数」という二重メンテナンス方式を採用しており、最終的には型は required と言い、検証は optional と言うといった不整合が必ず生じる。
第二層は検証であり、実行時に行われる。モデルが出力する呼び出しパラメータは本質的に信頼できない入力である——モデルはフィールドを欠落させたり、誤った型を渡したり、無関係なフィールドを余分に詰め込んだりする可能性が十分にある。defineTool は parameters に基づいて検証を行い、不合格な呼び出しを execute の外で遮断する。これはつまり、execute 内の args はすでに検証済みであるということであり、これは非常に重要なエンジニアリング上の保証である。execute 内では宣言どおりに args.name を安心して使用でき、各ツール内部で「if (!args.name) return エラー」といった防御的コードを繰り返し書く必要はない。
パラメータ schema については、実践で押さえておくべき落とし穴がいくつかある。
- 自由形式のオブジェクトでパラメータを表現してはならない。parameters を「任意のオブジェクト」として書くと検証が形骸化し、モデルもパラメータ入力の制約を失い、自由なテキストを大量に詰め込む傾向になる。型付き定義の価値はまさに制約にあり、制約を放棄することはこの定義自体を放棄することに等しい。
- description には「何であるか」ではなく「何を記入するか」を書く。「名前」とだけ書くのでは不十分であり、モデルはそれが「ユーザーのフルネーム」なのか「ユーザーのニックネーム」なのか「システム ID」なのかを知る必要がある。粒度が少し違うだけで、記入される値は大きく異なる。
- パラメータはポリシー層に入る前にすでに凍結されている。この点は素材の第二段落で明確に述べられている。パラメータは書き換え不可であり、それは履歴記録、監査、UI、実行が一貫していなければならないからである。つまり、後続のパイプライン段階ではパラメータを参照できるが、改ざんはできない。パラメータの書き換えを伴う要件は、呼び出しが発生する前に解決しなければならず、パイプライン内で「ついでに少し書き換える」ことを期待してはならない。
- 必須性はモデルに対するハード制約である。required が true のパラメータが欠落している場合、その呼び出しは execute に到達しない。したがってツールを設計する際には、「必ずモデルが提供しなければならない情報」と「ツール自身が導出できるデフォルト値」を明確に区別する必要がある。前者には required を設定し、後者には required を設定せず execute 内でデフォルト値を補完する。
output.schema と output.render の役割分担:規範値、表示値、モデル可視コンテンツ
多くの人はツールを書くときに入力パラメータだけを気にし、出力パラメータを無視する。その結果、ツールは動作するが使えないものになる。dsh はツール定義において output のセクションを專門に設けて出力パラメータを制約しており、output.schema と output.render の二つに分かれている。素材の例は以下のとおりである:
output: {
// 規範値類型:execute の戻り値
schema: { type: 'string' },
// render:規範値をモデル向けコンテンツに変換する
render: (_args, value) => [{ type: 'text', text: value }],
},output.schema が宣言するのは「規範値」(canonical value)の型であり、つまり execute の戻り値の型契約です。例では string と宣言されており、execute が文字列を返すことを制約しています。規範値はツール出力体系全体のアンカーです。それは宣言され、検証され、凍結される値であり、以降のすべての段階がそれを基準とします。たとえるなら、規範値はデータベースにおける強く型付けされたフィールドに相当し、render で出力される内容は表示向けのビューに相当します。この二つの概念を区別することが、パイプライン後半(特に post-execute の二種類の accept 判断)を理解する前提となります。
output.render の責務は「規範値をモデル向けの内容に変換すること」です。それは (_args, value) を受け取り、ここで value は execute が返した規範値であり、返すのは一連のコンテンツブロックで、例では [{ type: 'text', text: value }]、つまりプレーンテキストブロックです。ここでの設計意図は明確です:
- 規範値はプログラム向け:型が明確で、検証可能で、上位ロジックが安全に消費でき、監査、指標、後続の自動処理に適しています。
- レンダリング内容はモデル向け:モデルが最終的に見るのは render の産物であり、規範値そのものではありません。テキストブロックは人間が読める表現、単位、文脈を伴うことができ、規範値は純粋に保つことができます。
この分離は非常に実用的な利点をもたらします。同じツールで、規範値のレベルでは厳密な構造(例えば数値を返す)を保ちながら、render のレベルではそれを自然言語の一文(例えば「現在の温度は 26 摂氏です」)に包み、モデルがよりよく理解できるようにできます。もしこの二つを混ぜてしまえば——execute がモデルに見せる文字列を直接返す——プログラム側はその結果を確実に消費できず、統計を取ることも、アサーションを行うことも、二次処理を行うことも、自然言語を解析する悪夢になります。
パイプラインからの警告を特に覚えておく必要があります:内容の置換は表示戦略であり、秘匿戦略ではない。これは、render で何らかの内容を処理したとしても、それはモデルが見る見た目を変えただけであり、データを本当に隠したことにはならないという意味です。プログラム的な値を隠すには、値そのものを置き換えるか、結果を直接阻止しなければならず、この点は post-execute の判断表で非常に明確に示されます。
execute の戻り値契約:なぜ greet は文字列を一つだけ返すのか
execute はツールの実装であり、つまり実際にロジックを実行する場所です。素材内の実装は:
async execute(args) {
// 返回规范值,这里是一个字符串
return `Hello, ${args.name}!`
},わずか一行ですが、execute の三つの契約を正確に示しています。
第一の契約:execute が返すのは規範値であり、最終的な表示内容ではない。greet が返すのは Hello, ${args.name}! という文字列であり、これは output.schema で宣言された string 型に準拠していなければならない。モデルが最終的に何を見るかは、render の役割である。これこそが「実装層と宣言層の境界」である——実装層は宣言層の制約に適合する規範値を生成することだけを担い、どのように提示するかは実装層が決めることではない。この二つの層を混同することは、ツール定義において最もよくある構造的誤りである。
第二の契約:args はすでに検証済みである。parameters が name: { type: 'string', required: true } を宣言しているため、execute 内の args.name は必ず存在し、必ず文字列である。これが、実際のコードベースを grep しても、execute 内で args に対する重複した検証をほとんど見かけない理由である——それらの検証はパイプラインのより早い段階で完了している。逆に言えば、execute 内に「パラメータが欠落している場合はどうするか」という分岐を大量に書いているなら、おそらく parameters が適切に定義されていないか、ビジネス上の事前検証とパラメータ検証を混同しているのである。ビジネスロジック上の検証(例えば「この名前がデータベースに存在するかどうか」)はもちろん execute 内に書くべきだが、それはパラメータ検証とは別物である。
第三の契約:execute は非同期にできる。サンプルでは async を使用し、Promise を返しており、これはツール内部の IO 操作に十分な余地を残している。ツールの本質は「Agent が作業を行うための関数」であり、現実のほとんどの作業には IO が必要である:ファイルの読み取り、リクエストの送信、データベースへの問い合わせ。execute を非同期可能に設計することは、ツールを真に実用化するための前提である。
三つの契約を合わせて見ると、execute の位置づけは非常に明確である:それはすでに正当なパラメータを与えられ、ただ作業に専念すればよい関数であり、型が正しい規範値を生成する。パラメータの正当性は schema が担い、表示形態は render が担い、execute はその中間にある真のビジネスロジックのみを担う。この責務分離がもたらすエンジニアリング上の利点は、ツールの実装が極めて簡潔になり、ツールの制約が極めて強くなることである。
scratch-plugin/src/my-plugin.ts を最初の実行可能なツールに置き換える
理論は説明したので、実際の作業は一つだけである:scratch-plugin/src/my-plugin.ts の内容を以下の完全なコードに置き換える。これはそのまま実行可能な最小のツールプラグインである。
// ファイルパス:scratch-plugin/src/my-plugin.ts
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'greet-tool'
// tools サービスが必要:ツールを登録する前提
export const inject = ['tools']
export function apply(ctx: Context) {
// greet という名前のツールを登録する
ctx.tools.register(defineTool({
// ツール名:モデルはこの名前で呼び出しを行う
name: 'greet',
// ツール説明:モデルにいつ使うかを伝える
description: 'Greet someone by name.',
// 入力パラメータ schema:defineTool が args を推論し検証する
parameters: {
name: { type: 'string', required: true, description: 'The name to greet' },
},
// 出力定義
output: {
// 規範値の型:execute の戻り値
schema: { type: 'string' },
// render:規範値をモデル向けの内容に変換する
render: (_args, value) => [{ type: 'text', text: value }],
},
// ツール実装:実際にロジックを実行する
async execute(args) {
// 規範値を返す。ここでは文字列
return `Hello, ${args.name}!`
},
}))
}実装の手順はチェックリストとして整理できる。このとおりに進めれば漏れはない:
- ファイルの特定:
scratch-plugin/src/my-plugin.tsを開き、既存の内容をまるごと置き換える。 - import を書く:
@deepseek-ai/cordisからContext型をインポートし(型注釈にのみ使うので import type である点に注意)、@deepseek-ai/dsh-toolsからdefineToolをインポートする。 - name をエクスポート:
export const name = 'greet-tool'。これはプラグイン名で、フレームワークがプラグインを識別するために使う。 - inject を宣言:
export const inject = ['tools']。これが ctx.tools を取得できる前提条件となる。 - apply をエクスポート:apply の内部で
ctx.tools.register(defineTool({ ... }))を呼び出し、完全なツール定義をレジストリに登録する。 - 六項目の定義をすべて埋める:name、description、parameters、output.schema、output.render、execute。一つも省略してはならない。どれか一つでも欠けると、ツールが不可視になるか、出力が使えなくなる。
このコードのもう一つの価値は、本節のこれまでのすべての概念を一本の線につなげている点にある。inject と apply が「マウント」を解決し、name/description/parameters が「モデルがどう選び、どう埋めるか」を解決し、output.schema/output.render が「結果をどうモデルに見せるか」を解決し、execute が「実際に働く」を解決する。わずか30行のファイルが、完全なツールの最小閉環となっている。
tools/pre-execute → 単調ガード → tools/execute → tools/post-execute → finalizeContent → tools/result:一度の呼び出しにおける固定順序
ここからは本節の後半に入ります。モデルが一度ツール呼び出しを発行してから、ツールが実際に実行され、結果がモデルに戻るまで、その間に一体どのような段階を経るのでしょうか。答えは「関数を呼び出すだけ」那么简单ではありません。そこには固定された順序を持つパイプラインが存在します。公式ドキュメントでは、その順序を次のように要約しています:tools/pre-execute → 単調ガード → tools/execute → tools/post-execute → finalizeContent → tools/result。
これら六つの段階にはそれぞれ明確な責務の境界があり、次のように理解できます:
| 段階 | タイプ | 責務 | この呼び出しを書き換え可能か |
|---|---|---|---|
| tools/pre-execute | waterfall | フック、権限、Sandbox などの再配列可能なポリシーを担う | 可(allow / deny / ask) |
| 単調ガード | guard | 縮小のみを許可し、取り消し不可の最終防衛線 | 拒否は可能、許可への復帰は不可 |
| tools/execute | 环绕分派 | 実際のツール本体呼び出しをラップし、タイムアウト、リトライ、メトリクスを行う | 必須の exec.signal を置換可能 |
| tools/post-execute | 決定 | 結果正規化前の検査または書き換え | 可(accept / block) |
| finalizeContent | 定義自身コールバック | 最後の内容のみ不変式 | 内容層のみ修正 |
| tools/result | 同期通知 | 凍結後の権威ある結果を観測 | 書き換え不可、観測のみ |
素材の中に非常に重要な総括が一つあります:最初の三つの waterfall は一度の呼び出しを書き換えることができ、定義自身によって制御される finalizeContent と tools/result はその後に実行される。この一文がパイプラインの主要な分界を画しています——前半はポリシーと分派の領域であり、複数の Plugin 間で調整し、さらには互いの決定を覆すこともできます。後半は次第に「結果の確定」へと収束し、最終的に不変の権威ある結果として凍結されます。
この順序を一文で覚えましょう:「pre-execute はできるかどうかを決め、execute はどうやるかを決め、post-execute は結果をどう提示するかを決め、result は最終結果を一目見るだけを担う。」この一文は選定のための記憶法であり、「この要件はどの拡張ポイントに掛けるべきか」に直面したとき、まず自分の要件がどのカテゴリに属するかを自問すれば、答えはほぼ出てきます。
もう一つよくある認識のズレを補足しておきます。パイプラインは「フックを適当に挿し込む」ような散漫な集合ではなく、厳密な前後関係と権限の逓減関係を持つ一本のチェーンです。前の段階ほど権限が大きく、可塑性も高く、後の段階ほど権限が小さく、制約が硬くなります。この設計の目的は、「交渉可能なポリシー」と「揺るがせない不変条件」を時間軸上で層別化し、後から登録されたプラグインが、それ以前に下された安全上の決定をひそかに覆す事態を避けることにあります。この勾配を理解していれば、自分のプラグインを設計する際に、強制力のある安全制約を誤った段階にぶら下げることはなくなるでしょう。
waterfall と next():リスナーが決定権を委譲するか、ショートサーキットで返すか
パイプラインを真に使いこなすには、まず waterfall(ウォーターフォール型イベント)というイベント分配パターンを理解しなければなりません。これは通常のイベントブロードキャストではありません。通常のブロードキャストは「すべてのリスナーが一通り走り、それぞれが勝手に処理する」ものですが、waterfall のリスナーは決定権を握っており、それを次へ渡すことも、その場で打ち切ることも選べます。
具体的には、waterfall のリスナーには二つの出口があります:
- next() を呼んで委譲する:リスナーは自身の判断を終えた後、next() を呼び、決定権をパイプライン内の後続のリスナーに渡します。現在のリスナーに異論がない場合、これが標準的なやり方です。複数のポリシープラグインが next() で連鎖し、交渉可能な意思決定チェーンを形成します。
- 直接決定を返してチェーン全体をショートサーキットする:リスナーは型付きの決定を直接返し、next() を呼びません。このパイプラインはここで終了し、後続のリスナーは実行されません。ショートサーキットは強い動作であり、うまく使えば効率と安全の担保になりますが、使い方を誤れば原因究明が難しいブロック問題になります。
tools/pre-execute はパイプラインにおける最初の waterfall です。これは「フック、権限、サンドボックス」といった再配列可能なポリシーを担います。「再配列可能」と言うのは、リスナーが next() を通じて決定権を次のリスナーに渡せるため、複数のポリシープラグインの順序を設定で調整できるからです。この点は後述する単調ガードとは対照的です。ガードの順序は結果の方向を変えられません。なぜならガードには allow という結果が存在しないからです。
pre-execute が返すのは型付きの決定 PreToolDecision で、三つの値を持ちます:
| 決定 | 意味 | 後続の動作 |
|---|---|---|
{ kind: 'allow' } | 今回の呼び出しを許可する | 単調ガードとその後の段階へ進む |
{ kind: 'deny'; reason: string } | 今回の呼び出しを拒否する | エラー結果として具現化され、ツール本体はスキップされる |
{ kind: 'ask'; reason?: string } | ユーザーに問い合わせる | 承認サービスが allowed-once を返した場合のみ続行し、それ以外は拒否する |
これら三つの決定の設計は非常に巧みである。allow は最も一般的な通過経路である。deny はこの呼び出しを即座に終了させ、拒否をエラー結果として具現化して返す——ここで注意すべきは「何も返さない」のではなく「エラー結果」であるという点で、モデルはこの呼び出しが失敗したという情報を受け取り、戦略を調整する機会を得る。ask は決定権を人間に委ね、ctx.approval の一回限りの問い合わせをトリガーし、承認サービスが allowed-once を返した場合にのみ続行し、それ以外はすべて拒否する。ask 分岐に関わる承認の詳細は次回に展開するとして、本段落ではまずそのセマンティクスを記憶しておこう。ask は「デフォルト許可」ではなく、「ユーザーが明示的に一回限りの通過を認めない限りデフォルト拒否」である。このデフォルト値の選択は極めて重要であり、承認失敗時の安全方向を決定する。
pre-execute リスナーを書く際には、いくつかのエンジニアリング上の要点を必ず覚えておく必要がある。
- 引数は書き換えられない。素材は明確に述べている。引数は書き換えられない。なぜなら、履歴記録、監査、UI、実行が一貫していなければならないからである。この制約は「あなたが見る呼び出し」と「実際に実行される呼び出し」が同一であることを保証するためのものである。引数の改ざんに依存して初めて実現できる戦略は、すべて誤った設計である。
- next() は委譲であり、オプションの装飾ではない。リスナーが通過させるのにもかかわらず next() を呼び出さない場合、後続のリスナーは黙ってスキップされ、本来有効であるべき安全戦略が機能しない可能性が高い。通過させる際に next() を呼び出すのは、条件反射レベルの習慣であるべきである。
- 短絡のタイミングには注意が必要である。deny を返すと即座に終了し、後続のリスナーは実行されない。つまり、高優先度の deny を前に置くと、他の者に協議の機会を一切与えないことになる。これは通常正しい(安全優先)が、これが意図的な選択であることを明確に認識しておく必要がある。
- いつ pre-execute を使うか。戦略が「許可、拒否、または問い合わせ」の三種類のアクションのいずれかを必要とし、かつ戦略間で自由に順序付けできることを望む場合に、これを使う。Sandbox、権限、plan-mode などの Plugin はすべてこの拡張ポイントを使用している。
このメカニズムをエンジニアリングに落とし込むために、以下にそのまま貼り付け可能な権限ゲート Plugin の例を示す。これは waterfall の二つの出口(deny を返して短絡、next() を呼び出して委譲)を完全に実演している:
// 文件路径:my-plugins/permission-gate/src/index.ts
// 一个基于 tools/pre-execute 的权限门禁插件。
// 它返回类型化的决策:命中黑名单就 deny,否则调用 next() 委托下去。
import type { Context } from '@deepseek-ai/cordis'
import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
// 黑名单:runoob 项目里禁止直接写文件系统的工具。
// 这里用最简单的集合演示;真实项目里可以查数据库、问审批服务。
const DENY_TOOLS = new Set(['fs_write', 'fs_edit'])
// 策略判定函数:返回这次调用是否被允许。
// exec 携带不可变的调用身份(callId、name、arguments、agent、token、signal)。
async function isAllowed(exec: ToolExecution): Promise<boolean> {
if (DENY_TOOLS.has(exec.name)) return false
// 额外示例:runoob 演示里禁止修改 .env 文件(参数在进入策略前已被冻结)。
const raw = exec.arguments as { path?: string }
if (typeof raw.path === 'string' && raw.path.includes('.env')) return false
return true
}
export const name = 'permission-gate'
export function apply(ctx: Context) {
// tools/pre-execute 是 waterfall:监听器可以返回决策,或调用 next() 委托。
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
if (!(await isAllowed(exec))) {
// 返回 deny 会立即终止这次调用,后续监听器不再执行。
return { kind: 'deny', reason: 'Denied by policy: this tool is not allowed in the runoob workspace.' }
}
// 放行:把决定权交给流水线中后续的监听器。
return next()
})
}このコードはほぼ「戦略プラグインの書き方」のテンプレートとして使えます。いくつかの重要なポイントを示しています:リスナーは ctx.on を通じて tools/pre-execute にフックされる;exec は不変の呼び出しアイデンティティを運ぶ。これには callId、name、arguments、agent、token、signal が含まれます;戦略の判定は非同期である。実際のプロジェクトではデータベースを照会したり承認サービスに問い合わせたりする必要があるため、判定関数は async として宣言されます;戦略にヒットしたら直ちに deny を返してショートサーキットする。そして明確な reason 文字列を提示し、モデルと開発者が原因を特定しやすくします;通過させる場合は return next() する。決定権を次に委ねます。この5つのポイントで、戦略系プラグインの書き方の90%をカバーしています。
また特筆すべきは exec.arguments as { path?: string } というアサーションです。これは、引数が戦略に入る前にすでに凍結されていることを示しています——読むことしかできず、変更はできません。戦略が path の値を見ることができ、まさにこの可視値に基づいて判断を行うため、「.env の変更を禁止する」といったルールを確実に実装できます。この細部は逆に、前述の原則を裏付けています:引数が書き換え不可であることは、監査の一貫性の要求であると同時に、戦略判断に安定した事実の基盤を与えるものでもあります。
ここまでで、第1段の土台はすでに整いました:ツール定義とは何か、どう登録するか、引数と出力をどう宣言するか、実行パイプラインにはどの6段階があるか、waterfall の決定権がどう流れるか、そして実際に使える権限ゲートプラグインがどのようなものか。次に第2段ではさらにパイプラインの深部へと進みます——単調ガードがなぜ意図的に allow 結果を持たないのか、tools/execute がどのように signal を置き換えて締切時間を課すのか、tools/post-execute の2種類の accept 決定における内容の書き換えと値の書き換えの違い、finalizeContent という「最後の内容のみ不変式」が一体何を担うのか、そして tools/result がなぜ観測のみ可能で変換できないのか。これらを理解して初めて、介入すべき場所で介入し、手放すべき場所で手放すことが本当にできるようになります。
前段では defineTool のフィールド構造、引数 schema の導出メカニズム、そしてツールがレジストリに登録される完全な経路を分解しました。しかしツールを定義しただけでは第一歩にすぎません:モデルが tool call を1つ吐き出してから、ツール本体が実際に動き出し、結果が再びモデルのコンテキストに戻るまでには、公式に ツール実行パイプライン と呼ばれる固定されたチェーンが存在します。この段では、このチェーンを各段階に分けて分解し、実際に使える権限ゲートプラグインを1つ実装します。
PreToolDecision の三態:allow、deny(reason)、ask(reason?) の後続動作の違い
tools/pre-execute はパイプライン全体の最初の waterfall であり、戦略を担う主要な位置でもあります。その戻り値はブール値ではなく、型付きの決定 PreToolDecision です。この点が極めて重要です:ブール値は二態しかありませんが、実際のエンジニアリングでは戦略にしばしば第三態が必要です——「自分では決められないので、ユーザーに聞いてくれ」。これこそが ask が存在する理由です。
3 つの決定の後続動作の違いは以下のとおりです。1 つずつ照合し、混同しないでください:
- { kind: 'allow' }:今回の呼び出しを許可します。リスナーがこれを返すと、パイプラインはそのまま続行し、単調ガードを経て tools/execute へ進みます。allow はガードをバイパスしないことに注意してください——ガードは allow の後でも呼び出しを遮断できます。
- { kind: 'deny'; reason: string }:今回の呼び出しを拒否します。レジストリはこの拒否をエラー結果として具現化します。つまり、モデルが最終的に読むのは reason 付きのエラー内容であり、ツール本体は直接スキップされ、execute はまったく実行されません。deny は即時終了です:後続のリスナーは実行される機会がなくなります。
- { kind: 'ask'; reason?: string }:ユーザーに問い合わせます。このとき ctx.approval のワンショット問い合わせフローがトリガーされます。通常の「ポップアップ確認」とは異なり、ask のセマンティクスはより厳格です:承認サービスが allowed-once を返した場合のみ続行し、それ以外のあらゆる結果(拒否、タイムアウト、無応答)は拒否として扱われます。
これら 3 つの状態を 1 つの表で対照すると、違いがより明確になります:
| 決定 | 意味 | 後続動作 | 承認サービスをトリガーするか |
|---|---|---|---|
| { kind: 'allow' } | 今回の呼び出しを許可 | 単調ガードおよびその後の段階へ続行 | いいえ |
| { kind: 'deny'; reason } | 今回の呼び出しを拒否 | エラー結果として具現化し、ツール本体はスキップされる | いいえ |
| { kind: 'ask'; reason? } | ユーザーに問い合わせ | 承認サービスが allowed-once を返した場合のみ続行、それ以外は拒否 | はい、ワンショット |
ここで特に強調すべきは allow は「実行済み」ではないということです。多くの初学者はフックを書くとき、直感的な誤解を抱きます:最初のリスナーが allow を返せば万事解決だと思い込むのです。実際には allow は単に「私は反対しない」というだけで、決定権をパイプラインの後続に委ねます。もし後ろにガードや別のポリシープラグインがあれば、呼び出しは依然として遮断される可能性があります。逆に、deny の優先度は極めて高く、一度返されればショートサーキットし、誰も救うことはできません。
ask については、「破壊的だが可逆で、かつユーザーが自分が何をしているかを明確に把握している」操作が典型的な適用シーンです。たとえば、ユーザーが指定したディレクトリへの書き込みや、ユーザーがたった今口頭で指示した shell コマンドの実行などです。こうした操作は、一律の deny で遮断するのにも、無条件の allow にするのにも適しておらず、ユーザーに一度だけ確認を求めるのが最も合理的です。allowed-once という命名の重みに注意してください。これは「この一回の呼び出し」に対する許可であり、「このツール」に対する許可でも、「このセッション」に対する許可でもありません。セッション単位のホワイトリストにしたい場合は、プラグイン側で自分で状態を管理する必要があり、承認サービスが記憶してくれることを期待してはいけません。
いつ pre-execute を使うべきか。一言で判断するなら、あなたのポリシーが「許可・拒否・問い合わせ」のいずれかのアクションを必要とし、かつポリシー間を自由に並べ替えたい場合に使います。公式エコシステムにおける Sandbox、権限、plan-mode などのプラグインは、いずれもこの拡張ポイントを利用しています。
なぜ引数を書き換えられないのか:履歴記録、監査、UI、実行が一致していなければならない
これは見落とされがちですが、一度ハマると非常に痛い設計上の制約です。tools/pre-execute の段階では、引数は書き換えられません。exec.arguments を読むことはできますが、変更することはできません。
なぜか。一貫性の制約から出発すると、arguments はこの呼び出しにおける「事実」です。それは同時に少なくとも 4 つの消費者から観察されます。
- 履歴記録:セッションの transcript に記録されるのは、モデルが元々発行した呼び出し引数です。もし何らかのポリシーがこっそり引数を変更したら、履歴記録と実際の実行が食い違い、リプレイが歪んでしまいます。
- 監査:監査システムは「モデルが結局何を要求したのか」に答えられる必要があります。もし引数が途中で変更されたら、監査ログに記録されるのは変更後の値でしょうか、それとも元の値でしょうか。どちらを記録しても誤りです——元の値を記録すれば実行と一致せず、変更後の値を記録すればモデルの真の意図を覆い隠してしまいます。
- UI:画面上でユーザーに「Agent が fs_write を呼び出そうとしており、パスは ./a.txt です」と表示します。もしポリシーがパスを ./b.txt に差し替えたのに UI が同期していなければ、ユーザーが見ているものと実際に起きていることは別物になります。
- 実行:ツール本体が受け取る引数は、履歴・監査・UI の三者が合意したものでなければなりません。
これら 4 点を揃えるには、唯一の解はarguments を凍結することです。前述のとおり、exec が運ぶのは不変の呼び出しアイデンティティであり、具体的には callId、name、arguments、agent、token、signal といったフィールドを含みます。これらはこの呼び出しの身分証です。callId は一意の番号、name は呼び出されるツール名、arguments は引数のスナップショット、agent は発起元を示し、token は認可コンテキストに関連し、signal はキャンセル信号の媒体です。
そこで、典型的なエンジニアリング上の問題に直面します:パラメータはポリシー実行前にすでに凍結されているのに、「パラメータレベルの検証」をしたい場合はどうすればよいか?答えは、読み取りのみ可能で変更はできず、意思決定の結果を通じて自分の態度を表明するしかないということです。たとえば、権限ゲートでパスに .env が含まれていることに気づいた場合、あなたがすべきは deny であり、「パスを書き換えてから通す」ことではありません。後者は上記の4つの一貫性制約を破壊します。
「パラメータを修正する」という要求の正しい出口は、tools/post-execute の block 分岐です——ツールの実行が完了した後、feedback で修正意見をモデルに返し、モデル自身に再度呼び出しを開始させます。こうすることで、パラメータの変更はモデルの次のラウンドの意思決定の中で行われ、履歴記録は依然として自己整合的になります。
ctx.tools.guard() と ToolGuard:allow 結果を持たない単調ガード
waterfall の柔軟性には代償があり、その代償は「覆され得る」と呼ばれます。後から登録されたリスナーが allow を返すと、前のどこかのリスナーの deny がバイパスされてしまいます。ほとんどのポリシーシナリオではこれで問題ありません。なぜなら、ポリシーは本来ソート可能で交渉可能なものだからです。しかし、このやり方が通用しない種類の要求があります:不変条件(invariant)です。
不変条件の要求は「最終的に拒否し、かつ誰もそれを取り消せない」というものです。ここで登場するのが ctx.tools.guard() で、ToolGuard を登録します。その型シグネチャは非常に表現力豊かです:
// ToolGuard:感知作用域的最终预分派策略
type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
注目すべきは戻り値の型です:string | undefined。文字列を返すことは拒否を意味し、文字列自体が拒否理由です;undefined を返すことは現状維持を意味します。ここには意図的に allow 結果がありません。
allow がないということは何を意味するのか?それは、リスナー同士が互いに「判決を覆す」ことができないということです。新しいガードを登録して、ある種の「許可」を返すことで、前のガードの拒否を取り消すことは不可能です——なぜなら、そのような戻り値はそもそも存在しないからです。これが単調という言葉の意味です:減るだけで増えず、権限を収めるだけで与えない。権限はガードチェーンに沿って絶えず狭められることしかできず、決して緩められることはありません。
ここでの Readonly<ToolExecution> にも一言触れる価値があります。ガードが受け取るのは読み取り専用ビューであり、パラメータを変更する能力も、呼び出しアイデンティティを変更する能力もありません。ガードにできることはただ一つ:今回の呼び出しが不変条件に違反しているかどうかを判断し、違反していれば理由を添えて拒否することです。
実践的な原則に落とし込むと、選定ルールは非常にシンプルです:
- 並べ替え可能なポリシー → tools/pre-execute に配置。例えばビジネスレベルのレート制限、カナリアリリース、シナリオ別の権限など、これらのポリシーの順序は設定によって変わり得ます。
- 最終的に必ず有効になり、取り消し不可能な不変条件 → ctx.tools.guard() に配置。例えば「いかなる状況でもシステムディレクトリに書き込んではならない」「いかなる状況でも鍵を外部に送信してはならない」といったレッドラインです。
エンジニアリングでよくある落とし穴は、レッドラインを pre-execute に書いてしまうことです。pre-execute は waterfall であるため、後続のプラグインが allow を返せばそれを回避でき、レッドラインは形骸化します。逆に、並べ替え可能なビジネスポリシーを guard に書くのも適切ではありません——guard には allow がなく、すべてのガードは「不表明」か「拒否」しかできず、「このシナリオは承認する」を表現できません。2 種類のメカニズムはそれぞれの役割を果たすものであり、混用してはいけません。
tools/execute のラップ型ディスパッチ:ToolDispatchExecution と exec.signal の置換ルール
ポリシー層とガード層を通過すると、呼び出しがついに実際に実行されます。tools/execute が担当するのは「ラップ型ディスパッチ」——実際にツール本体を呼び出す処理を包み込むことです。「ラップ型」と呼ばれるのは、この拡張ポイントで得られる実行コンテキストが、本体の前後にそれぞれ 1 層の処理を行えるようにするためです。
この層で行うのに適した処理は何でしょうか?タイムアウト、リトライ、メトリクス収集、この 3 つが標準的な住人です。理由は直感的です:
- タイムアウトは呼び出しに締め切りを課す必要があり、本質的には signal の操作であり、この層でしかできません。
- リトライは本体が失敗したかどうか、何回失敗したかを知る必要があり、本体の外側にループを 1 層包む必要があります。
- メトリクス収集は本体の所要時間、成功・失敗のカウントを測定する必要があり、これも本体の外側で計時しなければなりません。
この層で得られるビューは ToolDispatchExecution です。このビューのみが必須の exec.signal を置換でき、締め切りを課すために使われます。置換ルールは非常に繊細なので、しっかり覚えておいてください:
置き換えは可能だが、削除はできない。レジストリはツール本体を呼び出す前に、呼び出し元の signal を再融合する。
この文は二つに分けて理解する必要がある。前半の「置き換えは可能」とは、deadline 付きの signal で現在の signal を上書きし、タイムアウト制御を実現できるという意味だ。後半の「削除はできない」+「レジストリは呼び出し元の signal を再融合する」とは、たとえ signal を置き換えても、レジストリは実際に本体を呼び出す前に、呼び出し元の元の signal を再び融合させるという意味である。
なぜわざわざそんなことをするのか。それはキャンセル権が呼び出し元に属するからだ。ユーザーがキャンセルを押した、上位の Agent が中止を決定した、セッション全体が kill された——これらのシグナルはツール本体まで貫通できなければならない。もしあるプラグインが signal を置き換えることで呼び出し元のキャンセルシグナルを「置き換えて」しまえるなら、そのツールはもう止められなくなり、これは受け入れられない。だから設計上、二重の保険をかけている。あなたは自分の締め切りを上乗せできるが、呼び出し元のキャンセルは永遠に保持される。
実際にはこれが注目に値する挙動をもたらす。あなたがタイムアウトプラグインを登録し、タイムアウトが発火すると本体の signal は abort する。しかし同時に、呼び出し元も abort した場合、どちらが先に到達した方が有効になる。したがってツール本体の正しい書き方はsignal に応答することであり、「自分がタイムアウトしていなければ必ずキャンセルされない」と仮定することではない。
次は tools/post-execute である。これはツール実行完了後、結果が正規化される前にチェックや書き換えを行い、戻り値は型付けされた PostToolDecision である。この拡張ポイントは「結果層」のポリシー位置であり、pre-execute の「意図層」とちょうど対称をなす。
PostToolDecision の四種類の結果書き換え:accept(content?)、accept(value)、block(feedback) と機密境界
PostToolDecision の値が PreToolDecision より多く見えるのは、「受け入れ」という事柄に二つの粒度があるからだ。表示の変更と、規範値の変更である。そこに拒否を加えて、全部で四種類となる:
| 決定 | 意味 | コストと副作用 |
|---|---|---|
| { kind: 'accept'; content? } | 結果を受け入れ、表示内容を置き換え可能 | 規範値とメタデータは保持し、モデルに見せる部分だけを差し替える |
| { kind: 'accept'; value } | 結果を受け入れ、規範値を置き換え可能 | 再検証と内容の再計算が行われる |
| { kind: 'block'; feedback } | 結果を阻止 | 訂正フィードバックをエラー結果としてモデルに返す |
| (返さない / 通過) | 元の結果を維持 | 呼び出しはパイプラインに沿って下流へ進む |
まず最初の2種類の accept の違いについて説明します。ここが最も混同しやすいポイントです:
- accept(content?):表示内容のみを置き換えます。規範値(つまり execute が返す値)とメタデータはそのまま保持されます。適したシナリオは「モデルに対して表現を変えたい」場合です。例えば、冗長な JSON を自然言語の一段落に要約するようなケースです。
- accept(value):規範値を置き換えます。これは重い操作です——レジストリは新しい value を使って output.schema の検証をもう一度実行し、さらに output.render を呼び出してモデル向けの内容を再計算します。つまり、value を変えれば content は自動的に再計算されるので、content を同時に手書きする必要はありません(むしろ書かない方がよいです)。
この2つの意味の違いが、それぞれが適するシナリオをまったく異なるものにしています。「モデルに見せる言い回し」だけを変えたいなら content を使ってください。「今回のツール呼び出しが生み出した権威ある結果」そのものを変えたいなら value を使い、再検証のコストと失敗リスクを受け入れてください——新しい value が output.schema を満たさなければ、その置き換えは誤りです。
次に block(feedback) について説明します。これはツールの結果をエラー結果に置き換え、そのエラー内容があなたの feedback になります。これは「結果を隠す」のではなく、「今回の結果は利用不可であり、feedback に従って修正してやり直してください」とモデルに伝えるものです。したがって、feedback の文言は同僚への code review の意見のように書くべきであり、「invalid」の一言ではいけません。例えばパスが範囲外の場合、feedback には「パスが許可された作業ディレクトリを超えています。作業ディレクトリ内の相対パスに変更してください」と明確に書くべきです。
最後に、必ず強調しておくべき保密境界があります:
内容の置き換えは表示戦略であり、保密戦略ではありません。プログラム的な値を隠すには、その値を置き換えるか、結果を阻止しなければなりません。
この文の意味はこうです:content だけを変えても、規範値は依然としてパイプラインを流れており、規範値を見ることができるあらゆる後続の場所(監査、ログ、その他のフック)がそれを取得できます。あるフィールドが機密情報であり、結果に一切現れてほしくないのであれば、accept(value) でそれを規範値から取り除くか、いっそ block しなければなりません。「表示テキストを少し変えれば隠せる」と期待するのは成り立ちません。
この境界は実際のプロジェクトで躓く確率が極めて高く、特に資格情報、内部パス、内部サービス名が関わる場合に顕著です。堅実なやり方は次のとおりです:「必ず消さなければならない」フィールドをすべてリストアップし、post-execute で規範値に対して処理を行う。「単に見栄えが悪いだけ」のフィールドは content に任せる。
finalizeContent と tools/result:最後の内容不変条件と凍結結果の読み取り専用観測
post-execute の後にはさらに 2 つの段階があり、それらの役割分担は非常に明確です。
1 つ目は finalizeContent です。これはツール定義(ToolDefinition)自身が持つコールバックであり、「自身が持つ」というキーワードに注意してください——これはフックプラグインに属するものではなく、defineTool を書くときに宣言できる部分です。レジストリはこれをちょうど 1 回だけ呼び出します。その位置づけは「最後の内容のみの不変条件」です:
- 同期実行:非同期の待機は行わず、新しいスケジューリングポイントも導入しません。
- 内容レベルの最終修正のみを行う:触れられるのは内容だけで、すでに受け入れられた規範値を覆すことはできません。
このコールバックはツール定義そのものに属するため、「このツールがどうであれ満たすべき内容ルール」を置くのに自然に適しています。たとえば、ある種のツールの出力には必ず固定の末尾ヒントを付けなければならない場合、ここに書けば一度で完結し、どのプラグインの登録順序にも依存しません。
finalizeContent の実行が終わると、レジストリは受け入れられた結果を具現化して凍結し、その後 tools/result を発火します。tools/result は同期通知であり、その役割は、あの凍結された、不変の権威ある結果を観測できるようにすることです。3 つの特徴をまとめて覚えておく必要があります:
- 同期:通知は同期的に発行され、次の tick まで遅延しません。
- 読み取り専用:観測者は結果を変換できません。結果はすでに凍結されており、変更しようとしても入口がありません。
- 失敗の分離:観測者自身のエラーは分離され、メインフローには影響しません。この点は可観測性プラグインにとって特に重要です——計測の記述が壊れても、業務呼び出しを巻き込んで落とすべきではありません。
では、いつ tools/result を使い、いつ tools/post-execute を使うべきでしょうか?選定基準を示します:
- 監査、メトリクス、最終結果のキャプチャが必要 → tools/result を使います。そこで見えるものが最終的な真実です。
- 結果の変換やコンテキストの付加が必要 → tools/post-execute を使います。result 段階ではもう変更できないからです。
公式ドキュメントには覚えやすい選択の口诀が示されています。ここでパイプラインの順序を整理し直します:pre-execute は「できるかどうか」を決め、execute は「どうやるか」を決め、post-execute は「結果をどう提示するか」を決め、result は「最終結果を一目見る」ことだけを担います。途中の guard は終始一貫したレッドラインのスイッチであり、誰も覆すことはできません。
チェーン全体の順序を完全に書き出しておく。問題の切り分けの際に見比べられるようにするためだ:tools/pre-execute → 単調ガード → tools/execute → tools/post-execute → finalizeContent → tools/result。このうち最初の 3 つの waterfall は 1 回の呼び出しを書き換えられるが、定義自身が制御する finalizeContent と tools/result はその後に実行される。
2026 年 9 月の実践:pre-execute で権限ゲートとポリシー順序を書く現在のやり方
理論はここまでにして、実装に移る。公式ドキュメントでは「権限ゲート」を例に、フックプラグインが tools/pre-execute をどのように使うかを示している。ここではそれを、そのままプロジェクトに組み込めるバージョンに書き直す。
まず、見落とされがちな前提を 1 つ挙げる:フックプラグインは普通の Cordis プラグインであり、外部プロトコルは必要ない。この言葉には 2 つの意味がある。第 1 に、フックのために新しいフォーマットを発明する必要はなく、export name、export apply、inject という Cordis プラグインの書き方はこれまで通りだ。第 2 に、それが普通のプラグインである以上、プラグインエコシステムのすべてを享受できる——インストールも、アンインストールも、設定による順序付けも可能だ。
以下の例のポリシーはこうだ:ブラックリストに一致したツールは即座に deny する。さらに、.env に触れようとするすべての行為をインターセプトする。コード内で引数にアクセスする方法に注意してほしい——引数はポリシーに入る前にすでに凍結されているため、読み取りのみ可能で、変更はできない。
// ファイルパス:my-plugins/permission-gate/src/index.ts
// tools/pre-execute に基づく権限ゲートプラグイン。
// 型付きの決定を返す:ブラックリストに一致すれば deny、そうでなければ next() を呼び出して委譲する。
import type { Context } from '@deepseek-ai/cordis'
import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
// ブラックリスト:runoob プロジェクトではファイルシステムへ直接書き込むツールを禁止する。
// ここでは最も単純な集合でデモする。実際のプロジェクトではデータベースを照会したり、承認サービスに問い合わせたりできる。
const DENY_TOOLS = new Set(['fs_write', 'fs_edit'])
// ポリシー判定関数:この呼び出しが許可されるかどうかを返す。
// exec は不変の呼び出し識別情報(callId、name、arguments、agent、token、signal)を保持する。
async function isAllowed(exec: ToolExecution): Promise<boolean> {
if (DENY_TOOLS.has(exec.name)) return false
// 追加の例:runoob のデモでは .env ファイルの変更を禁止する(引数はポリシーに入る前に凍結済み)。
const raw = exec.arguments as { path?: string }
if (typeof raw.path === 'string' && raw.path.includes('.env')) return false
return true
}
export const name = 'permission-gate'
export function apply(ctx: Context) {
// tools/pre-execute は waterfall:リスナーは決定を返すか、next() を呼び出して委譲できる。
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
if (!(await isAllowed(exec))) {
// deny を返すとこの呼び出しは即座に終了し、後続のリスナーは実行されない。
return { kind: 'deny', reason: 'Denied by policy: this tool is not allowed in the runoob workspace.' }
}
// 通過:決定権をパイプライン内の後続のリスナーに委ねる。
return next()
})
}
このファイルには、いくつか掘り下げる価値のある設計上の詳細があります。いずれも2026年9月現在、この種のゲートキーパープラグインを書く際の標準的なやり方です:
- 判定ロジックと登録ロジックの分離。isAllowed は純粋関数的な判定であり、ctx.on の中では判定結果を意思決定に変換する役割だけを担います。このようにする利点はテスト容易性です:isAllowed に対して単体テストを書き、さまざまな exec の形態を構築できます。プラグインのランタイムを起動する必要はありません。
- ヒットしたら deny、決して「修正してから通す」ことを試みない。理由は前述の「なぜパラメータを書き換えられないのか」で詳しく述べたとおりです:パラメータは凍結された呼び出しのアイデンティティであり、変更すれば一貫性が壊れます。本当にモデルにパラメータを変えて再試行させる必要があるなら、それは post-execute の block か次のターンの会話がやるべきことです。
- 通過させるには明示的に return next() が必要。これは最も漏らしやすい一行です。waterfall のセマンティクスは、リスナーが決定権を下へ渡すというものであり、next() を呼ばず決定も返さなければ、呼び出しチェーンはそこで止まってしまいます。空の return、return を忘れる、undefined だけを return する、いずれも異なる形の「サイレントサスペンド」です。
- deny の reason は人に見せる完全な文として書く。この reason はエラー結果として具現化されモデルに渡されるため、文言の質がそのままモデルが次のターンで自己修正できるかどうかを決めます。サンプルでは Denied by policy: this tool is not allowed in the runoob workspace. と書かれており、「どの環境で許可されていないか」を明確に述べているので、モデルは少なくとも環境を変えるかツールを変えるかがわかります。
次に ポリシーの並び順 の現在のやり方について。pre-execute は並べ替え可能なポリシー層であり、複数のポリシープラグインの前後順序は設定で調整できます。これは実際のプロジェクトで非常に有用です:あなたは「組織レベルポリシー」「プロジェクトレベルポリシー」「セッションレベルポリシー」の三層を持つかもしれません。論理的には組織レベルを先に走らせ(明らかな違反をより早く deny する)、プロジェクトレベルがその次、セッションレベルが最後に精细な判断を行うことを望むでしょう。実現方法は、コード内で優先度の数値をハードコードするのではなく、プラグインの登録/ロード順序によって制御することです。
順序が調整可能であることは、注意しなければならない問題を一つもたらします:deny は短絡するが、allow はそうではない。もし組織レベルポリシーがとある呼び出しに対して deny を返せば、後続のすべてのポリシーは実行されなくなります——これは望ましい動作です。しかし組織レベルポリシーが allow を返しても、後続のポリシーがチェックをスキップするわけではなく、後続のポリシーは依然として deny できます。したがって多層ポリシーを設計する際は、「最も厳格で、最も先に拒否したいもの」を前に置き、「より細緻で、より通過させたいもの」を後ろに置くべきです。逆に並べるのはよくある間違いです。
もう一点、ガードとの連携について述べておきます。もしあなたのゲートキーパーに真のレッドラインがあるなら、たとえば「どのようなポリシー順序であってもシステムディレクトリに書き込んではならない」という場合、その部分はこの pre-execute プラグインに書くべきではなく、ctx.tools.guard() を使ってさらに ToolGuard を登録すべきです。なぜなら pre-execute プラグインは後続の allow によって上書きされ得ますが、guard はそうではありません。実用的なプロジェクト構造は:guard にレッドラインを、pre-execute に交渉可能なポリシーを、post-execute に結果の書き換えとフィードバックを、result に計測・監査を置く。四層がそれぞれの持ち場につくことで、その後のメンテナンスで互いに衝突しなくなります。
最後に、デプロイと検証のチェックリストを提供します。ゲートが本当に機能しているかを確認するのに役立ちます:
- ブラックリストに登録されたツール(例:fs_write)を呼び出し、ツールが実際に実行された結果ではなく、deny に対応するエラー結果が返されることを確認します。
- 引数に .env を含む許可されたツールを呼び出し、ブロックされることを確認します。
- 完全に正常なツールを呼び出し、引き続き実際の結果が取得できることを確認します——このステップは「return next() を忘れてすべてがハングする」という古典的な事故を排除するために使用します。
- 一時的に guard でレッドラインを登録し、pre-execute 内の allow ではそれをバイパスできないことを確認します。
まとめとベストプラクティス
記事全体の要点を実行可能なチェックリストに圧縮しました。ツールを作成したり、パイプラインに接続したりする際に一通り確認してください:
- ツール定義は defineTool を使用し、フィールドを漏れなく揃えます:name、description、parameters、output.schema、output.render、execute。output.schema は正規値の型を宣言し、output.render は正規値をモデル向けの内容に変換する役割を担い、execute は正規値を返します。
- 登録は ctx.tools.register を経由し、プラグインは export inject = ['tools'] を行い、依存関係を明示的に宣言します。
- パイプラインの順序をしっかり覚える:tools/pre-execute → 単調ガード → tools/execute → tools/post-execute → finalizeContent → tools/result。
- PreToolDecision の三態:allow は通過して続行;deny(reason) はエラー結果として具現化しツール本体をスキップ;ask(reason?) は ctx.approval による一度限りの問い合わせをトリガーし、allowed-once の場合のみ続行します。
- 決して引数を書き換えない。arguments はポリシー適用前に凍結され、履歴記録、監査、UI、実行の四者の一貫性を保ちます。引数を修正する必要がある場合は、post-execute の block を通じてモデルにフィードバックし、やり直させます。
- invariant には ctx.tools.guard() を使用。ToolGuard のシグネチャは (execution: Readonly<ToolExecution>) => string | undefined で、文字列を返せば拒否、undefined なら現状維持;allow が存在しないため、順序によって拒否を許可に戻すことは永遠にできません——これが単調性です。
- タイムアウト、リトライ、メトリクスは tools/execute に配置。ここでは ToolDispatchExecution を取得し、ここでのみ exec.signal を置き換えて締め切り時間を適用できます;ルールは「置き換え可能、削除不可」であり、レジストリは呼び出し本体の前に呼び出し元の signal を再融合します。
- PostToolDecision は粒度を明確に区別:accept(content?) は表示内容のみを差し替え、正規値とメタデータは保持;accept(value) は正規値を差し替え、再検証と内容の再計算を行います;block(feedback) は訂正フィードバックをエラー結果に変換します。
- 機密保持を content に期待しない。内容の差し替えは表示ポリシーであり機密保持ポリシーではありません;プログラム的な値を隠すには、value を差し替えるか block する必要があります。
- finalizeContent はツール定義自身のコールバックであり、レジストリはちょうど一度だけ同期的に呼び出し、最後の内容修正のみを行います;その後、結果は具現化されて凍結され、同期的に tools/result をトリガーします。
- tools/result は読み取り専用の観測:観測者は結果を変換できず、観測者の失敗は隔離され、メインフローに影響しません。監査、メトリクス、最終結果のキャプチャにはこれを使用し;結果を変換したりコンテキストを付加したりするには post-execute を使用します。
- ゲートプラグインは普通の Cordis プラグインです。ブラックリスト(例:fs_write、fs_edit)や機密パス(.env を含む)にヒットしたら return deny;通過させるには明示的に return next() する必要があり、漏らすとチェーン全体がハングします。
- ポリシーの順序はプラグインの登録順に依存し、ハードコードされた優先度ではありません。最も厳格で、最も先に拒否したいものを前に置きます;deny はショートサーキットし、allow はショートサーキットしないことを覚えておき、順序設計は「誰が先に拒否する権利を持つか」を中心に組み立てます。
- レイヤーごとの配置:guard にはレッドライン不変条件、pre-execute には交渉可能なポリシー、execute には横断的関心事、post-execute には結果の書き換えとフィードバック、result には計測と監査を配置します。
- リリース前に四段階の検証を実行:ブラックリストがブロックされる、機密引数がブロックされる、正常なツールは依然として通過する、guard のレッドラインが allow でバイパスできない。
ここまでで、defineTool によるツール定義とツール実行パイプラインの2つのセクションが完結しました。ツール定義はモデルが「何を見ることができ、どのように呼び出すか」を決定し、パイプラインは各呼び出しが「どのような関門を経て、結果がどのようにモデルに戻るか」を決定します。この2つの層を整理すれば、その後に承認、Sandbox、可観測性といった高度なトピックに取り組む道筋がスムーズになります。