コンテンツにスキップ

人間の介入(HITL)

このガイドでは、SDK の承認ベースの Human in the loop (人間の介入) フローについて説明します。ツール呼び出しに承認が必要な場合、SDK は実行を一時停止して interruptions を返し、後で同じ RunState から再開できるようにします。

この承認の対象範囲は実行全体であり、現在のトップレベルエージェントに限定されません。同じパターンは、ツールが現在のエージェントに属する場合、ハンドオフを通じて到達したエージェントに属する場合、ネストされた agent.asTool() の実行に属する場合のいずれにも適用されます。ネストされた agent.asTool() の場合も、中断は外側の実行に表示されるため、外側の result.state で承認または拒否し、元のルート実行を再開します。

agent.asTool() では、承認が二つの異なるレイヤーで発生する可能性があります。エージェントツール自体が asTool({ needsApproval }) を通じて承認を要求する場合と、ネストされた実行の開始後に、ネストされたエージェント内のツールが独自の承認を要求する場合です。どちらも、同じ外側の実行の中断フローを通じて処理されます。

このページでは、interruptions を使用する手動承認フローを中心に説明します。アプリがコード内で判断できる場合、一部のツールタイプではプログラムによる承認コールバックもサポートされており、実行を一時停止せずに続行できます。agent.asTool() 自体を設定する場合は、ツールを参照してください。このページでは、その実行階層内のいずれかのツールで承認が必要になった後の動作を説明します。

needsApproval オプションを true に設定するか、真偽値を返す非同期関数に設定することで、承認が必要なツールを定義できます。

ツール承認の定義
import { tool } from '@openai/agents';
import z from 'zod';
const sensitiveTool = tool({
name: 'cancelOrder',
description: 'Cancel order',
parameters: z.object({
orderId: z.number(),
}),
// always requires approval
needsApproval: true,
execute: async ({ orderId }, args) => {
// prepare order return
},
});
const sendEmail = tool({
name: 'sendEmail',
description: 'Send an email',
parameters: z.object({
to: z.string(),
subject: z.string(),
body: z.string(),
}),
needsApproval: async (_context, { subject }) => {
// check if the email is spam
return subject.includes('spam');
},
execute: async ({ to, subject, body }, args) => {
// send email
},
});
  1. ツール呼び出しを実行する直前に、SDK はその承認ルール(needsApproval または組み込み MCP の同等機能)を評価します。
  2. 承認が必要で、まだ決定が保存されていない場合、ツール呼び出しは実行されません。代わりに、実行によって RunToolApprovalItem が記録されます。
  3. そのターンの終了時に実行は一時停止し、保留中のすべての承認がエージェントの実行結果interruptions 配列で返されます。これには、ネストされた agent.asTool() の実行内で要求された承認も含まれます。
  4. 保留中の各項目を result.state.approve(interruption) または result.state.reject(interruption) で処理します。実行の残りの期間に同じツールを承認済みまたは拒否済みのままにする場合は、{ alwaysApprove: true } または { alwaysReject: true } を渡します。拒否時には { message: '...' } を渡して、その特定のツール呼び出しについてモデルに返される拒否メッセージを制御することもできます。
  5. 更新した result.staterunner.run(agent, state) に再度渡して再開します。ここで、agent はその実行の元のトップレベルエージェントです。SDK は、ネストされたエージェントツールの実行を含め、中断された地点から続行します。

デフォルトでは、関数ツールの入力ガードレールは承認後、ツールが実行される直前にのみ実行されます。保留中の承認が表示される前に、同じ入力ガードレールでローカル関数ツール呼び出しを検証するには、run() または RunnertoolExecution: { preApprovalInputGuardrails: true } を渡します。承認前のガードレールが拒否した場合、SDK は承認による中断を作成せず、ガードレールのメッセージをツール出力としてモデルに返します。呼び出しが許可された場合も、実行は承認のために一時停止します。また、待機中にツール呼び出しが安全でなくなった場合に備え、承認後に入力ガードレールが再度実行されます。

