コンテンツにスキップ

人間の介入(HITL)

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

この承認の対象範囲は、現在の最上位エージェントに限定されず、実行全体に及びます。同じパターンは、ツールが現在のエージェントに属する場合、ハンドオフ先のエージェントに属する場合、またはネストされた agent.asTool() の実行に属する場合にも適用されます。ネストされた agent.asTool() の場合でも、中断は外側の実行に通知されるため、外側の result.state で承認または拒否し、元のルート実行を再開します。

agent.asTool() では、承認が 2 つの異なるレイヤーで発生する可能性があります。エージェントツール自体が 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.state を runner.run(agent, state) に渡して再開します。ここで agent は、その実行における元の最上位エージェントです。SDK は、ネストされたエージェントツールの実行を含め、中断された地点から処理を続行します。

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

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

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

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

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

message を指定しない場合、SDK は設定済みの toolErrorFormatter があればそれを使用し、その後にデフォルトの拒否テキストへフォールバックします。

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

alwaysApprove: true または alwaysReject: true で作成された固定判断は、同じツールに対する以降の呼び出しのデフォルトになります。1 つの呼び出し ID に対する個別の判断は、その固定されたデフォルトより優先されます。固定承認のもとで 1 つの呼び出しだけを拒否したり、固定拒否のもとで 1 つの呼び出しだけを承認したりしても、他の呼び出しに対するデフォルトは変更されません。後でその個別判断を置き換えると、新しい個別判断がその呼び出しに適用され、固定されたデフォルトは残りの呼び出しで引き続き使用できます。

実行の一時停止中に新しいユーザー入力が到着し、次に再開されるモデル呼び出しの前にその入力を取り込む必要がある場合は、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 サーバーツールでは、requireApproval と onApproval を組み合わせて、同様にプログラムで判断できます
  • 通常の関数ツールでは、このページで説明する手動の中断フローを使用します

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

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

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

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

Human in the loop (人間の介入)
// This local CLI trusts its saved state. Browser/mobile approval UIs should keep
// snapshots on the server; see human-in-the-loop-server.ts in agent-patterns.
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 が証明できる場合、現在のレスポンスで生成された項目の正確な所有権を記録するため、出力を生成し得る、サポート対象の部分承認チェックポイントは、シリアライズ済み状態を介してラウンドトリップできます。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 }) を渡してください。

シリアライズ済み状態は、サーバー側データベースなど、アプリケーションが管理するストレージに保存してください。

シリアライズされた RunState には、承認判断、保留中のツール呼び出し、ツール引数、アプリケーションコンテキストなどの実行状態が含まれます。RunState.fromString() はその状態を復元しますが、このメソッドはスナップショットや、それを送信した人物を認証しません。信頼できるストレージに保存されたスナップショットのみをデシリアライズするか、その前にアプリケーションでスナップショット全体の完全性、所有権、リプレイ防止を検証してください。スキーマ検証とツール呼び出しのフィンガープリントでは、スナップショットを認証できません。エージェントや生成された項目の所有権に関する SDK の参照も、アプリケーションのユーザーを認可するものではありません。

ブラウザーまたはモバイルの承認インターフェースでは、完全なスナップショットをアプリケーションが管理するサーバーストレージに保持してください。レビュー担当者には、認可された表示情報と、保留中の判断に対応する不透明な ID のみを送信します。実行 ID や判断 ID は、認可の証明にはなりません。ツール名と引数は信頼できない表示コンテンツとして扱い、機密値を除外し、HTML として表示する際はコンテンツをエスケープしてください。完全な実行結果とエラーはサーバーに保持し、アプリケーションが選択した出力のみをクライアントへ返してください。

判断を受信したら、サーバーは次の処理を行う必要があります。

  1. アプリケーションのセッションまたは認証ミドルウェアを使用して、レビュー担当者を認証します。承認リクエストの本文から、認証済み ID を取得してはなりません。
  2. 保存されている実行と選択された保留中の呼び出しに対して、レビュー担当者を認可します。
  3. サーバーに保存された保留中のリクエストに照らして、判断 ID と真偽値の判断を検証します。置き換え用のツール呼び出し、引数、承認レコード、またはシリアライズ済み状態をクライアントから受け取ってはなりません。
  4. デシリアライズと再開後の実行を行う前に、所有権をアトミックに確認し、保留中のリクエストを消費します。同時送信または再送信されたリクエストによって、同じスナップショットが 2 回再開されないようにする必要があります。共有ストレージでは、トランザクションまたは同等のアトミックな条件付き遷移を使用してください。
  5. サーバーが所有するスナップショットを読み込み、state.getInterruptions() で保留中の項目を取得し、実行を再開する前に、それらの項目に対してのみ state.approve() または state.reject() を適用します。

