Claude Code modのserverToolUsesでadvisorの呼び出しを読む
modのturn.stepフックが返すresult.serverToolUsesは、API側が実行したツール呼び出しを渡すフィールドです。advisorの相談回数を数える最小フックと、空になる原因の切り分けをまとめます。
serverToolUsesは何を返すか
serverToolUses は、modの turn.step フックが受け取る result に付くフィールドです。1回のモデルへのリクエストの中で、API側が自分で実行したツール呼び出しが入ります。典型例はadvisorツールです。
changelogによると、各呼び出しにはid・name・input・開始と終了が付きます。Claude Code v2.1.290で追加されたフィールドで、それ以前のバージョンでは読めず、フックを入れても何も記録されません。このバージョンの変更全体はv2.1.290のリリースノートにあります。
大事なのは、Claude Codeがこの呼び出しを実行していない点です。そのため tool.call も tool.check も発火しません。Bashの実行を見張るのと同じ感覚で tool.call にフックを書いても、advisorへの相談は一件も見えません。止めたい場合は、advisor自体を無効にします。/advisor off か環境変数 CLAUDE_CODE_DISABLE_ADVISOR_TOOL=1 が使えます。
見える場所は turn.step の結果だけです。レスポンスにサーバー側のツール呼び出しが無ければ、フィールド自体が存在しません。空配列ではなく「無い」ので、読むときは存在確認が要ります。
| 見たいもの | 使うイベント |
|---|---|
| Claude Codeが実行するツール(Bash・Editなど) | 使うイベントtool.call / tool.check |
| API側が実行したツール(advisor) | 使うイベントturn.step の result.serverToolUses |
| 1回のリクエストのトークン使用量 | 使うイベントturn.step の result.usage |
| ターン全体の所要時間と最終回答 | 使うイベントturn.complete |
最小のフックを書く
turn.step はレスポンスがストリームで流れてくるイベントなので、フックは非同期ジェネレーターで書きます。yield* next(e) でレスポンスを素通しにしつつ、完了した結果を受け取ります。
// hooks/register.js
export function register(on) {
on('turn.step', async function* ($, e, next) {
// 通常どおりリクエストを送り、ストリームをそのまま流して結果を受け取る
const result = yield* next(e)
// フィールドは、サーバー側のツール呼び出しが無いと存在しない
const uses = result.serverToolUses
if (uses && uses.length > 0) {
$.ui.log('server tools: ' + uses.length + ' call(s)')
}
// 結果は変えずに返し、ターンを普段どおり続ける
return result
})
}advisorが相談を受けたリクエストの後に、transcriptへ server tools: 1 call(s) のような淡色の行が出ます。相談が無いリクエストでは何も出ません。形は、キャッシュの読み書きを記録する公式のフック例と同じです。
内容を見たいときは、最初は JSON.stringify(uses) をそのままログに出すのが確実です。changelogが挙げるのはid・name・input・開始と終了の5項目で、開始と終了のプロパティ名までは挙げていません。名前を決め打ちして undefined を読むより、一度生の形を目で確かめてから、使う項目を絞るほうが安全です。
if (uses) $.ui.log(JSON.stringify(uses))advisorの呼び出しだけを数える
name が入っているので、ツール別に絞り込めます。ただしadvisorの name に何が入るかは、modのドキュメントに記載がありません。次の書き方なら、決め打ちせずに実際の値を集計できます。
export function register(on) {
// ターンごとの集計表。キーはターンID、値は「ツール名 → 回数」
const counts = new Map()
on('turn.step', async function* ($, e, next) {
const result = yield* next(e)
for (const use of result.serverToolUses ?? []) {
const byName = counts.get(e.turnId) ?? new Map()
byName.set(use.name, (byName.get(use.name) ?? 0) + 1)
counts.set(e.turnId, byName)
}
return result
})
on('turn.complete', async ($, e, next) => {
const byName = counts.get(e.turnId)
// 取り出したら捨てる。残すと表がセッション中ずっと育つ
counts.delete(e.turnId)
if (!byName) return next(e)
const text = [...byName].map(([name, n]) => name + ' x' + n).join(', ')
return { text: 'server tools: ' + text }
})
}turn.step は1ターンの中で何度も発火するため、counts は register の中に置き、ターンIDで仕切ります。turn.complete の返り値に text を持つオブジェクトを返すと、回答の下に1行が出ます。サーバー側のツール呼び出しが無かったターンは、表に何も入っていないので next(e) で素通しにします。
turn.complete は中断されたターンでも、サブエージェントのターンでも発火します。メインの会話と分けて数えたいときは、次の e.agentId で見分けます。ログを見て名前を確認してから、use.name === '...' の比較を足してください。
メインの会話だけを数えたいなら e.agentId で絞ります。turn.step はサブエージェントのリクエストでも発火し、その場合に agentId がセットされます。advisorはサブエージェントにも引き継がれ、サブエージェント自身のモデルで組み合わせの確認を受けます。サブエージェントの相談も含めて数えるなら、絞り込みは不要です。
// メイン会話のリクエストだけを対象にする
if (e.agentId === undefined && result.serverToolUses) {
// ...
}agentId を使った見分け方はtool.checkでサブエージェントを見分ける記事に詳しくあります。
別のmodがturn.stepを包んでいるとき
turn.step のフックも、同じイベントを扱うmodと一つの連鎖になります。先に走るmodが最も外側で、イベントを先に見て、結果を後から見ます。自分のフックは next を呼んだ時点で、内側のmodとClaude Code本体の処理が終わった後の結果を受け取ります。
そのため、yield* next(e) の後で読む result.serverToolUses には、内側のmodが手を加えた後の値が入ります。内側のmodが結果を作り替えていれば、読めるのは作り替えた後のフィールドです。順序は、組織の prependPlugins が先、自分で入れたmodが次、appendPlugins が最後です。結果を書き換えるmodと組み合わせるときは、JSON.stringify(uses) で生の値を一度ログに出して確かめてください。
相談が一件も見えないときの切り分け
フィールドが無いのは、API側のツール呼び出しが無かったからです。フックの不具合とは限りません。advisorが動いていない理由は、advisorのドキュメントから次のとおり拾えます。
- advisor自体が無効:
/advisorで選んでいない、advisorModelが未設定、あるいはCLAUDE_CODE_DISABLE_ADVISOR_TOOL=1が立っている。後者は設定済みのadvisorModelも無視されます - Anthropic API以外を使っている: advisorはサーバー側で実行されるツールで、Amazon Bedrock・Claude Platform on AWS・Google CloudのAgent Platform・Microsoft Foundryでは使えません。LLMゲートウェイ経由では、ゲートウェイがリクエストをそのままAnthropic APIへ渡すかどうかで決まります
- フィーチャーフラグの取得が止まっている: advisorはAnthropicから取得するフィーチャーフラグで有効になります。
DISABLE_TELEMETRYのようにフラグ取得を止める変数が立っていると、advisorはオフのままです - 組み合わせがはじかれた: advisorはメインモデルと同等以上のランクが必要です。下回る場合、Claude Codeはメインモデルのリクエストにadvisorを付けません。APIが組み合わせを拒否したときは、advisorなしでリクエストを再送し、エラーも出ません
- Claudeが呼ばなかった: 呼ぶタイミングはモデルの判断です。呼び出し回数の上限を決める設定も、強制する設定もありません
最後の項目が見落としやすいところです。設定が正しくても、短いタスクでは一度も相談しないのが普通です。「フィールドが無い」はエラーの証拠ではないので、serverToolUses の有無だけで警告を出すと誤報になります。
組み合わせのランク表はadvisorModelの記事にあります。切り分けは、/advisor で選んだモデルが残っているかを見たうえで、transcriptの Advising 行と serverToolUses の組み合わせで判定します。
Advising 行 | serverToolUses | 判定 |
|---|---|---|
| あり | serverToolUsesあり | 判定正常。フックも読めている |
| あり | serverToolUsesなし | 判定フック側を疑う。turn.step で読んでいるか、v2.1.290以降かを確かめる |
| なし | serverToolUsesなし | 判定advisorが呼ばれていない。上の5項目のどれかを見る |
| なし | serverToolUsesあり | 判定想定外の組み合わせ。生の値をログに出して確かめる |
取れない情報と、代わりの手段
changelogが列挙する項目に、advisorが返した助言の本文は入っていません。助言を読みたい場合、transcriptの Advising 行が「Reviewed」になったあとで Ctrl+O を押す方法があります。serverToolUses は「相談があったか」「いつ・何を渡して始まり、いつ終わったか」を機械的に拾うための手段です。
トークンの扱いにも注意が要ります。result.usage が持つのは input_tokens・output_tokens・cache_read_input_tokens・cache_creation_input_tokens と回答した model です。advisorのトークンがこの数字に含まれるかどうかは、modのドキュメントに記載がありません。advisorの消費は /usage のセッション合計に入ると説明されているので、コストを突き合わせたいときは /usage 側を基準にしてください。
ターン全体の合計は turn.complete の e.usage で読めます。こちらにadvisorの分が含まれるかも、modのドキュメントには書かれていません。turn.step の result.usage を足し上げた値と e.usage が一致するかを見ても、advisorの分の有無は判別できません。どちらの数字もadvisorの消費を示すものとして扱わず、金額の確認は /usage に寄せるのが確実です。
advisorの料金はAPI課金ならadvisorモデルの入出力単価、サブスクリプションならプランの利用上限から引かれます。Fableをadvisorにする場合だけ、Fable利用がusage creditsに課金されるプランではそちらへ請求されます。
フックが壊れたときの動き
観察するだけのフックは、壊れても会話を止めません。フックが next を呼ぶ前に例外を出すとClaude Codeはそのフックを飛ばし、次のハンドラーが代わりに走ります。next が解決した後に壊れた場合は、その時点の結果がそのまま使われ、再実行はありません。
turn.step のフックでは yield* next(e) の後に処理を書くので、後者になります。result.serverToolUses を読む部分で例外が出ても、返ったレスポンスは失われません。ただし、1行のエラー文が出ます。for ... of に undefined を渡すなど、フィールドが無い場合の落とし穴は、前節のように ?? [] で避けます。
フック自体の実行時間には10秒の上限があります。next の中で待つ時間はこの数に含まれないので、ログを出す程度なら収まります。外部へHTTPで送るような重い処理を足すときは、この上限を意識してください。
testで検証する
claude plugin test では、turn.step のスタブも非同期ジェネレーターで書き、テストがストリームを最後まで読んで結果を取り出します。スタブが返す結果に serverToolUses を載せれば、フックの分岐を実機のadvisorなしで検証できます。
// テスト内に置くスタブ。serverToolUsesの中身は検証用に自分で作った値で、実機の出力ではない
on('turn.step', async function* ($, e) {
yield { kind: 'text', index: 0, text: 'ok' }
return {
turnId: e.turnId,
index: e.index,
answer: 'ok',
toolUses: [],
stopReason: 'end_turn',
usage: null,
serverToolUses: [{ id: 'use-1', name: 'sample-tool', input: {} }],
}
})集計まで確かめるなら、同じテストの中で turn.step の後に turn.complete を発火させます。テスト側の $ はClaude Codeの役を演じるので、$.turn.complete に turnId を渡すと、先ほどの counts が引かれて text が返ります。turnId をスタブの結果と揃えないと表が引けず、素通しの分岐に落ちる点に注意してください。
// turn.step を通したあと、同じ turnId で turn.complete を発火する
const done = await $.turn.complete({
turnId: 't1', answer: 'ok', durationMs: 10, isAborted: false, usage: null,
})
// done.text に 'server tools: ...' が入っていれば集計は通っているserverToolUses を載せないスタブも用意しておくと、「フィールドが無いとき何も出さない」分岐を確かめられます。テストの書き方の全体はmodのtestを書く記事にまとめています。
modの作り方そのものはClaude Codeのmodを80行で作るから始められます。turn.step で別のモデルに差し替える方向ならmodel.completeの記事も参考になります。リクエストごとのキャッシュ読み書きを表示するフックはturn.stepでキャッシュの読み書きを表示する記事にあります。