needsApproval が関数の場合、SDK はツール引数が検査可能なオブジェクトに解析された後にのみ、その関数を呼び出します。不正な形式の JSON やオブジェクト以外の値については安全側に倒し、SDK はコールバックの呼び出しやツールの実行を行わずに承認を要求します。その呼び出しを承認してもツールは実行されず、通常の引数解析エラーの処理へ進みます。Realtime の関数ツールも同じルールに従い、無効な呼び出しに対して tool_approval_requested を発行します。

{ alwaysApprove: true } または { alwaysReject: true } で作成された固定の決定は実行状態に保存されるため、後で同じ一時停止中の実行を再開する際に toString() / fromString() を使用しても維持されます。

コンピュータツールの中断は、GA モデルでは一つの computer_call 内のアクションのバッチを表すことがあります。SDK は実行前にアクションごとに needsApproval を評価するため、一つの保留中の承認で、移動 + クリックのような一連の操作を対象にできます。UI を表示するために interruption.rawItem を検査する場合は、GA の actions 配列と従来の単一 action フィールドの両方を処理してください。

シリアル化された RunState では、現在の computer ツール名と従来の computer_use_preview ツール名の両方についてコンピュータ操作の承認も保持されるため、プレビューから GA への移行中も一時停止した実行を問題なく再開できます。

message を指定しない場合、SDK は設定済みの toolErrorFormatter(存在する場合)を使用し、その後、デフォルトの拒否メッセージを使用します。

保留中のすべての承認を同じ処理で解決する必要はありません。一部の項目のみを承認または拒否して再実行すると、解決済みの呼び出しは続行できますが、未解決の呼び出しは interruptions に残り、実行が再び一時停止します。

alwaysApprove: true または alwaysReject: true で作成された固定の決定は、同じツールへの後続の呼び出しに対するデフォルトになります。一つの呼び出し ID に対する個別の決定は、その固定のデフォルトより優先されます。ほかの呼び出しのデフォルトを変更せずに、固定承認の下で一つの呼び出しを拒否したり、固定拒否の下で一つの呼び出しを承認したりできます。後でその個別の決定を置き換えると、新しい個別の決定がその呼び出しに適用され、固定のデフォルトは残りの呼び出しに引き続き適用できます。

実行の一時停止中に新しいユーザー入力が届き、次に再開されたモデル呼び出しの前に取り込む必要がある場合は、RunState.addInput() を使用します。承認を解決して同じ状態を Runner.run() に戻す前に、入力を追加してください。準備された入力はシリアル化された状態の一部となるため、未解決の承認やローカルツールの処理によってモデル呼び出しが遅延しても、toString() / fromString() を通じて維持されます。

準備された項目の複製されたスナップショットを確認するには state.pendingInput を読み取ります。また、再開前にすべて削除するには state.clearPendingInput() を呼び出します。addInput() は、文字列または入力項目の配列を受け取ります。終端状態、残りのターンがない状態、またはツール結果によって実行が終了する可能性がある中断など、その状態から安全に別のモデル呼び出しへ到達できない場合は、UserError が発生します。

取り込まれると、準備された各入力は実行の newItems 内の RunInputItem となり、その元の入力項目が history に含まれます。ローカルの session を使用する場合、SDK はモデルリクエストを開始する前に、取り込まれた入力を一度だけ永続化します。conversationId または previousResponseId を使用する場合、サーバーがレスポンスを受け入れるまで入力は保留されたままになるため、受け入れ前に発生したことが分かっている失敗では、入力を失わずに再開できます。リクエストがすでに受け入れられた可能性があるとプロバイダーから報告された場合、SDK はその入力をチェックポイントに保存し、暗黙的に再実行せず安全側に倒して失敗させます。これとは別の明示的な安全でない再実行の上書きについては、モデルを参照してください。

