M.

Claude Code のサブエージェントの結果はどう届くのか。届かなかった件を家で再現しようとした

Claude Code では、調べものや作業の一部をサブエージェントに任せられます。Agent ツールで起動すると、別のコンテキストで作業し、終わると結果を親(呼び出した側)に返します。

仕事で使っていたとき、サブエージェントが作業を最後まで終えているのに、その結果が親に届かない ことがありました。親のほうは結果を待ったまま、あるいは結果を受け取ったつもりで先に進んでしまいます。

原因を確かめたくて、家で同じような状況を作ってみました。結論から言うと、家では再現しませんでした。そのかわり、結果がどう届くのかの仕組みと、届かなかったときの回収方法がはっきりしました。実験の途中で、別の種類の「食い違い」にも出会ったので、それも書きます。

※ 環境は Windows、Claude Code 2.1.296 です。

実験の内容

課題はわざと単純にしました。このサイトのリポジトリで、3つのフォルダのファイル数を数えるだけです。

  • 記事(src/content/articles/*.md)
  • 制作物(src/content/projects/*.md)
  • ページ(src/pages/**/*.astro)

これを2通りで試しました。

  • 実験A:メインの会話から、サブエージェントを3つ同時に起動する
  • 実験B:メインの会話からサブエージェントを1つ起動し、そのサブエージェントが さらに3つを同時に起動する(入れ子)

答えは自分で ls と find を使って数え、記事35件・制作物21件・ページ10件だと確かめておきました。

実験A:起動した直後に返ってくるのは「起動確認」だけ

Agent ツールを呼ぶと、すぐに次のような結果が返ってきます。

Async agent launched successfully.
The agent is working in the background. You will be notified automatically when it completes.

これは 結果ではありません。「バックグラウンドで起動した、終わったら知らせる」という起動確認です。サブエージェントは非同期で動いていて、この時点ではまだ何も数えていません。

結果はあとから、別のメッセージとして届きます。今回は、3つとも次の2つが届きました。

届くもの中身
報告のメッセージサブエージェントの最終報告の本文(今回なら「articles: 35件」)
完了通知(task-notification)「完了した」という知らせと、使ったトークン数・ツール呼び出しの回数・かかった時間

かかった時間は、3つとも6〜9秒でした。結果は3つとも正解と一致しました。

仕事で「届かなかった」と思ったときを振り返ると、親が 起動確認を結果と取り違えて 先に進んでいた場面もありました。起動確認には「結果はまだ何も分かっていない」とはっきり書かれていますが、読み手(親のモデル)がそれを無視することはあります。

実験B:入れ子でも結果は届いた

次は入れ子です。真ん中のサブエージェントに「子を3つ起動して、結果が届くのを待ち、届いたかどうかを正直に報告して」と頼みました。

結果は、3つとも届きました。真ん中のサブエージェントの報告にも、正しい件数が並んでいました。

真ん中のサブエージェントのログを見ると、流れは次のとおりです(時刻は分:秒)。

時刻起きたこと
39:09〜12子を3つ起動する。返ってきたのは起動確認だけ
39:15「結果を待ちます」と書いて手番を終える
39:19〜23子の報告が1つずつ届く。そのたびに起き上がって「あと2つ」「あと1つ」と書き、また手番を終える
39:293つそろったので、最終報告を親(メインの会話)に送る

完了通知に付いている注記によると、サブエージェントの完了通知は 「自分が起動した子が、もう動いていない状態で止まったとき」 に出ます。子が動いている間に手番を終えても、まだ完了扱いにはなりません。今回も、真ん中のサブエージェントの完了通知は、子が3つとも終わったあとに届きました。

仕事で届かなかったときは、親をバックグラウンドで動かしていたり、Claude Code の版が古かったりと、条件がいくつか違いました。どれが原因だったのかは、今回の実験では分かりません。また、途中でメインの会話を中断した場合も試していません。

結果は届いたのに、説明が間違っていた

実験Bには続きがありました。最終報告を受け取ったあと、真ん中のサブエージェントから もう1通 メッセージが届いたのです。

前に届いた「待ちますの」の1行は途中経過で、最終報告ではありませんわ。手番を終えた時点のテキストが報告として先に送られたようですの。

(わたくし口調なのは、私の CLAUDE.md の設定のせいです)

ところが、私の手元に最初に届いていたのは、1行ではなく全文の最終報告でした。どちらが正しいのか、ログで確かめました。

時刻起きたこと
39:29最終報告を送る。「Report delivered to your caller.」で 成功
39:30完了通知(task-notification)を受け取る
39:36同じ報告をもう一度送ろうとして、「Nothing was sent: your report was already delivered」で断られる
39:48断られた理由を「前の1行が報告扱いになったから」と考え、別の手段で送り直す

つまり、結果の受け渡しは正しく動いていて、間違っていたのはサブエージェントによる「何が起きたか」の説明 でした。報告は1回目でちゃんと届いていました。二度目を断られた理由を、サブエージェントが推測で埋め、それが外れていたわけです。

サブエージェントは、自分の外で何が起きたかを直接は見られません。見えないところは、もっともらしい説明で埋めてしまいます。今回はログを見ればすぐに分かりましたが、報告をそのまま信じていたら、「途中のテキストが最終報告扱いになることがある」という、ありもしない仕様を覚えるところでした。

結果はログに残っている

結果が届かなかったとき、あるいは届いた内容が怪しいときは、ログを見ます。サブエージェントのログは、セッションのログと同じ場所の下にあります。

~/.claude/projects/<プロジェクト>/<セッションID>/subagents/
  agent-<ID>.jsonl       サブエージェントの会話ログ
  agent-<ID>.meta.json   起動したときの情報

