自動投稿パイプラインを Claude API で組んで運用していると、ある日突然このエラーが連発し始めます。tool_result could not be submitted。一度出るとそのセッションは打ち切られ、リトライしても直らず、しばらくすると別のエージェントでも同じことが起きる。2014年からアプリ事業をやってきた人間として、個人開発で運営している6サイトのコンテンツ自動投稿パイプライン(Claude Lab・Gemini Lab・Antigravity Lab・Rork Lab・Lacrima・Mystery)で、ある夜に4サイト連続でこの症状にぶつかり、夜が明けるまで原因を切り分けていたことがあります。
このエラーは Anthropic の公式ドキュメントには再現条件がほとんど書かれていません。ただ、発生メッセージはひとつでも、実態は4種類のまったく別の根本原因が同じエラー文に集約されているだけ — というのが半年運用してわかったことです。リトライで直るのは1種類だけで、残りの3種類は API リクエストの組み立てを変えないと永遠に再発します 。
ここでは、本番運用中のパイプラインで実際に観測した4種類の発生パターンと、それぞれに対するリカバリの実装を、TypeScript のコードと一緒に整理します。
発生する4種類の根本原因
エージェントを長く動かしていると tool_result could not be submitted は次の4種類のいずれかで発生します。エラーメッセージ自体に区別はないので、API レスポンスの周辺情報と tool_use_id の対応関係から手動で切り分けます。
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 のスリープを挟むようにしました。地味ですが、月数件の発生をゼロにできた最後のひと押しでした。
もうひとつ、これは Dolice として6サイトのパイプラインを並行運用していて気づいたことなのですが、エージェントを長く動かす設計は、エラー処理の99%以上が「リトライではなく状態復元」になります。tool_result could not be submitted は、その設計が甘いと一番最初に火を噴く箇所です。本番で同じ症状に踏まれた方の参考になれば幸いです。
このエラーは何が起きているのか
「tool result could not be submitted」は、Claude が tool_use ブロックで呼び出した外部ツールの結果(tool_result)を、次の API リクエストに載せようとした段階で 検証に失敗 または 送信に失敗 したときに表示されます。チャット UI のバナーや Claude Code の通知として現れますが、本質は API レイヤーの messages 配列の整合性エラーです。
直接の原因はだいたい次の3パターンに分類できます。
形式違反 : tool_result の content が想定外の型(バイナリ・巨大すぎるオブジェクト・null など)になっている
対応関係の崩れ : 直前の assistant メッセージにある tool_use_id と、送り返している tool_result.tool_use_id がずれている、もしくは欠落している
転送系の失敗 : MCP サーバーや Skills が応答を返さずタイムアウトし、空の結果が送られている
「赤バナーが出る/出ない」ではなく、tool_use_id と content の二点を必ず疑うのが、復旧までの最短ルートです。
まず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 ツール使用エラー完全解決ガイド と Claude API ストリーミング × Tool Use の実装パターン もあわせて読むと、状態遷移の全体像が掴めます。
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 の挙動全般がおかしいときは、Claude Code の MCP 接続トラブルシューティング で先に接続レイヤーを切り分けると遠回りが減ります。
それでも直らないときの最終手段
ここまで試しても再発する場合、私はだいたい以下の手順で完全リセットしています。これで戻らなかったケースは、ここ半年では1件もありません。
該当チャットを アーカイブ or 削除 し、関連する Project のシステムプロンプトを別タブで開いて素のテキストとして退避
ブラウザの拡張機能を一時的に全停止(特に CORS 系・拡張アクセス系の拡張機能はリクエストを書き換えていることがある)
プロジェクトに紐づく MCP サーバーを1つずつ外し、再現する最小構成 を割り出す
最小構成で再現するなら、その MCP サーバーに 絞った修正 (出力を文字列化する/巨大バイナリを返さない)を入れる
直近で導入したばかりの 新しい Skill / カスタム指示 を一度切って様子を見る
ここまでやって再発する場合は、Anthropic 側の一時的な不具合のことも稀にあります。Claude Lab の 稼働ステータス確認の記事 を参考に、サービス側のインシデントが出ていないかチェックしてみてください。
長く Claude を使うほど、こうした細かなエラーは「腕の見せ所」になります。tool_result を真面目に組むと安定性は跳ね上がるので、本記事の検証ヘルパーをそのままお手元のリポジトリに置いておくと、次の障害でも数分で復旧できるはずです。