0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

claude -pの非同期エージェント、1回の実行でresultが2回返ってきた

0
Last updated at Posted at 2026-07-19

はじめに

claude -p ... --output-format stream-json の出力を jq で1行だけ拾って自動化を組んでいる人は多いはずです。「最初に来た result タイプの行が最終結果」という前提は、サブエージェントが絡まない限り正しく動きます。

ところが Claude Code 2.1.211(Week 29・2026-07-13〜17の更新)で追加された --forward-subagent-text を使い、実際にバックグラウンドのサブエージェントを1つ起動しただけの単純なリクエストを実行してみると、1回の claude -p 呼び出しの中で result タイプの行が 2回 出てきました。しかも最初の result は「まだ実行中です」という中間報告で、本当に欲しい最終結果は2番目の result にしか入っていません。

jq -r '.result' を素朴にパイプする自動化は、先頭の result を拾って処理を終えてしまう可能性があります。この記事では実際に流したコマンドとその生ログを示しながら、何が起きているのかを公式ドキュメントの記述と突き合わせて確認します。

この記事で学べること

  • --forward-subagent-text / CLAUDE_CODE_FORWARD_SUBAGENT_TEXT が stream-json 出力に何を追加するか
  • parent_tool_use_id を使ってメインエージェントとサブエージェントの発言を区別する方法
  • 非同期(バックグラウンド)サブエージェントを使うと result イベントが複数回出るケースがあること
  • claude -p を自動化パイプラインに組み込むときに気をつけるべき点

対象読者

  • claude -p をCI・スクリプト・自前のオーケストレーションに組み込んでいる方
  • Claude Agent SDK(CLI版)のstream-json出力をパースするコードを書く方
  • サブエージェントの動きをログとして可視化したい方

前提環境

  • Claude Code CLI 2.1.211(claude --version で確認)
  • 実行場所: Claude Code on the web のクラウド実行コンテナ(Linux)
  • 検証は --tools "Task" --permission-mode dontAsk で限定した非対話セッションを、リポジトリ外の一時ディレクトリで実行(本番の作業ツリーには影響しない)

TL;DR

  • --forward-subagent-text(v2.1.211〜)を付けると、サブエージェントの thinking/text ブロックも assistant メッセージとして stream-json に流れてくる(デフォルトは tool_use/tool_result のみ)
  • サブエージェントのメッセージは parent_tool_use_id に「そのサブエージェントを起動した tool_use のID」が入る。メインの発言は parent_tool_use_id: null
  • サブエージェントを バックグラウンド(非同期) で起動すると、claude -p は1回の呼び出し中で result タイプの行を複数回出すことがある。最初の result は暫定の応答で、本当の最終出力は末尾の result にしか入っていない

検証: 実際に動かしてみる

ステップ1: 何もない状態のstream-json

まずサブエージェントを使わない単純なリクエストで、ベースラインを確認します。

claude -p "Say hello in one short sentence." \
  --output-format=stream-json --model haiku --tools "" \
  --no-session-persistence --safe-mode --verbose

このケースでは system(init)assistant(thinking)assistant(text)result の順で4行出力され、result タイプの行は1回だけでした。サブエージェントが登場しない限り「result は1回」という前提は成り立ちます。

ステップ2: --forward-subagent-text でサブエージェントを覗く

次に、Task(Agent)ツールだけを許可し、サブエージェントを1つ起動させるプロンプトを与えます。

claude -p "Use the Task/Agent tool once to launch a general-purpose subagent \
with the exact instruction 'Reply with only the word DONE, no explanation.' \
Do not ask for confirmation. Do nothing else." \
  --output-format=stream-json --forward-subagent-text --model haiku \
  --tools "Task" --permission-mode dontAsk --max-budget-usd 0.10 \
  --no-session-persistence --safe-mode --verbose

出力は48行に増えました。メインエージェントがサブエージェントを起動する行はこうなります(agentId は内部識別子のため伏せています)。

