ツールストリーミングで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でツール入力を高速表示する設定を有効にしたツールに限りません。ストリーミングを使っていれば、応答が生成トークンの上限で終わり、パラメータが途中で切れることでも同じ症状が起こります。サーバー側はツール入力の妥当性を保証しない場面があるという前提でクライアント側を書く必要があります。
普段のツール呼び出しでは、届いた入力オブジェクトをそのまま関数の引数として渡せば動きます。壊れた入力を想定していない実装では、パースに失敗した瞬間に例外が発生してプロセスごと落ちるか、握りつぶされて何も応答が返らないまま会話が止まるかのどちらかになりがちです。どちらも、ユーザーから見れば理由の分からない失敗として現れます。公式が定めた対処パターンをそのまま踏襲しておけば、この種の失敗を会話の流れの中で自然に回復させられます。
INVALID_JSONラッパーで返す
まず、パースできなかった生の文字列をそのまま失わずに保持します。次に、そのままClaudeに渡すのではなく、INVALID_JSON というキーを持つJSONオブジェクトに包みます。
{
"INVALID_JSON": "<パースできなかった入力そのもの>"
}キーを1つに絞ってラップする理由は、Claude側から見て「これは壊れたJSONを受け取った通知だ」と一意に判別できるようにするためです。生の壊れた文字列をそのまま渡すと、Claudeがそれを正常な結果と誤読する余地が残ります。ラップすることで、元の入力も失わずに保持できるため、Claude自身がどこで崩れたかを見て書き直す手がかりにもなります。
is_error:trueをtool_resultに付けて返す
ラップしたオブジェクトを文字列化し、tool_result コンテンツブロックの content に入れ、is_error を true に設定します。
{
"type": "tool_result",
"tool_use_id": "toolu_01A09q90qw90lq917835lq9",
"is_error": true,
"content": "{\"INVALID_JSON\": \"<パースできなかった入力そのもの>\"}"
}is_error は通常のツール実行エラーでも使うフィールドで、ツール実行が失敗したことをClaudeに伝える標準的な手段です。tool_use_id には、パースに失敗した tool_use ブロックのidをそのまま指定します。ここを間違えたidにすると、Claudeがどの呼び出しに対する結果なのかを対応付けられません。このtool_result は、対応する tool_use を含むassistantターンの直後にあるuserメッセージの中に置く必要があります。間に他のメッセージを挟むと、フォーマットエラーになります。さらに、そのuserメッセージのcontent配列では tool_result ブロックを先頭に並べ、テキストは全てのtool_resultより後に置く必要があります。テキストをtool_resultより前に置くと400エラーになります。
{
"role": "user",
"content": [
{ "type": "text", "text": "結果はこちらです" }, // ❌ text を tool_result より前に置いた例
{ "type": "tool_result", "tool_use_id": "toolu_01" }
]
}INVALID_JSON ラッパーを返す実装でも、他のツール結果と同じuserメッセージにまとめる場合は、この並び順を崩さないようにします。さらに、assistantターンが結果未解決のサーバーツールも呼んでいる場合は、userメッセージをtool_resultブロックだけで構成する必要があります。テキストを後ろに置くとそのターンが早期終了してしまい、Claudeが直接呼んだサーバーツールについては、未解決のツール名を含む400エラーになります。
なぜ握りつぶさずにClaudeへ返すのか
Claudeに壊れたJSONをそのまま見せる理由は単純で、Claude自身が壊れた箇所を修正して再実行できるからです。ツール呼び出しの往復は会話の一部として続いているので、is_error: true の結果を受け取ったClaudeは、次のターンで同じツールを正しい入力で呼び直すか、別のアプローチを取るかを自分で判断します。クライアント側でリトライループを実装しなくても、この判断の大部分はモデル側の会話の流れに委ねられます。
逆に、パースに失敗した時点で何も返さずに処理を止めてしまうと、Claudeは自分のツール呼び出しがどうなったのか分からないまま次のテキストを生成しようとし、会話の整合性が崩れます。ツール呼び出しを含むターンの直後は、対応する結果だけを含むメッセージが必須という制約があるため、失敗したからといって黙ってスキップすることもできません。
クライアント側で独自の再試行ロジックを組んで、Claudeに知らせる前に自分でリトライしてしまう設計も選べます。たとえば一定回数までは同じツール呼び出しをサーバーに投げ直し、それでも壊れたままなら初めてClaudeへ通知する、といった段階的な実装です。この方式はストリーミング特有の一時的な乱れを吸収できる一方、リトライのたびに新しいリクエストが必要になり、実質的にはツール呼び出し自体をもう一度やり直すのに近いコストがかかります。多くの実装では、まず失敗をそのままClaudeに返し、会話の流れの中で修正させる方式から始めるのが単純で、後から必要に応じてクライアント側のリトライを足していく順序が扱いやすくなります。
途中切れとフォーマットエラーを区別する
同じ「パースできない」でも、原因は2つに分かれます。どちらも症状としては同じJSONDecodeErrorのような形で観測されるため、stop_reason を見ずに一律で INVALID_JSON ラッパーを返すコードも書けてしまいますが、それでは根本原因を取り違えます。
stop_reason: max_tokensによるもの: 生成トークンの上限に達し、パラメータが途中で物理的に切れています。max_tokensを引き上げて再試行するか、そもそも巨大すぎる出力を求めていないか設計を見直すのが妥当な対応です。- それ以外の理由によるもの:
stop_reasonが正常に終了しているのにパースできない場合です。INVALID_JSONラッパーで返し、Claude自身に修正した入力で再度呼び直させます。
応答の終了理由を先に確認してからラッパーを組み立てる実装にしておくと、この2つを取り違えずに済みます。トークン上限起因の切れを「JSONが壊れていた」とだけ伝えても、Claudeはトークン上限という根本原因を知らないまま同じ入力を繰り返し生成しかねません。
実装上は、ストリーミングイベントを処理するループの中で stop_reason を保持しておき、パース処理の直前に参照できるようにしておくのが自然な形です。判定そのものは呼び出し側のパース処理の直前で完結させ、ループの外に持ち出さないようにするとコードの見通しがよくなります。
手動実装とTool Runnerの関係
ここまでの tool_result / is_error の組み立ては、ツール呼び出しの往復を自分で書く場合の実装です。往復自体をSDKに任せるTool Runnerでツール呼び出しループを自動化するを使っている場合、ツール関数自体が投げた例外はランナーが自動的にキャッチして is_error: true の結果へ変換してくれますが、これはツール関数が正常に呼び出された後に投げた例外が対象です。eager_input_streaming で届いた入力がJSONとしてそもそもパースできない場合はツール関数自体を呼び出せていないため、この例外変換の対象に入るかどうかはランナーの実装依存になります。境界を自分のコードで確認する手順としては、ツール関数を呼び出す前段で自分のJSONパーサによるパースを試み、失敗した場合にだけ stop_reason を見て INVALID_JSON ラッパーを組み立てる形にしておき、Tool Runnerがこの手前で壊れた入力を吸収してくれるのか、それともツール関数に生の文字列のまま渡してくるのかを、意図的に壊れたJSONを送るテストで一度確かめておくと安心です。
なお、Tool Runnerが例外をtool_resultへ変換する際、結果のcontentに含まれるのは例外のメッセージ(Pythonでは型名とメッセージ)であり、スタックトレース全体ではありません。パース失敗の原因を後から自分のログで追いたい場合は、ツール関数側でも例外を捕まえてログに残してから返す、または再送出する実装にしておく必要があります。ドキュメントに明記がない場合、Tool Runnerの挙動をSDKのバージョンをまたいで前提にしないことも合わせて意識しておくと、後からSDKのバージョンを更新したときに生じる、意図しない挙動変化にも早く気づけます。
まとめ
fine-grained tool streamingを使っていると、ツール入力がJSONとしてパースできない状態で届くことがあります。このときは黙ってスキップせず、パースできなかった文字列を {"INVALID_JSON": "..."} の形で包み、is_error: true を付けたツール結果としてClaudeに返します。このtool_resultは、対応するtool_useの直後のuserメッセージのcontent配列で先頭に置き、テキストは後に回します。ラッパーは文字列連結ではなくJSONライブラリで組み立て、応答の終了理由を見てトークン上限由来の途中切れとそれ以外のフォーマットエラーを区別すると、Claude側の修正判断も的確になります。壊れた入力を例外的な想定外として扱うのではなく、ストリーミングを使う以上は普通に起こりうる分岐の1つとして最初から実装に組み込んでおくのが、この機能を安定運用する近道です。