手動の interruptions は最も汎用的なパターンですが、唯一の方法ではありません。

  • ローカルの shellTool()applyPatchTool() では、onApproval を使用してコード内で即座に承認または拒否できます。
  • リモート MCP サーバーツールでは、requireApprovalonApproval を組み合わせて、同様のプログラムによる決定を行えます。
  • 通常の関数ツールでは、このページで説明する手動の中断フローを使用します。

これらのコールバックが決定を返すと、人間の応答を待つために一時停止することなく実行が続行されます。Realtime セッション API については、音声エージェントの構築の承認フローを参照してください。

同じ中断フローは、ストリーミング実行でも機能します。ストリーミング実行が一時停止したら、stream.completed を待ち、stream.interruptions を読み取って解決し、再開後の出力でもストリーミングを継続する場合は { stream: true } を指定して run() を再度呼び出します。このパターンのストリーミング版については、ストリーミング中の Human in the loop (人間の介入)を参照してください。

session も使用している場合は、RunState から再開するときに同じ session を渡し続けてください。再開されたターンは、入力を再準備することなくセッションメモリに追加されます。セッションのライフサイクルの詳細については、セッションを参照してください。

以下は、ターミナルで承認を求め、状態を一時的にファイルへ保存する Human in the loop (人間の介入) フローの、より完全な例です。

Human in the loop (人間の介入)
import { z } from 'zod';
import readline from 'node:readline/promises';
import fs from 'node:fs/promises';
import { Agent, run, tool, RunState, RunResult } from '@openai/agents';
const getWeatherTool = tool({
name: 'get_weather',
description: 'Get the weather for a given city',
parameters: z.object({
location: z.string(),
}),
needsApproval: async (_context, { location }) => {
// forces approval to look up the weather in San Francisco
return location === 'San Francisco';
},
execute: async ({ location }) => {
return `The weather in ${location} is sunny`;
},
});
const dataAgentTwo = new Agent({
name: 'Data agent',
instructions: 'You are a data agent',
handoffDescription: 'You know everything about the weather',
tools: [getWeatherTool],
});
const agent = new Agent({
name: 'Basic test agent',
instructions: 'You are a basic agent',
handoffs: [dataAgentTwo],
});
async function confirm(question: string) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const answer = await rl.question(`${question} (y/n): `);
const normalizedAnswer = answer.toLowerCase();
rl.close();
return normalizedAnswer === 'y' || normalizedAnswer === 'yes';
}
async function main() {
let result: RunResult<unknown, Agent<unknown, any>> = await run(
agent,
'What is the weather in Oakland and San Francisco?',
);
let hasInterruptions = result.interruptions?.length > 0;
while (hasInterruptions) {
// Store the current run state
await fs.writeFile(
'result.json',
JSON.stringify(result.state, null, 2),
'utf-8',
);
// At this point, another process could review the saved state
// Read the saved state later
const storedState = await fs.readFile('result.json', 'utf-8');
const state = await RunState.fromString(agent, storedState);
for (const interruption of result.interruptions) {
const confirmed = await confirm(
`Agent ${interruption.agent.name} would like to use the tool ${interruption.name} with "${interruption.arguments}". Do you approve?`,
);
if (confirmed) {
state.approve(interruption);
} else {
state.reject(interruption);
}
}
// Resume execution from the restored state
result = await run(agent, state);
hasInterruptions = result.interruptions?.length > 0;
}
console.log(result.finalOutput);
}
main().catch((error) => {
console.dir(error, { depth: null });
});

実際に動作するエンドツーエンド版については、完全なサンプルスクリプトを参照してください。

Human in the loop (人間の介入) フローは、サーバーを稼働させたままにすることなく、長時間中断できるように設計されています。リクエストを終了して後で続行する必要がある場合は、状態をシリアル化して後から再開できます。

