Claude Media
Claude Code modのserverToolUsesでadvisorの呼び出しを読む

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でキャッシュの読み書きを表示する記事にあります。

この記事を共有:XはてブLinkedIn