{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_01ChSAq3quLKn5dAkuCMeP4p","name":"Agent","input":{"description":"Simple agent test","subagent_type":"general-purpose","prompt":"Reply with only the word DONE, no explanation."}}]},"parent_tool_use_id":null, ...}

続くツール結果は「非同期でエージェントを起動した」という内容で、system イベントとして background_tasks_changedtask_started も流れてきます。

{"type":"system","subtype":"background_tasks_changed","tasks":[{"task_id":"<redacted>","task_type":"local_agent","description":"Simple agent test"}]}
{"type":"system","subtype":"task_started","task_id":"<redacted>","tool_use_id":"toolu_01ChSAq3quLKn5dAkuCMeP4p","subagent_type":"general-purpose","task_type":"local_agent","prompt":"Reply with only the word DONE, no explanation."}

ここまでは通常の非同期エージェント管理イベントです。--forward-subagent-text の効果が出るのはこの直後で、サブエージェント自身の思考・返答が assistant メッセージとして流れてきます。

{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"The user is asking me to reply with only the word \"DONE\"..."}]},"parent_tool_use_id":"toolu_01ChSAq3quLKn5dAkuCMeP4p"}
{"type":"assistant","message":{"content":[{"type":"text","text":"DONE"}]},"parent_tool_use_id":"toolu_01ChSAq3quLKn5dAkuCMeP4p"}

この2行の parent_tool_use_id は、サブエージェントを起動した tool_use のIDと一致しています。--forward-subagent-text を付けなければ、この2行は流れず、サブエージェントの完了はツール結果(tool_result)としてしか分かりません。

観察: result タイプの行が2回出た

全48行を type で見ていくと、result タイプの行は次の2つだけでした。

出現位置 subtype result の中身 num_turns duration_ms
35行目 success Agent launched. Awaiting response. 2 5523
48行目(最終行) success DONE 1 2107

つまりメインエージェントは「起動しました。応答を待っています」という発言を自分の1ターン目の結論として result に出力し、そこで見かけ上いったん完結します。ところがサブエージェントの完了通知が届くと、claude -p のプロセスはまだ生きたままもう1ターン実行し、最終的な DONE を含む2つ目の result を出してから終了しました。

なぜこうなるのか

Anthropicのheadless実行ドキュメントには次の記述があります。

Background subagents and workflows are exempt from the five-second grace because their result is part of the final output, so claude -p waits for them to complete. From v2.1.182, that wait is capped at ten minutes by default so a stuck background agent cannot hold the process open indefinitely.
Run Claude Code programmatically(Background tasks at exit)

「サブエージェントの完了を待ってからプロセスを終了する」という説明は正しく、実際に claude -p はDONEが返るまでプロセスを終了しませんでした。ただし今回の実測で分かったのは、「待っている間、メインエージェント自身が先に1つの result を出力している」という点です。ドキュメントは「最終的にプロセスが待つ」ことは説明していますが、「途中経過も result として出力されうる」点までは明記していません。

--forward-subagent-text のドキュメントにあるとおり、parent_tool_use_id を見ればメインとサブエージェントの発言は区別できます。しかし result タイプの行が複数出るケースがある以上、parent_tool_use_id だけでは「これが本当に最後の result か」は判定できません。

実務への影響

claude -p ... --output-format stream-json | jq -r '.result' のように 最初に見つかった result を採用する書き方は、サブエージェントを非同期で使うプロンプトでは意図しない中間報告を拾うリスクがあります。パイプライン側で対応するなら次のいずれかが安全です。

  • ストリームを最後まで読み切り、最後に出現した result 行を採用する
  • jq -s '.[-1]' のようにJSON配列化してから末尾を取る
  • --output-format json(stream-jsonではなく単発JSON)に切り替え、プロセス終了後の1レスポンスだけを扱う設計にする

本プロジェクトの hourly-dispatch のように、Claude自身が別のClaude Codeプロセスをオーケストレーションする構成では、この「result は1回とは限らない」という前提はそのまま設計上の注意点になります。

まとめ

  • --forward-subagent-text(v2.1.211〜)は parent_tool_use_id 付きでサブエージェントの thinking/text を stream-json に流す
  • 非同期サブエージェントを使うと、claude -presult タイプの行は1回とは限らない。今回の検証では2回出力され、本当の最終結果は末尾の行にしかなかった
  • claude -p の出力をパースする自動化は「最初の result」ではなく「最後の result」を最終値として扱うのが安全

著者視点の発見ポイント

公式ドキュメントは「バックグラウンドサブエージェントの完了をプロセスが待つ」ことまでは明記していますが、「待っている間にメインエージェントの中間発言が独立した result として出力される」点までは記載がありませんでした。実際にコマンドを流して生ログを行ごとに確認するまで、result の出現回数が1回に固定されていないことには気づけませんでした。

関連記事

参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?