Claude Code でサブエージェントが5段ネスト!?トークンを溶かす多段委譲をハーネスで防ぐ!

Gradient purple-pink hero with 'SIOS TECH LAB' at top left, large Japanese headline about Claude Code and multi-agent delegation, pale starburst on the right, and rounded badges reading 'AI', 'Claude Code', 'サブエージェント' at bottom left.

はじめに

こんにちは!サイオステクノロジーのなーがです。2026年7月上旬、いったん提供停止されていた Claude Fable 5 が再公開されましたね。Fable 5 は並列サブエージェントのディスパッチ・管理が得意とされる一方で高価なモデルなので、メインセッションの Fable にはオーケストレーション(計画・分解・統合)だけをさせて、実作業は安価なモデルのサブエージェントに委譲する運用が定石として広まっています。

私もこの流れに乗って、個人開発の Python プロジェクトで Claude Code のマルチエージェント運用を始めてみました。ところが、いざ動かしてみると「サブエージェントの多段委譲問題」と呼ばれる典型的な落とし穴にきれいにハマり、トークンを溶かすはめになりました。

参考: 「Claude Fable 5」が復活、7月7日まではプランの上限内で試用可能(窓の杜) / サブエージェント活用で Claude Fable 5 をコスパよく運用する(Zenn)

今回は、サブエージェントを設定してからこの問題を踏むまでの流れと、「プロンプトによる指示」と「hooks による機械的な拒否」の多層防御で解決するまでの経緯を、途中でやらかした hook 自身の誤検知も含めて、実際の実装とあわせて紹介します。

サブエージェントを設定する

まずは私がやった設定から紹介します。Claude Code では、.claude/agents/ にカスタムエージェントを定義しておくと、メインセッションが Agent ツールでそれらを呼び出せます。今回は役割を3つに分けました。

  • investigator(調査、haiku):複数ファイルにまたがる調査・検索。読み取り専用
  • implementer(実装、sonnet):設計確定後のコード編集・テスト・lint
  • reviewer(レビュー、opus):コミット前の品質・セキュリティレビュー。読み取り専用

参考: Claude Code のサブエージェント(公式ドキュメント)

エージェント定義はこんな感じです(investigator.md)。読み取り専用の役割なので、tools を参照系に絞っています。

---
name: investigator
description: 複数ファイルにまたがる調査・コード検索・現状把握を行う読み取り専用エージェント。
model: haiku
tools: Read, Glob, Grep, Bash
---

あなたは調査専門エージェント。

- 読み取り専用で動く。ファイルの作成・編集・削除はしない。
- 結論を先に、根拠となるファイルパスと行番号(`path:line`)を添えて報告する。

あわせて、メインセッションが迷わないように、CLAUDE.md に「エージェント委譲ルール」を書きました。ポイントは、役割の対応表を固定パイプラインではないと明言することです。

## エージェント委譲ルール

メインセッションは設計・統括・結果検証に徹し、作業はサブエージェントに委譲する。
下記は役割の対応表であり、全タスクに強制する固定パイプラインではない:

- 複数ファイルにまたがる調査・検索 → `investigator`
- 設計確定後の実装(コード編集・テスト・lint) → `implementer`
- コミット前のコードレビュー → `reviewer`

運用原則:

- 同じ作業内容を複数のエージェントに順番にリレーしない。
  1つのタスクで各役割のエージェントを使うのは最大1回ずつ。
  前段の結果を丸ごと次段の課題として再送するのは禁止。
- 互いに独立したタスクは、1つのメッセージで複数のAgent呼び出しを
  同時に発行して並列実行する。
- 不要なフェーズは飛ばす。調査が不要なら `investigator` を起動しない。
- 単一ファイルの軽微な修正はメインセッションが直接行ってよい。

「リレー禁止」「独立タスクは並列実行」「不要フェーズのスキップ」の3点セットまで書いて、これでメインの Fable は統括に専念、実作業は3役に振れる分業体制ができました。……はずでした。

早速つまずいた「サブエージェントの多段委譲問題」

