エージェントに全ツールをコンテキストへ載せる必要はない
DynoTable の AI エージェントは数十のツールに届きますが、そのすべてを一度に見ることはめったにありません。カタログの大部分は最初から見えています。少数は、モデルが検索するまで隠れたままです。
この分け方は、最初に始めた形の逆です。ローンチ時には小さなコアだけが見えていて、残りは検索の背後で待っていました。その後、安価なモデルがそれを使う様子を観察し、見えるコアは大きく広がりました。
検索の仕組みは生き残りました。どのツールがその背後で待てるかという私たちの理屈は、生き残りませんでした。
公開するツールが増えるほど、モデルの選択は難しくなる
モデルにとって、各ツールは名前、説明、そして完全な入力スキーマです。そのすべてが、ユーザーが何かを入力する前にリクエストへ入ります。
これらの定義はトークンを消費します。選択の正確さも損ないます。モデルが見比べる選択肢が増えるほど、間違ったものを選ぶ頻度が上がるのです。
Anthropic のツール検索ドキュメントは、Claude についてこれを数字で示しています。利用可能なツールが 30 から 50 を超えると、ツールの選択精度が落ちます。
私たちのカタログには、意図的に似た選択肢が入っています。いくつかのツールはペアになっています。たとえば openTable と proposeOpenTable です。どちらもテーブルを開きます。前者はすぐに開きます。
後者はチャットにチップを出し、ユーザーがそれをクリックするまで何も開きません。この違いはユーザーによる制御にとって重要ですが、名前を並べただけの一覧ではほとんど見えません。
ディスカバリは、検索ツールと絞り込んだツール一覧でできている
エージェントはステップ単位で動きます。各ステップで、モデルは会話を読み、ツールを呼び、その結果を受け取ります。ユーザーの 1 つのメッセージ、私たちがターンと呼ぶものには、多くのステップがかかることがあります。
各ステップの前に、エージェントループは、そのステップでモデルが呼べるツールを決めます。この一覧をアクティブセットと呼びます。
各ツールは、インラインかディスカバリ対象のどちらかです。インラインのツールは、最初のステップからアクティブセットに入っています。ディスカバリ対象のツールは、モデルが検索したあとにだけ加わります。
検索ツールは、名前、キーワード、説明でカタログ全体をクエリと照らし合わせてスコアリングします。デフォルトでは上位 5 件の一致を返し、ディスカバリ対象の一致はそれぞれ次のステップのアクティブセットに加わります。
検索ツールは自身の実行の中でアクティブセットを更新します。そのため、ループが次のステップを準備する前に変更が反映されています。
検索ツールの説明には、保留しているすべてのツールが名前で列挙されています。検索がそれらを返すまでリクエストに入らないのは、説明と入力スキーマだけです。
それでもモデルが保留中のツールを呼んだ場合、私たちが土台にしている AI SDK が、私たちのコードが動く前にその呼び出しを拒否します。モデルはツールの結果として SDK のエラーを受け取り、そこにはツール名とアクティブセットが埋め込まれています。
Model tried to call unavailable tool '<name>'. Available tools: <active set>.検索ツールはインラインなので、この一覧には必ずその名前が含まれます。以前、私たちはこのケースのために、より明示的な独自の拒否メッセージを書いていました。
その後 SDK を追跡したところ、SDK のほうが先に呼び出しを拒否していることがわかりました。私たちのメッセージは一度もモデルに届いていなかったので、削除しました。
Anthropic はツール検索をサーバー側の機能としても提供しています。対応するのは Claude モデルだけで、Amazon Bedrock では InvokeModel API 経由でしか動きません。Amazon Nova も動かす必要があるエージェントには、独自の仕組みが必要です。
安価なモデルは確実には検索しない
このエージェントをフロンティアモデルに合わせてチューニングすることはありません。これはあなた自身の Bedrock 認証情報の上で動き、多くの人が安価なモデルを選びます。
そこで私たちの eval は、2 つの安価な下限モデル、Amazon Nova 2 Lite と Claude Haiku 4.5 に対して実行しています。最初の問題の兆しは、添付ファイルについての実際のチャットから現れました。
そのモデルは Nova Lite でした。のちに Nova 2 Lite が下限モデルとして置き換えた第 1 世代のモデルです。それはファイル用のツールを検索する代わりに、開いているタブの一覧からファイルを探しました。
同じモデルが、同じ日に同じ理由でさらに 2 回失敗しました。テーブルを一覧するツールと、2 つのテーブルを結合したビューを提案するツールを見逃したのです。
私たちはそれらのツールをすべてインラインに昇格させました。モデルは、それらを使う前に検索する必要がなくなりました。
最もはっきりした例は、のちに bulkEdit で起きました。これは 1 回の呼び出しで多数のアイテムに 1 つの変更をステージするツールで、変更はステージングエリアに入ります。エージェントの書き込みは、ユーザーがレビューしてコミットするまでそこで待ちます。
このツールはディスカバリ対象として出荷され、どちらの下限モデルもそれを見つけられませんでした。Haiku 4.5 は 6 回の試行で一度も検索ツールを呼び出しませんでした。
Nova 2 Lite が検索したのは、1 行用のツールである stageItem が 1 行ずつのステージングを続けることを拒否したあとだけで、しばしばステップ上限に達しました。bulkEdit が最初のステップから見えている状態では、35 行の変更を 1 回の呼び出しでステージしました。
bulkEdit をインラインにしたあと、両モデルは 2 つの一括シナリオの両方に、3 回の繰り返しすべてで合格しました。のちに一括シナリオを 5 つにした実行では、Haiku は 5 つすべてで 100% を記録しました。
Nova 2 Lite は 4 つで 100% を記録しました。フィルターによる一括削除では 57% でした。削除をステージする代わりに、まずアイテムを確認することを申し出たためです。
この昇格には、別の場所でコストがありました。ステージングパネルを開くツールを Haiku が見つける必要があるシナリオで、3 回中 3 回の合格が 0 回になりました。
bulkEdit が見えている状態では、Haiku は検索する代わりに、そのためのツールはないと答えました。私たちは bulkEdit をインラインのままにし、v1.14.0 でもそのまま出荷しています。
これらの失敗から、リクエストが名指しせずに必要とするツールについては、検索を唯一の経路にできないことがわかります。そうしたリクエストはどれも、モデルが探しに行くと決めるかどうかに左右されてしまうからです。
可視性はユーザーの言い回しで決まる
そこで、ツールごとに 1 つの問いを立ててカタログを分けました。ユーザーがそれを求めるときの言い方は、そのツールを名指ししているか、という問いです。
「これを CSV にエクスポートして」と言われれば、モデルは export を検索します。そのためエクスポートのツールはディスカバリ対象のままです。インデックスの統計、ベクトル検索、保存されたスペックも同じです。
ステージングエリアの保留中の書き込みを表示するツールも同じです。
人々はこれらを名指しで求めるか、まったく求めないかのどちらかです。それ以外のツールは、名指ししないリクエストによって必要とされます。
「先週の注文を見せて」は時計に一切触れません。それでもモデルが答えるには今日の日付が必要なので、getCurrentTime はインラインです。
添付ファイルも同じです。人々は中身について尋ねますが、それを読むツールを名指ししません。そのため、ファイル用のツールもインラインです。
Anthropic は自社のツール検索について、最もよく使う 3 から 5 個のツールを読み込んだままにすることを推奨しています。安価なモデルのために、私たちのインラインのコアはそれを大きく超えました。
検索は 3 回まで、そこで止める
検索ツールには、1 ターンあたり 3 回の呼び出しという予算があります。4 回目の呼び出しは、実行される代わりにこれを返します。
{
"error": "search-budget-exhausted",
"message": "search budget exhausted, call ask() to clarify with the user or use a previously-discovered tool",
"budgetCap": 3
}この上限は、特定のループのために存在します。モデルが検索し、想像していたものが見つからない。同義語でもう一度検索し、さらに別の語で検索し、データベースに触れないままステップを使い切ってしまうのです。
上限は、これ以上の検索が割に合わなくなる時点で決断を強制します。モデルは、すでに見つけたツールに踏み切るか、ユーザーに尋ねるかのどちらかを選びます。
メッセージは両方の出口を名指ししています。私たちは同じルールを私たちのバリデータにも適用しています。回復の手段を名指しする拒否は、モデルに行き止まりではなく次の一手を与えます。
各ツールは 1 行で一度だけ宣言する
カタログは、ツールごとに 1 行のフラットなリストです。その行が、ツールのカタログ上のアイデンティティを担います。名前、検索が返す説明、検索キーワード、インラインかディスカバリ対象か、ティア、そして MCP での公開です。
ティアは、呼び出しに許可が必要かどうかを表します。サイレントのツールは、誰にも尋ねずに動きます。ゲート付きのツールは、まず許可チェックを通ります。
検索ツールはどちらのティアにも属しません。これはエージェントループの一部で、あなたのデータには一切触れないからです。
MCP での公開は、MCP サーバーに接続した外部クライアントが何を呼べるかを表します。値は、そのツールを許可する最も低いアクセスレベルです。「読み取り専用」「読み取りとステージ」「フルアクセス」のいずれかです。
MCP から除外されているツールはごくわずかで、検索ツールはその 1 つです。
アプリ内の他のツール一覧はすべて、これらの行から導出されます。サイレントの集合、各 MCP アクセスレベルが許可するツール、そして書き込みとして扱うツールの集合も含みます。どれも手で保守していません。
手で保守するリストはずれていきます。サイレントのリストと MCP のリストを別々に保守していれば、食い違うことがありえます。そうなると、チャットではゲートされているツールが、同じチェックなしに外部クライアントへ届いてしまいかねません。
このリストには、予想していなかった制約が 1 つあります。デスクトップ UI とバックエンドの両方が読み込むため、ランタイムの import をゼロにしなければならないのです。
import が 1 つでもあれば、あるツールの実装を引き込み、それを通じて Node 専用の暗号モジュールまで引き込みます。UI のブラウザーバンドルでは、アプリがモジュールのロード時に落ちます。
型チェッカーもユニットテストもこれを見逃します。どちらも import を問題なく解決するからです。これを捕まえるのは、ファイルをテキストとして読むテストです。
そのテストは、実行時にモジュールを読み込む文があれば必ず失敗します。唯一許されるのは型だけの import です。コンパイラがそれを消すからです。
自分で作るなら応用できること
- 分け方は計測すること。あるツールをインラインにした場合とディスカバリ対象にした場合で eval を実行し、スコアに決めさせます。スイートの残りも再実行します。1 つのツールを昇格させると、モデルが別のツールを扱う方法が変わることがあります。
- ディスカバリは最も弱いモデルでテストすること。フロンティアモデルは検索すべきときに検索するかもしれませんが、それはユーザーが選ぶモデルについて何も教えてくれません。
- リクエストが名指しせずに必要とするツールはインラインにすること。人々が名指しで求めるツールはディスカバリ対象のままにします。
- 検索のように、データではなくエージェント自体を管理するツールには上限を設けること。実際の状態に一切触れないツールは何度でも呼べてしまい、呼び出しのたびにステップを消費します。
- すべての拒否で回復の手段を名指しすること。独自のメッセージを書く前に、SDK がすでに何を返すかを確認します。
- 各ツールのカタログエントリーは一度だけ宣言し、他のすべての一覧はそこから導出すること。
- コンパイラが表現できない制約をモジュールが抱えているなら、ソースをテキストとして読むテストで強制すること。
これが動作する場所
以上はすべて DynoTable のツールカタログで出荷されており、エージェントはあなた自身の Bedrock 認証情報の上で動きます。同じ行が、外部エージェントの接続先である MCP サーバーも駆動しています。
そのサーバーでは、各行の公開値が、クライアントがツールを呼ぶのに必要なアクセスレベルを決めます。OAuth、同意、認証情報の分離によってそれをどう安全にしたかは、別の話です。
これらのツールを安価なモデルでも使えるものにしているバリデータについては、それ自体が 1 本の記事になっています。