第7章 ツール / アクション — 解答例
本文: 第7章 ツール / アクション
→ 参照: 7.2.1節(APIのラッパーを作るのではない)/ 7.2.2節(統合)/ 7.2.3節(名前空間)/ 7.3.1節
再設計の考え方
元のAPIは「リソース1つにつき1エンドポイント」というプログラマ向けの分割である。エージェントの実際の使われ方は「ある人を特定する」「その人について知りたいことをまとめて知る」の2つに集約されるので、そこへ寄せる。特に GET /employees(全社員一覧)は、7.2.1節が典型例として挙げたコンテキストを圧迫するだけで役に立たないツールなので、検索に置き換える。
統合後のツール一覧
{ "name": "hr_search_employees", "description": ( "社員を氏名・部署・役職で検索し、該当者の一覧を返す。" "「営業部の誰々さん」のように、社員IDが分からない状態から人を特定するときに使う。" "以降のツールに渡す employee_id を得るための入口であり、まずこれを呼ぶこと。" "返るのは employee_id・氏名・部署・役職の4項目のみで、" "上長・部下・有給残日数は含まれない。それらが必要な場合は hr_get_employee_context を使うこと。" "氏名は部分一致・かな漢字の表記ゆれに対応する。" "全社員を一覧する用途には使えない(1回あたり最大50件)。" "絞り込みたい部署名が分からない場合は hr_list_departments で確認すること。" ), "input_schema": { "type": "object", "properties": { "query": {"type": "string", "description": "氏名または役職の一部。例: 田中、部長"}, "department": {"type": "string", "description": "部署コード。hr_list_departments で得られる値。例: SALES-01"}, "limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 10, "description": "取得する最大件数。既定は10件"} }, "required": [] }}const hrSearchEmployeesTool: Anthropic.Tool = { name: 'hr_search_employees', description: '社員を氏名・部署・役職で検索し、該当者の一覧を返す。' + '「営業部の誰々さん」のように、社員IDが分からない状態から人を特定するときに使う。' + '以降のツールに渡す employee_id を得るための入口であり、まずこれを呼ぶこと。' + '返るのは employee_id・氏名・部署・役職の4項目のみで、' + '上長・部下・有給残日数は含まれない。それらが必要な場合は hr_get_employee_context を使うこと。' + '氏名は部分一致・かな漢字の表記ゆれに対応する。' + '全社員を一覧する用途には使えない(1回あたり最大50件)。' + '絞り込みたい部署名が分からない場合は hr_list_departments で確認すること。', input_schema: { type: 'object', properties: { query: { type: 'string', description: '氏名または役職の一部。例: 田中、部長' }, department: { type: 'string', description: '部署コード。hr_list_departments で得られる値。例: SALES-01', }, limit: { type: 'integer', minimum: 1, maximum: 50, default: 10, description: '取得する最大件数。既定は10件', }, }, required: [], },}{ "name": "hr_get_employee_context", "description": ( "1人の社員について、基本情報・上長・部下一覧をまとめて取得する。" "「この人の上司は誰か」「この人の下に何人いるか」といった問いに1回で答えられる。" "employee_id は hr_search_employees で取得すること。氏名では指定できない。" "include で取得範囲を選べる。既定は basic と manager のみで、" "部下一覧が必要なときだけ reports を追加すること(組織が大きいと返り値が膨らむため)。" "有給残日数(leave_balance)は本人および人事部からの照会に限り取得できる。" "権限がない場合はエラーが返り、再試行しても解決しない。" ), "input_schema": { "type": "object", "properties": { "employee_id": {"type": "string", "pattern": "^EMP-[0-9]{6}$", "description": "社員ID。例: EMP-004512"}, "include": { "type": "array", "items": {"type": "string", "enum": ["basic", "manager", "reports", "leave_balance"]}, "default": ["basic", "manager"], "description": "取得する情報の種類。必要なものだけを指定すること" } }, "required": ["employee_id"] }}const hrGetEmployeeContextTool: Anthropic.Tool = { name: 'hr_get_employee_context', description: '1人の社員について、基本情報・上長・部下一覧をまとめて取得する。' + '「この人の上司は誰か」「この人の下に何人いるか」といった問いに1回で答えられる。' + 'employee_id は hr_search_employees で取得すること。氏名では指定できない。' + 'include で取得範囲を選べる。既定は basic と manager のみで、' + '部下一覧が必要なときだけ reports を追加すること(組織が大きいと返り値が膨らむため)。' + '有給残日数(leave_balance)は本人および人事部からの照会に限り取得できる。' + '権限がない場合はエラーが返り、再試行しても解決しない。', input_schema: { type: 'object', properties: { employee_id: { type: 'string', pattern: '^EMP-[0-9]{6}$', description: '社員ID。例: EMP-004512', }, include: { type: 'array', items: { type: 'string', enum: ['basic', 'manager', 'reports', 'leave_balance'], }, default: ['basic', 'manager'], description: '取得する情報の種類。必要なものだけを指定すること', }, }, required: ['employee_id'], },}{ "name": "hr_list_departments", "description": ( "全部署の一覧を、部署コード・部署名・所属人数とともに返す。" "hr_search_employees の department に渡す正しい部署コードを知りたいときに使う。" "部署数は50程度なので全件を返す。" "各部署の所属メンバーの氏名は含まれない。" "メンバーを知りたい場合は hr_search_employees に department を指定すること。" ), "input_schema": {"type": "object", "properties": {}, "required": []}}const hrListDepartmentsTool: Anthropic.Tool = { name: 'hr_list_departments', description: '全部署の一覧を、部署コード・部署名・所属人数とともに返す。' + 'hr_search_employees の department に渡す正しい部署コードを知りたいときに使う。' + '部署数は50程度なので全件を返す。' + '各部署の所属メンバーの氏名は含まれない。' + 'メンバーを知りたい場合は hr_search_employees に department を指定すること。', input_schema: { type: 'object', properties: {}, required: [] },}設計判断の要点
GET /employees(全件)はツール化しない。hr_search_employeesに置き換えた(7.2.1節)/employees/{id}/manager/reports/leave_balanceの4本はほぼ必ず連続して呼ばれるため統合した。4ステップが1ステップになり、誤り累積の機会も減る(7.2.2節)- ただし全部を無条件に返すのではなく
includeで選ばせた。トークン効率と、有給残日数という機微情報の露出制御を兼ねている(7.4.2節⑤・第13章) /departments/{id}/membersはhr_search_employees(department=...)に吸収した。同じ「人を探す」操作を2つのツールに分けると、モデルが選択を誤る(7.2.2節の2番目の害)- 全ツールに
hr_接頭辞を付けた(7.2.3節)
別解: hr_list_departments を廃し、部署コードを hr_search_employees の department に enum で埋め込む2ツール構成も良い。スキーマで縛るほうが強い(7.3.2節)。ただし部署改編のたびにツール定義の再デプロイが要る点はトレードオフ。あるいは部署一覧を MCP の Resource として提供する手もある(第9章9.5.4節)。
→ 参照: 7.3.1節(説明文)/ 7.3.2節(スキーマ)/ 7.3.3節(使用例)
元の定義の問題点
descriptionが1行で、何のデータか・いつ使うか・何が返るかが一切不明(7.3.1節の4点すべてを欠く)typefromtonはいずれも曖昧な名前。fromは日付か送信元か部署かが判別できない。nは件数か日数か不明(7.3.2節)typeが自由文字列。enumがないので表記ゆれが起きるnにmaximumがないので、モデルが100000を渡してコンテキストを溢れさせるdefaultがないので、モデルは省略してよいか判断できないfrom/toにformatがなく、日付形式が伝わらない
書き直した定義(仮定: 社内BIの売上メトリクス取得ツール)
{ "name": "sales_get_metrics", "description": ( "社内BIから、指定期間の売上系メトリクスを日次で取得する。" "売上の推移、前年同期比、地域別の比較を求められたときに使う。" "1レコードは「日付・地域・指標値」の3項目で、明細(個別の注文)は含まれない。" "個別の注文を調べる必要がある場合は search_orders を使うこと。" "データは前日分までが確定しており、当日分は集計中のため返らない。" "参照できるのは過去3年分に限られる。それ以前はデータウェアハウスの別系統にある。" "期間を指定しない場合は直近30日が対象になる。" ), "input_schema": { "type": "object", "properties": { "metric": { "type": "string", "enum": ["revenue", "order_count", "average_order_value", "refund_amount"], "description": "取得する指標。revenue は税抜の売上金額(円)", }, "since": { "type": "string", "format": "date", "description": "集計開始日(この日を含む)。YYYY-MM-DD 形式。省略時は until の30日前", }, "until": { "type": "string", "format": "date", "description": "集計終了日(この日を含む)。YYYY-MM-DD 形式。省略時は前日", }, "region": { "type": "string", "enum": ["all", "east", "west", "overseas"], "default": "all", "description": "集計対象の地域。all は全地域の合計を1系列で返す", }, "limit": { "type": "integer", "minimum": 1, "maximum": 365, "default": 90, "description": "返す最大レコード数(日数)。上限を超える期間を指定した場合は新しい順に切り詰められる", }, }, "required": ["metric"], },}const salesGetMetricsTool: Anthropic.Tool = { name: 'sales_get_metrics', description: '社内BIから、指定期間の売上系メトリクスを日次で取得する。' + '売上の推移、前年同期比、地域別の比較を求められたときに使う。' + '1レコードは「日付・地域・指標値」の3項目で、明細(個別の注文)は含まれない。' + '個別の注文を調べる必要がある場合は search_orders を使うこと。' + 'データは前日分までが確定しており、当日分は集計中のため返らない。' + '参照できるのは過去3年分に限られる。それ以前はデータウェアハウスの別系統にある。' + '期間を指定しない場合は直近30日が対象になる。', input_schema: { type: 'object', properties: { metric: { type: 'string', enum: ['revenue', 'order_count', 'average_order_value', 'refund_amount'], description: '取得する指標。revenue は税抜の売上金額(円)', }, since: { type: 'string', format: 'date', description: '集計開始日(この日を含む)。YYYY-MM-DD 形式。省略時は until の30日前', }, until: { type: 'string', format: 'date', description: '集計終了日(この日を含む)。YYYY-MM-DD 形式。省略時は前日', }, region: { type: 'string', enum: ['all', 'east', 'west', 'overseas'], default: 'all', description: '集計対象の地域。all は全地域の合計を1系列で返す', }, limit: { type: 'integer', minimum: 1, maximum: 365, default: 90, description: '返す最大レコード数(日数)。上限を超える期間を指定した場合は新しい順に切り詰められる', }, }, required: ['metric'], },}採点の観点: (1) 説明文が3〜4文以上あり、何が返らないかと制約に触れているか、(2) パラメータ名が単独で意味を持つか(n → limit、from → since)、(3) enum / format / maximum / default が使われているか、(4) 必須パラメータが最小限か。仮定するドメインは何でもよいが、明示すること。なお本ツールはパラメータが自明なので input_examples は不要である(7.3.3節)。
(a) KeyError: 'customer_name'
モデルには「何のキーが、どこに無かったのか」が伝わらない。どちらの層で起きたかで書き分ける。
外部APIの応答にフィールドが無かった場合:
「顧客レコードに氏名が含まれていませんでした。取得できた項目は customer_id, email, created_at, status です。氏名が必要な場合は、
get_customer_profileを使ってください(本ツールは連絡先のみを返します)。」
ツールの引数が欠けていた場合:
「必須パラメータ
customer_nameが指定されていません。受け取った引数: {customer_id: ‘CUS-00123’}。氏名が分からない場合は、先にsearch_customersで顧客を特定してください。」
実装側で取得すべき情報: 実際に存在したキーの一覧、エラーが起きた層(引数検証か応答パースか)、受け取った引数の内容、代替手段となるツール名。
(b) HTTP 429 Too Many Requests
これは7.5.2節の一時的障害にあたるので、まずツール内部で指数バックオフつきの再試行を行う(第1章1.4.2節)。それでも解消しない場合にだけ、次を返す。
「外部APIのレート制限に達しました。3回再試行しましたが解消しませんでした(最終待機20秒)。応答ヘッダによれば、あと45秒待てば再度呼び出せます。この間は他の調査を進めるか、時間がかかる旨をユーザーに報告してください。同じ引数ですぐに再試行しても失敗します。」
実装側で取得すべき情報: Retry-After ヘッダ、実施した再試行の回数と待機時間、制限の単位(ユーザー単位かAPIキー全体か)。これらがないと、モデルは「もう一度呼べば通るかもしれない」と判断して停滞する(5.4.2節)。
(c) AssertionError
メッセージが空で、情報量がゼロという最悪の例である。裸の assert は使わないこと。原因によって扱いが分かれる。
入力の妥当性検査だった場合 — 明示的な検証に置き換え、値を含めて返す。
「
since(2026-08-01)がuntil(2026-07-01)より後になっています。sinceはuntil以前の日付を指定してください。」
内部の不変条件違反(ツールのバグ)だった場合 — これは7.5.2節の設定エラーに近く、モデルには解けない。開発者にアラートを上げたうえで、モデルには打ち切りを促す。
「ツールの内部エラーが発生しました(参照ID: err-8f14e45f)。これは実装上の問題であり、引数を変えて再試行しても解決しません。別の手段を検討するか、参照IDを添えてユーザーに報告してください。」
実装側で取得すべき情報: 検証内容(何と何を比較したか)と実際の値。バグ側は、内部詳細を漏らさずに追跡できる参照ID、そしてトレースへのスタックトレース記録(第12章)。「再試行しても無駄」と明示するのが要点で、これを書かないとモデルは引数を変えて何度も試み、停滞に陥る(7.5.2節)。
→ 参照: 7.6.6節
(a) resolve() 前の文字列検査では不十分な理由
「.. を含むか」という文字列検査は、パスの見た目を見ているだけで、そのパスが最終的にどこを指すかを見ていない。回避例を3つ挙げる。
- シンボリックリンク経由。
WORKSPACE配下にlink → /etcというリンクがあれば、"link/passwd"という文字列には..が1つも含まれない。検査を素通りして/etc/passwdに到達する。 - 絶対パスの指定。
pathlibではPath("/workspace") / "/etc/passwd"が/etc/passwdになる(右辺が絶対パスなら左辺は破棄される)。"/etc/passwd"にも..は無い。 - エンコードによる迂回。
%2e%2e%2fのようにURLエンコードされた入力は、文字列としては..を含まないが、デコード後は../になる(第8章8.4.4節が同じ問題を扱っている)。
さらに、この検査は逆向きにも誤る。"src/../docs/spec.md" は .. を含むが、解決すれば WORKSPACE 配下であり、正当なパスである。これを弾いてしまうと、モデルは正しい操作を拒否され、原因の分からないまま停滞する。
つまり文字列検査は取りこぼしと過剰拒否を同時に起こす。正しいのは、本文の実装のように「解決してから、解決後の実体が範囲内かを検査する」ことである。
(b) WORKSPACE 配下のシンボリックリンクが /etc を指す場合
防げる。 Path.resolve() はシンボリックリンクを再帰的に解決するため、(WORKSPACE / "link/passwd").resolve() は /etc/passwd を返す。したがって candidate.is_relative_to(WORKSPACE) が False となり、PermissionError が送出される。本文が「is_relative_to による検査を resolve() の後に行っている点が重要である」と述べているのは、まさにこのケースを念頭に置いている。
本文の範囲外だが、この実装にも限界はある。検査が通ってから実際にファイルを開くまでの間に、攻撃者がそのパスをシンボリックリンクに差し替える競合(TOCTOU)は、
resolve()だけでは防げない。より強い保証が必要なら、OS レベルでの隔離(コンテナ、bind mount、openat2のRESOLVE_BENEATH)を併用する。
→ 参照: 7.7.2節 / 7.2.1節 / 7.2.2節 / 7.4.2節 / 7.3.1節 / 7.5.1節
まず1タスクあたりの消費トークンを概算すると、問題の所在が見える。
| ツール | 回数 × トークン | 合計 | 割合 |
|---|---|---|---|
search_docs |
1.2 × 450 | 540 | 0.9% |
read_doc |
8.4 × 6,200 | 52,080 | 77.7% |
list_all_projects |
1.0 × 11,000 | 11,000 | 16.4% |
get_project |
4.1 × 380 | 1,558 | 2.3% |
① list_all_projects — APIをそのまま写した典型例(7.2.1節)
エラー率0%なので「正常に動いている」ように見えるが、1回で11,000トークンを消費し、その大半は捨てられている。全件を返すツールは、必要な1件を探すためにコンテキストを丸ごと使う設計である。しかも毎タスク必ず1回呼ばれている(1.0回)ことから、get_project に渡すIDを得る手段が他に無く、やむを得ず全件取得していると読める。
改善: search_projects(query, status, limit≤20) に置き換える。返り値は id と name と updated の3点にとどめ、total_matches と絞り込み方法を添える(7.4.1節)。全件一覧が本当に必要な場面がなければ、list_all_projects は削除する。
② get_project — エラー率31%が突出(7.3.1節・7.5.1節)
4本のうち唯一エラー率が異常に高い。7.7.2節が述べるとおり、ツール別のエラー率は改善すべき箇所を直接指し示す。原因の候補は、(i) project_id の形式が説明されておらず、モデルが名前やスラッグを渡している、(ii) list_all_projects の返り値が切り詰められていてIDを取り違えている、(iii) エラーメッセージが回復に必要な情報を欠いており、同じ誤りを繰り返している。
改善: スキーマに pattern を入れて形式を強制し(7.3.2節)、説明文に「project_id は search_projects で取得すること。名前では指定できない」と書く。エラー時には「‘Acme刷新’ は project_id ではありません。IDは PRJ- + 6桁です。名前から探すには search_projects を使ってください」と次の手を示す。そして ① の改善によって、そもそもIDの取得元が正しくなればエラーの多くは自然に消える。なお 4.1回という呼び出し回数からは、複数プロジェクトをまとめて取れる project_ids: [] 形式への拡張も検討に値する。
③ read_doc — 全体の8割を占める最大の膨張源(7.4.2節)
8.4回 × 6,200トークンは、文書を丸ごと読んでいることを示す。しかも search_docs が1.2回・450トークンしか使われていない点と合わせると、構図が見える。検索が薄すぎて手がかりにならないため、候補を片端から全文で読んでいる。
改善は2方向。
- 入口の改善:
search_docsの返り値を厚くする。マッチ箇所の前後を含むスニペットと、該当セクション名・行番号を返すようにする(第7章7.6.6節のsearch_logs的な設計)。450トークンが1,500トークンに増えても、read_docが8.4回から2回に減れば圧倒的に得である。 - 出口の改善:
read_docにsection/start_line・end_lineを追加し、既定で全文を返さないようにする。response_format: concise|detailedの方式も有効(7.4.2節⑤)。上限を設け、切り詰めたら切り詰めたと伝える(7.4.1節)。
④ search_docs は健全
1.2回・3%・450トークンは問題ない数値である。ただし③で述べたとおり「使われなさすぎ」であり、これは search_docs 自体の欠陥ではなく、返り値が判断材料として不十分であることの現れと解釈する。
改善の優先順位: 効果の大きさから、③(52,000トークン)→ ①(11,000トークン)→ ②(エラー率)の順。ただし ① と ② は原因が繋がっているため、search_projects への置き換えで同時に解決する見込みがある。改善後は同じ評価タスクで再測定し、ホールドアウトセットで過学習を確認する(7.7.4節)。
Built with Astro ・ Deployed on Cloudflare Pages
© 2026 watakumi — made with 💜 & ☕ ・watakumi.page