運用を始めてすぐ、ダッシュボードまわりの1タスクで様子がおかしくなりました。呼び出しツリーを見ると、メインセッションから呼ばれた implementer が(本来は禁止のはずの)Agent ツールでさらに implementer を呼び、それがまた次へ……と、気づけば5段ネスト。各段が2.6万トークン前後を消費し、最深段は6.5万トークンに達していました。CLAUDE.md にわざわざ「リレー禁止」と書いたのに、です。この呼び出しツリーを見つけたときは、さすがに焦りました。

あとで知ったのですが、これは「サブエージェントの多段委譲問題」として知られる典型的な失敗でした。委譲の「深さ」と「回数」を放任すると起きるもので、整理すると原因は「誰が誰を呼ぶか(深さ)」と「何を渡すか(内容)」という2つの軸に分かれます。

再委譲: サブエージェントがさらにサブエージェントを呼ぶ

1つ目は構造(深さ)の問題です。委譲されたサブエージェントが自分でも Agent ツールを使い、さらに別のサブエージェントへ仕事を投げてしまうケースです。

こうなると、実際に手を動かしているのが誰なのかメインセッションから見えなくなります。孫エージェントはユーザーの元の意図を知らないまま作業するのでコンテキストが失われ、結果の品質も制御できません。

トークン消費の面でも、単に段数に比例して増えるだけでは済みません。本来不要な中間層のエージェントが1つずつ起動すること自体がコストで、各中間層はコンテキストの読み込み・状況把握・指示の再構成といった同じような処理を重複して行います。つまり、中抜きすれば丸ごと不要だったはずのコストが、層の数だけ積み上がっていくわけです。

多段リレー: 前段の結果を丸ごと次段に再送する

2つ目は内容の問題で、もう少し気づきにくいです。メインセッションが「調査 → 実装 → レビュー」をパイプラインだと思い込み、前段のエージェントが返した長大な結果をほぼそのまま次段のプロンプトに貼り付けて順送りしてしまうケースです。

一見それらしく動いているのですが、実態は同じテキストがセッション内を何往復もしているだけです。本来メインセッションがやるべき「結果を咀嚼して、次のフェーズに必要な情報だけを渡す」という仕事が抜け落ちており、次のような問題が起きます。

  • 結果の劣化: 各エージェントが要点の抽出をサボり、丸投げの連鎖になる
  • トークン消費: 長文が段数ぶん重複して送られ、コストが跳ね上がる
  • 制御不能: 不要なフェーズ(調査不要のタスクでの investigator 起動など)まで律儀に実行される

冒頭の5段ネストは、まさにこの2つが同時に噴き出した状態でした。implementerimplementer を呼んでネストが深くなっている点は再委譲そのもの、渡している内容がほぼ同じ点はリレーそのものです。「深さ」と「内容」という別々の軸なので、原因が違えば防ぎ方も層で分かれます。これは仕組みで止めるしかありません。次章から、その対策を見ていきます。

解決アプローチ: プロンプトと hooks の多層防御

対策は1つではなく、階層の異なる4つを重ねています。すでに設定時に書いた CLAUDE.md の委譲ルールが1つ目のプロンプト層で、ここにエージェント定義の制約(もう1つのプロンプト層)と、2つの hooks(機械的な強制)を足していきます。先ほどの2軸に対応づけると、再委譲(構造)は主にエージェント定義のツール制限で根本から止め、多段リレー(内容)は主に hook で止めるという役割分担になっています。

関連ファイルの構成は以下のとおりです。

.claude/
├── agents/
│   ├── investigator.md      # 調査担当(読み取り専用)
│   ├── implementer.md       # 実装担当
│   └── reviewer.md          # レビュー担当(読み取り専用)
├── hooks/
│   ├── agent-relay-guard.sh # PreToolUse: リレー検出・拒否
│   └── agent-turn-reset.sh  # UserPromptSubmit: 履歴リセット
├── tests/
│   └── test_agent_relay_guard.py  # hook の挙動テスト
├── agent-calls/             # Agent呼び出し履歴(セッションごとのJSONL、gitignore対象)
└── settings.json            # hooks の登録

この一式は、そのまま .claude/ に置いて使える最小構成のサンプルとして GitHub で公開しています。hook・テスト・エージェント定義がそろっているので、動かしな
がら読むとわかりやすいと思います。

