Claude Media
ツールストリーミングでinvalid jsonが来たときの直し方

ツールストリーミングでinvalid jsonが来たときの直し方

fine-grained tool streamingで届いた入力がJSONとして壊れているとき、INVALID_JSONラッパーとis_error:trueでClaudeに返す公式の対処パターンを解説します。

ツールストリーミングでinvalid jsonが来たらどうするか

結論は「ツールを実行せず、壊れた入力をそのままClaudeに送り返す」です。JSONとしてパースできない以上、そのツールは実行できません。かといって黙って無視すると、Claudeは自分の出力がどう処理されたか分からないまま次のターンに進みます。公式の返し方は、パースできなかった文字列を INVALID_JSON というキー1つのJSONオブジェクトで包み、is_error: true を付けたツール結果として返すものです。

壊れた入力が届く場面は、eager_input_streamingでツール入力を高速表示する設定を有効にしたツールだけではありません。この設定ではAPIがパラメータ値のバッファリングも検証もせずに断片を流すため、組み上げた文字列がJSONとして不完全になりえます。設定を使わない通常のストリーミングでも、応答が max_tokens で止まるとパラメータが途中で切れます。

壊れた入力を想定していない実装では、パースの例外でプロセスごと落ちるか、例外が握りつぶされて会話が止まるかのどちらかになりがちです。どちらも、ユーザーからは理由の分からない失敗に見えます。

パース失敗はストリームのどこで分かるか

壊れた入力が見つかるのは content_block_stop の時点です。

落とし穴は、その時点では止まった理由をまだ知らないことです。stop_reason は message_start では null で、値は後ろの message_delta で届きます。

この連結とパースの手順は、eager_input_streaming を使うかどうかに関係なく同じです。違うのは、サーバーが断片を検証せずに流すため、組み上げた文字列が有効なJSONとは限らないと前提にする点です。

手順

パース失敗が見つかるまでのイベントの順

  1. 1

    input_json_delta を連結する

    partial_json の断片を、ブロックの index ごとの文字列に足していきます。

  2. 2

    content_block_stop でパースする

    ここで失敗したら、生の文字列とブロックのidを手元に保持します。まだ返信は組み立てません。

  3. 3

    message_delta で stop_reason が届く

    max_tokens かどうかが、このイベント以降に分かります。SDKの最終メッセージ(Pythonの get_final_message() など)から読んでも同じ値です。

  4. 4

    stop_reason を見て返信を組み立てる

    分岐の中身は次の節の2通りです。tool_result は、ここで初めてuserメッセージに入れます。

