夜間に走らせているエージェントのログを翌朝に開いたら、同じ一行が延々と並んでいました。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 件以下まで落ちました。発生率にして 95% 削減です。コストにすると、1件のリトライで Claude Sonnet 4.6 を平均 18,000 入力トークン消費していたので、削減できた API コストは月あたりおよそ ¥4,500 相当でした。
メッセージ配列の順序破綻を防ぐ型設計
これは 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 の約 1/12 なので、サマリのコストはほぼ無視できます。私の運用では、サマリを通したツール返り値のおかげで 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 分以上停止させた事例はゼロ件です。
個人開発の運用で踏んだ追加の落とし穴
公式ドキュメントには載っていないが、半年運用して気づいた挙動を3つ共有します。
ひとつめは、tool_result の content が空文字列だと挙動が不安定 だということ。空文字より "no result" や "completed" のような短い文字列を入れたほうが、後続の応答品質も安定します。手元の計測では、長時間走るエージェントの完走率がツールの戻り値設計を変えただけで 8% ほど改善しました。
ふたつめは、parallel tool_use で並列度が 5 を越えると、could not be submitted の発生率が急に上がる こと。私の手元の集計では、並列度4までは月1件未満、5は月2件、6以上は月5件以上です。私はこの数字を見てから、並列度4を上限にしてセマフォを張るようにしました。速度より、朝のログが静かであることを取った形です。
みっつめは、ストリーミング中の 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分類はそのまま使えます。クライアント側から見える症状は、次のように読み替えられます。
クライアント側で見える症状 対応する根本原因 手を打つ場所
特定の会話でだけ、同じ位置で何度も失敗する 1番目(ID不一致)が履歴に焼き付いている 会話履歴そのもの
MCP サーバーを追加した直後から出始めた 4番目(過大)または転送の失敗 MCP サーバーの戻り値
長い応答の途中で切れてから出る 3番目(ストリーム中断) セッションファイル
再現性がなく、翌日には出ない API 側の一時的な状態 時間を置く
赤バナーの文言そのものではなく、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)
# 1MB を大きく超える結果は 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 が応答しない・遅い・途中で止まる時の対処 を参考に、サービス側のインシデントが出ていないかを先に確かめてみてください。
次に同じバナーを見たら、リトライを押す前に、直前の assistant ターンの tool_use ブロック数と、自分が返した tool_result ブロック数を数えてみてください。まずはその一手だけを習慣にする。それで切り分けの半分は終わります。
数えた結果が合わなかったときの直し方は、本記事の検証ヘルパーをリポジトリに置いておけば、そのまま手元にあります。お読みいただきありがとうございました。