エージェント定義に再委譲禁止を明記する

設定時に CLAUDE.md へ書いた委譲ルールだけでは足りませんでした。そこで .claude/agents/ の各エージェント定義にも、再委譲を禁止する制約を追記しました。先ほどの investigator.md に、次の「## 制約」セクションを足した形です。

## 制約

- 他のサブエージェントを呼び出さない(Agentツール使用禁止)。
  タスクが担当範囲を超える場合は、その旨を報告して終了する。
- 報告は結論と根拠のみを簡潔に。調査ログや試行過程を全文貼り付けない。

この「制約」セクションは implementer.md / reviewer.md にも同じ文面で入れています。ポイントは2つです。

  • Agent ツール使用禁止を明記し、担当範囲を超えたら「報告して終了」という逃げ道を用意する(禁止だけだと無理に自力で解決しようとするため)
  • 報告を「結論と根拠のみ」に絞る。前段の報告が短ければ、そもそも丸ごとリレーする材料が生まれにくい

なお investigatorreviewer は frontmatter の tools で使えるツール自体を読み取り系に絞っており、そもそも Agent ツールを持たせていません。プロンプトの制約とツール制限の二重がけです。

再委譲(サブエージェントがさらにサブエージェントを呼ぶ構造)を根本から止めているのは、実はこのツール制限です。 Agent ツールを持っていなければ、そもそもネストのしようがありません。この後の hook は、主にもう一方の軸である多段リレー(内容の丸ごと再送)を担当します。

ただし、ここまでは全部「お願い」です。CLAUDE.md もエージェント定義もプロンプトの一部でしかないので、コンテキストが長くなると平気で忘れられるんですよね。実際、明文化した後もリレーは散発しました。そこで hooks の出番です。

agent-relay-guard: PreToolUse hook でリレーを拒否する

本丸が agent-relay-guard.sh です。PreToolUse hook を Agent ツールにマッチさせ、Agent 呼び出しが実行される前にリレーかどうかを判定して、リレーなら拒否します。

参考: Claude Code の hooks(公式ドキュメント)

実体は bash スクリプトですが、bash 部分は薄いラッパーで、判定ロジック本体はスクリプト内にヒアドキュメントで埋め込んだ Python コード(PYSCRIPT)を python3 -c に渡して実行しています(抽出〜判定〜履歴の記録までを1つの Python プロセスに一本化し、ロジックの二重管理を避けるためです)。以降のコード例は、この Python 部分からの抜粋です。

仕組みはシンプルで、セッションごとの Agent 呼び出し履歴を .claude/agent-calls/<session_id>.jsonl に記録しておき、新しい呼び出しのたびに履歴と突き合わせます。

実は最初に作ったバージョンは、この履歴を同一セッション内でずっと持ち続ける実装でした。これがあとで誤検知の原因になるのですが、それは後述するとして、まずは判定ロジックを見ていきます。判定は2つです。

判定1: 同一役割への2回目の呼び出しを拒否する

investigator / implementer / reviewer の3役については、履歴に同じ役割の呼び出しが残っていれば一律拒否します。CLAUDE.md の「1つのタスクで各役割は最大1回ずつ」をそのまま機械化したものです(以下、最終版の該当部分の抜粋)。

ROLE_NAMES = {"investigator", "implementer", "reviewer"}

if subagent_type in ROLE_NAMES:
    for entry in history:
        if entry.get("subagent_type") == subagent_type:
            # この reason が最終的に deny の JSON として stdout に出力される
            reason = (
                f"同一タスク内で役割 '{subagent_type}' への2回目以降のAgent呼び出しは"
                f"サブエージェントの多段リレー防止のため拒否します。..."
            )
            break

この判定は、冒頭で挙げた再委譲のバックストップも兼ねています。implementer は(investigator / reviewer と違って)ツール制限をかけていないため Agent ツールを持っており、プロンプトの禁止をすり抜けて implementerimplementer を呼ぶ再委譲が起こり得ます。そのネストも「同じ役割の2回目」としてここで引っかかるので、エージェント定義のツール制限を主とし、この hook が二段目の網になります。