PHP以外の主要SDK(Python / TypeScript / Go / Java / Ruby / C#)にはアキュムレータのヘルパーがあります。公式はPHPについて、手動で組む方法が想定された使い方だと説明しています。手動で組むなら、上の順序をそのままコードにします。

INVALID_JSONラッパーとis_error:trueで返す

パースできなかった生の文字列を失わずに保持し、INVALID_JSON というキーを持つJSONオブジェクトに包みます。

{
  "INVALID_JSON": "<パースできなかった入力そのもの>"
}

キー1つのラッパーを勧める理由は2つで、Claudeに「壊れたJSONを受け取った」と一意に伝わること、元の入力がデバッグ用に残ることです。tool_result の content はJSONである必要はありません。ラッパーは約束事であって、必須の書式ではない点は覚えておくと設計しやすくなります。

ラップしたオブジェクトを文字列化して content に入れ、is_error を true にします。

{
  "type": "tool_result",
  "tool_use_id": "toolu_01A09q90qw90lq917835lq9",
  "is_error": true,
  "content": "{\"INVALID_JSON\": \"<パースできなかった入力そのもの>\"}"
}

tool_use_id には、パースに失敗した tool_use ブロックのidをそのまま指定します。この tool_result はAPIの形式の制約を受けます。

対応する tool_use を含むassistantターンの直後のuserメッセージに入れます。間に別のメッセージを挟むことはできません。

くらべる

同じuserメッセージの並べ方

tool_result が先

通る並び

tool_result ブロックを並べたあとに、必要ならテキストを置きます。INVALID_JSON を返す tool_result も、他のツール結果と同じメッセージにまとめられます。

text が先

400 になる並び

{ "type": "text", ... } を tool_result の前に置くと、400エラーになります。

assistantターンに、結果がまだ返っていないサーバーツールの呼び出しが含まれている場合は、さらに厳しくなります。userメッセージを tool_result だけで構成しないと、そのターンが早期に終了し、Claudeが直接呼んだサーバーツールについては、未解決のツール名を含む400エラーになります。

返したあと、Claudeは何をするか

is_error: true の結果を受け取ったClaudeは、次のターンで入力を直して同じツールを呼び直すか、別の手を選びます。会話の往復の中で修正させるので、クライアント側に専用のリトライループは要りません。

エラー処理のページには、近い例が載っています。必須パラメータが足りないツール呼び出しに is_error: true で不足を伝えると、Claudeは補った入力で呼び直します。このページは、無効または必須パラメータ不足のツール呼び出しについて、Claudeが2〜3回修正を試みたあとにユーザーへ謝る、とも説明しています。ただし INVALID_JSON への言及はなく、同じ回数が当てはまるとは書かれていません。

返すときのコツも同じページにあります。"failed" のような一般的な文字列ではなく、何が起きたかと次に試すことを書いたエラーメッセージのほうが、Claudeが立て直しやすくなります。公式の例は "Rate limit exceeded. Retry after 60 seconds." のように、原因と次の行動を1文に収めています。content はJSONでなくてよいので、ラッパーに加えて一文の指示を添える設計も取れます。

逆に、tool_result を返さずに次のリクエストを送ると、ツール結果が直後にないという形式エラーになります。ツール呼び出しを含むターンの直後には、対応する tool_result を持つuserメッセージが必要です。壊れた入力でも、スキップはできません。

max_tokensで切れたときはラッパーを返さない

同じ「パースできない」でも、原因は2つに分かれます。ここで stop_reason が役に立ちます。

まとめ

stop_reason で分ける2通りの対応

  • max_tokens で切れた

    生成の上限に達し、パラメータが物理的に途中で止まっています。max_tokens を上げて同じリクエストを送り直します。

  • それ以外の理由で壊れた

    INVALID_JSON ラッパーを is_error: true で返し、Claude自身に入力を直して呼び直させます。

トークン上限で切れた入力を「JSONが壊れていた」とだけ伝えても、Claudeは上限に当たった事実を知らず、上限に当たった事実は伝わりません。

ただし、対処は2か所で少し違います。fine-grained tool streaming(eager_input_streaming)のページは、max_tokens で止まったら「max_tokens を上げて再試行するか、途中までの入力を修復するか」を判断するよう書いています。一方、停止理由のページは、不完全な tool_use ブロックを含んで max_tokens で切れた応答には、max_tokens を上げて再試行するよう求め、最後のブロックが tool_use かどうかを見るコード例を載せています。途中の入力を修復して使う道を取るなら、壊れた箇所が値の中なのか構造の途中なのかを、自分のコードで判断する必要があります。停止理由全般の扱いは、stop_reasonとエラーの違いにまとめています。

Tool Runnerを使っているときの境目

ここまでの tool_result と is_error の組み立ては、ツール呼び出しの往復を自分で書く場合の実装です。往復をSDKに任せるTool Runnerでツール呼び出しループを自動化するでは、ツール関数が投げた例外をランナーがキャッチし、is_error: true のツール結果としてClaudeに返します。

この変換の対象は、ツール関数が呼ばれたあとの例外です。ストリームから組み上げた入力がJSONとしてパースできない場合は、ツール関数に引数を渡す手前の問題になります。ランナーのページが説明する例外処理は関数が投げたものに限られるため、この手前の失敗をランナーがどう扱うかは、意図的に壊したJSONを返すテストで自分のSDKとバージョンで確かめるのが手堅い方法です。

変換されたツール結果に入る内容と、ログの扱いは、SDKごとに違います。

項目PythonTypeScript / JavaGo / Ruby / C# / PHP
content に入る内容Python例外の型名とメッセージTypeScript / Java例外のメッセージGo / Ruby / C# / PHP例外のメッセージ
失敗したツールのログPythonスタックトレースごと logging に出るTypeScript / Java出ない。ANTHROPIC_LOG はリクエストとレスポンスの詳細を出すGo / Ruby / C# / PHP出ない。ANTHROPIC_LOG も読まない

どのSDKでも、content にスタックトレース全体は入りません。Python以外では、失敗の理由をSDKは記録しないので、ツール関数の中で例外を捕まえてログに残してから、返すか再送出する実装にします。

ランナーが返す前の結果を調べたいときは、PythonとTypeScriptにフックがあります。Rubyも、結果の block[:is_error] を調べる形で検査できます。PythonとTypeScriptでは、ループの中で runner.generate_tool_call_response()(TypeScriptは runner.generateToolResponse())を呼ぶと、Claudeへ送る前のツール結果を取り出せます。is_error を持つブロックが見つかったら、例外を投げてループを止めるか、ログだけ残して続行させるかを選べます。

C#は結果を見るフックを持たず、BetaToolError を投げて content を指定する形です。GoとJavaは、送る前に横取りする手段がないので、ツール関数の中で例外を捕まえ、自分で組んだ結果を返します。PHPは pushMessages() を使う手動のパターンで、結果を差し替えます。INVALID_JSON のような独自の形式で返したい場合は、この違いがそのまま実装の難しさになります。

まとめ

パース失敗はエラーとして握りつぶさず、止まった理由で再送するか差し戻すかを分けます。max_tokens なら上限を上げて再送し、それ以外なら INVALID_JSON ラッパーを is_error: true で返します。

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