夜間に走らせているエージェントのログを翌朝に開いたら、同じ一行が延々と並んでいました。tool_result could not be submitted。一度出るとそのセッションは打ち切られ、リトライしても直らず、しばらくすると別のジョブでも同じことが起きます。個人開発の傍らで無人稼働させているエージェント基盤で、ある夜に4本のジョブが連続でこの症状にぶつかり、夜が明けるまで原因を切り分けていたことがあります。
このエラーは Anthropic の公式ドキュメントには再現条件がほとんど書かれていません。ただ、発生メッセージはひとつでも、実態は4種類のまったく別の根本原因が同じエラー文に集約されているだけ — というのが半年運用してわかったことです。リトライで直るのは1種類だけで、残りの3種類は API リクエストの組み立てを変えないと永遠に再発します 。
ここでは、本番で動かしている常駐エージェントで実際に観測した4種類の発生パターンと、それぞれに対するリカバリの実装を、TypeScript のコードと一緒に整理します。
発生する4種類の根本原因
エージェントを長く動かしていると tool_result could not be submitted は次の4種類のいずれかで発生します。エラーメッセージ自体に区別はないので、API レスポンスの周辺情報と tool_use_id の対応関係から手動で切り分けます。私自身、最初の2ヶ月はこの区別がつかないまま、効かないリトライを足し続けていました。
tool_use_id と tool_result の対応が壊れています : 前のターンで Claude が tool_use を返したのに、こちらが返す tool_result の tool_use_id が一致していないか、一部の tool_use に対する結果が抜けています
メッセージ配列の順序が tool_use → tool_result の直後ペアになっていません : 間に text ブロックを挟んでしまったか、tool_result のメッセージを assistant ロールで送ってしまった状態です
ストリーミング途中で接続が切れて parallel tool_use が中途半端になっています : 5個並列で tool_use が返ってきたうち3個まで実行した時点でクライアントが落ち、次回再開時に残り2個を実行する前に新しい user メッセージを送ってしまった状態です
tool_result の content が過大です(おおむね 200KB 超) : 1件あたりの返り値が大きすぎて API 側でリジェクトされています。別エラーで返ることもありますが、parallel tool_use の中の1件だけが超過しているケースでは could not be submitted に統合されて返ってきます
私の環境で観測した発生比率は、ざっくり 1番目 50% / 2番目 20% / 3番目 25% / 4番目 5% でした。1番目だけはコード上のバグ、2番目は SDK の使い方の誤解、3番目はストリーミング処理の設計ミス、4番目はツール返り値の圧縮不足です。リトライで直るのは2番目だけで、それ以外は 送信するメッセージ配列そのものを書き換えないと根治しません 。
tool_use_id 不一致の検出と修復
最も多いのがこれです。Claude が parallel tool_use で複数の tool_use_id を返してきたとき、こちらが tool_result を返す側で1個でも tool_use_id を取り違えると、API は丸ごと拒否してきます。
import Anthropic from "@anthropic-ai/sdk" ;
import type { MessageParam, ToolUseBlock } from "@anthropic-ai/sdk/resources/messages" ;
interface ToolInvocation {
id : string ; // tool_use_id
name : string ;
input : unknown ;
result ?: string ;
error ?: string ;
}
/**
* 直前の assistant メッセージから tool_use ブロックを全て抽出し、
* tool_use_id をキーにした Map を作る。実行結果はこの Map に書き戻す。
*/
function extractPendingToolUses ( messages : MessageParam []) : Map < string , ToolInvocation > {
const last = messages[messages. length - 1 ];
if (last.role !== "assistant" || typeof last.content === "string" ) {
return new Map ();
}
const pending = new Map < string , ToolInvocation >();
for ( const block of last.content) {
if (block.type === "tool_use" ) {
const tu = block as ToolUseBlock ;
pending. set (tu.id, { id: tu.id, name: tu.name, input: tu.input });
}
}
return pending;
}
/**
* tool_result メッセージを組み立てる前に、pending と完全一致するか検証する。
* 1件でも漏れがあれば、空の結果("tool execution skipped" 等)で穴埋めする。
* 余計な tool_use_id を含めないことも同じくらい重要。
*/
function buildToolResultMessage (
pending : Map < string , ToolInvocation >,
executed : Map < string , ToolInvocation >
) : MessageParam {
const content = [];
for ( const [ id , inv ] of pending) {
const done = executed. get (id);
if (done) {
content. push ({
type: "tool_result" as const ,
tool_use_id: id,
content: done.result ?? "" ,
is_error: Boolean (done.error),
});
} else {
content. push ({
type: "tool_result" as const ,
tool_use_id: id,
content: "tool execution skipped (client recovery)" ,
is_error: true ,
});
}
}
return { role: "user" , content };
}
この pending と executed の差分検証を、API に送信する直前に必ず通すようにしました。私の環境では、これだけで tool_result could not be submitted の発生件数が月 80 件規模から月 5 件以下まで落ちています。
削減できた額は、次のように分解すれば手元でも数え直せます。失敗 1 件は指数バックオフを使い切るまでにおよそ 8 回再送していて、1 回あたりの入力が平均 18,000 トークンでした。差し引き月 75 件が消えた計算ですから、75 × 8 × 18,000 でおよそ 1,080 万入力トークン。Claude Sonnet 4.6 の入力単価は $3 / MTok ですので、月およそ $32 です。
金額そのものより、失敗 1 件の裏でリトライが何回走っているかを先に数えること のほうが効きます。私はこの掛け算を出すまで、失敗 1 件イコール再送 1 回だと思い込んでいて、削減効果を実際の 8 分の 1 に見積もっていました。
メッセージ配列の順序破綻を防ぐ型設計
これは SDK の使い方を理解していれば起きないのですが、tool_use と tool_result の対応関係を メッセージ配列の隣接ペア として保つ必要があります。具体的には次の順序を絶対に崩さないこと。
user メッセージ
assistant メッセージ(中に text と tool_use が混在してよい)
user メッセージ(中身は tool_result のみ。text を混ぜない)
assistant メッセージ(次の応答)
tool_result を含む user メッセージの中に「ユーザーからの追加コメント」を text ブロックで混ぜたくなる場面がありますが、これをやると一見動くのに、ストリーミング再開時に高確率で could not be submitted が出ます。追加コメントは 次のターンの user メッセージとして独立させる のが鉄則です。
私の実装では、tool_result を組み立てる関数は他のテキストブロックを受け取らない型シグネチャにして、コンパイル時に混入を防いでいます。
type ToolResultOnlyMessage = {
role : "user" ;
content : Array <{
type : "tool_result" ;
tool_use_id : string ;
content : string ;
is_error ?: boolean ;
}>;
};
// この型を受け取る関数は、type が "tool_result" 以外のブロックを混ぜられない
function appendToolResults (
history : MessageParam [],
resultMessage : ToolResultOnlyMessage
) : MessageParam [] {
return [ ... history, resultMessage];
}
ストリーミング中断時の状態復元
これが一番厄介です。parallel tool_use で5個の tool_use が返ってきたうち、3個まで実行した時点でプロセスが落ちる・タイムアウトする・ネットワークが切れる、ということが本番運用では月1〜2回は起きます。再起動後に「続きから再開」したくなりますが、ここで実行済みの3個分だけ tool_result を返してはいけません。5個全部の tool_use_id に対応する tool_result を返さないと、API は丸ごと拒否します 。
回復ハンドラの中核はチェックポイント設計です。手元の実装では、各 tool_use の実行直前と直後にディスクへ状態をフラッシュしています。
interface Checkpoint {
conversationId : string ;
messageIndex : number ; // どの assistant ターンの tool_use か
pendingToolUses : ToolInvocation [];
executedToolUses : ToolInvocation [];
lastFlushedAt : number ;
}
async function executeToolWithCheckpoint (
cp : Checkpoint ,
inv : ToolInvocation ,
toolFn : ( input : unknown ) => Promise < string >
) : Promise < void > {
// 実行直前にチェックポイントを書く: クラッシュしても「実行中だった」を残せる
cp.pendingToolUses. push (inv);
await flushCheckpoint (cp);
try {
inv.result = await toolFn (inv.input);
} catch (err) {
inv.error = err instanceof Error ? err.message : String (err);
inv.result = `error: ${ inv . error }` ;
}
cp.executedToolUses. push (inv);
await flushCheckpoint (cp);
}
/**
* 再開時のリカバリ: pending には全 tool_use が、executed には完走分のみが残っている。
* 未完走分は「タイムアウト扱い」で is_error: true の tool_result を返して整合させる。
*/
async function resumeFromCheckpoint (
client : Anthropic ,
cp : Checkpoint ,
history : MessageParam []
) : Promise < MessageParam []> {
const executedIds = new Set (cp.executedToolUses. map (( e ) => e.id));
const missing = cp.pendingToolUses. filter (( p ) => ! executedIds. has (p.id));
// 未完走分を「中断扱い」で埋める
for ( const m of missing) {
cp.executedToolUses. push ({
... m,
result: "tool execution interrupted by client restart" ,
error: "interrupted" ,
});
}
const pendingMap = new Map (cp.pendingToolUses. map (( p ) => [p.id, p]));
const executedMap = new Map (cp.executedToolUses. map (( e ) => [e.id, e]));
const resultMessage = buildToolResultMessage (pendingMap, executedMap);
return [ ... history, resultMessage];
}
この設計に切り替えた直後、月1〜2回は出ていた再開失敗が、3ヶ月ぶっ通しでゼロ回になりました。チェックポイントの I/O コストはローカルディスクへの数 KB の JSON 書き込みだけなので、エージェントのレイテンシには 1ms 未満しか影響していません。
tool_result が大きすぎる時の段階的サマリ
parallel tool_use の中の1件だけが過大で拒否されるケースは少数派ですが、本番では実際に踏みます。私の環境でも、データベースからレコードのメタデータを抽出するツールが、開発初期に「全カラム返す」設計だったため、1件あたり 400KB を超えて拒否されたことがあります。
tool_result の content には事実上 200KB 程度のソフト上限があると考えるのが安全です。これを越えそうなツールでは、必ず2段階のサマリを挟みます。
async function safeToolResult ( rawOutput : string , summarize : ( s : string ) => Promise < string >) : Promise < string > {
const MAX = 180 * 1024 ; // 180KB を安全ライン
if (rawOutput. length <= MAX ) return rawOutput;
// 1段目: ヘッドとテイルだけ残してミドルを Haiku で要約
const head = rawOutput. slice ( 0 , 30_000 );
const tail = rawOutput. slice ( - 30_000 );
const middle = rawOutput. slice ( 30_000 , - 30_000 );
const summarized = await summarize (middle);
return [
"=== HEAD (first 30KB raw) ===" ,
head,
`=== MIDDLE (summarized from ${ middle . length } bytes) ===` ,
summarized,
"=== TAIL (last 30KB raw) ===" ,
tail,
]. join ( " \n " );
}
要約には Claude Haiku 4.5 を使っています。ここは長いあいだ「Haiku なら Sonnet より一桁安い」という感覚のまま見積もっていたのですが、Anthropic の料金ページ を引き直したところ、思っていたほどの差ではありませんでした。
入力も出力も、ちょうど 1/3 です。一桁ではありません。120KB のミドル部分を要約させると入力がおよそ 30,000 トークン、返ってくる要約が 1,500 トークンほどですから、1 回あたり (30,000 × $1 + 1,500 × $5) ÷ 1,000,000 で $0.0375 。1 日 20 回通しても月 $22 前後です。
無視してよい額ではありますが、「ほぼゼロ」と言い切れるほどでもない——入力と出力をひとつの単価に丸めて概算するのをやめ、二つの数字を別々に置いて数えるようにしてから、見積もりが請求書とずれなくなりました。
私の運用では、サマリを通したツール返り値のおかげで tool_result could not be submitted の content-too-large 系の発生が、月 3〜4 件から 半年でゼロ件 になりました。
エラーログから根本原因を切り分けるチェックリスト
tool_result could not be submitted を観測したら、リトライする前に次のチェックリストを上から順に通します。半年運用してわかったのは、最初の2項目で 70% は確定するということです。
直前の assistant メッセージの tool_use ブロック数 ≠ 自分が返した tool_result ブロック数 → 1番目の原因(ID不一致/実行漏れ)
tool_result を含む user メッセージに text ブロックが混ざっている → 2番目の原因(順序破綻)。混入したテキストを次のターンに分離して再送
直前の実行ログにストリーミング中断・タイムアウト・SIGTERM の痕跡がある → 3番目の原因(中断状態)。チェックポイントから整合復元
特定の tool_use_id の content が 180KB を超えている → 4番目の原因(過大)。サマリを挟んで再送
上記いずれにも該当しない → リトライ。Anthropic 側の一時的な状態(過去半年で月 1〜2 回程度の頻度で観測)
このチェックリストを実装したのは2026年3月で、それから今日まで、tool_result could not be submitted が常駐ジョブを 30 分以上停止させた事例はゼロ件です。
チェックリストを 1 つの関数に落とす
上のチェックリストは、毎朝ログを開いて目で追っていると必ずどこかを飛ばします。いまは失敗レスポンスのボディと送信直前のメッセージ配列を渡すと 4 分類のどれかを返すだけの関数を置いて、通知の本文にその分類を載せています。
type Cause = "id-mismatch" | "ordering" | "interrupted" | "oversize" | "transient" ;
interface ClassifyInput {
errorBody : string ; // API から返ったエラーレスポンスの本文
pendingIds : string []; // 直前 assistant ターンの tool_use.id 全部
submitted : Array <{ tool_use_id : string ; content : string }>;
hadStreamInterruption : boolean ; // 直前の実行ログから立てるフラグ
}
const OVERSIZE_LIMIT = 180 * 1024 ;
function classifyToolResultFailure ( inp : ClassifyInput ) : Cause {
// 1) 過大を最初に見る: 1 件でも越えていると、残りの検査結果は意味を持たない
if (inp.submitted. some (( s ) => s.content. length > OVERSIZE_LIMIT )) return "oversize" ;
// 2) ID 集合の食い違い。欠落と余剰の両方を数える
const sent = new Set (inp.submitted. map (( s ) => s.tool_use_id));
const missing = inp.pendingIds. filter (( id ) => ! sent. has (id));
const extra = [ ... sent]. filter (( id ) => ! inp.pendingIds. includes (id));
if (missing. length > 0 || extra. length > 0 ) {
// 中断の痕跡があるなら ID 不一致ではなく「途中で落ちた」として扱う
return inp.hadStreamInterruption ? "interrupted" : "id-mismatch" ;
}
// 3) 順序破綻は、手元の状態より API の文言のほうが確かなことがある
if ( /must (be | immediately) follow | unexpected ` ? text` ? / i . test (inp.errorBody)) return "ordering" ;
// 4) どれにも当たらない
return "transient" ;
}
判定の順番には理由があります。過大を先に置くのは、1 件が越えているだけで他の検査が素通りしてしまうからです。ID の食い違いを見つけたときに中断フラグを優先するのは、同じ「ID が足りない」状態でも、直す場所がツール実行のコードなのか再開処理なのかで正反対になるからです。私は最初この分岐を入れておらず、中断由来の失敗をすべて実装のバグに分類して、効かない修正を二週間ほど重ねていました。
リトライを許すのは transient と分類できたときだけ。 それ以外は即座に手元へ通知を飛ばすようにしています。この一本の線を引いてから、朝にログを読む時間がはっきり短くなりました。
個人開発の運用で踏んだ追加の落とし穴
公式ドキュメントには載っていないが、半年運用して気づいた挙動を3つ共有します。
ひとつめは、tool_result の content が空文字列だと挙動が不安定 だということ。空文字より "no result" や "completed" のような短い文字列を入れたほうが、後続の応答品質も安定します。手元の計測では、長時間走るエージェントの完走率がツールの戻り値設計を変えただけで 8% ほど改善しました。
ふたつめは、parallel tool_use で並列度が 5 を越えると、could not be submitted の発生率が急に上がる こと。私の手元の集計では、並列度4までは月1件未満、5は月2件、6以上は月5件以上です。私はこの数字を見てから、並列度4を上限にしてセマフォを張るようにしました。速度より、朝のログが穏やかであることを取った形です。この相関は、私のツールが1件あたり長く走るせいで出ているだけかもしれません。それでも上限を決めておくこと自体は、どの環境でも損をしない判断だと感じています。
みっつめは、ストリーミング中の message_delta イベントで stop_reason: "tool_use" を受け取った直後に、すぐ次のリクエストを投げないこと 。Anthropic の内部状態の確定に若干のラグがあるようで、即時に次のリクエストを投げると稀に could not be submitted が出ます。私は 50ms のスリープを挟むようにしました。地味ですが、月数件の発生をゼロにできた最後のひと押しでした。
3つを並べて気づくのは、エージェントを長く動かす設計では、エラー処理の重心が「リトライ」から「状態復元」へ静かに移っていくということです。tool_result could not be submitted は、その移行が済んでいないときに一番最初に火を噴く箇所でした。
ここまでは、messages 配列を自分で組み立てている前提の話です。ここから先は、Claude Code やデスクトップアプリを使っていて同じバナーに出くわしたときの切り分けを、いま整理した4分類に紐づけて書いていきます。
Claude Code・デスクトップアプリで同じバナーを踏んだとき
自分で messages を組み立てていなくても、このバナーは出ます。内部で起きていることは変わらず、API レイヤーでの messages 配列の整合性エラーです。違うのは、配列を組み立てているのがあなたではなくクライアント側だ、という一点だけ。
つまり、前半の4分類はそのまま使えます。クライアント側から見える症状は、次のように読み替えられます。
赤バナーの文言そのものではなく、tool_use_id と content の二点を疑います。クライアント経由でも、そこが最短ルートであることは変わりません。
バナーの裏側にある本当のエラーを読む
バナーに出る文言は要約でしかなく、判断材料としては足りません。ブラウザで使っているなら、DevTools の Network タブを開き、messages または complete でフィルタしてから症状を再現させてみてください。失敗した POST のレスポンスボディには、messages.X.content.Y.tool_use_id: required や tool_result content size exceeds limit といった具体的な理由が入っています。
この一行さえ読めれば、上の表のどこに落ちているかが確定します。以降の作業は当てずっぽうではなく、狙い撃ちになります。
まず1分で試したい復旧手順
仕事中に踏んでしまったとき、まず以下の順で試してください。半分はこれで戻ります。
同じ会話を続けず、新しいチャットを開いて同じ依頼を投げ直します (壊れた messages 履歴を丸ごと捨てるため)
Claude.ai / Claude Desktop のページを 完全リロード (macOS なら ⌘ + Shift + R、Windows は Ctrl + Shift + R)
Claude Code 利用者は claude --reset-session または該当プロジェクトの .claude/sessions/ 内で対象セッションファイルを退避
MCP サーバーを使っている場合は claude mcp list で接続中のサーバーを確認し、疑わしいサーバーを claude mcp remove <name> で外して再現確認
ここで治れば、原因はほぼ「壊れた会話履歴の再送」です。会話履歴に一度でも不正な tool_result が混じると、その会話を続ける限り同じ場所で詰まり続けます。
API / SDK 側のコードでの直し方
API や SDK から直接呼び出していて再発する場合は、tool_result の作り方を見直します。私が現場でよく作るチェック関数を貼っておきます。
# tool_result を Claude に送る前に、構造を検証するヘルパー
# - content が空・null・bytes になっていないか
# - tool_use_id が直前 assistant の tool_use と一致しているか
import json
from typing import Any
def build_tool_result (tool_use_id: str , raw: Any) -> dict :
if raw is None :
# None をそのまま渡すと Claude 側で content が空になり詰まる
content = "(no result)"
elif isinstance (raw, ( dict , list )):
# 構造体は必ず JSON 文字列化する。bytes / 関数 / set はここで弾かれる
content = json.dumps(raw, ensure_ascii = False , default = str )
else :
content = str (raw)
# 200KB を大きく超える結果は API 側で蹴られやすい。要約 or 切り詰め
if len (content) > 200_000 :
content = content[: 200_000 ] + " \n ... (truncated)"
if not tool_use_id:
raise ValueError ( "tool_use_id is empty — assistant の tool_use と対応していません" )
return {
"type" : "tool_result" ,
"tool_use_id" : tool_use_id,
"content" : content,
}
期待する出力は、Claude SDK の messages.create に渡したとき tool_result が何事もなく通り、次の assistant メッセージが返ってくる状態です。tool_use_id を「直前 assistant ターンに含まれていた tool_use.id のいずれか」と一致させることが最重要で、ここを外すと messages: each tool_result.tool_use_id must match a prior tool_use.id のような派生エラーまで出ることがあります。
なお、is_error: true を立てて content にエラー文字列を入れて返すのは正規の使い方です。Claude はその結果を踏まえて自己修復してくれることが多く、無理に成功扱いで返すよりずっと安定します。
ツールサイクル全体の設計に不安がある方は、Claude API の Parallel Tool Use を本番で使いこなす と ツール出力10万文字の境界で消える一行 もあわせて読むと、並列度と返り値サイズという二つの軸から全体像が掴めます。
MCP / Skills を使っているときの追加チェック
MCP サーバーや Skills 経由でこのエラーが出るときは、Claude 本体ではなくサブプロセス側が原因のことがほとんどです。私が同じトラブルにあたったときは、自作 MCP のレスポンスがたまたま Buffer のまま渡っていて、JSON シリアライズで欠ける挙動が引き金になっていました。
確認すべきポイントは次のとおりです。
MCP サーバーのレスポンスを console.error(JSON.stringify(result)) などで一度標準エラーに吐き、人間が読める JSON であることを確かめます
レスポンスのサイズが極端に大きくないか(画像バイナリをそのまま含めると一発で限界を超える)
スキル内の Python / Node スクリプトが例外で落ち、stdout が途中で切れていないか
ネットワークが不安定で MCP がタイムアウトしている場合は、mcp__server__bash 等の長時間実行ツールで timeout_ms を見直します
MCP の挙動全般がおかしいときは、MCPサーバーは繋がっているのに、ツールが0件で返る で先に接続レイヤーとツール表の側を切り分けると、遠回りが減ります。
それでも直らないときの最終手段
ここまで試しても再発する場合、私はだいたい以下の手順で完全リセットしています。これで戻らなかったケースは、ここ半年では1件もありません。
該当チャットを アーカイブ or 削除 し、関連する Project のシステムプロンプトを別タブで開いて素のテキストとして退避
ブラウザの拡張機能を一時的に全停止(特に CORS 系・拡張アクセス系の拡張機能はリクエストを書き換えていることがある)
プロジェクトに紐づく MCP サーバーを1つずつ外し、再現する最小構成 を割り出します
最小構成で再現するなら、その MCP サーバーに 絞った修正 (出力の文字列化・巨大バイナリの除外)を入れます
直近で導入したばかりの 新しい Skill / カスタム指示 を一度切って様子を見ます
ここまでやって再発する場合は、Anthropic 側の一時的な不具合のことも稀にあります。Claude が応答しない・遅い・途中で止まる時の対処 を参考に、サービス側のインシデントが出ていないかを先に確かめてみてください。
失敗の翌朝を短くするログ設計
バナーが画面に出た時点で、原因になったペイロードは手元から消えています。このエラーが実態よりも不可解に見えるのは、そこが理由でした。私は最初の数ヶ月、朝にログを開くたびに「昨夜は何を送ったのか」を思い出せないまま、推測で修正を当てておりました。無駄にした時間の大半は、切り分けが下手だったからではありません。材料を残していなかったからです。
失敗を再現する材料は、失敗する前にしか集められません。 いまはツール利用のあるプロジェクトすべてに、最初の一日で次の三つを置いています。
開発時は messages.create に渡したリクエストボディを丸ごとローカルのファイルへ落とします。失敗した一回の全文は、一週間ぶんの推測よりも確かです
送信前に tool_result の content の長さとダイジェストの先頭 10 桁を残します。前の失敗と同じダイジェストが出たら、その時点で迂回できます
tool_use_id の対応を構造化ログへ入れて、pair=tool_42 status=rejected のかたちで後から grep できるようにします
// tool-trace.ts — TOOL_DEBUG=1 のときだけ詳細を出す軽量トレーサ
import { createHash } from "node:crypto" ;
const DEBUG = process.env. TOOL_DEBUG === "1" ;
export function traceToolResult ( toolUseId : string , content : string ) : void {
if ( ! DEBUG ) return ;
const sha = createHash ( "sha1" ). update (content, "utf8" ). digest ( "hex" ). slice ( 0 , 10 );
// preview は 120 文字で足ります。全文は別ファイルに落としている前提です
console. error (
`tool_result pair=${ toolUseId } len=${ content . length } sha=${ sha } ` +
`preview=${ JSON . stringify ( content . slice ( 0 , 120 )) }` ,
);
}
期待する出力は、ツールのターンごとに一行だけです。次に could not be submitted が出たとき、原因になった行は失敗のタイムスタンプのすぐ上に並んでいます。手元では、この一行があるかないかで、事後の切り分けにかかる時間が数時間単位で変わりました。
4種類をわざと起こして分類器を確かめる
前節の classifyToolResultFailure を書いたあと、私はその正しさを本番の失敗が出るまで確かめられずにいました。月に数回しか動かせない関数は、そもそも信用のしようがありません。壊れ方を待つのをやめて、手元で作ることにしました。
4種類のうち三つ——ID 不一致、順序破綻、過大——は、送信直前のメッセージ配列を組み立てるだけで再現できます。API を叩く必要はなく、費用もかかりません。
// classify.test.ts — node --test でも vitest でも動くかたち
import { test } from "node:test" ;
import assert from "node:assert/strict" ;
import { classifyToolResultFailure } from "./classify" ;
const base = { errorBody: "" , pendingIds: [ "t1" , "t2" ], hadStreamInterruption: false };
test ( "ID 不一致: 片方の結果を返し忘れた" , () => {
const got = classifyToolResultFailure ({
... base,
submitted: [{ tool_use_id: "t1" , content: "ok" }],
});
assert. equal (got, "id-mismatch" );
});
test ( "中断: 同じ欠落でも、痕跡があれば直す場所が変わる" , () => {
const got = classifyToolResultFailure ({
... base,
submitted: [{ tool_use_id: "t1" , content: "ok" }],
hadStreamInterruption: true ,
});
assert. equal (got, "interrupted" );
});
test ( "過大: 1件でも越えていれば、他の検査より先に確定する" , () => {
const got = classifyToolResultFailure ({
... base,
submitted: [
{ tool_use_id: "t1" , content: "x" . repeat ( 200_000 ) },
{ tool_use_id: "t2" , content: "ok" },
],
});
assert. equal (got, "oversize" );
});
test ( "順序破綻: 手元の状態は揃っていて、API の文言だけが理由を持つ" , () => {
const got = classifyToolResultFailure ({
... base,
errorBody: "`tool_result` blocks must immediately follow the `tool_use`" ,
submitted: [
{ tool_use_id: "t1" , content: "ok" },
{ tool_use_id: "t2" , content: "ok" },
],
});
assert. equal (got, "ordering" );
});
二つ目と三つ目は、私が実装当初に取り違えていた組み合わせそのものです。ID の欠落という見た目は同じなのに、直す場所はツール実行のコードと再開処理で正反対に分かれます。fixture にしておくと、その分岐を壊したときにテストのほうが先に落ちてくれます。
中断だけは fixture では作れません。接続が実際に切れる必要があるからです。私は claude-haiku-4-5-20251001 に軽いツールを4件並列で返させ、2件目の tool_result を送った直後に自分のプロセスへ SIGTERM を投げるスクリプトを用意しました。一回あたりの費用はごくわずかで、再開処理を正直に通せる方法は、いまのところこれしか見つかっていません。
壊れ方は待つものではなく、作るものでした。 順番を入れ替えてから、分類器の取り違えは本番のログではなく手元のテストで見つかるようになっています。
次に同じバナーを見たら、リトライを押す前に、直前の assistant ターンの tool_use ブロック数と、自分が返した tool_result ブロック数を数えてみてください。まずはその一手だけを習慣にしていただければと思います。それで切り分けの半分は終わります。
数えた結果が合わなかったときの直し方は、本記事の検証ヘルパーをリポジトリに置いておけば、そのまま手元にあります。お読みいただきありがとうございました。