判定2: 直前呼び出しとの prompt 類似度で「丸ごと再送」を拒否する

役割が違っても、直前の Agent 呼び出しと prompt がほぼ同じなら、それは前段の結果の丸ごと再送です。prompt を行単位に正規化(前後空白を除去し空行を捨てる)した上で、行集合の Jaccard 係数を計算し、0.7 を超えたら拒否します。Jaccard 係数は、2つの集合の共通要素が全体(和集合)に占める割合を表す 0〜1 の類似度指標です(以下、該当部分の抜粋)。

SIMILARITY_THRESHOLD = 0.7

def line_similarity(a_lines: list[str], b_lines: list[str]) -> float:
    set_a, set_b = set(a_lines), set(b_lines)
    union = set_a | set_b
    if not union:
        return 0.0
    return len(set_a &amp; set_b) / len(union)

「前段の結果を丸ごと貼って、末尾に指示を1行足しただけ」のようなプロンプトは、行の大半が共通するので類似度が高く出ます。逆に、フェーズごとに内容を咀嚼して書き直したプロンプトなら共通行はほとんど残らないので通過します。

文字単位ではなく行単位の集合比較にしているのは、この「コピペ再送」の検出に特化するためです。こちらの判定は3役に限らず general-purpose などすべての subagent_type に効きます。

拒否時は、PreToolUse hook の JSON 出力で permissionDecision: "deny" を返します。出力の構造は次のとおりで、理由の全文は後述の「実際の挙動」で紹介します。

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "(拒否理由のテキスト)"
  }
}

設計上の工夫: ガードは fail-open に倒す

このガードで一番気を使ったのが異常系の扱いです。開発を止めないことが最優先なので、判定に必要な情報が揃わないケースはすべて許可側に倒す(fail-open) 設計にしています。

  • stdin が空・JSON が不正 → 許可
  • session_id が取れない(セッション単位で状態を分離できない) → 許可
  • 状態ディレクトリが作れない・履歴ファイルが書けない → 許可

さらに、誤検知したときのために環境変数によるバイパスを用意しています。

AGENT_RELAY_GUARD_DISABLE=1

これをセットするとチェックも履歴の記録もすべてスキップされます。拒否メッセージ自体にこのバイパス方法を書いてあるのもポイントで、誤検知に遭遇した未来の自分(や Claude)がその場で回避策にたどり着けます。

ここまでが対策の第1弾です。これで一件落着……と思いきや、導入してみると今度はガード自身が誤検知を起こしました。

agent-turn-reset: ユーザー発言をタスク境界として履歴をリセットする

誤検知の症状はこうです。初版のガードは履歴をセッション単位で持っていたため、同一セッション内では役割ごとに1回しか Agent を呼べず、独立した別タスクなのに2回目以降の呼び出しが拒否される。午前中に implementer を使ったせいで、午後の全く別の修正依頼で implementer が呼べない、という状態です。これは明らかにおかしいですよね。

原因は判定単位のズレでした。CLAUDE.md のリレー禁止ルールは「1つのタスクで各役割は最大1回ずつ」というタスク単位のルールなのに、初版のガードはこれをセッション単位で判定していたのです。ルールを機械化するときは、条件だけでなく適用単位まで正確に写し取る必要がありました。

そこで第2弾の修正として、判定単位をセッションからタスクへ揃えました。「ユーザーの新しい発言 = 新しいタスクの開始」とみなし、UserPromptSubmit hook(agent-turn-reset.sh)でそのセッションの呼び出し履歴を削除します。

# UserPromptSubmit hook: ユーザーの新しい発言 = 新しいタスクの開始とみなし、
# agent-relay-guard.sh が使う「そのタスク内のAgent呼び出し履歴」をリセットする。

# 古い(7日以上前の)セッション状態ファイルを掃除する。
find "$STATE_DIR" -maxdepth 1 -name '*.jsonl' -mtime +7 -delete 2>/dev/null || true

safe_session=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9_.-' '_')
rm -f "$STATE_DIR/$safe_session.jsonl" 2>/dev/null || true

exit 0