次の例では、バッチ内の保留中の呼び出しごとに 1 つの判断を要求します。これはアプリケーションのポリシーであり、SDK は前述の部分承認もサポートしています。ストアは、1 つのプロセス内の 1 つのイベントループに限定されています。所有者の確認、検証、消費の間に await はありません。デシリアライズや実行が失敗した場合、または実行がキャンセルされた場合でも、リクエストは消費済みのままです。消費によってこのスナップショットの再送信は防止できますが、ツールの副作用が厳密に 1 回だけ発生することは保証されません。復旧や別の実行を開始する前に、完了済みまたは結果が不確かなツールの副作用を照合してください。

サーバー管理の承認状態
import { randomUUID } from 'node:crypto';
import { Agent, Runner, RunState, type RunResult } from '@openai/agents';
import { z } from 'zod';
export type PendingApproval = {
kind: 'approval';
requestId: string;
prompts: { decisionId: string; toolName: string; arguments: string }[];
};
type StoredRun = {
ownerId: string;
snapshot: string;
decisionIds: string[];
};
// Simulation only: one event loop in one process. Production storage needs an
// atomic owner-checked consume operation and bounded retention. Consumption is
// permanent even after failure/cancellation; reconcile side effects before retry.
export class ApprovalServer {
#agent: Agent;
#runner = new Runner({ tracingDisabled: true });
#pending = new Map<string, StoredRun>();
constructor(agent: Agent) {
this.#agent = agent;
}
// An HTTP adapter must obtain this identity from trusted authentication
// middleware and apply request/CSRF protections. Never read it from the body.
async start(authenticatedUserId: string, message: string) {
const result = await this.#runner.run(this.#agent, message);
return this.#save(authenticatedUserId, result);
}
#save<TContext, TAgent extends Agent<any, any>>(
ownerId: string,
result: RunResult<TContext, TAgent>,
) {
const interruptions = result.state.getInterruptions();
if (interruptions.length === 0) {
// RunResult stays on the server; the app selects what the client may see.
return { kind: 'completed' as const, output: result.finalOutput };
}
const requestId = randomUUID();
const decisionIds = interruptions.map(() => randomUUID());
this.#pending.set(requestId, {
ownerId,
snapshot: result.state.toString(),
decisionIds,
});
const response: PendingApproval = {
kind: 'approval',
requestId,
// Detached display values only. Filter arguments for the reviewer's access
// policy; this demo uses synthetic weather data. Escape HTML in a web UI.
prompts: interruptions.map((item, index) => ({
decisionId: decisionIds[index],
toolName: item.name ?? 'unknown_tool',
arguments: item.arguments ?? '',
})),
};
return response;
}
async decide(
authenticatedUserId: string,
requestId: string,
decisions: unknown,
signal?: AbortSignal,
) {
const stored = this.#pending.get(requestId);
if (!stored || stored.ownerId !== authenticatedUserId) {
throw new Error('Approval request is unavailable.');
}
// Validate JSON request data, not client-supplied calls, state, or identity.
const parsed = z.record(z.string(), z.boolean()).safeParse(decisions);
if (
!parsed.success ||
Object.keys(parsed.data).length !== stored.decisionIds.length ||
!stored.decisionIds.every((id) =>
Object.prototype.hasOwnProperty.call(parsed.data, id),
)
) {
throw new Error(
'Provide one boolean decision for every pending tool call.',
);
}
// No await between the owner check and consume. The parsed decisions are a
// detached copy, so client mutation while deserialization awaits has no effect.
this.#pending.delete(requestId);
const state = await RunState.fromString(this.#agent, stored.snapshot);
const interruptions = state.getInterruptions();
for (const [index, interruption] of interruptions.entries()) {
if (parsed.data[stored.decisionIds[index]]) {
state.approve(interruption);
} else {
state.reject(interruption);
}
}
const result = await this.#runner.run(this.#agent, state, { signal });
return this.#save(authenticatedUserId, result);
}
}

完全なコード例については、CLI クライアント/サーバーシミュレーションを参照してください。これはデプロイ可能な HTTP サービスではありません。本番環境のアプリケーションでは、信頼できる認証、表示される詳細情報と選択された呼び出しに対する認可、必要に応じたリクエスト保護と CSRF 対策、ストレージ保持期間の制限、共有ストレージでのアトミックな消費、復旧ポリシーを用意する必要があります。CLI は合成されたツールデータを使用し、トレーシングを無効にしています。実際のアプリケーションのスナップショットや診断情報には、機密データが含まれる可能性があります。

contextStrategy: 'replace' を指定した RunState.fromStringWithContext() を使用したり、シリアライズされた承認レコードのみを削除したりしても、信頼できないスナップショットが安全になるわけではありません。他のフィールドも引き続き実行を制御します。クライアントが完全なスナップショットを転送する場合は、デシリアライズの前にスナップショット全体の完全性を検証し、認可されたユーザーと実行に関連付け、リプレイを防止してください。完全性の検証では、スナップショットを暗号化したり、その内容をクライアントから隠したりすることはできません。

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

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

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

実際には、独自のコードにバージョン番号を割り当ててシリアライズ済み状態とともに保存し、正しいバージョンのコードでデシリアライズされるように制御します。