コンテンツにスキップ

人間の介入(HITL)

このガイドでは、 SDK の承認ベースの human-in-the-loop フローを扱います。ツール呼び出しに承認が必要な場合、 SDK は実行を一時停止し、 interruptions を返し、同じ RunState から後で再開できます。

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

agent.asTool() では、承認は 2 つの異なるレイヤーで発生することがあります。エージェントツール自体が asTool({ needsApproval }) により承認を要求でき、さらにネストした実行開始後に、そのネスト先エージェント内のツールが独自の承認を要求する場合があります。どちらも同じ外側実行の割り込みフローで処理されます。

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

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

Tool approval definition
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 または hosted 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 は中断地点から継続し、ネストしたエージェントツール実行も含めて再開します

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

GA モデルでは、コンピューターツールの割り込みが 1 つの computer_call 内のアクションバッチを表す場合があります。 SDK は実行前にアクションごとに needsApproval を評価するため、 1 つの保留承認が move + click のような連続操作をカバーすることがあります。 interruption.rawItem を調べて UI を描画する場合は、 GA の actions 配列と従来の単一 action フィールドの両方に対応してください。

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

message を指定しない場合、 SDK は設定された toolErrorFormatter (存在する場合)を使い、それでもなければデフォルトの拒否テキストを使います。

同じパスで保留中の承認をすべて解決する必要はありません。一部の項目だけを承認または拒否して再実行した場合、解決済みの呼び出しは継続され、未解決のものは interruptions に残って再び実行を一時停止します。

手動の interruptions は最も汎用的なパターンですが、それだけではありません。

  • ローカルの shellTool()applyPatchTool()onApproval を使ってコード内で即座に承認または拒否できます
  • Hosted 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) {
// storing
await fs.writeFile(
'result.json',
JSON.stringify(result.state, null, 2),
'utf-8',
);
// from here on you could run things on a different thread/process
// reading later on
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 of the current 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.fromStringWithContext(agent, serializedState, context, { contextStrategy }) を使います。

  • contextStrategy: 'merge' (デフォルト)は、渡した RunContext を維持しつつシリアライズ済み承認状態をマージし、新しいコンテキストに未定義の場合はシリアライズ済み toolInput を復元します
  • contextStrategy: 'replace' は、渡した RunContext をそのまま使って実行を再構築します

シリアライズされた実行状態には、アプリのコンテキストに加えて、承認・使用量・ネストした toolInput・保留中のネストしたエージェントツール再開など、 SDK 管理のランタイムメタデータも含まれます。シリアライズ状態を保存または転送する予定がある場合は、 runContext.context を永続化データとして扱い、意図的に状態に含めたい場合を除いて秘密情報は置かないでください。

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

これにより、シリアライズ済み状態をデータベースやリクエストと一緒に保存できます。

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

実運用では、自分のコードにバージョン番号を割り当ててシリアライズ済み状態と一緒に保存し、デシリアライズを正しいバージョンのコードへ導くことを意味します。