どちらも、起動した瞬間に作られます。入れ子の子も、同じフォルダに並びます。

meta.json には、次のような情報が入っています(入れ子の子の例)。

{
  "agentType": "general-purpose",
  "description": "articles の md 数カウント",
  "parentAgentId": "a4e4…",
  "spawnDepth": 2,
  "requestShape": "background",
  "model": "haiku"
}
  • description:起動したときに付けた短い説明。どのログがどの作業かは、これで見分ける
  • spawnDepth:メインの会話から何段目か。入れ子の子は2
  • parentAgentId:起動したサブエージェント。入れ子のときだけ付く

結果は .jsonl の中、最後のほうの assistant の発言にあります。ただ、ファイルは大きめです。ファイル数を数えるだけの作業でも、1つ120KB 前後ありました。Claude にそのまま読ませるとコンテキストを圧迫するので、結果だけを抜き出す小さなスクリプトを書きました。

import json, sys
from pathlib import Path

# 使い方: python subagent_results.py ~/.claude/projects/<プロジェクト>/<セッションID>
d = Path(sys.argv[1]).expanduser() / "subagents"
for meta_path in sorted(d.glob("agent-*.meta.json")):
    meta = json.loads(meta_path.read_text(encoding="utf-8"))
    log = meta_path.with_name(meta_path.name.replace(".meta.json", ".jsonl"))
    last, handback = "", ""
    for line in log.read_text(encoding="utf-8").splitlines():
        o = json.loads(line)
        if o.get("type") != "assistant":
            continue
        for c in o["message"].get("content", []):
            if c.get("type") == "text":
                last = c["text"]
            elif c.get("type") == "tool_use" and c.get("name") == "SubagentHandback" and not handback:
                handback = c["input"].get("message", "")
    last = handback or last  # 最終報告(最初の SubagentHandback)を優先し、なければ最後の発言
    print(f"[depth {meta.get('spawnDepth')}] {meta.get('description')}")
    print("  " + last.strip().splitlines()[0] if last.strip() else "  (結果なし)")

今回のセッションで実行すると、こう出ます。

[depth 1] 実験A: ファイル数カウント1
  articles: 35件
[depth 1] 実験B: 入れ子の親
  観察結果の報告ですわ。(以下略)
[depth 2] articles の md 数カウント
  articles: 35件
...

2.1.296 では、最終報告は SubagentHandback というツールの呼び出しとして記録されていました。ログの形式は公開された仕様ではないので、版が変わると変わるかもしれません。

もう一つの回収方法は、そのサブエージェントに続きを頼むことです。起動確認に書かれている ID 宛てに SendMessage で「結果をもう一度送って」と頼めば、会話の続きとして答えが返ってきます。ログを読むより手軽ですが、相手がまだ再開できる状態にある必要があります。

まとめ

  • Agent ツールが起動直後に返すのは 起動確認 で、結果ではない。結果はあとから、報告のメッセージと完了通知として届く
  • 2.1.296 の家での実験では、3並列でも入れ子でも、結果は正しく届いた。仕事で届かなかった原因は分からないまま
  • 完了通知は、サブエージェントが「自分の子がもう動いていない状態で止まった」ときに出る
  • サブエージェントは、自分に起きたことの説明を間違えることがある。結果の値だけでなく、経緯の説明もログで確かめる
  • ログは ~/.claude/projects/<プロジェクト>/<セッションID>/subagents/ にある。meta.json の description で作業を見分け、spawnDepth と parentAgentId で入れ子をたどる
  • 届かなかったら、SendMessage で続きを頼むか、ログから抜き出す

「届かなかった」の原因を探しにいったら、見つかったのは「説明が外れていた」でした。サブエージェントの報告は便利ですが、それ自体もモデルの出力です。数字は確かめるのに、経緯の説明は信じてしまう、ということがないようにしたいところです。

この記事をシェア

関連する記事

Claude Code の auto mode に止められた操作を1か月分集計した(64件の内訳と、許可リストの落とし穴)

Claude Code の auto mode が拒否した操作を、会話ログから1か月分集計しました。2.2万回のツール呼び出しのうち止められたのは64件。半分近くは本番デプロイで、読むだけの grep が止められた例もありました。公式ドキュメントと照らし合わせて分かった、許可リスト(permissions.allow)が効く・効かない条件もまとめます。

Claude Code に GCP の課金を棚卸しさせたら、月1万円が半分になった(失敗込みの記録)

個人開発で使っている GCP の全プロジェクトを、Claude Code と一緒に横断して見直しました。gcloud での調査、ストレージの掃除、通信料の削減、そして CUD の二重契約という失敗まで。AI に任せられたこと・人間がやる必要があったことを整理します。

Claude Code の会話は30日で消え、compact で要約される。何をどこに残すか

Claude Code の会話ログは既定で30日で削除され、長い会話は compact で要約されます。1か月分のログを数えると、3割のセッションで compact が起きていました。compact のあとに何が残り、何が落ちるのかを公式ドキュメントで確かめ、CLAUDE.md・auto memory・リポジトリのドキュメントの使い分けをまとめます。

npm 版の Claude Code は更新が遅いと思って移行したら、原因は自分の設定だった

新しいモデルがすぐ使えないのは npm 版のせいだと思い、Claude Code をネイティブ版に移行しました。ところが公式ドキュメントでは npm 版も中身は同じで、自動更新もあります。claude doctor で調べると、自動更新を止めていたのは自分で入れた環境変数でした。npm 版の残りの消し方と合わせてまとめます。

テーマから探す