あわせて、ガード側の履歴にも TTL(既定30分 = 1800秒、AGENT_RELAY_GUARD_TTL_SEC で変更可)を導入し、リセットが何らかの理由で動かなかった場合も古い履歴を引きずらないようにしました。拒否メッセージも「同一タスク内」という表現に改め、「独立した別タスクなら、ユーザーの次の指示以降は再び呼び出せます」という再呼び出し可能な条件を明記しています。

これで「同一セッションでも、別タスクなら同じ役割を再び呼び出せる」という自然な挙動になりました。この hook もノンブロッキングで、何が起きても exit 0 します(リセットに失敗しても TTL が最終的に古い履歴を無効化してくれます)。

地味な注意点として、履歴を書く側(agent-relay-guard)と消す側(agent-turn-reset)で状態ディレクトリの既定値を同じ導出方法で揃える必要があります。ここがすれ違うと、リセットが効かずに誤検知が復活します。実装では両方とも hook スクリプト自身の位置からリポジトリルートを導出しており、この契約はテストで検証しています。

振り返ると、プロンプトで守らせられないルールを hook で機械化したら、今度は hook 側の「判定単位バグ」と付き合うことになったわけです。機械化は誤検知とセットで考え、fail-open・バイパス・境界でのリセットといった逃げ道を最初から用意しておくことの大切さを痛感しました。

実際の挙動

2つの hook は .claude/settings.json に次のように登録します。

{
  "hooks": {
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/agent-turn-reset.sh\"",
            "timeout": 10
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Agent",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/agent-relay-guard.sh\"",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

hook 単体の挙動は、JSON を stdin に流せば手元で確認できます。同じセッション ID で implementer を2回呼んでみます。

echo '{"session_id":"demo","tool_name":"Agent","tool_input":{"subagent_type":"implementer","prompt":"課題Aの実装をお願いします。"}}' \
  | .claude/hooks/agent-relay-guard.sh

1回目は何も出力されず終了コード0(許可)です。続けて2回目を実行すると deny の JSON が返ります。

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "同一タスク内で役割 'implementer' への2回目以降のAgent呼び出しはサブエージェントの多段リレー防止のため拒否します。独立した別タスクなら、ユーザーの次の指示以降は再び呼び出せます。誤検知の場合は環境変数 AGENT_RELAY_GUARD_DISABLE=1 で一時的に無効化できます。"
  }
}

類似度判定に引っかかった場合は、理由に類似度の実測値が入ります。investigator の報告を丸ごと implementer に再送しようとしたケースでは、こんなメッセージで拒否されました。

直前のAgent呼び出し(役割: investigator)とpromptの類似度が高く(0.83 > 0.7)、
前段の結果の丸ごと再送とみなし拒否します。
誤検知の場合は環境変数 AGENT_RELAY_GUARD_DISABLE=1 で一時的に無効化できます。

実セッションでは、Claude が Agent ツールを呼ぼうとした瞬間にこの deny が割り込み、ツール実行はブロックされます。Claude 側には拒否理由がそのままフィードバックされるので、Claude は「リレーが禁止されている」ことをその場で理解し、前段の結果を自分で咀嚼して直接作業を進めるか、次フェーズ用にプロンプトを書き直す方向に軌道修正します。

つまり、拒否理由がそのまま Claude への行動指針になるように文面を書いておくのがコツです。

実際にガードが拒否したときの様子がこちらです。拒否理由が赤字のエラーとして表示され、直後に Claude が「自分は implementer なのだから Agent ツールを呼ぶべきではない、直接実装しよう」と軌道修正しています。

なお、こうした hook はエージェントの動作を止めうるものなので、テストを書いておくと安心です。このリポジトリでは「同一役割の2回目は拒否」「ユーザー発言後は再び許可」「別セッションには影響しない」「TTL切れの履歴は無視」「壊れた JSON は許可(fail-open)」といった分岐を unittest で網羅しています。

追記: リレーガードが並列実行を止めてしまった

ここまでで一段落……と思っていたのですが、しばらく運用するうちに、このガード自身にもう1つ穴が見つかりました。今度は誤検知どころか、推奨していたはずの並列委譲まで巻き添えで止めてしまうという、なかなか根の深い問題でした。この後日談も含めて共有します。

