AIエージェントが同じツールを呼び続けているなら、モデルへの追加指示より先にruntimeを止めます。
今すぐ止める3手
- runをpauseする:自動retryとsub-agentの新規起動を止め、trace、最後の安全なstate、完了済みoperationを保存します。
- side effectを閉じる:書き込み、送信、課金、削除などのtoolは、次の実行前にdenyします。workerが別routeから実行できないことも確認します。
- 終端理由を返す:
STOPPED_LOOP_GUARDとして、停止理由、途中成果、未解決の前提、checkpoint、再開条件をoperatorへ返します。
合格条件は、画面が静かになることではありません。次のunsafe callがtool実行前に拒否され、成功済みの副作用が再実行されず、traceに発火したguardが残り、同じ入力をreplayしても有限回で終わることです。

最大ステップは最後のfuseです。しかし、max stepsだけでは「前に進んでいるか」「そのerrorをretryしてよいか」「成功したtoolを再実行してよいか」は分かりません。本番では、Budget、Error contract、Stuck detection、Idempotency、Graceful stopを一つの実行経路に並べます。
「動いている」と「進んでいる」を分ける
正常な探索でも、searchやreadを何度か使うことがあります。tool名の重複だけで停止すると、必要なpaginationまで誤検知します。反対に、回答文が毎回違っても、取得した事実、完了したsubgoal、cursor、resource versionが変わらないなら進捗はありません。
traceには少なくとも次を記録します。
run_id、step、tool名、canonicalized arguments- tool resultのkindとerror code
- 外部stateから導いた
progressVersion - side-effect用の
operationKey - runtimeが選んだdecisionとguard reason
この情報から、事故を次のように切り分けます。
| 観測パターン | 判定に使う信号 | runtimeの処置 |
|---|---|---|
| 同じtool、同じ有効引数 | fingerprintの出現回数 | per-call budget到達前にstop |
| 空白、key順、無意味な既定値だけが違う | canonical JSON | 同一callとして集計 |
A → B → A → B | 短い履歴window | strategy switchまたはpartial stop |
| resultは変わるがtask stateが不変 | progressVersion | no-progress budgetでstop |
| 成功したwriteをもう一度要求 | operation ledger | cached successを返し、実行しない |
| 同じ権限errorを繰り返す | error classとprecondition | NEEDS_HUMANへ遷移 |
fingerprintで削ってよいのは意味のない差だけです。page、cursor、version、tenant、destinationのように結果や副作用を変える値は残します。意味的な類似度をLLMに判定させる方法は補助にできますが、主制御には外部stateの差分を優先します。
Tool resultを5種類に正規化する
retry loopの多くは、toolが文字列しか返さず、controllerが失敗の意味を判断できないところから始まります。client toolを実行してtool_resultをモデルへ戻すのはアプリケーション側の役割だと、Anthropicのtool use概要とtool call処理ガイドにも示されています。つまり、再実行の可否もruntimeで明示する必要があります。
| kind | 例 | 同じ条件でretry | 遷移先 |
|---|---|---|---|
SUCCESS | 目的の取得、commit済みwrite | しない | 次のsubgoalまたはCOMPLETED |
PENDING | 非同期jobが実行中 | retry_afterとpoll budget内のみ | bounded wait |
RETRYABLE | 一時的timeout、5xx、管理された429 | backoff付きで有限回 | RETRY |
PRECONDITION | credential期限切れ、version conflict | 条件変更後のみ | WAITING_FOR_CHANGE |
FATAL | 403、policy denial、invalid schema | しない | FAILEDまたはNEEDS_HUMAN |
retryは「もう一度」ではなく、失敗を起こした前提を変更する操作です。server指定時間まで待つ、credential scopeを直す、schemaを修正する、新しいcursorを得る、といった変化が必要です。入力もstateも同じなら、retry countを消費する前にloopとして扱います。
5層LoopGuardを実装する
次のJavaScriptはframeworkに依存しない最小構成です。sameCallLimit = 2とnoProgressLimit = 3は後述の固定fixtureに使った値であり、全ての本番環境に最適な閾値ではありません。
jsconst sortValue = (value) => { if (Array.isArray(value)) return value.map(sortValue); if (value && typeof value === "object") { return Object.fromEntries( Object.keys(value).sort().map((key) => [key, sortValue(value[key])]) ); } return typeof value === "string" ? value.trim() : value; }; const callId = ({ tool, args }) => `${tool}:${JSON.stringify(sortValue(args))}`; class LoopGuard { constructor({ maxSteps = 20, maxDurationMs = 60_000, sameCallLimit = 2, noProgressLimit = 3, ledger = new Map(), initialProgressVersion = 0, } = {}) { this.maxSteps = maxSteps; this.deadline = Date.now() + maxDurationMs; this.sameCallLimit = sameCallLimit; this.noProgressLimit = noProgressLimit; this.ledger = ledger; this.history = []; this.step = 0; this.progressVersion = initialProgressVersion; this.stalled = 0; } gate(call) { if (this.step >= this.maxSteps) return this.stop("step_budget"); if (Date.now() >= this.deadline) return this.stop("time_budget"); if (call.operationKey && this.ledger.has(call.operationKey)) { return { action: "use_completed_result", result: this.ledger.get(call.operationKey), }; } const id = callId(call); const sameCount = this.history.filter((x) => x.id === id).length; if (sameCount >= this.sameCallLimit) { return this.stop("same_call_repeat"); } const tail = this.history.slice(-3).map((x) => x.id); if (tail.length === 3 && tail[0] === tail[2] && tail[1] === id) { return this.stop("short_cycle"); } this.step += 1; return { action: "execute", id }; } observe({ id, result, nextProgressVersion, operationKey }) { if (nextProgressVersion > this.progressVersion) { this.progressVersion = nextProgressVersion; this.stalled = 0; this.history = []; } else { this.stalled += 1; } this.history.push({ id, kind: result.kind }); if (result.kind === "FATAL") { const terminalState = ["permission_denied", "policy_blocked", "needs_human"] .includes(result.code) ? "NEEDS_HUMAN" : "FAILED"; return this.stop(result.code || "fatal", terminalState); } if (result.kind === "SUCCESS") { if (operationKey) this.ledger.set(operationKey, result); if (result.terminal) return { action: "complete" }; } if (this.stalled >= this.noProgressLimit) return this.stop("no_progress"); if (result.kind === "SUCCESS") return { action: "continue" }; if (result.kind === "RETRYABLE") { return { action: "retry_after", waitMs: result.retryAfterMs }; } if (result.kind === "PRECONDITION") { return { action: "wait_for_changed_precondition" }; } return { action: "poll_bounded", waitMs: result.retryAfterMs }; } stop(reason, terminalState = "STOPPED_LOOP_GUARD") { return { action: "stop_partial", report: { terminalState, reason, completedSteps: this.step, progressVersion: this.progressVersion, resumeRequires: "verified_precondition_change", }, }; } }
実行側の規約は単純です。gate()がexecuteを返したときだけtoolを呼びます。use_completed_resultなら台帳の結果を返します。stop_partialならplannerも停止します。resume時は保存済みcheckpointのversionをinitialProgressVersionへ渡し、最初の不変resultを新しい進捗と誤判定しないようにします。進捗確認後は反復検知windowを新しくするため、同じjob-idの正常なpollingを累積回数だけで止めません。非終端SUCCESSもno-progress判定を通ります。guardの出力を通常のtool errorとしてモデルへ戻して続行すると、停止判定そのものが次のloop inputになります。
5層の責任は次のように分かれます。
- Budget fuse:step、wall-clock、token/costの最悪値を制限する。
- Error classifier:retryable、precondition、pending、fatal、successを分ける。
- Loop detector:exact repeat、短周期、no-progressをhard capより前に見つける。
- Side-effect gate:operation keyと完了台帳で二重実行を防ぐ。
- Graceful stop:途中成果、checkpoint、human handoff、resume条件を返す。
hard capが発火したことは、fuseが動いた証拠にすぎません。根本原因の解消、途中成果の保存、副作用のrollbackまで証明したことにはなりません。
冪等性はwriteの直前で確認する
メール送信、決済、issue作成、データ削除は、2回目のstepで既に被害を出せます。20 stepの上限は間に合いません。そのため、同じ業務操作を識別するoperationKeyをcaller側で作り、tool実行直前にcompleted-operation ledgerを確認します。
例えば通知ならtenant + incident + notification_type、注文作成ならcaller生成のrequest idを使えます。ledger確認とbusiness commitは、providerのidempotency protocol、database transaction、unique constraintなどの原子的な境界に置きます。memory上のMapはfixture用であり、multi-worker、process restart、network partitionには不十分です。
二回目が正当な業務操作なら、新しいoperation keyと承認記録を作ります。既存keyを削除して強行すると、監査上は重複実行と区別できません。自動補償できないtoolで不整合が疑われる場合は、人間へのエスカレーションを終端状態にします。
固定fixture 5本の実行結果
Node.js上のfake toolと固定traceで、同じ実装を決定的に実行しました。argument keyはsortし、総step budget、同一call上限2、no-progress上限3、wall-clock、terminal state、単調なprogressVersionを使用しています。
| Fixture | 入力 | 観測した結果 | 判定 |
|---|---|---|---|
success_after_one | 1回目でterminal success | 1 call後にcompleted | 正常完了を止めない |
identical_retry | 同じsearchがretryable、進捗なし | 3回目の実行前にsame_call_repeat、side effectは2回だけ | exact repeatを事前阻止 |
alternating_cycle | read/checkが交互、state不変 | 3回実行後にno_progress | 振動を有限化 |
changed_state_then_success | cursorとprogressVersionが前進 | 異なる3 call後に成功 | 正常な探索を許可 |
fatal_no_retry | writeがpermission_denied | 1回で停止 | fatalを再試行しない |
これは、上記fixtureでの制御フローを確認した結果です。productionでの誤検知率、latency改善、費用削減率を示すbenchmarkではありません。
自社の回帰testには、argument jitter、長いPENDING、同一operationKeyの並列実行、再起動後のresume、成功responseの通信断を追加してください。合格判定は「exceptionが出た」ではなく、「guard発火後、fake providerまたはtool counterが増えていない」です。
グレースフル停止とresume
強制killだけではbroken stateが残ります。停止時には次のようなreportを保存します。
json{ "terminal_state": "NEEDS_HUMAN", "guard_reason": "permission_denied", "completed_operations": ["read-config", "schema-check"], "last_safe_checkpoint": "checkpoint-17", "unmet_precondition": "repository write permission", "resume_condition": "scope change approved and verified", "next_action": "resume_from_checkpoint" }
terminal stateは最低でも分けます。
| State | 意味 | 再開 |
|---|---|---|
COMPLETED | 目的達成 | 不要 |
STOPPED_LOOP_GUARD | repeat、cycle、no-progress、budgetで停止 | 原因修正とreplay test後 |
NEEDS_HUMAN | 権限、承認、判断が必要 | 人間が前提変更を確認後 |
PARTIAL | 安全な途中成果を返せる | 未完部分だけ再開 |
FAILED | 回復不能、代替なし | 新しいrunまたは終了 |
resumeはcheckpointから始め、completed ledgerと外部stateを再検証します。元の会話を最初から再投入して全toolを再生すると、成功済みoperationまで戻ってしまいます。「前提を変えた」だけでなく、その変更が実際に観測できたことを再開条件にします。
Frameworkの設定だけで終わらせない
主要frameworkには有界実行の仕組みがあります。ただし、business progressや冪等性を自動で理解するわけではありません。
| Framework | 公式に確認できる停止機構 | application側に残る責任 |
|---|---|---|
| OpenAI Agents SDK | runnerのmax_turnsと超過時のerror handling。Running agents | progress、error taxonomy、operation ledger、checkpoint |
| LangChain | middlewareでmodel/tool call数を制限し、超過時の挙動を設定。Built-in middleware | toolをまたぐcycleとstate差分 |
| Google ADK | LoopAgentのmax_iterationsとsub-agentからのexit signal。Loop agents | result contract、冪等性、resume policy |
| Anthropic tool use | applicationがclient toolを実行してtool_resultを返す。Handle tool calls | controller loop全体と再実行可否 |
OpenAI Agents SDKのreset_tool_choiceは、強制されたtool choiceが次のturnでもtoolを選ばせ続ける特定パターンへの対策です。no-progress detection、総budget、side-effect safetyの代替ではありません。またmax_turns超過は「上限で止めた」ことだけを示し、safe recoveryを保証しません。
本番投入前チェック
- guardをsystem promptではなく実際のtool execution boundaryに置いた
- step、wall-clock、token/cost budgetがterminal reasonを返す
- exact repeat、ABAB、no-progressを固定traceで再現できる
SUCCESS、PENDING、RETRYABLE、PRECONDITION、FATALを分けた- side-effect toolが永続operation ledgerを実行前に確認する
- stop reportに途中成果、checkpoint、guard reason、resume conditionがある
- resume前に関連preconditionの変化を外部stateで検証する
- guard後のfake tool counterが増えないことをtestした
LoopGuardはtool control flowを止める仕組みであり、providerへの金額ベースの強制停止とは別です。モデルrequestが外部へ出る前の費用上限も必要なら、LLMエージェントAPIの費用キルスイッチを組み合わせてください。loopとpaid request pathの両方に、独立した停止点を持たせることができます。


