コンテンツにスキップ

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

ツール承認の定義
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 は、ネストされたエージェントツール実行を含め、中断された地点から続行します。

デフォルトでは、関数ツールの入力ガードレールは承認後、ツール実行直前にのみ実行されます。保留中の承認が表示される前に、同じ入力ガードレールでローカルの関数ツール呼び出しを検証したい場合は、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 を使用してコード内ですぐに承認または拒否できます。
  • 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 がシリアライズされると、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 を並行インストールし、独自の分岐ロジックを実装することを推奨します。

実際には、これは自分のコードにバージョン番号を割り当て、シリアライズした状態と一緒に保存し、コードの正しいバージョンにデシリアライズを誘導することを意味します。