症状はこうです。設定時の CLAUDE.md には「互いに独立したタスクは、1つのメッセージで複数の Agent 呼び出しを同時に発行して並列実行する」と書いていました。ところがいざ独立タスクを並列で投げると、implementer を3本同時に発行したうちの2本目以降がガードに拒否されるのです。前述の「判定1: 同一役割への2回目の呼び出しを拒否する」が、並列に発射した兄弟呼び出しまで「2回目」と数えてしまっていました。リレーを止めるつもりのガードが、自分で推奨した並列委譲を殺していたわけです。

なぜ並列とリレーを取り違えたのか

原因は、判定が回数と順序だけを見ていたことでした。「同一役割の2回目」も「直前の呼び出しとの類似度」も、時間的な前後関係しか見ていません。しかし、そもそも本物のリレーの定義は「前段が 完了 し、その結果を受け取ってから、その内容を次段へ渡す」ことです。この「完了してから」という条件がすっぽり抜けていました。

1つのメッセージで同時に発射した並列の兄弟呼び出しは、まだ誰も完了していません。それを「同じ役割の2回目」と数えてしまったのが取り違えの正体でした。リレーと並列は、回数で見ると区別がつかないのです。

判定対象を「完了済みの呼び出し」だけに絞る

そこで判定の軸を回数から完了へ切り替えました。比較対象を「完了済みの Agent 呼び出し」だけに限定するのがポイントです。

こうすると並列は構造的に必ず許可されます。1メッセージで同時発行した兄弟たちは、お互いまだ完了していないので、判定時点で比較対象がゼロ。比較する相手がいなければ、リレー判定のしようがなく素通りします。役割ごとの回数制限はきれいに撤廃し、「何回呼んだか」ではなく「完了した前段の内容を使い回しているか」という内容ベースの判定に置き換えました。

「完了」は SubagentStop で捉える(PostToolUse ではない)

ここで地味に嵌まったのが、「完了」をどのイベントで捉えるかです。素直に考えると Agent ツールの PostToolUse(実行後)が完了に思えますが、これはでした。

Claude Code のサブエージェントは既定でバックグラウンド実行されるため、PostToolUse起動が返った瞬間tool_response.statusasync_launched)に発火します。つまり実処理の完了ではなく、あくまで「起動できた」の合図です。実測すると、PostToolUse は起動の約0.4秒後、並列呼び出しどうしの間隔は約0.8秒、実際の完了イベントは約6秒後でした。PostToolUse を完了とみなすと、並列2本目の PreToolUse より前に「1本目は完了済み」と誤認してしまい、また並列が壊れます。

そこで「完了 = SubagentStopと定義し直しました。役割を3つの hook に分けます。

  • PostToolUse(Agent、agent-call-record.sh)… その呼び出しの prompt と agent_id の紐付けを記録する(起動時点。完了ではない
  • SubagentStopagent-call-complete.sh)… サブエージェントの最終報告テキストと完了を記録する
  • PreToolUse(Agent、agent-relay-guard.sh)… 上記2つが書いた記録を読んで判定する

判定側が見るのは、SubagentStop が書いた「完了済み」の記録だけ。これで初めて、並列の兄弟が互いを完了済みとみなさないことが保証されます。

deny と ask を使い分ける

内容ベースに寄せたことで、判定は2段階になりました。

  • 完了済みエージェントの「出力」を丸ごと貼り付けて再送している(出力の行が高い割合で prompt に含まれる) → 強い証拠なので deny
  • 完了済みエージェントの「prompt」の使い回し(行集合の類似度が高い) → グレーなので ask

以前は類似度が高ければ一律 deny でしたが、ここを ask(確認)に緩めました。似た前置き(リポジトリの説明やテストコマンドなど)を共有する独立タスクを、うっかり殺さないためです。ask なら「これは独立した別作業です」と承認してそのまま続行できます。あわせて、箇条書き記号やコードフェンスのような短い定型行だけの偶然の一致で誤検知しないよう、比較する行に最小文字数・最小行数の下限も設けました。

