コンテンツにスキップ

人間の介入(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 に設定するか、ブール値を返す非同期関数に設定することで、承認を必要とするツールを定義できます。

ツール承認の定義
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 は承認の割り込みを作成せず、ガードレールのメッセージをツール出力としてモデルに返します。呼び出しを許可した場合、実行は引き続き承認のために一時停止し、承認待ちの間にツール呼び出しが安全でなくなっている可能性に備えて、承認後に入力ガードレールが再度動作します。

{ 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 に残って実行を再び一時停止します。

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

  • ローカルの shellTool()applyPatchTool() では、onApproval を使用してコード内ですぐに承認または拒否できます。
  • リモート MCP ツールでは、requireApprovalonApproval を併用して、同様にプログラムから判定できます。
  • 通常の関数ツールでは、このページで説明する手動の割り込みフローを使用します。

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

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

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 はハンドオフと Agent.asTool() のグラフについて、安定したエージェント識別情報を記録します。これにより、異なるエージェントが同じ 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 のバージョンを上げる場合は、現在のところ、パッケージエイリアスを使用して 2 つのバージョンの Agents SDK を並行してインストールし、独自の分岐ロジックを実装することを推奨します。

実際には、独自のコードにバージョン番号を割り当ててシリアライズした状態とともに保存し、デシリアライズ時に適切なバージョンのコードを使用するよう制御します。