result.state.toString()(または JSON.stringify(result.state))を使用して状態をシリアル化し、シリアル化された状態を RunState.fromString(agent, serializedState) に渡すことで後から再開できます。ここで、agent は実行全体を開始したエージェントのインスタンスです。

承認された関数ツールの結果が実行の最終出力になる可能性がある場合、出力ガードレールにより、再開可能な境界がより厳密になります。ライブの RunState では、シリアル化後には利用できない出力の所有権情報を保持できます。シリアル化されたチェックポイントで、保留中の出力をどのレスポンスが所有しているかを証明できない場合、または出力を含む曖昧なチェックポイントをセッションが接続された状態で再開した場合、SDK はそれ以降のモデル、ツール、セッションへの副作用が発生する前に、安全側に倒して UserError を発生させます。この場合は、安全な入力から新しい実行を開始してください。同じエージェントグラフを再構築し、元の toolUseBehavior を維持してください。出力を含む部分的な承認がすべて、シリアル化された状態を通じてラウンドトリップできるとは想定しないでください。

RunState がシリアル化されると、SDK はハンドオフと Agent.asTool() のグラフについて、安定したエージェント ID を記録します。これにより、再開するプロセスが同じエージェントグラフを再構築する限り、異なるエージェントが同じ name を共有していても、一時停止した実行を再開できます。

RunState.fromString(agent, serializedState) に渡す agent は、再構築されたグラフのルートです。逆シリアル化時に、SDK はそのエージェントのハンドオフと Agent.asTool() の参照をたどり、状態内のシリアル化されたすべてのエージェント参照を再構築済みのグラフと照合して解決します。これには、現在のエージェントに加え、生成された項目、処理済みのモデルレスポンス、キューに入っている次のステップが保持するネストされた参照も含まれます。

別のランタイムによってモデルやツールがラップされたエージェントなど、差し替えたグラフで再開する必要がある場合は、元のグラフで状態を逆シリアル化し、再度シリアル化してから、その文字列を差し替え後のルートエージェントで逆シリアル化します。state.setCurrentAgent(agent) を呼び出しても、アクティブなエージェントが変更されるだけで、逆シリアル化時にすでに解決されたネストされた参照は書き換えられません。

再開したプロセスで新しいコンテキストオブジェクトを注入する必要がある場合は、代わりに RunState.fromStringWithContext(agent, serializedState, context, { contextStrategy }) を使用します。

  • contextStrategy: 'merge'(デフォルト)は、指定された RunContext を維持し、シリアル化された承認状態をマージします。また、新しいコンテキストに toolInput がまだ定義されていない場合は、シリアル化された toolInput を復元します。
  • contextStrategy: 'replace' は、指定された RunContext をそのまま使用して実行を再構築します。

シリアル化された実行状態には、アプリのコンテキストに加え、承認、使用量、ネストされた toolInput、保留中のネストされたエージェントツールの再開といった、SDK が管理するランタイムメタデータが含まれます。シリアル化された状態を保存または送信する場合は、runContext.context を永続化されるデータとして扱い、意図的に状態とともに移動させる場合を除き、そこにシークレットを格納しないでください。

デフォルトでは、シークレットが誤って永続化されないよう、トレーシング API キーはシリアル化された状態から除外されます。トレーシング認証情報を意図的に状態とともに移動する必要がある場合にのみ、result.state.toString({ includeTracingApiKey: true }) を渡してください。

この方法で、シリアル化した状態をデータベース、またはリクエストとともに保存できます。

保留中タスクのバージョン管理

Section titled “保留中タスクのバージョン管理”

承認リクエストに長時間かかり、エージェント定義を意味のある形でバージョン管理する、または Agents SDK のバージョンを上げる予定がある場合、現時点ではパッケージエイリアスを使用して二つのバージョンの Agents SDK を並行してインストールし、独自の分岐ロジックを実装することを推奨します。

実際には、独自のコードにバージョン番号を割り当ててシリアル化された状態とともに保存し、適切なバージョンのコードで逆シリアル化が行われるように制御します。