状態設計を作り直し、再委譲の深さは公式の仕組みに任せる

判定単位をタスクに揃えるために前章では agent-turn-reset.sh で履歴を削除していましたが、この作り直しでその hook 自体が不要になりました。状態を 1呼び出し1ファイル<session>/<prompt_id>/<id>.{start,call,done}.json)に分解し、タスク境界を prompt_id で表現するようにしたためです。ユーザーの新しい発言は新しい prompt_id、つまり別ディレクトリになるので、履歴は削除しなくても自動的に切り替わります。1ファイル1呼び出しなのでロックなしで並列安全になり、「拒否された呼び出しが次の判定を巻き込む」カスケードも消えました。かつての削除リセット hook は、古いセッションを掃除するだけの agent-calls-gc.sh に縮小しています。

もう1つ、内容ベースに寄せたことで、以前は「判定1」が兼ねていた再委譲(ネスト)のバックストップが外れました。これは公式の仕組みに委ねます。サブエージェント内からの呼び出し(入力に agent_id が入る)はガードの判定対象外にし、入れ子の深さ制限は Claude Code 公式の環境変数 CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH に任せることにしました。.claude/settings.jsonenv1 に設定しています。

{
  "env": {
    "CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH": "1"
  }
}

hook の登録も、記録用の2つ(PostToolUse / SubagentStop)が増え、UserPromptSubmit は掃除用の agent-calls-gc.sh に差し替わります。

{
  "hooks": {
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/agent-calls-gc.sh\"",
            "timeout": 10
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Agent",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/agent-relay-guard.sh\"",
            "timeout": 10
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Agent",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/agent-call-record.sh\"",
            "timeout": 10
          }
        ]
      }
    ],
    "SubagentStop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/agent-call-complete.sh\"",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

結果として、防御は「ツール制限(エージェント定義)+ 深さ上限(公式の env)+ 内容判定(hook)」という、役割がきれいに分かれた3枚構成に落ち着きました。回数で殴るのをやめて「完了」と「内容」で見るようにしただけで、並列委譲もリレー防止も両立できたのは、我ながらスッキリした着地でした。

さいごに

Claude Code のマルチエージェント運用で起きた「再委譲」と「多段リレー」を、多層の防御で解決するまでの試行錯誤の話でした。要点をまとめます。

  • 問題は2軸あり、再委譲(構造・深さ: サブエージェントがサブエージェントを呼ぶ)と、多段リレー(内容: 前段の結果を咀嚼せず丸ごと再送する)。似て見えるが原因が違う。
  • 防御は役割で分ける。再委譲はエージェント定義のツール制限+公式の CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTHで深さを止め、多段リレーは PreToolUse hook(agent-relay-guard)が内容を見て止める
  • リレーと並列は回数では区別できない。判定を「回数」から「完了」へ切り替え、比較対象を完了済み(SubagentStop)の呼び出しだけに絞ることで、並列の同時発行を構造的に常に許可しつつリレーだけを止める
  • 内容判定は2段階。完了済みの出力の丸ごと再送は deny、prompt の使い回しは ask にして、似た前置きを共有する独立タスクを殺さない

一般化すると、プロンプト指示だけでは守られないルールは、hook で機械的に強制するというのが今回の教訓です。CLAUDE.md に何を書いても、それはあくまで「お願い」であり、コンテキストが長くなれば忘れられます。破られると困るルールほど、hook のような決定的な仕組みに落とすべきで、その際は fail-open とバイパスをセットで用意しておくと運用が破綻しません。

ちなみに、エージェント定義に「再委譲禁止の記述が存在すること」自体は、自作の Linter である agentlint(ルール AL401)で静的に検証するようにしています。agentlint については別の記事で詳しく紹介しているので、あわせてどうぞ。

サブエージェントの委譲制御に悩んでいる方は、いきなり拒否まで作り込まなくても、まずは PreToolUse hook で Agent 呼び出しをログに記録するところから試してみてください!それでは!

ご覧いただきありがとうございます! この投稿はお役に立ちましたか?

役に立った 役に立たなかった

0人がこの投稿は役に立ったと言っています。
エンジニア募集中!

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です