OpenAI Agents SDK 実践教科書 — Agents API 増補版
第0〜18章の主要なAPI・実行状態・ツール・音声・サンドボックスの説明を現行ガイドと照合しました。演習は openai-agents==0.22.2 / openai==3.13.0 に固定して再現性を保ちます。最新版SDKの全examplesとの一致や、実APIの動作を検証したという意味ではありません。モデルの新規選定、支出制御、音声方式、継続状態は各章の更新注記を参照してください。
2026-10-04に確認した最新リリースは v0.23.1(10月2日)です。教材の固定版v0.22.2と同じではありません。v0.23.0では次の変更があり、v0.23.1ではリリース検査が修正されました。
- ツールと承認(第3・9・12章): 捨てられていた
**kwargsの引数を拒否するようになりました。ツールには明示的な型付き引数を定義します。承認は所有エージェントや元のMCP受信先に結び付くため、古い保存状態からの再開は改めて確認します。 - セッションとトレース(第8・10章): 暗号化セッション履歴は暗号化エンベロープを要求します。既存の平文履歴をそのまま暗号化ストアとして扱わず、信頼できるデータを通常の追加経路で移行します。推論は既定のトレース出力から除外され、機密データ収集設定もメタデータに適用されます。
- 音声(第14章): 既定のSTTモデルが
gpt-4o-transcribeからgpt-transcribeに変更されました。既定値に依存するコードは更新で使用モデルが変わるので、再現したい演習ではモデルを明示します。 - 長時間実行(第12・15・16章): MCP一覧のページ数上限、暗号化履歴走査の予算、メモリ統合ターン数、Docker削除保護などの設定が追加されています。ストリーミングのコールバック滞留やWebSocket受信サイズにも上限があるため、境界条件を確認して移行します。
教材の構文・表示検査は最新版による実API実行の代わりにはなりません。本番導入時は固定版を維持するか、依存関係をまとめて更新して承認・保存状態・ストリーミングを検証してください。v0.23.1公式リリース · 公式変更履歴
OpenAI Agents SDK の公式リポジトリに収録された examples を、1 本残らず「概念の解説 → コードの読み解き → 実際の実行手順 → 理解度チェック」の流れで学び切るための日本語教科書です。断片的なチュートリアルではなく、公式 examples という「動く実物」を教材に、AI エージェント開発の基礎から実践アプリ・音声・サンドボックスまでを体系的に習得できます。
2026年9月のAgents API発表を受け、第18章に管理されたエージェントの実行・継続・成果物の取得を追加しました。SDKの基礎から読む方は第0章、新しいAPIから学ぶ方は第18章へ進んでください。
対象読者
- Python の基礎文法(関数・クラス・
async/awaitの初歩)は理解している方 - AI エージェント開発は初めて、または LLM API を単発で呼んだ経験しかない方
- 「公式 examples を上から順に読んでみたが、前提知識が足りず挫折した」方
LLM・トークン・プロンプトといった前提用語は第 0 章で丁寧に定義するため、AI 開発の事前知識は不要です。
2026-09-12 更新:Agents APIとSDK v0.22.2
第18章 Agents APIを新設しました。SDKとの比較、3種類の実行環境、Pythonコード、セッション継続、ツール結果の返却、artifact取得を扱います。SDK v0.22.1 / v0.22.2の変更は更新表と各関連章で確認できます。
v0.18.1 → v0.22.0 の主な変更(2026-09-01 更新)
- 視覚化改訂(2026-09-01): 全 17 章に inline SVG 図解 95 枚を追加(テキスト製フロー図の SVG 化を含む)。デザインを StellarArc design-system 最新版(callout フラット意匠・Inter + JetBrains Mono)に更新
- 既定モデルが
gpt-5.4-mini→gpt-5.6-lunaに変更(v0.20.0) @function_tool→@tool(agents.decoratorsの短縮エイリアス。従来名も有効)。公式 examples は全面移行(v0.19.0)- Programmatic Tool Calling(モデルが JavaScript を生成し複数ツールを並列に呼ぶ)を第 3 章に追加(v0.19.0)
- OpenAI ホスト型マルチエージェント(実験的)を第 4 章に追加(v0.18.2)
- MCP Python SDK v1 / v2 の両対応。examples は v2 へ移行(
FastMCP→MCPServer、httpx→httpx2)(v0.20.0) openai依存が 2.x →>=3.0.0,<4(v0.21.0)、Realtime の入力書き起こし GA 設定に対応(v0.20.0)- 実行スコープのサンドボックス作業ディレクトリを第 16 章に追加(v0.21.1)、金融リサーチ example が根拠ベースの検証 + 自動書き直しに刷新
SDKの第0〜16章は5つのブロックで構成しています。第17章は用語集、第18章はAgents APIの増補です。SDKから学ぶ方は上から、新APIとの違いを知りたい方は第18章から読み進められます。
環境構築・Agent/Runner・ツール・設計パターン・handoff
MCP・メモリ・他社モデル・推論コンテンツ
カスタマーサービス・リサーチボット 2 種
VoicePipeline・Realtime API
サンドボックスエージェント
- 基礎(第 0〜5 章): 全員必読です。以降のすべての章がここで学ぶ部品を前提とします。
- 連携(第 6〜10 章): 外部ツール・データ・他社モデルとの接続方法です。必要な章だけの拾い読みも可能です。
- 実践アプリ(第 11〜13 章): 基礎の部品が実アプリでどう組み合わさるかを通読で体感します。
- 音声・リアルタイム(第 14〜15 章): 音声デバイスが必要なため、環境が整ってから取り組むことを推奨します。
- 発展(第 16 章): サンドボックス実行という最前線のトピックです。Docker があると学習効率が上がります。
- 各章は「概念の解説 → コードの読み解き → 実行手順 → 理解度チェック」という共通の構成です。まず概念をつかみ、コードで裏を取り、必ず手元で実行して確かめてください。読むだけと動かすのとでは定着度がまったく違います。
- 実行時の前提(追加 extras・環境変数・外部ツール)は各章冒頭と本ページ各カードの「要件」にまとめています。実行前に確認してください。
- つまずいたら第 1 章のトラブルシューティングと用語集に戻るのが近道です。
注意書き
第0章 はじめに — AIエージェントと Agents SDK の世界
本書は、OpenAI が公開している OpenAI Agents SDK(Python 版、本書の確認バージョンは v0.22.2)の公式リポジトリに含まれる examples(サンプルコード集)を、1 章ずつ動かしながら学ぶ教科書です。対象読者は「Python の基本文法は分かるが、LLM や AI エージェント開発は初めて」という方です。
この章ではコードをほとんど書きません。その代わり、これから 17 章かけて学ぶ世界の全体地図を手に入れます。
この章で学ぶこと
- AI エージェントとは何か。普通のチャットボットと何が違うのか
- LLM・トークン・プロンプト・API という基礎用語の意味
- Agents SDK の設計思想と、7 つの基本部品(Agent / Runner / function tool / handoff / guardrail / session / tracing)
- 世界一短いエージェントプログラム(Hello World)の読み方
- 本書全体の構成マップと、おすすめの学習順序
- 学習にかかる費用(API 利用料)の注意点
0.1 AI エージェントとは何か — チャットボットとの違い
まず「エージェント」という言葉を、身近な比喩で押さえましょう。
チャットボットは「物知りな電話相談員」です。あなたが「大阪でおすすめの観光地は?」と聞けば、知識をもとに答えてくれます。しかし相談員は電話口から一歩も動けません。「じゃあ新幹線とホテルを予約しておいて」と頼んでも、「予約サイトの使い方をご案内しますね」と説明が返ってくるだけです。質問に答えることはできても、行動はできないのです。
一方、AI エージェントは「旅行代理店の担当者」です。「来週大阪に出張するので手配をお願いします」と目的だけを伝えれば、担当者は自分で判断して動きます。空席検索システムで新幹線を調べ、予約端末を操作し、ホテルの空室を確認し、条件が合わなければ代替案を考え、最後に「手配が完了しました」と結果だけを報告してくれます。
この比喩を技術用語に置き換えると、AI エージェントの定義が見えてきます。
| 旅行代理店の担当者 | AI エージェント |
|---|---|
| 頭脳(知識と判断力) | LLM(大規模言語モデル) |
| 予約端末・検索システム | ツール(tool): 検索、計算、ファイル操作などの外部機能 |
| 「調べる→予約する→確認する」という段取り | エージェントループ: 完了するまで判断と行動を繰り返す仕組み |
| 「この案件は海外旅行専門の同僚へ」という引き継ぎ | handoff(ハンドオフ): 別の専門エージェントへの委任 |
つまり AI エージェントとは、LLM が自分でツールを使い、状況を判断し、複数ステップのタスクを最後まで遂行するプログラムです。「1 回の質問に 1 回答えて終わり」のチャットボットとの本質的な違いは、この「自律的に複数ステップを進める」点にあります。
0.2 LLM の基礎のおさらい
エージェントの頭脳である LLM について、本書を読むのに必要な最小限だけ確認します。
LLM(Large Language Model、大規模言語モデル) とは、膨大なテキストから言葉のつながりを学習した AI モデルです。文章を入力すると、その続きとして最も自然な文章を生成します。OpenAI の GPT シリーズが代表例です。
トークンとは、LLM が文章を処理するときの最小単位です。LLM は文章を文字単位ではなく、「単語や文字のかたまり」に区切って扱います。分割のされ方はモデル・言語・文章によって異なり、文字数から一定比率では換算できません。実際の使用量はレスポンスの usage で確認します。後述する API の利用料金は、このトークン数に応じた従量課金で計算されるため、コスト管理の観点でも重要な概念です。
プロンプトとは、LLM に与える入力文のことです。プロンプトには大きく 2 種類あります。
- システムプロンプト(指示): 「あなたは親切なアシスタントです」のように、LLM の役割や振る舞いを定める文。Agents SDK では
instructionsという引数で指定します - ユーザー入力: 「大阪の観光地を教えて」のような、利用者からの実際の質問や依頼
API(Application Programming Interface)呼び出しとは、プログラムからインターネット越しに OpenAI のサーバーへプロンプトを送信し、LLM の応答を受け取ることです。ChatGPT の画面をブラウザで操作する代わりに、Python コードで同じことを行うイメージです。API を使うには API キー(利用者を識別する秘密の文字列)が必要で、使ったトークン数に応じて料金が発生します(詳しくは 0.8 節)。
ここで重要なのは、LLM の API は 1 回の呼び出しで「テキストを受け取り、テキストを返す」ことしかできないという点です。ファイルを開くことも、Web を検索することも、それ単体ではできません。この「1 往復のテキスト処理」を部品として、「ツールを使い、結果を見て、次の行動を決める」というループを組み上げたものがエージェントです。そして、そのループを自分でゼロから書かなくて済むようにしてくれるのが Agents SDK です。
0.3 Agents SDK の設計思想
Agents APIとの使い分け
この節は、自分のプロセスで Runner を動かすAgents SDKの設計です。2026年9月に発表されたAgents APIでは、OpenAIがハーネスとセッションを管理します。第18章の比較表で、同じ「エージェント」でもどこを管理するかが異なることを確認してください。
OpenAI Agents SDK は、エージェント型 AI アプリケーションを構築するための軽量なフレームワークです。公式ドキュメントは、設計を支える原則を 2 つ挙げています。
- 使う価値がある十分な機能を備えつつ、すばやく学べるだけの少数の基本部品に抑えること
- そのままでも優れた動作をしつつ、必要なら何が起こるかを正確にカスタマイズできること
この思想の中核がエージェントループです。SDK は「LLM を呼ぶ → ツール呼び出しがあれば実行して結果を LLM に返す → 別のエージェントへの handoff があれば切り替える → 最終出力が出たら終了する」という繰り返しを組み込みで提供します。開発者はループの中身を自分で書く必要がなく、「どんなエージェントを、どんなツールとともに用意するか」の設計に集中できます。
もうひとつの特徴は Python ファーストであることです。エージェント同士の連携や条件分岐に独自の設定言語を覚える必要はなく、普通の Python の関数・クラス・制御構文で表現します。「Python が書ければエージェントが書ける」ことを目指した設計です。
また、SDK は OpenAI 専用ではなく、他社の LLM を含む 100 以上のモデルに対応しています(第9章で扱います)。
このエージェントループを図で表すと、2 箇所の分岐と 2 本の周回で成り立っていることが分かります。
0.4 基本プリミティブ — 7 つの部品
Agents SDK は少数の基本部品(プリミティブ)の組み合わせでできています。ここでは 7 つを順に紹介します。名前だけでも覚えておくと、以降の章がぐっと読みやすくなります。
Agent(エージェント)
Agent は SDK の主役となるクラスで、「instructions(指示)とツールを備えた LLM」を表します。name(名前)と instructions(システムプロンプト)を渡すだけで最小のエージェントが完成し、必要に応じてツール、handoff 先、guardrail、使用するモデルなどを追加していきます。本書のすべての章に登場しますが、基本形は第2章で、実践的な構成は第11〜13章で詳しく学びます。
Runner(実行エンジン)
Runner は、Agent を実際に動かすための実行エンジンです。前節で説明したエージェントループ(LLM 呼び出し → ツール実行 → 繰り返し)を担当するのが Runner で、Runner.run()(非同期版)、Runner.run_sync()(同期版)、Runner.run_streamed()(ストリーミング版)の 3 つの実行方法があります。ストリーミングとは、応答の全文が完成するのを待たず、生成された端から少しずつ受け取る方式のことです(ChatGPT の画面で文字がパラパラと表示されるのと同じ仕組みです)。Runner の使い分けは第2章で学びます。
function tool(関数ツール)
function tool は、普通の Python 関数をエージェントのツールに変換する仕組みです。関数に @tool(従来名 @function_tool。v0.19.0 で追加された短縮エイリアスで実体は同じ)というデコレータ(関数に機能を追加する Python の記法)を付けるだけで、SDK が関数の引数や説明文(docstring)を自動で読み取り、LLM が「この関数をこの引数で呼びたい」と判断できる形式に変換してくれます。天気の取得、データベース検索、社内 API の呼び出しなど、あらゆる処理をエージェントの「手足」にできます。基本は第2章で、Web 検索やコード実行などの OpenAI 提供の組み込みツールは第3章で学びます。
handoff(ハンドオフ)
handoff は、あるエージェントから別の専門エージェントへ会話を引き継ぐ仕組みです。たとえば受付役の「振り分けエージェント」が質問内容を見て、歴史の質問なら歴史専門エージェントへ、数学の質問なら数学専門エージェントへ会話ごと委任する、といった構成が作れます。1 つの万能エージェントを作るより、専門特化した小さなエージェントを連携させるほうが品質を保ちやすい、というのがマルチエージェント設計の基本的な考え方です。第5章で詳しく学び、第11章の実践アプリで本格的に活用します。
guardrail(ガードレール)
guardrail は、エージェントへの入力やエージェントからの出力を検証する安全装置です。名前のとおり道路のガードレールのように、「宿題の代行依頼は拒否する」「個人情報を含む出力を止める」といったチェックをエージェント本体の処理と並行して実行し、違反を検知したら即座に処理を打ち切ります。本番運用に耐えるエージェントには欠かせない部品です。第4章(設計パターン)で学びます。
session(セッション / メモリ)
LLM の API 呼び出しは 1 回ごとに独立しており、そのままでは前の会話を覚えていません。session は、会話履歴を自動で保存・復元してくれる記憶装置です。実行前に履歴を読み込み、実行後に新しいやり取りを保存する処理を SDK が肩代わりするため、「さっきの続きだけど」が通じるエージェントを簡単に作れます。保存先も SQLite(ファイルベースの軽量データベース)や Redis などから選べます。第8章で詳しく学びます。
tracing(トレーシング)
tracing は、エージェントの実行記録を自動で収集し、可視化する仕組みです。エージェントは内部で複数回の LLM 呼び出しやツール実行を行うため、「なぜこの答えになったのか」が外から見えにくくなりがちです。SDK は各ステップを自動で記録しており、OpenAI ダッシュボードの Trace viewer(https://platform.openai.com/traces )で「どのエージェントが、どのツールを、どんな引数で呼んだか」を時系列で確認できます。デバッグ(不具合調査)の強力な味方として、第2章以降のあらゆる場面で活用します。
7 つの部品を Agent を中心に配置すると、それぞれの役割の違いが見渡せます。
0.5 最小コード例 — 世界一短いエージェント
理屈はこのくらいにして、実際のコードを見てみましょう。公式ドキュメントの冒頭に掲載されている Hello World の例です。
from agents import Agent, Runner
agent = Agent(name="Assistant", instructions="You are a helpful assistant")
result = Runner.run_sync(agent, "Write a haiku about recursion in programming.")
print(result.final_output)
# Code within the code,
# Functions calling themselves,
# Infinite loop's dance.
わずか 4 行の実行文です。1 行ずつ読み解きます。
- 1 行目
from agents import Agent, Runner— SDK のパッケージ名はagentsです(インストール時の名前はopenai-agentsですが、import ではagentsと書きます)。主役のAgentと実行エンジンのRunnerを読み込みます。 - 3 行目
agent = Agent(name="Assistant", instructions="You are a helpful assistant")— エージェントを定義します。nameは人間が識別するための名前(tracing の画面にも表示されます)、instructionsはシステムプロンプト、つまり「あなたは親切なアシスタントです」というエージェントの役割設定です。この時点では API 呼び出しは発生しません。設計図を作っただけです。 - 5 行目
result = Runner.run_sync(agent, "Write a haiku about recursion in programming.")— ここで初めてエージェントが動きます。第 1 引数に実行したいエージェント、第 2 引数にユーザー入力(「プログラミングの再帰について俳句を書いて」)を渡します。run_syncは同期版の実行メソッドで、エージェントループが完了するまでこの行で待機します。裏側では SDK が OpenAI の API を呼び出しています。 - 6 行目
print(result.final_output)— 実行結果はRunResultというオブジェクトで返され、そのfinal_output属性に最終出力(この例では英語の俳句)が入っています。 - 8〜10 行目のコメント — 実行したときの出力例です。LLM の出力は毎回変わるため、手元で実行すると別の俳句になります。
この例にはツールも handoff も登場しませんが、「Agent を定義し、Runner で実行し、final_output を受け取る」という流れは、本書に登場するすべての examples に共通する基本形です。
なお、リポジトリの examples に収録されている同等のコード examples/basic/hello_world.py は、run_sync の代わりに非同期版を使っています。非同期(async/await)とは、待ち時間の長い処理(API 応答待ちなど)の間に他の処理を進められる Python の仕組みで、examples の大半はこのスタイルで書かれています。
import asyncio
from agents import Agent, Runner
async def main():
agent = Agent(
name="Assistant",
instructions="You only respond in haikus.",
)
result = await Runner.run(agent, "Tell me about recursion in programming.")
print(result.final_output)
# Function calls itself,
# Looping in smaller pieces,
# Endless by design.
if __name__ == "__main__":
asyncio.run(main())
環境構築(第1章)を終えたあとであれば、リポジトリのルートで次のように実行できます。実行には環境変数 OPENAI_API_KEY の設定が必要です(設定方法も第1章で解説します)。
export OPENAI_API_KEY=sk-...
uv run python examples/basic/hello_world.py
0.6 この教科書の構成マップ
本書は全 17 章と用語集で構成されています。各章は公式リポジトリの examples ディレクトリのカテゴリに対応しており、「解説を読む → 実際に動かす」を繰り返しながら進みます。難易度は ★(入門)〜★★★(応用)の 3 段階です。
| 章 | ファイル | 学べること | 難易度 |
|---|---|---|---|
| 第0章 はじめに | 00_はじめに_AIエージェント入門.md | AI エージェントの概念、SDK の全体像、学習の進め方(本章) | ★ |
| 第1章 環境構築 | 01_環境構築.md | Python / uv のセットアップ、API キーの取得と設定、examples の実行方法 | ★ |
| 第2章 基礎機能(basic) | 02_basic_基礎機能.md | Agent と Runner の基本、ストリーミング、動的プロンプト、ライフサイクル、使用量トラッキング | ★ |
| 第3章 組み込みツール(tools) | 03_tools_組み込みツール.md | Web 検索・ファイル検索・コード実行・画像生成など OpenAI ホスト型ツールの使い方 | ★★ |
| 第4章 設計パターン(agent_patterns) | 04_agent_patterns_設計パターン.md | ルーティング、並列実行、LLM-as-a-judge、guardrail、人間による承認などの定番パターン | ★★ |
| 第5章 ハンドオフ(handoffs) | 05_handoffs_ハンドオフ.md | エージェント間の会話の引き継ぎと、引き継ぐ履歴を加工するメッセージフィルタ | ★★ |
| 第6章 MCP 連携(mcp) | 06_mcp_MCP連携.md | MCP サーバー(ファイルシステム・Git 等)を外部ツールとして接続する方法 | ★★ |
| 第7章 ホスト型 MCP(hosted_mcp) | 07_hosted_mcp_ホスト型MCP.md | OpenAI がサーバー側で実行してくれる MCP と承認フロー | ★★ |
| 第8章 セッションメモリ(memory) | 08_memory_セッションメモリ.md | SQLite / Redis / SQLAlchemy 等による会話履歴の永続化と暗号化 | ★★ |
| 第9章 他社モデル利用(model_providers) | 09_model_providers_他社モデル利用.md | OpenAI 以外の LLM(他社モデル・ローカルモデル)を SDK から使う方法 | ★★ |
| 第10章 推論コンテンツ(reasoning_content) | 10_reasoning_content_推論コンテンツ.md | 推論モデルの思考過程(reasoning content)の取得と表示 | ★★ |
| 第11章 実践①: カスタマーサービス(customer_service) | 11_customer_service_カスタマーサービス実装例.md | 航空会社の問い合わせ対応を題材にした handoff + guardrail の実践構成 | ★★★ |
| 第12章 実践②: リサーチボット(research_bot) | 12_research_bot_リサーチボット実装例.md | 計画 → 並列検索 → レポート執筆を行うマルチエージェント・ワークフロー | ★★★ |
| 第13章 実践③: 金融リサーチ(financial_research_agent) | 13_financial_research_agent_金融リサーチ実装例.md | 金融分析を題材にした構造化リサーチと専門エージェントの検証連携 | ★★★ |
| 第14章 音声エージェント(voice) | 14_voice_音声エージェント.md | 音声認識 → エージェント → 音声合成のパイプライン構築 | ★★★ |
| 第15章 リアルタイムエージェント(realtime) | 15_realtime_リアルタイムエージェント.md | 低遅延で双方向の音声対話を行うリアルタイムエージェント | ★★★ |
| 第16章 サンドボックスエージェント(sandbox) | 16_sandbox_サンドボックスエージェント.md | 隔離されたワークスペースでファイル操作・コマンド実行を行うエージェント | ★★★ |
| 用語集 | 17_用語集.md | 本書に登場する専門用語の五十音・アルファベット索引 | ★ |
この構成マップを 5 ブロックの一本道として図にし、急いでいる方向けの最短ルートも合わせて示します。
難易度で俯瞰すると、17 章は ★1〜★3 の 3 段に分かれます。
0.7 推奨学習順序
はじめて学ぶ方は、次の 5 ブロックの順に進むことをおすすめします。
- 基礎(第0〜5章) — まずここを順番に。エージェントの基本形(Agent / Runner / function tool)から、マルチエージェントの入り口(設計パターン・handoff)までを一気通貫で身につけます。本書の背骨にあたるブロックです。
- 連携(第6〜10章) — エージェントを外の世界とつなげます。MCP による外部ツール接続、会話の記憶(session)、他社モデルの利用、推論モデルの活用と、実用化に必要な周辺技術を学びます。興味のある章からのつまみ食いも可能です。
- 実践アプリ(第11〜13章) — 基礎と連携で学んだ部品が、実際のアプリケーションでどう組み合わさるかを 3 つの題材で確認します。「部品は分かったが、どう組み立てればよいか分からない」という段階を抜け出すためのブロックです。
- 音声・リアルタイム(第14〜15章) — テキストから音声へ。追加の依存関係やオーディオ機器が必要になるため、テキストベースのエージェントに慣れてから取り組むのが安全です。
- 発展(第16章) — 隔離環境でファイルやコマンドを扱うサンドボックスエージェントです。SDK の中でも新しく発展中の領域なので、最後の挑戦として位置づけています。
急いでいる方向けの最短ルートは「第1章 → 第2章 → 第4章 → 第5章 → 第11章」です。ただし途中の章で登場した用語につまずいたら、用語集(17_用語集.md)と該当章に立ち戻ってください。
0.8 学習にかかる費用の注意
本書の examples を動かすには OpenAI の API キーが必要で、API の利用は従量課金です。ChatGPT の有料プラン(ChatGPT Plus など)とは別の契約であることに注意してください。ChatGPT に加入していても、API は API として使った分だけ課金されます。
料金は「入力トークン数 + 出力トークン数 × モデルごとの単価」で決まります。単価はモデルによって大きく異なり、改定されることもあるため、必ず公式の料金ページ(https://openai.com/api/pricing/ )で最新情報を確認してください。そのうえで、学習コストの感覚として次の点を押さえておくと安心です。
- 小さな examples でも料金は一定ではありません。モデル、入力履歴、推論量、ツール、反復回数によって変わるため、実行後の usage と公式料金表を照合します
- 第11〜13章の実践アプリは、1 回の実行で多数の LLM 呼び出しを行います。特にリサーチ系(第12・13章)は検索とレポート生成を繰り返すため、1 回で数十円規模になることがあります
- 第14〜15章の音声・リアルタイム系は、音声の入出力分だけテキストより割高になる傾向があります
- Web 検索などの組み込みツール(第3章)には、トークン課金とは別にツール利用料が設定されている場合があります
安全に学習を進めるために、最初に次の 2 つを設定しておくことを強くおすすめします。
- 支出通知と強制上限の設定 — 支出通知だけではAPIは停止しません。組織またはプロジェクトの Limits → Spend で月額を設定し、停止させたい場合は
Enforce a hard limitを有効にします。反映には遅延があり少額の超過が起こり得るため、アプリ側でも反復回数を制限します。公式:Spend limits - 使用量ダッシュボードの定期確認 — 章をひとつ終えるごとに使用量ページを見る習慣をつけると、「どの example がどれくらい消費するか」の感覚が身につきます
本書でも、費用が大きくなりがちな example には各章で個別に注意書きを入れています。
まとめ
- AI エージェントとは、LLM が自分でツールを使い、判断し、複数ステップのタスクを遂行するプログラムです。「答えるだけ」のチャットボットとの違いは自律的な行動にあります
- Agents SDK は「少数の部品 + 組み込みのエージェントループ」という設計思想で、Agent / Runner / function tool / handoff / guardrail / session / tracing の組み合わせでエージェントを構築します
- 最小のエージェントは「Agent を定義し、Runner で実行し、final_output を受け取る」の 4 行で書けます
- 学習は「基礎 → 連携 → 実践アプリ → 音声・リアルタイム → 発展」の順がおすすめです
- API は従量課金です。利用上限の設定と使用量の確認を習慣にしましょう
次章では、examples を実際に動かすための環境(Python、uv、リポジトリのクローン、API キー)を整えます。
第1章 環境構築 — 公式 examples を動かす準備
以下はv0.22.2のexamplesを再現する手順です。SDKの既定モデルはリリースに依存します。新規開発では Agent(model=...) または RunConfig(model=...) で意図を明示し、プロセス全体のフォールバックには OPENAI_DEFAULT_MODEL を使います。現行ガイドは新しいSDKワークフローの出発点に gpt-6-astra を挙げていますが、料金・遅延・利用権限を確認して選びます。教材のモデルを一律に置換する必要はありません。公式:モデルとプロバイダー
この章では、OpenAI Agents SDK(openai-agents v0.22.2、Python 3.10 以上)の公式リポジトリに含まれる examples(サンプルコード集)を、自分のパソコンで実際に動かせる状態にします。手を動かしながら 1 ステップずつ進めれば、最後には最初のエージェントが動きます。
この章のゴール:
- [ ] Python 3.10 以上が使える
- [ ] uv がインストールされている
- [ ] リポジトリを clone して依存関係をインストールした
- [ ] OpenAI API キーを取得し、環境変数に設定した
- [ ]
examples/basic/hello_world.pyが動いた
この 5 つのゴールは、次の一本道で繋がっています。
1. Python 3.10 以上を用意する
OpenAI Agents SDK は Python 3.10 以上 を必須としています(リポジトリの pyproject.toml に requires-python = ">=3.10" と定義されています)。v0.22.2 時点でサポート対象として明記されているのは 3.10 / 3.11 / 3.12 / 3.13 / 3.14 です。
1-1. バージョンを確認する
- ターミナル(macOS は「ターミナル」、Windows は「PowerShell」、Linux は任意のシェル)を開きます。
- 次のコマンドを実行します。
python --version
Python 3.10.0以上(例:Python 3.12.4)と表示されれば OK です。pythonコマンドが見つからない場合や 2.x 系が表示される場合は、python3 --versionも試してください。
1-2. Python をインストールする(バージョンが足りない場合)
macOS:
- Homebrew が入っていれば
brew install pythonを実行します。 - または python.org のダウンロードページ から最新版のインストーラを取得して実行します。
Windows:
- python.org のダウンロードページ からインストーラを取得します。
- インストール時に 「Add python.exe to PATH」のチェックを必ず入れて ください(これを忘れると
pythonコマンドが認識されません)。 winget install Python.Python.3.12でもインストールできます。
Linux:
- Ubuntu 22.04 以降であれば標準の Python が 3.10 以上です。
sudo apt install python3 python3-venvで不足分を導入します。 - それ以前のディストリビューションの場合は、後述の uv で Python 自体を導入するのが簡単です(
uv python install 3.12)。
2. uv をインストールする
2-1. uv とは何か
uv は、Rust 製の高速な Python パッケージマネージャです。パッケージマネージャとは、ライブラリ(他人が作った再利用可能なコード)のインストールやバージョン管理を自動化してくれるツールのことです。uv は従来の pip(インストール)と venv(仮想環境 = プロジェクトごとに独立したライブラリ置き場)の役割を 1 つにまとめ、しかも非常に高速に動作します。
OpenAI Agents SDK のリポジトリは uv を公式の開発ツールとして採用しており、依存関係を固定した uv.lock ファイルが同梱されています。そのため examples を動かすなら uv を使うのが最も確実 です。
2-2. インストール手順(公式手順)
OS に応じて次のコマンドを実行します。
macOS / Linux:
curl -LsSf https://astral.sh/uv/install.sh | shWindows (PowerShell):
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"ターミナルを開き直し、インストールを確認します。
uv --versionバージョン番号が表示されれば成功です。詳細は uv 公式ドキュメント を参照してください。
3. リポジトリを clone して uv sync する(推奨ルート)
本書と同じサンプルを再現できるように v0.22.2 タグを指定します。別のバージョンを使うときは、そのリリースの変更点を確認してください。
examples をすべて動かすための推奨ルートです。以降の章のコマンドはすべて「この手順を完了し、リポジトリのルートディレクトリにいる」前提で書かれています。
- Git がインストールされていることを確認します(
git --version)。入っていない場合は macOS ならxcode-select --install、Windows なら Git for Windows、Linux ならsudo apt install gitなどで導入します。 リポジトリを clone(リモートのソースコード一式を手元に複製)します。
git clone --branch v0.22.2 https://github.com/openai/openai-agents-python.git cd openai-agents-python依存関係をインストールします。
uv syncuv syncは、uv.lockに記録された正確なバージョンのライブラリ群を、プロジェクト専用の仮想環境(.venvディレクトリ)に自動で構築するコマンドです。Python の仮想環境を手動で作成・有効化する必要はありません。以降はuv run <コマンド>と打つだけで、この仮想環境の中でコマンドが実行されます。
補足: リポジトリの
Makefileにはmake syncというターゲットがあり、これはuv sync --all-extras --all-packages --group devを実行して追加依存(後述の extras)を含むすべてを一括インストールします。まずは素のuv syncで十分です。音声などの extras は必要になった章で追加します(本章の第 7 節参照)。
4. OpenAI API キーを取得して設定する
エージェントの頭脳である LLM(Large Language Model: 大規模言語モデル。文章を理解・生成する AI モデル)は OpenAI のサーバー上で動きます。手元のコードからそれを呼び出すために API キー(あなたのアカウントを識別する秘密の文字列)が必要です。
4-1. アカウントを作成する
- ブラウザで platform.openai.com を開きます。
- 「Sign up」からアカウントを作成します(Google アカウント等でも登録できます)。
- 注意: ChatGPT の有料プラン(ChatGPT Plus など)と API の課金は完全に別物 です。ChatGPT に課金していても API は使えません。API 用に別途課金設定が必要です。
4-2. API キーを発行する
- ログイン後、ダッシュボードの API keys ページ を開きます。
- 「Create new secret key」をクリックし、任意の名前(例:
agents-sdk-learning)を付けて作成します。 sk-で始まる文字列が表示されます。この画面を閉じると二度と表示されない ので、パスワードマネージャ等の安全な場所に控えてください。- キーは他人に見せない・コードに直接書かない・Git にコミットしないのが鉄則です。漏洩するとあなたのアカウントで課金され続けます。
4-3. 課金設定と使用上限
- Billing ページ で支払い方法を登録し、クレジット(前払い残高。最低 5 ドル程度から)を購入します。残高がゼロだと API 呼び出しはエラーになります。
- API は 従量課金 です。料金はトークン(token: LLM が文章を処理する最小単位。文字数との比率はモデル・文章により異なる)の消費量に応じて発生します。費用はモデル・履歴・推論量・ツール・反復回数で変わります。実行前に公式料金表を確認し、実行後は usage を記録します。
- 事故防止のため、Limits ページ の Spend で月額を設定します。通知だけなら到達後もAPIは続きます。停止を必要とする場合は Enforce a hard limit を有効にします。組織・プロジェクトの強制上限到達時は
429とorganization_spend_limit_exceeded/project_spend_limit_exceededが返ります。反映遅延による少額の超過はあり得ます。公式:支出制御
4-4. 環境変数 OPENAI_API_KEY を設定する
SDK は 環境変数(OS がプログラムに渡す設定値)OPENAI_API_KEY から自動的にキーを読み取ります。
注意: このリポジトリの examples は
.envファイル(環境変数をファイルに書いておく仕組み)を読み込まない構成です。必ず環境変数として直接設定してください。
macOS / Linux(現在のターミナルセッションのみ有効):
export OPENAI_API_KEY=sk-...
ターミナルを閉じると消えるので、毎回設定するのが面倒な場合はシェルの設定ファイル(zsh なら ~/.zshrc、bash なら ~/.bashrc)に同じ行を追記し、source ~/.zshrc などで再読み込みします。
Windows PowerShell(現在のセッションのみ有効):
$env:OPENAI_API_KEY = "sk-..."
Windows(永続的に設定する場合):
setx OPENAI_API_KEY "sk-..."
setx で設定した値は 新しく開いたターミナルから 有効になります。実行中のウィンドウには反映されない点に注意してください。
設定できたか確認する:
# macOS / Linux
echo $OPENAI_API_KEY
# Windows PowerShell
echo $env:OPENAI_API_KEY
sk- で始まる文字列が表示されれば OK です。
5. 最初の実行 — hello_world.py
いよいよ最初のエージェントを動かします。リポジトリのルート(openai-agents-python ディレクトリ)で次を実行してください。
uv run python examples/basic/hello_world.py
uv run は「このプロジェクトの仮想環境の中でコマンドを実行する」という意味です。仮想環境の有効化(activate)を意識する必要はありません。
実行されるコードは次のとおりです(examples/basic/hello_world.py)。
import asyncio
from agents import Agent, Runner
async def main():
agent = Agent(
name="Assistant",
instructions="You only respond in haikus.",
)
result = await Runner.run(agent, "Tell me about recursion in programming.")
print(result.final_output)
# Function calls itself,
# Looping in smaller pieces,
# Endless by design.
if __name__ == "__main__":
asyncio.run(main())
Agent(エージェントの定義: 名前と instructions = 指示文を持つ)を作り、Runner(エージェントを実際に実行するクラス)で「プログラミングの再帰について教えて」と依頼しています。async / await は非同期処理(ネットワーク応答などの待ち時間に他の処理を進められる Python の仕組み)の構文で、SDK の基本スタイルです。詳しくは次章で解説します。
期待される出力例(コード中のコメントにある例):
Function calls itself,
Looping in smaller pieces,
Endless by design.
instructions で「俳句(haiku)でのみ答える」と指示しているため、英語の三行詩が返ってきます。LLM の出力は毎回変わるので、コメントと一言一句同じにはなりません。三行の俳句らしきテキストが表示されれば 環境構築は成功 です。エラーが出た場合は本章末尾のトラブルシューティングを参照してください。
6. 代替ルート: pip + venv で自作スクリプトを動かす
「リポジトリは clone せず、自分のプロジェクトに SDK を入れて書きたい」という pip 派の方は、公式 README にあるとおり次の手順でも始められます。
python -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
python -m pip install "openai-agents==0.22.2"
python -m venv .venvで仮想環境を作成します。source .venv/bin/activate(Windows は.venv\Scripts\activate)で有効化します。python -m pip install "openai-agents==0.22.2"で SDK をインストールします。- 前節の
hello_world.pyと同じ内容のファイルを作成し、python hello_world.pyで実行します。
音声機能など追加依存が必要な場合は python -m pip install 'openai-agents[voice]==0.22.2' のように角括弧付きで指定します。
ただし、本書のように公式 examples を一通り動かすのが目的なら、第 3 節の「clone + uv sync」ルートを推奨 します。examples にはリポジトリ内のヘルパーモジュール(後述の examples/auto_mode.py など)を import するものがあり、単体のファイルコピーでは動かないためです。
7. 追加依存(extras)一覧 — どの章で何が必要になるか
SDK 本体は軽量に保たれており、音声やデータベース連携などの機能は extras(オプションの追加依存グループ)として分離されています。pyproject.toml の [project.optional-dependencies] に定義されており、必要なものだけ次のように追加インストールします。
# 例: voice extra を追加してインストール
uv sync --extra voice
# 複数指定する場合
uv sync --extra voice --extra litellm
# すべての extras を一括で入れる場合(Makefile の make sync と同等)
uv sync --all-extras --all-packages --group dev
本書の章立て(examples のカテゴリ)との対応は次のとおりです。basic・handoffs・agent_patterns など大半の章は extras なしの uv sync だけで動きます。
| extra | 追加されるライブラリ | 必要になる章 / example |
|---|---|---|
voice | numpy, websockets | voice の章(examples/voice/ の音声エージェント全般) |
realtime | websockets | realtime の章(examples/realtime/ のリアルタイム音声対話) |
viz | graphviz | エージェント構成図の可視化機能(draw_graph)。examples では未使用ですが、エージェントの関係を図にしたいときに便利です |
litellm | litellm | model_providers の章(litellm_provider.py、litellm_auto.py)と basic の章の retry_litellm.py。OpenAI 以外の LLM を使う場合 |
any-llm | any-llm-sdk(Python 3.11 以上が必要) | model_providers の章(any_llm_auto.py、any_llm_provider.py) |
redis | redis | memory の章(redis_session_example.py)。別途 Redis サーバー本体も必要 |
sqlalchemy | SQLAlchemy, asyncpg | memory の章(sqlalchemy_session_example.py) |
encrypt | cryptography | memory の章(encrypted_session_example.py) |
dapr | dapr, grpcio | memory の章(dapr_session_example.py)。別途 Dapr サイドカーの起動が必要 |
mongodb | pymongo | memory の章(mongodb_session_example.py)。別途 MongoDB サーバーも必要 |
docker | docker | sandbox の章(examples/sandbox/docker/)。別途 Docker Desktop 等の Docker 実行環境が必要 |
s3 / temporal / e2b / modal / daytona / blaxel / cloudflare / runloop / vercel | 各クラウドサービスの SDK | sandbox の章のうち、外部クラウド実行環境と連携する example。該当サービスのアカウント・認証情報が必要 |
各章の冒頭でも「この章で必要な extras」を明記するので、今すべて覚える必要はありません。「エラーで ModuleNotFoundError が出たら extras の入れ忘れを疑う」とだけ覚えておいてください。
この対応関係を図にすると、次のようになります。
主要な依存ライブラリのバージョン(v0.22.2 時点)
uv sync を使う限り意識する必要はありませんが、既存プロジェクトへ組み込むときに衝突しやすいので挙げておきます。
| 依存 | 制約 | 補足 |
|---|---|---|
openai | >=3.0.0,<4 | v0.21.0 で 2.x 系から 3.x 系へ移行しました。 HTTPX2 に対応したリクエスト・レスポンス・トランスポート・例外処理が入っています |
pydantic | >=2.12.2,<3 | 構造化出力(output_type)の基盤 |
mcp | >=1.19.0,<3 | v0.20.0 から MCP Python SDK の v1 と v2 の両方をサポートします。カスタム MCP HTTP 認証やクライアントファクトリを使っている場合のみ注意が必要です(第6章参照) |
websockets | >=15.0,<17 | Realtime と Responses の WebSocket トランスポートで使用 |
example をまとめて流す(make examples-run)
v0.22.0 のリポジトリには examples/README.md が追加され、example 群の一括実行はリポジトリのランナーと Make ターゲットが担当することが明記されました。1 本ずつ動かす本書の進め方とは別に、全体をまとめて回したいときはこちらを使えます。
# 前景で一括実行
make examples-run
# 対象を絞る / サーバー・音声系も含める
make examples-run EXAMPLES_ARGS="--filter basic"
make examples-run EXAMPLES_ARGS="--include-server --include-audio"
# バックグラウンド実行とその管理
make examples-run-background
make examples-status
make examples-stop
make examples-logs
make examples-tail
既定では自動入力・自動承認が有効で、対話的 example は含まれますが、サーバー系・音声系・外部サービス系は明示的に選択しない限り除外されます。実行ログは .tmp/examples-start-logs/ の下に、全体ログと example ごとのログとして残ります。
なお、このランナーは各 example を python -m <モジュールパス> の形式で起動します。from examples.web_search_utils import ... のようにリポジトリ内の共通モジュールを import する example があるためです。本書でも該当する example では -m 付きの実行コマンドを示します。
8. examples ディレクトリの歩き方
examples/ 配下は機能カテゴリごとのサブディレクトリ(basic、tools、handoffs、agent_patterns、mcp、memory、model_providers、voice、realtime、research_bot など)に分かれています。本書は第2章以降、このカテゴリを 1 つずつ順番に解説していきます。
ディレクトリ直下には example 本体のほかに、実行を補助する 2 つのファイルがあります。
8-1. run_examples.py — examples の一括実行ランナー
examples/run_examples.py は、examples 配下のスクリプトをまとめて実行・検証するためのランナーです。主な機能は次のとおりです。
if __name__ == "__main__":ガードを持つ example ファイルを自動検出して順に実行する- 対話入力が必要なもの・サーバーとして動き続けるもの・マイク/スピーカーが必要なもの・Redis 等の外部サービスが必要なものは、既定ではスキップする(
--include-interactive/--include-server/--include-audio/--include-externalで個別に含められます) - 実行ログを
.tmp/examples-start-logs/に example ごとに保存し、失敗したものの再実行リストを.tmp/examples-rerun.txtに書き出せる - 環境変数
EXAMPLES_UV_EXTRAS(空白区切りで extras 名を指定)を設定すると、各 example をuv run --extra <名前> ...付きで起動する
使い方の例:
# 何が実行対象になるかコマンドだけ確認する(API は呼ばれない)
uv run python examples/run_examples.py --dry-run
# パスに "basic" を含む example だけ実行する
uv run python examples/run_examples.py --filter basic
# 対話プロンプトに自動応答しながら実行する(auto mode)
uv run python examples/run_examples.py --auto-mode --filter basic
課金注意: フィルタなしの一括実行は多数の API 呼び出しを発生させます。学習中はまず
--dry-runで対象を確認し、--filterで範囲を絞って使ってください。
8-2. auto_mode.py — 対話 example を自動応答させるヘルパー
examples/auto_mode.py は直接実行するファイルではなく、対話型 example が import するヘルパーモジュールです。環境変数 EXAMPLES_INTERACTIVE_MODE=auto が設定されていると、input_with_fallback()(キーボード入力の代わりに既定値を返す)や confirm_with_fallback()(承認プロンプトに自動で Yes/No を返す)が働き、人間の入力なしで example を最後まで走らせられます。前述の run_examples.py --auto-mode は、内部的にこの環境変数を設定して動いています。
本書で「この example は対話型です」と出てきたら、手で入力して試すか、EXAMPLES_INTERACTIVE_MODE=auto を付けて自動実行するかを選べる、と覚えておいてください。
どちらの実行コマンドを使うかは、その example がリポジトリ内の共通モジュールを import しているかどうかで決まります。
9. トレーシング — エージェントの動きを可視化する
SDK には tracing(トレーシング: プログラムの実行過程を記録・可視化する仕組み)が標準で組み込まれており、追加設定なしで有効になっています。
hello_world.pyなどの example を実行します。- ブラウザで platform.openai.com/traces を開きます。
- 直近の実行が一覧に表示され、クリックすると「どのエージェントが」「どんな入力を受けて」「LLM を何回呼び、どのツールを使い、何を出力したか」がタイムラインで確認できます。
エージェントは内部で複数ステップを自律的に実行するため、「なぜこの答えになったのか」を追うにはトレースが不可欠です。以降の章で handoff(エージェント間の引き継ぎ)やツール呼び出しが登場すると、このトレース画面の価値が実感できるはずです。example を動かしたら traces ページを見る、という習慣をつけることをお勧めします。
10. トラブルシューティング
10-1. 「api_key を設定せよ」というエラーが出る
症状: 実行時に次のようなエラーで停止する。
openai.OpenAIError: The api_key client option must be set either by passing
api_key to the client or by setting the OPENAI_API_KEY environment variable
原因: 環境変数 OPENAI_API_KEY が現在のターミナルに設定されていません。
対処:
echo $OPENAI_API_KEY(Windows PowerShell はecho $env:OPENAI_API_KEY)で値を確認します。- 空なら第 4-4 節の手順で設定し直します。
exportはターミナルを閉じると消えること、Windows のsetxは新しいターミナルでのみ有効になることを思い出してください。
10-2. 401 Unauthorized(認証エラー)
症状: Error code: 401 や invalid_api_key を含むエラー。
原因: キーの値が間違っている、失効している、またはコピー時に余分な文字が混入しています。
対処:
- 設定した値の先頭が
sk-で始まっているか、前後に空白や引用符が紛れ込んでいないかを確認します。 - API keys ページ でキーが削除・無効化されていないか確認し、必要なら新しいキーを発行して設定し直します。
10-3. 429 Too Many Requests(レート制限 / クレジット不足)
症状: Error code: 429。429 には原因が 2 種類あるので、エラーメッセージ本文で見分けます。
rate_limit_exceededを含む場合: レート制限(一定時間あたりの API 呼び出し回数・トークン量の上限)に達しています。数十秒待って再実行するか、run_examples.pyの一括実行なら対象を絞ります。アカウントの利用実績に応じて上限は自動的に引き上がっていきます。insufficient_quotaやYou exceeded your current quotaを含む場合: クレジット残高が不足 しています。Billing ページ で残高を確認し、クレジットを追加購入してください。新規アカウントで一度も課金設定をしていない場合もこのエラーになります。
10-4. モデルが見つからない / アクセス権がない
症状: model_not_found や、指定モデルへのアクセスがない旨の 404 / 403 エラー。
原因: SDK がモデル未指定時に使う既定モデル(v0.22.2 では gpt-5.6-luna。v0.20.0 で gpt-5.4-mini から変更されました)に、あなたのアカウントがまだアクセスできない場合があります。新しいモデルは組織の認証状態や利用実績によって開放時期が異なります。
対処:
- Limits ページ 等で自分のアカウントが使えるモデルを確認します。
環境変数
OPENAI_DEFAULT_MODELで既定モデルを差し替えられます。例:export OPENAI_DEFAULT_MODEL=gpt-4.1-mini uv run python examples/basic/hello_world.py- コード側で
Agent(name=..., instructions=..., model="gpt-4.1-mini")のように明示指定する方法は次章で扱います。
10-5. 社内プロキシ環境で接続できない
症状: 会社のネットワークで Connection error、接続タイムアウト、または SSL 証明書エラー(certificate verify failed 等)が出る。
原因: 社内プロキシ(外部通信を中継・検査するサーバー)経由でないと外部に出られない環境では、プロキシ設定と社内 CA 証明書の指定が必要です。
対処:
プロキシの URL を環境変数で指定します。
export HTTPS_PROXY=http://proxy.example.com:8080 export HTTP_PROXY=http://proxy.example.com:8080- SSL 検査を行うプロキシの場合は、社内 CA 証明書のパスを
SSL_CERT_FILE/REQUESTS_CA_BUNDLEに設定します(証明書ファイルの入手先は社内のネットワーク管理者に確認してください)。 - そもそも
api.openai.comへの通信が許可されているかをネットワーク管理者に確認してください。許可されていない場合、設定では解決できません。
ここまでの 5 パターンを早見表にすると、次のようになります。
まとめ
- Python 3.10 以上と uv を用意し、
git clone+uv syncで examples 実行環境を構築しました - API キーは platform.openai.com で発行し、環境変数
OPENAI_API_KEYに設定します(従量課金のため使用上限の設定を推奨) uv run python examples/basic/hello_world.pyで最初のエージェントが動くことを確認しました- 追加機能は extras(
uv sync --extra voice等)で必要な章ごとに導入します - 実行後は platform.openai.com/traces でエージェントの内部動作を確認できます
次章からは examples/basic/ のコードを 1 本ずつ読み解きながら、Agent と Runner の基礎、ストリーミング、ツール呼び出しといった SDK の基本機能を学んでいきます。
第2章 基礎機能(basic)
この章で学ぶこと
- Agent(エージェント)と Runner(実行エンジン)を使った最小構成のプログラム(Hello World)の書き方
- ストリーミング(生成中の応答を少しずつ受け取る仕組み)の 3 つのレベル(テキスト差分・アイテム単位・ツール引数)と WebSocket 経由の実行
- ライフサイクルフック(実行の各段階に差し込める処理)による実行過程の観察方法
- function tool(Python 関数をエージェントの道具として登録する仕組み)の基礎と、ツール専用 guardrail(安全チェック)
- 画像・PDF などのファイルをエージェントへの入力として渡す方法
- トークン使用量の追跡、会話の継続(previous_response_id)、自動リトライ、非 strict な出力型といった実務で役立つ周辺機能
前提知識
- 第1章(環境構築): リポジトリのクローン、
uv syncによる依存関係のインストール、OPENAI_API_KEYの設定が済んでいることを前提とします。この章のコマンドはすべてリポジトリのルートディレクトリ(openai-agents-python/)で実行します - Python の基本文法(関数、クラス、デコレータ、型ヒント)を読めること
async/awaitの構文を見たことがあること(詳しい意味はこの章の概念解説で補足します)
概念解説
エージェントとは「指示書を持った新人アシスタント」
OpenAI Agents SDK における Agent は、LLM(Large Language Model: 大量のテキストから学習した、文章を生成する AI モデル)に「名前」と「指示書(instructions)」を与えたものです。日常の比喩で言えば、Agent は「業務マニュアルを渡された新人アシスタント」です。マニュアル(instructions)に「返答はすべて俳句で」と書けば俳句で答えますし、「あなたは丁寧なサポート担当です」と書けばその役割を演じます。
そして Runner は、そのアシスタントに仕事を依頼して結果を受け取る「マネージャー」役です。Runner.run(agent, "質問文") と呼び出すと、SDK 内部で次の エージェントループ が回ります。
- 現在の Agent の指示と入力を LLM に送る
- LLM が最終的な回答を返したらループ終了
- LLM が「ツールを使いたい」と言えばツールを実行して結果を LLM に戻し、ループ続行
- LLM が「他の Agent に任せたい」と言えば handoff(ハンドオフ: 担当エージェントの交代)してループ続行
この章の examples は、このループの入口(Hello World)から、途中経過の観察(ストリーミング・フック)、道具の持たせ方(ツール)、渡せる入力の種類(画像・ファイル)までを一つずつ体験するものです。
非同期処理について
examples のほとんどは async def main() と asyncio.run(main()) という形をしています。非同期処理とは、「LLM からの返事を待っている間、プログラムが他の作業もできるようにする仕組み」です。LLM の応答には数秒かかることが多いため、SDK は非同期を基本としています。書き方としては「await を付けて結果を待つ」「async def の関数は asyncio.run() で起動する」の 2 点を押さえれば examples は読めます。同期版の Runner.run_sync() も用意されています。
ストリーミングについて
ストリーミングとは、LLM が文章を最後まで生成し終えるのを待たず、生成中のテキストを断片(デルタ)として順次受け取る仕組みです。手紙が完成してから郵送してもらうのが通常の Runner.run()、電話口で相手が話すそばから聞き取るのがストリーミング(Runner.run_streamed())だとイメージしてください。チャット UI で文字がパラパラと表示されるのはこの仕組みによるものです。
実行コマンドの前提
この章のコマンドはすべて、第1章で準備したリポジトリのルートで実行します。ほとんどの example は環境変数 OPENAI_API_KEY が設定されている必要があります(例外は Ollama を使う hello_world_gpt_oss.py)。
なお、一部の example は examples/auto_mode.py という共通モジュールを from examples.auto_mode import ... の形で import しています。これらは Python のパッケージとして実行する必要があるため、uv run python -m examples.basic.<モジュール名> 形式で起動します。それ以外の単独スクリプトは uv run python examples/basic/<ファイル名>.py で起動できます。各 example の実行手順に個別に明記します。
また、対話入力を求める example は、環境変数 EXAMPLES_INTERACTIVE_MODE=auto を設定すると既定値で自動実行できます(CI などで人手の入力なしに走らせるための仕組みです)。
各 example の解説
2.1 Hello World と最初の一歩
まずは「Agent を作って Runner で走らせる」という SDK の最小サイクルを体験します。あわせて、モデルの切り替え・指示(システムプロンプト)の動的生成・プロンプトテンプレートという「最初の一歩の応用」もここで扱います。
hello_world.py
SDK の最小構成です。俳句でしか答えない Agent を作り、1 回だけ実行して結果を表示します。
import asyncio
from agents import Agent, Runner
async def main():
agent = Agent(
name="Assistant",
instructions="You only respond in haikus.",
)
result = await Runner.run(agent, "Tell me about recursion in programming.")
print(result.final_output)
# Function calls itself,
# Looping in smaller pieces,
# Endless by design.
if __name__ == "__main__":
asyncio.run(main())
コード解説
Agent(name=..., instructions=...): Agent の定義はこれだけです。instructionsはいわゆるシステムプロンプト(LLM に対して会話全体の前提として与える指示文)に相当します。modelを指定していない点に注目してください。省略時は SDK の既定モデル(本バージョン v0.22.2 ではgpt-5.6-luna。v0.20.0 でgpt-5.4-miniから変更されました。環境変数OPENAI_DEFAULT_MODELで変更可能)が使われますawait Runner.run(agent, "..."): 第 1 引数に開始 Agent、第 2 引数にユーザー入力(文字列)を渡します。戻り値はRunResultオブジェクトですresult.final_output: 実行結果のうち「最後の Agent が出した最終回答」を取り出すプロパティです。output_type(後述)を指定していない場合は文字列になります
実行手順
OPENAI_API_KEYが設定されていることを確認します(echo $OPENAI_API_KEY)- リポジトリルートで次を実行します
uv run python examples/basic/hello_world.py
期待される動作・出力例
再帰(recursion)についての俳句が 1 つ表示されます。コード中のコメントにある通り、例えば次のような出力です。
Function calls itself,
Looping in smaller pieces,
Endless by design.
つまずきポイント
OPENAI_API_KEY未設定だと認証エラーになります。API キーは「SDK が OpenAI クライアントを最初に作った時点」で読み込まれるため、実行前にシェルで export しておく必要があります- 出力は LLM の生成結果なので、実行のたびに内容は変わります。コメントの出力例と一字一句同じにはなりません
hello_world_gpt_5.py
モデルを明示指定し、ModelSettings で推論の深さ(reasoning effort)と回答の冗長さ(verbosity)を調整する example です。
import asyncio
from openai.types.shared import Reasoning
from agents import Agent, ModelSettings, Runner
async def main():
agent = Agent(
name="Knowledgeable GPT-5 Assistant",
instructions="You're a knowledgeable assistant. You always provide an interesting answer.",
model="gpt-5.6-sol",
model_settings=ModelSettings(
reasoning=Reasoning(effort="low"), # "none", "low", "medium", "high", "xhigh"
verbosity="low", # "low", "medium", "high"
),
)
result = await Runner.run(agent, "Tell me something about recursion in programming.")
print(result.final_output)
if __name__ == "__main__":
asyncio.run(main())
コード解説
model="gpt-5.6-sol": Agent 単位でモデル名を文字列指定できますModelSettings(reasoning=..., verbosity=...): モデル呼び出し時のパラメータをまとめるオブジェクトです。reasoning.effortは「回答前にどれだけ考えるか」、verbosityは「回答をどれだけ詳しく書くか」の調整で、いずれも reasoning 系モデル(回答前に内部で思考を行うモデル)向けの設定です- ファイル冒頭のコメントには、Responses API ではなく Chat Completions API(従来型の OpenAI API)を使いたい場合に
OpenAIChatCompletionsModelを作ってAgentに渡す方法が示されています
実行手順
OPENAI_API_KEYを設定します- 次を実行します
uv run python examples/basic/hello_world_gpt_5.py
期待される動作・出力例
再帰に関する「興味深い」説明が、verbosity="low" のため比較的簡潔に表示されます。
つまずきポイント
- 指定したモデル(ここでは
gpt-5.6-sol)が自分の API アカウントで利用できない場合はエラーになります。その場合はmodelを利用可能なモデル名に変更してください
hello_world_gpt_oss.py
OpenAI のクラウドではなく、ローカルマシンの Ollama(オープンソースの LLM 実行ツール)で動く gpt-oss モデルを使う example です。「OpenAI 互換 API を持つ別のサーバー」に SDK を接続する基本パターンでもあります。
import asyncio
from openai import AsyncOpenAI
from agents import Agent, OpenAIChatCompletionsModel, Runner, set_tracing_disabled
set_tracing_disabled(True)
gpt_oss_model = OpenAIChatCompletionsModel(
model="gpt-oss:20b",
openai_client=AsyncOpenAI(
base_url="http://localhost:11434/v1",
api_key="ollama",
),
)
async def main():
agent = Agent(
name="Assistant",
instructions="You're a helpful assistant. You provide a concise answer to the user's question.",
model=gpt_oss_model,
)
result = await Runner.run(agent, "Tell me about recursion in programming.")
print(result.final_output)
if __name__ == "__main__":
asyncio.run(main())
コード解説
set_tracing_disabled(True): トレーシング(実行履歴を OpenAI のダッシュボードに送って可視化する機能)を無効化します。トレーシングは OpenAI の API キーを使ってアップロードされるため、OpenAI API を使わない構成では切っておくのが定石ですAsyncOpenAI(base_url="http://localhost:11434/v1", api_key="ollama"): 接続先を Ollama のローカルサーバーに向けています。Ollama は API キーを検証しないため、api_keyはダミー文字列で構いませんOpenAIChatCompletionsModel(model=..., openai_client=...): Chat Completions 形式でモデルを呼び出すラッパーです。これをAgentのmodelにオブジェクトとして渡します
実行手順
- Ollama をインストールし、起動します
- モデルを取得します:
ollama pull gpt-oss:20b(約 13GB のダウンロードと相応のメモリが必要です) - 次を実行します(
OPENAI_API_KEYは不要です)
uv run python examples/basic/hello_world_gpt_oss.py
期待される動作・出力例
ローカルモデルによる再帰の簡潔な説明が表示されます。クラウドのモデルより応答に時間がかかることがあります。
つまずきポイント
- Ollama が起動していない、またはモデル未取得だと接続エラーになります
- コード中のコメントにある通り、gpt-oss 系モデルでは独自の
output_type(構造化出力)がうまく動かないことがあります。既定のテキスト出力で使うのが無難です
hello_world_jupyter.ipynb
Jupyter Notebook(ブラウザ上でコードを対話的に実行できる環境)で SDK を動かす example です。ノートブックのセルは次の 1 つだけです。
from agents import Agent, Runner
agent = Agent(name="Assistant", instructions="You are a helpful assistant")
# Intended for Jupyter notebooks where there's an existing event loop
result = await Runner.run(agent, "Write a haiku about recursion in programming.") # type: ignore[top-level-await] # noqa: F704
print(result.final_output)
コード解説
- 通常のスクリプトでは
asyncio.run(main())が必要でしたが、Jupyter にはあらかじめイベントループ(非同期処理を回す仕組み)が動いているため、セル内で直接awaitが書けます。asyncio.run()を使うと逆に「イベントループが既に動いている」というエラーになります
実行手順
- Jupyter を追加して起動します(リポジトリの依存には含まれないため
--withで一時追加します)
uv run --with jupyter jupyter lab examples/basic/hello_world_jupyter.ipynb
OPENAI_API_KEYが Jupyter 起動時のシェルに設定されていることを確認し、セルを実行します
期待される動作・出力例
再帰についての俳句が出力されます(ノートブックには実行済みの出力例が保存されています)。
つまずきポイント
- 普通の
.pyファイルにこのコードをコピーすると「トップレベルawaitは使えない」という構文エラーになります。スクリプトではasyncio.run()を使ってください
dynamic_system_prompt.py
instructions を固定文字列ではなく関数として渡し、実行時の状況(コンテキスト)に応じてシステムプロンプトを動的に切り替える example です。
@dataclass
class CustomContext:
style: Literal["haiku", "pirate", "robot"]
def custom_instructions(
run_context: RunContextWrapper[CustomContext], agent: Agent[CustomContext]
) -> str:
context = run_context.context
if context.style == "haiku":
return "Only respond in haikus."
elif context.style == "pirate":
return "Respond as a pirate."
else:
return "Respond as a robot and say 'beep boop' a lot."
agent = Agent(
name="Chat agent",
instructions=custom_instructions,
)
async def main():
context = CustomContext(style=random.choice(["haiku", "pirate", "robot"]))
print(f"Using style: {context.style}\n")
user_message = "Tell me a joke."
print(f"User: {user_message}")
result = await Runner.run(agent, user_message, context=context)
print(f"Assistant: {result.final_output}")
コード解説
- コンテキストとは、実行中のツールやフックなど「自分の Python コード側」で共有したいデータの入れ物です。ここでは
CustomContextという dataclass に応答スタイルを入れています。重要な性質として、コンテキストオブジェクトは LLM には送信されません。あくまでローカルのデータ共有用です instructions=custom_instructions: 関数を渡すと、LLM 呼び出しの直前に毎回この関数が呼ばれ、戻り値の文字列がシステムプロンプトとして使われます。関数はRunContextWrapper(コンテキストを包む SDK のラッパー)とAgentを受け取りますRunner.run(agent, user_message, context=context):context=で自作オブジェクトを実行に持ち込みます
実行手順
OPENAI_API_KEYを設定します- 次を実行します(結果が変わるので何度か実行してみてください)
uv run python examples/basic/dynamic_system_prompt.py
期待される動作・出力例
3 スタイルからランダムに 1 つ選ばれ、そのスタイルでジョークが返ります。
Using style: robot
User: Tell me a joke.
Assistant: Beep boop! Why was the robot so bad at soccer? Beep boop... because it kept kicking up a debug! Beep boop!
つまずきポイント
- 1 回の実行に関わる Agent・ツール・フックはすべて同じ型のコンテキストを使う必要があります。型がバラバラだと型チェックエラーや実行時の混乱の元になります
prompt_template.py
OpenAI プラットフォーム上に保存した「プロンプトテンプレート」(変数を埋め込める再利用可能なプロンプト定義)を Agent から使う example です。instructions を書く代わりに、ダッシュボードで管理しているプロンプトの ID を参照します。
async def static_prompt(prompt_id: str):
agent = Agent(
name="Assistant",
prompt={
"id": prompt_id,
"version": "1",
"variables": {
"poem_style": "limerick",
},
},
)
result = await Runner.run(agent, "Tell me about recursion in programming.")
print(result.final_output)
動的版では、prompt にも関数を渡せます。
async def _get_dynamic_prompt(data: GenerateDynamicPromptData):
ctx: DynamicContext = data.context.context
return {
"id": ctx.prompt_id,
"version": "1",
"variables": {
"poem_style": ctx.poem_style,
},
}
async def dynamic_prompt(prompt_id: str):
context = DynamicContext(prompt_id)
agent = Agent(
name="Assistant",
prompt=_get_dynamic_prompt,
)
result = await Runner.run(agent, "Tell me about recursion in programming.", context=context)
print(result.final_output)
コード解説
prompt={"id": ..., "version": ..., "variables": {...}}: プラットフォーム側で定義したテンプレートの ID・バージョン・変数の値を指定します。テンプレート本文(例:Write a poem in {{poem_style}})はダッシュボード側にあり、コードには含まれません- 動的版は
GenerateDynamicPromptDataを受け取る関数を渡し、実行のたびに変数(ここではランダムに選んだ詩の形式)を差し替えます
実行手順
このファイル冒頭の NOTE にある通り、そのままでは動きません。コードに書かれた既定のプロンプト ID はあなたのプロジェクトには存在しないためです。
- https://platform.openai.com/playground/prompts を開きます
- プロンプト変数
poem_styleを作成します - システムプロンプト本文を
Write a poem in {{poem_style}}として保存し、発行されたプロンプト ID(pmpt_...)を控えます OPENAI_API_KEYを設定し、次を実行します
# 静的テンプレート版
uv run python examples/basic/prompt_template.py --prompt-id pmpt_あなたのID
# 動的テンプレート版
uv run python examples/basic/prompt_template.py --dynamic --prompt-id pmpt_あなたのID
期待される動作・出力例
静的版では limerick(五行戯詩)形式で、動的版では limerick / haiku / ballad のいずれかの形式で、再帰についての詩が出力されます。動的版は [debug] DynamicContext initialized with poem_style: haiku のようなデバッグ行が先に表示されます。
つまずきポイント
--prompt-idを渡し忘れると既定 ID が使われ、「プロンプトが見つからない」エラーになります- プロンプトテンプレートは OpenAI Responses API 専用の機能です
2.2 ストリーミング
Runner.run_streamed() は RunResultStreaming を返し、stream_events() を async for で回すことでイベントを受信します。イベントには粒度の異なる 3 種類があります。
raw_response_event: LLM から届く生イベント(トークン単位のテキスト差分など)。トークンとは LLM が文章を処理する最小単位(おおよそ単語や文字の断片)のことですrun_item_stream_event: 「メッセージが 1 件完成した」「ツールが呼ばれた」などアイテム単位の高レベルイベントagent_updated_stream_event: handoff などで現在の Agent が切り替わったことの通知
stream_text.py
もっとも基本的な「テキストを 1 トークンずつ表示する」example です。全文引用します。
import asyncio
from openai.types.responses import ResponseTextDeltaEvent
from agents import Agent, Runner
async def main():
agent = Agent(
name="Joker",
instructions="You are a helpful assistant.",
)
result = Runner.run_streamed(agent, input="Please tell me 5 jokes.")
async for event in result.stream_events():
if event.type == "raw_response_event" and isinstance(event.data, ResponseTextDeltaEvent):
print(event.data.delta, end="", flush=True)
if __name__ == "__main__":
asyncio.run(main())
コード解説
Runner.run_streamed(...)にはawaitを付けません。戻り値のRunResultStreamingからstream_events()を async for で消費することで実行が進みますResponseTextDeltaEventは OpenAI Responses API の「テキスト差分」イベント型です。event.data.deltaに新しく生成された数文字が入っていますprint(..., end="", flush=True): 改行せず、バッファリングもせずに即座に画面へ出すことで「タイプライターのように文字が流れる」表示になります
実行手順
OPENAI_API_KEYを設定します- 次を実行します
uv run python examples/basic/stream_text.py
期待される動作・出力例
5 つのジョークが、少しずつ画面に流れながら表示されます。
つまずきポイント
- ストリーミング実行は
stream_events()のイテレータが終わるまで完了しません。途中で break すると後処理(セッション保存など)が済まないことがあるため、基本は最後まで消費します result.final_outputはストリーム完了後にはじめて値が入ります
stream_items.py
トークン単位ではなく「ツールが呼ばれた」「メッセージが完成した」というアイテム単位でイベントを受け取る example です。ジョークの数をツールで決めてから、その数だけジョークを言う Agent を使います。
@tool
def how_many_jokes() -> int:
"""Return a random integer of jokes to tell between 1 and 10 (inclusive)."""
return random.randint(1, 10)
async def main():
agent = Agent(
name="Joker",
instructions="First call the `how_many_jokes` tool, then tell that many jokes.",
tools=[how_many_jokes],
)
result = Runner.run_streamed(
agent,
input="Hello",
)
print("=== Run starting ===")
async for event in result.stream_events():
# We'll ignore the raw responses event deltas
if event.type == "raw_response_event":
continue
elif event.type == "agent_updated_stream_event":
print(f"Agent updated: {event.new_agent.name}")
continue
elif event.type == "run_item_stream_event":
if event.item.type == "tool_call_item":
print(f"-- Tool was called: {getattr(event.item.raw_item, 'name', 'Unknown Tool')}")
elif event.item.type == "tool_call_output_item":
print(f"-- Tool output: {event.item.output}")
elif event.item.type == "message_output_item":
print(f"-- Message output:\n {ItemHelpers.text_message_output(event.item)}")
else:
pass # Ignore other event types
print("=== Run complete ===")
コード解説
@tool: Python 関数をエージェントの道具(function tool)に変換するデコレータです。v0.19.0 で追加されたagents.decoratorsモジュールの短縮エイリアスで、実体は従来からあるfunction_toolと同一です(tool = function_tool)。公式 examples は v0.19.0 以降この短い書き方に統一されましたが、from agents import function_toolと書く従来のコードもそのまま動きます。詳細は 2.4 節で扱いますraw_response_eventはcontinueで読み飛ばし、高レベルイベントだけを処理しています。UI で「ツール実行中…」のような進捗表示を出したい場合はこの粒度が便利ですItemHelpers.text_message_output(item): メッセージアイテムからテキスト部分だけを取り出すヘルパーです
実行手順
OPENAI_API_KEYを設定します- 次を実行します
uv run python examples/basic/stream_items.py
期待される動作・出力例
=== Run starting ===
Agent updated: Joker
-- Tool was called: how_many_jokes
-- Tool output: 4
-- Message output:
Sure, here are four jokes for you:
...
=== Run complete ===
ツールが返した数(1〜10 のランダム)だけジョークが並びます。
stream_function_call_args.py
LLM がツールを呼ぶときの引数そのものが JSON として少しずつ生成される様子をストリーミングで観察する example です。ファイル作成や設定生成のようにツール引数が長くなるケースで、「いま何を作ろうとしているか」をリアルタイム表示できます。
主要部分を引用します。
async for event in result.stream_events():
if event.type == "raw_response_event":
# Function call started
if event.data.type == "response.output_item.added":
if getattr(event.data.item, "type", None) == "function_call":
function_name = getattr(event.data.item, "name", "unknown")
call_id = getattr(event.data.item, "call_id", "unknown")
function_calls[call_id] = {"name": function_name, "arguments": ""}
current_active_call_id = call_id
print(f"\n📞 Function call streaming started: {function_name}()")
print("📝 Arguments building...")
# Real-time argument streaming
elif isinstance(event.data, ResponseFunctionCallArgumentsDeltaEvent):
if current_active_call_id and current_active_call_id in function_calls:
function_calls[current_active_call_id]["arguments"] += event.data.delta
print(event.data.delta, end="", flush=True)
# Function call completed
elif event.data.type == "response.output_item.done":
if hasattr(event.data.item, "call_id"):
call_id = getattr(event.data.item, "call_id", "unknown")
if call_id in function_calls:
function_info = function_calls[call_id]
print(f"\n✅ Function call streaming completed: {function_info['name']}")
コード解説
response.output_item.addedイベントで「ツール呼び出しが始まった」ことを検知し、call_id(呼び出しごとの識別子)をキーに引数を蓄積する辞書を用意しますResponseFunctionCallArgumentsDeltaEventが引数 JSON の差分です。{"filename": "main.py", ...}のような JSON が数文字ずつ届きますresponse.output_item.doneで 1 つのツール呼び出しの引数生成が完了したと分かります- ツール定義側では
Annotated[str, "Name of the file"]のように型ヒントへ説明を添えています。これはツールのパラメータ説明として LLM に伝わります
実行手順
OPENAI_API_KEYを設定します- 次を実行します
uv run python examples/basic/stream_function_call_args.py
期待される動作・出力例
「FastAPI プロジェクトを作って」という入力に対し、write_file や create_config の引数 JSON が流れながら表示され、最後に全ツール呼び出しのサマリと最終回答が表示されます。
📞 Function call streaming started: create_config()
📝 Arguments building...
{"project_name":"my-app","version":"1.0.0","dependencies":["fastapi","uvicorn"]}
✅ Function call streaming completed: create_config
つまずきポイント
- この example のツールは実際にはファイルを作りません(文字列を返すだけのダミーです)。ストリーミングの観察が目的です
stream_ws.py
この章で最も高機能な example です。OpenAI Responses API を WebSocket(サーバーと接続を張りっぱなしにして双方向通信する仕組み。リクエストごとに接続し直す HTTP より低遅延)経由で使い、次の要素を一度に実演します。
responses_websocket_session()による接続の再利用- 通常の function tool(
lookup_order) Agent.as_tool(...)で別の Agent を「ツールとして」呼び出すパターン@tool(needs_approval=True)による HITL(Human-in-the-loop: 人間の承認を挟む)フローprevious_response_idによる 2 ターン目の会話継続
ファイル冒頭の docstring に必要な環境変数がまとまっています。
"""Responses websocket streaming example with function tools, agent-as-tool, and approval.
...
Required environment variable:
- `OPENAI_API_KEY`
Optional environment variables:
- `OPENAI_MODEL` (defaults to `gpt-5.6-sol`)
- `OPENAI_BASE_URL`
- `OPENAI_WEBSOCKET_BASE_URL`
- `EXAMPLES_INTERACTIVE_MODE=auto` (auto-approve HITL prompts for scripted runs)
"""
承認が必要なツールの定義と、エージェントの組み立て部分を見てみます。
@tool(needs_approval=True)
def submit_refund(order_id: str, amount: float, reason: str) -> dict[str, Any]:
"""Create a refund request. This tool requires approval."""
ticket = "RF-1001" if order_id == "ORD-1001" else f"RF-{order_id[-4:]}"
return {
"refund_ticket": ticket,
"order_id": order_id,
"amount": amount,
"reason": reason,
"status": "approved_pending_processing",
}
support_agent = Agent(
name="SupportAgent",
instructions=(
"You are a support agent. For refund requests, do this in order: "
"1) call lookup_order, 2) call refund_policy_specialist, 3) if the user "
"asked to proceed and the order is eligible, call submit_refund. "
"When asked for only the refund ticket, return only the ticket token "
"(for example RF-1001)."
),
tools=[
lookup_order,
policy_agent.as_tool(
tool_name="refund_policy_specialist",
tool_description="Check refund eligibility and explain the policy decision.",
),
submit_refund,
],
model=model_name,
model_settings=ModelSettings(
max_tokens=200,
reasoning=Reasoning(effort="medium", summary="detailed"),
),
)
承認の待ち受けとレジューム(再開)はこう書かれています。
if not result.interruptions:
break
state = result.to_state()
for interruption in result.interruptions:
question = f"Approve {interruption.name} with args {interruption.arguments}?"
if ask_approval(question):
state.approve(interruption)
else:
state.reject(interruption)
result = ws.run_streamed(agent, state)
コード解説
async with responses_websocket_session() as ws:で WebSocket 対応のセッションを開き、ws.run_streamed(...)で実行します。複数ターンや agent-as-tool の入れ子呼び出しでも同じ接続を温存でき、毎回の再接続を避けられますpolicy_agent.as_tool(...): handoff(担当交代)と違い、呼び出し元の Agent が主導権を持ったまま、別の Agent を 1 つのツールとして実行して結果だけ受け取りますneeds_approval=Trueのツールが呼ばれそうになると、ストリームがいったん終わりresult.interruptionsに「承認待ち」が入ります。result.to_state()で実行状態(RunState)を取り出し、approve/rejectした上でws.run_streamed(agent, state)に渡すと続きから再開されます- 2 ターン目は
previous_response_id=first_response_idを渡すことで、1 ターン目の会話をサーバー側の記録から引き継ぎます(詳細は 2.7 節の previous_response_id.py 参照) trace("Responses WS support example")で 2 ターンを 1 つのトレース(実行履歴のまとまり)として記録しています
実行手順
OPENAI_API_KEYを設定します(任意でOPENAI_MODELも)examples.auto_modeを import しているため、モジュール形式で実行します
uv run python -m examples.basic.stream_ws
- 実行中に
[approval] Approve submit_refund with args {...}? [y/N]:と聞かれたらyを入力します。対話なしで流したい場合はEXAMPLES_INTERACTIVE_MODE=auto uv run python -m examples.basic.stream_wsとします(自動承認されます)
期待される動作・出力例
推論サマリ(Reasoning:)と回答(Assistant:)が流れ、[tool call] lookup_order(...) → [tool call] refund_policy_specialist(...) → 承認プロンプト → [tool call] submit_refund(...) と進み、1 ターン目の最終出力として返金チケット RF-1001 が表示されます。2 ターン目では「さっき作ったチケットは?」に対し、履歴を再送せずに RF-1001 と答えます。
つまずきポイント
- WebSocket トランスポートはアカウント/モデルによっては未開放のことがあります。その場合、この example は「Websocket mode closed before sending events. This usually means the feature is not enabled for this account/model yet.」と表示して終了します
- ストリームを消費し終える前に
async withを抜けると、共有接続が強制切断されることがあります
2.3 ライフサイクルとフック
フック(hook)とは、「実行の節目(開始・終了・ツール実行・handoff など)に自動で呼ばれる自作の関数群」です。ホテルに例えると、客の到着・チェックイン・チェックアウトのたびに通知を受け取るコンシェルジュのようなものです。ログ記録、監視、デバッグに使います。
フックには 2 段階あります。
AgentHooks: 特定の Agent に取り付け、その Agent に関するイベントだけを受け取る(agent.hooksに設定)RunHooks: 実行(run)全体 に取り付け、すべての Agent・LLM 呼び出し・ツールのイベントを受け取る(Runner.run(..., hooks=...)に渡す)
agent_lifecycle_example.py
AgentHooks を継承したクラスで、Agent ごとのイベント(開始・終了・ツール開始/終了・handoff)を数えながら表示する example です。乱数を生成し、奇数なら別の Agent に handoff して 2 倍にする、という 2 エージェント構成です。
フック定義の中心部分を引用します。
class CustomAgentHooks(AgentHooks):
def __init__(self, display_name: str):
self.event_counter = 0
self.display_name = display_name
async def on_start(self, context: AgentHookContext, agent: Agent) -> None:
self.event_counter += 1
# Access the turn_input from the context to see what input the agent received
print(
f"### ({self.display_name}) {self.event_counter}: Agent {agent.name} started with turn_input: {context.turn_input}"
)
async def on_end(self, context: RunContextWrapper, agent: Agent, output: Any) -> None:
self.event_counter += 1
print(
f"### ({self.display_name}) {self.event_counter}: Agent {agent.name} ended with output {output}"
)
async def on_handoff(self, context: RunContextWrapper, agent: Agent, source: Agent) -> None:
self.event_counter += 1
print(
f"### ({self.display_name}) {self.event_counter}: Agent {source.name} handed off to {agent.name}"
)
エージェントの組み立てはこうです。
multiply_agent = Agent(
name="Multiply Agent",
instructions="Multiply the number by 2 and then return the final result.",
tools=[multiply_by_two],
output_type=FinalResult,
hooks=CustomAgentHooks(display_name="Multiply Agent"),
)
start_agent = Agent(
name="Start Agent",
instructions="Generate a random number. If it's even, stop. If it's odd, hand off to the multiply agent.",
tools=[random_number],
output_type=FinalResult,
handoffs=[multiply_agent],
hooks=CustomAgentHooks(display_name="Start Agent"),
)
コード解説
hooks=CustomAgentHooks(...): Agent ごとに別インスタンスを渡しているため、イベントカウンタも Agent ごとに独立しますon_startの第 1 引数AgentHookContextからはcontext.turn_input(その Agent がそのターンに受け取った入力)を参照できますhandoffs=[multiply_agent]: Start Agent が「奇数だったら」Multiply Agent に担当を交代できるようにしています。handoff が起きると、引き継ぎ先 のon_handoffフックが呼ばれますoutput_type=FinalResult: Pydantic モデル(データの型と検証を定義する Python ライブラリのクラス)を指定すると、最終出力が自由文ではなくFinalResult(number=74)のような構造化データになります- ソース中のコメントにある通り、
on_tool_start/on_tool_endはローカルの function tool のみが対象で、OpenAI サーバー側で実行される hosted tool(WebSearchTool など。第3章参照)では呼ばれません - ツール
random_numberには auto mode(EXAMPLES_INTERACTIVE_MODE=auto)のとき決定的な奇数を返す分岐が入っています。デモで必ず handoff まで進むようにするための工夫です
実行手順
OPENAI_API_KEYを設定しますexamples.auto_modeを import しているため、モジュール形式で実行します
uv run python -m examples.basic.agent_lifecycle_example
Enter a max number:と聞かれたら適当な整数(例: 250)を入力します。対話なしならEXAMPLES_INTERACTIVE_MODE=autoを付けて実行します(既定値 50 が使われます)
期待される動作・出力例(ファイル末尾の docstring より)
Enter a max number: 250
### (Start Agent) 1: Agent Start Agent started
### (Start Agent) 2: Agent Start Agent started tool random_number
### (Start Agent) 3: Agent Start Agent ended tool random_number with result 37
### (Start Agent) 4: Agent Start Agent handed off to Multiply Agent
### (Multiply Agent) 1: Agent Multiply Agent started
### (Multiply Agent) 2: Agent Multiply Agent started tool multiply_by_two
### (Multiply Agent) 3: Agent Multiply Agent ended tool multiply_by_two with result 74
### (Multiply Agent) 4: Agent Multiply Agent ended with output number=74
Done!
つまずきポイント
- 乱数が偶数だと handoff は起きず、Start Agent がそのまま終了します。handoff のログを見たい場合は何度か実行してください(または auto mode を使ってください)
lifecycle_example.py
同じ 2 エージェント構成を題材に、今度は RunHooks(実行全体のフック) を主役にした example です。AgentHooks との併用と、各イベント時点での累積トークン使用量の表示が見どころです。
RunHooks 側の抜粋です。
class ExampleHooks(RunHooks):
def __init__(self):
self.event_counter = 0
def _usage_to_str(self, usage: Usage) -> str:
return f"{usage.requests} requests, {usage.input_tokens} input tokens, {usage.output_tokens} output tokens, {usage.total_tokens} total tokens"
async def on_agent_start(self, context: AgentHookContext, agent: Agent) -> None:
self.event_counter += 1
print(
f"### {self.event_counter}: Agent {agent.name} started. turn_input: {context.turn_input}. Usage: {self._usage_to_str(context.usage)}"
)
async def on_llm_start(
self,
context: RunContextWrapper,
agent: Agent,
system_prompt: str | None,
input_items: list[TResponseInputItem],
) -> None:
self.event_counter += 1
print(f"### {self.event_counter}: LLM started. Usage: {self._usage_to_str(context.usage)}")
ツールフックでは ToolContext へのキャストにより呼び出しメタデータを取得しています。
async def on_tool_start(self, context: RunContextWrapper, agent: Agent, tool: Tool) -> None:
self.event_counter += 1
# While this type cast is not ideal,
# we don't plan to change the context arg type in the near future for backwards compatibility.
tool_context = cast(ToolContext[Any], context)
print(
f"### {self.event_counter}: Tool {tool.name} started. name={tool_context.tool_name}, call_id={tool_context.tool_call_id}, args={tool_context.tool_arguments}. Usage: {self._usage_to_str(tool_context.usage)}"
)
実行時に hooks= として渡します。
await Runner.run(
start_agent,
hooks=hooks,
input=f"Generate a random number between 0 and {max_number}.",
)
コード解説
RunHooksはon_agent_start/on_agent_end/on_llm_start/on_llm_end/on_tool_start/on_tool_end/on_handoffを持ち、実行全体を横断して呼ばれます。Agent 個別のAgentHooks(この例ではLoggingHooks)と併用できますcontext.usage: どのフックでも、その時点までの累積使用量(Usage)を参照できます。LLM 呼び出しが終わるたびに requests とトークン数が増えていく様子が観察できますon_tool_start/on_tool_endに渡されるコンテキストは実際にはToolContextで、tool_name/tool_call_id/tool_arguments(LLM が指定した引数の JSON 文字列)が取れますon_handoffはRunHooksではfrom_agent/to_agentの 2 つを受け取る形で、AgentHooks版(agent/source)と引数構成が異なる点に注意してください
実行手順
OPENAI_API_KEYを設定します- モジュール形式で実行します
uv run python -m examples.basic.lifecycle_example
Enter a max number:に整数を入力します(auto mode 可)
期待される動作・出力例(ファイル末尾の docstring より抜粋)
### 1: Agent Start Agent started. Usage: 0 requests, 0 input tokens, 0 output tokens, 0 total tokens
### 2: LLM started. Usage: 0 requests, 0 input tokens, 0 output tokens, 0 total tokens
### 3: LLM ended. Usage: 1 requests, 143 input tokens, 15 output tokens, 158 total tokens
### 4: Tool random_number started. name=random_number, call_id=call_..., args={"max":250}. ...
...
### 16: Agent Multiply Agent ended with output number=214. Usage: 4 requests, 660 input tokens, 56 output tokens, 716 total tokens
Done!
つまずきポイント
- 出力例と同じく 16 イベントになるとは限りません。乱数が偶数なら handoff が起きず、イベント数は減ります
2.4 ツールの基礎
function tool は、Python 関数に @tool(別名 @function_tool)デコレータを付けるだけで「LLM が呼び出せる道具」になる仕組みです。関数の型ヒントから引数スキーマが、docstring から説明文が自動生成され、LLM に伝えられます。LLM は「どのツールをどんな引数で使うか」を自分で判断します。
tools.py
function tool の最小構成です。天気を返すダミーツールを持つ Agent に「東京の天気は?」と尋ねます。全文引用します。
import asyncio
from typing import Annotated
from pydantic import BaseModel, Field
from agents import (
Agent,
Runner,
)
from agents.decorators import tool
class Weather(BaseModel):
city: str = Field(description="The city name")
temperature_range: str = Field(description="The temperature range in Celsius")
conditions: str = Field(description="The weather conditions")
@tool
def get_weather(city: Annotated[str, "The city to get the weather for"]) -> Weather:
"""Get the current weather information for a specified city."""
print("[debug] get_weather called")
return Weather(city=city, temperature_range="14-20C", conditions="Sunny with wind.")
agent = Agent(
name="Hello world",
instructions="You are a helpful agent.",
tools=[get_weather],
)
async def main():
result = await Runner.run(agent, input="What's the weather in Tokyo?")
print(result.final_output)
# The weather in Tokyo is sunny.
if __name__ == "__main__":
asyncio.run(main())
コード解説
@toolを付けたget_weatherは、関数名がツール名に、docstring がツールの説明に、Annotated[str, "..."]の注釈が引数の説明になりますfrom agents.decorators import toolのtoolはfunction_toolのエイリアスです(v0.19.0 で追加)。from agents import function_toolと書いて@function_toolを使っても挙動は完全に同じで、公式ドキュメントも API リファレンス上はfunction_toolを正式名として扱っています。本書では examples の実物に合わせて@tool表記を用います- 戻り値は Pydantic モデルでも構いません。ツールの結果は文字列化されて LLM に渡され、LLM がそれを踏まえて最終回答を作文します
- 実行の流れは「LLM が get_weather(city="Tokyo") の呼び出しを決定 → SDK が関数を実行 → 結果を LLM に返す → LLM が自然文で回答」というエージェントループの 1 周です
実行手順
OPENAI_API_KEYを設定します- 次を実行します
uv run python examples/basic/tools.py
期待される動作・出力例
[debug] get_weather called
The weather in Tokyo is currently sunny with wind, and the temperature ranges from 14°C to 20°C.
[debug] 行が出ていれば、LLM が実際にツールを呼んだ証拠です。
つまずきポイント
- ツールを渡しても、LLM が「呼ぶ必要がない」と判断すれば呼びません。確実に呼ばせたい場合は instructions で明示するか、
ModelSettings(tool_choice=...)を使います(この example では質問内容から自然に呼ばれます)
tool_guardrails.py
guardrail(ガードレール)とは、エージェントの入出力を検証して危険な動作を止める安全チェックの仕組みです。この example はその中でもツール単位の guardrail、すなわち「ツールに渡される引数(入力)」と「ツールが返した結果(出力)」を検査する仕組みを実演します。
入力 guardrail の例です。
@tool_input_guardrail
def reject_sensitive_words(data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput:
"""Reject tool calls that contain sensitive words in arguments."""
try:
args = json.loads(data.context.tool_arguments) if data.context.tool_arguments else {}
except json.JSONDecodeError:
return ToolGuardrailFunctionOutput(output_info="Invalid JSON arguments")
# Check for suspicious content
sensitive_words = [
"password",
"hack",
"exploit",
"malware",
"ACME",
]
for key, value in args.items():
value_str = str(value).lower()
for word in sensitive_words:
if word.lower() in value_str:
# Reject tool call and inform the model the function was not called
return ToolGuardrailFunctionOutput.reject_content(
message=f"🚨 Tool call blocked: contains '{word}'",
output_info={"blocked_word": word, "argument": key},
)
return ToolGuardrailFunctionOutput(output_info="Input validated")
出力 guardrail は 2 種類の対応が示されています。
@tool_output_guardrail
def block_sensitive_output(data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutput:
"""Block tool outputs that contain sensitive data."""
output_str = str(data.output).lower()
# Check for sensitive data patterns
if "ssn" in output_str or "123-45-6789" in output_str:
# Use raise_exception to halt execution completely for sensitive data
return ToolGuardrailFunctionOutput.raise_exception(
output_info={"blocked_pattern": "SSN", "tool": data.context.tool_name},
)
return ToolGuardrailFunctionOutput(output_info="Output validated")
guardrail のツールへの取り付けは属性代入で行います。
# Apply guardrails to tools
send_email.tool_input_guardrails = [reject_sensitive_words]
get_user_data.tool_output_guardrails = [block_sensitive_output]
get_contact_info.tool_output_guardrails = [reject_phone_numbers]
コード解説
guardrail 関数の戻り値 ToolGuardrailFunctionOutput には 3 通りの挙動があります。
- そのまま通す:
ToolGuardrailFunctionOutput(output_info=...)を返す(チェック合格) - reject_content: ツール呼び出し(または結果)を差し止め、代わりに
messageを LLM に伝えて実行自体は継続する。LLM は「その操作はできなかった」ことを踏まえて回答を続けます - raise_exception:
ToolOutputGuardrailTripwireTriggered例外を発生させて実行全体を停止する。SSN(米国の社会保障番号)のような重大な情報漏えいの恐れがある場合の最終手段です
main 関数では 4 つのシナリオ(正常送信 / 入力ブロック / 例外で停止 / 出力差し止め)を順に試します。
実行手順
OPENAI_API_KEYを設定します- 次を実行します
uv run python examples/basic/tool_guardrails.py
期待される動作・出力例(ファイル末尾の docstring より)
=== Tool Guardrails Example ===
1. Normal email sending:
✅ Successful tool execution: I've sent a welcome email to john@example.com ...
2. Attempting to send email with suspicious content:
❌ Guardrail rejected function tool call: I'm unable to send the email as mentioning ACME Corp. is restricted.
3. Attempting to get user data (contains SSN). Execution blocked:
🚨 Output guardrail triggered: Execution halted for sensitive data
Details: {'blocked_pattern': 'SSN', 'tool': 'get_user_data'}
4. Rejecting function tool output containing sensitive data:
❌ Guardrail rejected function tool output: I'm unable to retrieve the contact info for user456 ...
つまずきポイント
- ここで扱ったのは「ツール単位」の guardrail です。エージェント全体の入出力に掛ける guardrail(
@input_guardrail/@output_guardrail)は別章(agent_patterns)で扱います - シナリオ 2 と 4 は reject(継続)、シナリオ 3 は例外(停止)と、止め方の強さが違う点を意識して出力を読み比べてください
image_tool_output.py
ツールの戻り値として画像を LLM に渡す example です。通常ツールの結果は文字列ですが、ToolOutputImage 型(またはそれに対応する辞書)を返すと、画像そのものがモデルへの入力になります。全文引用します。
import asyncio
from agents import (
Agent,
Runner,
ToolOutputImage,
ToolOutputImageDict,
)
from agents.decorators import tool
return_typed_dict = True
URL = "https://images.unsplash.com/photo-1505761671935-60b3a7427bad?auto=format&fit=crop&w=400&q=80"
@tool
def fetch_random_image() -> ToolOutputImage | ToolOutputImageDict:
"""Fetch a random image."""
print("Image tool called")
if return_typed_dict:
return {"type": "image", "image_url": URL, "detail": "auto"}
return ToolOutputImage(image_url=URL, detail="auto")
async def main():
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant.",
tools=[fetch_random_image],
)
result = await Runner.run(
agent,
input="Fetch an image using the random_image tool, then describe it",
)
print(result.final_output)
"""This image features the famous clock tower, commonly known as Big Ben, ..."""
if __name__ == "__main__":
asyncio.run(main())
コード解説
ToolOutputImage(image_url=..., detail=...):image_urlには通常の URL のほか data URL(Base64 埋め込み)も指定でき、代わりに事前アップロード済みファイルのfile_idも使えます。detailは画像認識の解像度("low"/"high"/"auto")ですToolOutputImageDictは同じ内容を TypedDict(辞書)で表現したもので、return_typed_dictフラグで両方の書き方を示しています。どちらを返しても動作は同じです
実行手順
OPENAI_API_KEYを設定します- 次を実行します
uv run python examples/basic/image_tool_output.py
期待される動作・出力例
Image tool called に続き、URL の画像(ロンドンの時計塔ビッグ・ベン)の描写が表示されます。
つまずきポイント
- 画像を理解できる(vision 対応の)モデルで実行する必要があります。既定モデルは対応しています
2.5 画像・ファイル入力
Runner.run() の入力は文字列だけでなく、OpenAI Responses API 形式の「入力アイテムのリスト」も渡せます。これを使うと、画像や PDF をユーザーメッセージとして添付できます。4 つの example は「画像 or ファイル」×「ローカル or リモート」の組み合わせです。
| example | 種類 | 置き場所 | 使うコンテンツ型 |
|---|---|---|---|
| local_image.py | 画像 | ローカル(Base64) | input_image + data URL |
| remote_image.py | 画像 | Web 上の URL | input_image + URL |
| local_file.py | ローカル(Base64) | input_file + file_data | |
| remote_pdf.py | Web 上の URL | input_file + file_url |
local_image.py
ローカルの JPEG 画像(examples/basic/media/image_bison.jpg、バイソンの写真)を Base64(バイナリデータをテキスト文字列に変換する符号化方式)で埋め込んで送る example です。
FILEPATH = os.path.join(os.path.dirname(__file__), "media/image_bison.jpg")
def image_to_base64(image_path):
with open(image_path, "rb") as image_file:
encoded_string = base64.b64encode(image_file.read()).decode("utf-8")
return encoded_string
result = await Runner.run(
agent,
[
{
"role": "user",
"content": [
{
"type": "input_image",
"detail": "auto",
"image_url": f"data:image/jpeg;base64,{b64_image}",
}
],
},
{
"role": "user",
"content": "What do you see in this image?",
},
],
)
print(result.final_output)
コード解説
- 入力がリスト形式になっています。1 件目のユーザーメッセージが画像(
type: "input_image")、2 件目が質問文です image_urlにdata:image/jpeg;base64,...という data URL を渡すことで、ファイルの中身ごとリクエストに埋め込みます。外部から到達できないローカル画像でも送れるのがこの方式の利点ですos.path.dirname(__file__)基準でパスを組み立てているので、どのディレクトリから実行しても画像が見つかります
実行手順
OPENAI_API_KEYを設定します- 次を実行します
uv run python examples/basic/local_image.py
期待される動作・出力例
「草原に立つバイソン(アメリカバイソン)が写っています」といった画像描写が表示されます。
つまずきポイント
- Base64 化するとデータ量が約 1.33 倍になります。大きな画像はリクエストサイズ制限に注意してください
remote_image.py
Web 上の画像 URL をそのまま渡す example です。全文引用します。
import asyncio
from agents import Agent, Runner
URL = "https://images.unsplash.com/photo-1505761671935-60b3a7427bad?auto=format&fit=crop&w=400&q=80"
async def main():
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant.",
)
result = await Runner.run(
agent,
[
{
"role": "user",
"content": [{"type": "input_image", "detail": "auto", "image_url": URL}],
},
{
"role": "user",
"content": "What do you see in this image?",
},
],
)
print(result.final_output)
if __name__ == "__main__":
asyncio.run(main())
コード解説
- local_image.py との違いは
image_urlが data URL ではなく普通の URL である点だけです。画像の取得は OpenAI 側のサーバーが行うため、URL はインターネットから到達可能である必要があります
実行手順
OPENAI_API_KEYを設定します- 次を実行します
uv run python examples/basic/remote_image.py
期待される動作・出力例
URL の画像(ビッグ・ベンの時計塔)の描写が表示されます。
local_file.py
ローカルの PDF ファイル(examples/basic/media/partial_o3-and-o4-mini-system-card.pdf)を Base64 で添付し、内容について質問する example です。
FILEPATH = os.path.join(os.path.dirname(__file__), "media/partial_o3-and-o4-mini-system-card.pdf")
b64_file = file_to_base64(FILEPATH)
result = await Runner.run(
agent,
[
{
"role": "user",
"content": [
{
"type": "input_file",
"file_data": f"data:application/pdf;base64,{b64_file}",
"filename": "partial_o3-and-o4-mini-system-card.pdf",
}
],
},
{
"role": "user",
"content": "What is the first sentence of the introduction?",
},
],
)
print(result.final_output)
コード解説
- 画像との違いは、コンテンツ型が
input_fileになり、file_data(data URL 形式の中身)とfilenameを指定する点です - PDF はモデル側でテキストとページ画像として解釈され、内容に関する質問に答えられます
実行手順
OPENAI_API_KEYを設定します- 次を実行します
uv run python examples/basic/local_file.py
期待される動作・出力例
同梱 PDF(OpenAI o3/o4-mini システムカードの抜粋)の introduction 冒頭の一文が引用されて表示されます。
remote_pdf.py
Web 上の PDF を URL 指定で渡す example です。全文引用します。
import asyncio
from agents import Agent, Runner
URL = "https://www.berkshirehathaway.com/letters/2024ltr.pdf"
async def main():
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant.",
)
result = await Runner.run(
agent,
[
{
"role": "user",
"content": [{"type": "input_file", "file_url": URL}],
},
{
"role": "user",
"content": "Can you summarize the letter?",
},
],
)
print(result.final_output)
if __name__ == "__main__":
asyncio.run(main())
コード解説
- リモート PDF は
file_dataではなくfile_urlを使います(remote_image.py のimage_urlに対応する書き方です) - 題材はバークシャー・ハサウェイ社の 2024 年株主向けレター(ウォーレン・バフェット氏の書簡)です
実行手順
OPENAI_API_KEYを設定します- 次を実行します
uv run python examples/basic/remote_pdf.py
期待される動作・出力例
レターの要約(投資方針、後継者、業績などのポイント)が数段落で表示されます。PDF が長いため、他の example より応答に時間がかかり、消費トークンも多くなります。
つまずきポイント
- リモート URL の PDF は取得可能な公開 URL である必要があります。リンク切れや認証付き URL では失敗します
2.6 出力型(output_type)の応用
non_strict_output_type.py
output_type による構造化出力(回答を自由文ではなく決まった JSON 構造で受け取る仕組み)には、既定で strict モードが使われます。strict モードは「必ずスキーマ通りの有効な JSON が返る」ことを保証しますが、対応できるスキーマに制約があります。この example は、strict 非対応のスキーマをどう扱うかを 3 段階で示します。
問題となる型は「整数をキーにした辞書」です。
@dataclass
class OutputType:
jokes: dict[int, str]
"""A list of jokes, indexed by joke number."""
dict[int, str] のように任意のキーを持つ辞書は strict モードのスキーマ制約(追加プロパティ禁止など)を満たせません。main 関数は次の 3 段階を順に試します。
async def main():
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant.",
output_type=OutputType,
)
input = "Tell me 3 short jokes."
# First, let's try with a strict output type. This should raise an exception.
try:
await Runner.run(agent, input)
except UserError as e:
print(f"Error (expected): {e}")
else:
raise AssertionError("Strict schema validation should have raised UserError")
# Now let's try again with a non-strict output type. This should work.
# In some cases, it will raise an error - the schema isn't strict, so the model may
# produce an invalid JSON object.
agent.output_type = AgentOutputSchema(OutputType, strict_json_schema=False)
try:
result = await Runner.run(agent, input)
print(result.final_output)
except ModelBehaviorError as e:
print(f"Non-strict output validation failed (expected possibility): {e}")
# Finally, let's try a custom output type.
agent.output_type = CustomOutputSchema()
result = await Runner.run(agent, input)
print(result.final_output)
3 段階目の完全自作スキーマは AgentOutputSchemaBase を継承して作ります。
class CustomOutputSchema(AgentOutputSchemaBase):
"""A demonstration of a custom output schema."""
def is_plain_text(self) -> bool:
return False
def name(self) -> str:
return "CustomOutputSchema"
def json_schema(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {"jokes": {"type": "object", "properties": {"joke": {"type": "string"}}}},
}
def is_strict_json_schema(self) -> bool:
return False
def validate_json(self, json_str: str) -> Any:
json_obj = json.loads(json_str)
# Just for demonstration, we'll return a list.
return list(json_obj["jokes"].values())
コード解説
output_type=OutputTypeをそのまま使う → strict 変換できず例外(意図的な失敗例)AgentOutputSchema(OutputType, strict_json_schema=False)に差し替える → strict を諦めて動かす。ただし JSON の正しさが保証されなくなるため、モデルが不正な JSON を返すとModelBehaviorErrorが起きうる(コードもそれを try/except で受けています)AgentOutputSchemaBaseを継承したCustomOutputSchema→ JSON スキーマの生成(json_schema)と検証・変換(validate_json)を完全に自作。この例では検証時にジョークの辞書をリストに変換して返しており、final_outputの型すら自由に決められることを示しています
実行手順
OPENAI_API_KEYを設定します- 次を実行します
uv run python examples/basic/non_strict_output_type.py
期待される動作・出力例
1 行目に「Error (expected): ...」という strict 変換エラー、続いて非 strict モードでのジョーク辞書(例: OutputType(jokes={1: '...', 2: '...', 3: '...'}))、最後にカスタムスキーマによるジョークのリストが表示されます。
つまずきポイント
- 最初のエラーは仕様通りの失敗です。壊れているわけではありません
- どのスキーマが strict 対応かは OpenAI の構造化出力ドキュメント(コード冒頭のコメントにリンクがあります)を参照してください。迷ったら「Pydantic の BaseModel で、固定フィールドのみ」の形にしておくのが安全です
2.7 その他(usage、previous_response_id、リトライ)
usage_tracking.py
実行で消費したトークン数と API リクエスト数を集計・表示する example です。トークン使用量はそのまま API 料金に直結するため、実運用では必ず押さえたい機能です。
def print_usage(usage: Usage) -> None:
print("\n=== Usage ===")
print(f"Input tokens: {usage.input_tokens}")
print(f"Output tokens: {usage.output_tokens}")
print(f"Total tokens: {usage.total_tokens}")
print(f"Requests: {usage.requests}")
for i, request in enumerate(usage.request_usage_entries):
print(f" {i + 1}: {request.input_tokens} input, {request.output_tokens} output")
async def main() -> None:
agent = Agent(
name="Usage Demo",
instructions="You are a concise assistant. Use tools if needed.",
tools=[get_weather],
)
result = await Runner.run(agent, "What's the weather in Tokyo?")
print("\nFinal output:")
print(result.final_output)
# Access usage from the run context
print_usage(result.context_wrapper.usage)
コード解説
result.context_wrapper.usage: 実行全体の使用量です。ツール呼び出しや handoff を含むすべての LLM 呼び出しの合計が集計されますUsageの主なフィールドはrequests(API 呼び出し回数)、input_tokens/output_tokens/total_tokens、そしてrequest_usage_entries(リクエストごとの内訳リスト)です- この example ではツール呼び出しを挟むため LLM が 2 回呼ばれ、
Requests: 2となり内訳も 2 行表示されます
実行手順
OPENAI_API_KEYを設定します- 次を実行します
uv run python examples/basic/usage_tracking.py
期待される動作・出力例
Final output:
The weather in Tokyo is sunny with wind, 14-20°C.
=== Usage ===
Input tokens: 3xx
Output tokens: xx
Total tokens: 4xx
Requests: 2
1: 1xx input, xx output
2: 2xx input, xx output
(数値は実行ごとに変わります)
つまずきポイント
- LiteLLM などサードパーティ経由のモデルでは、プロバイダによって使用量が既定で返らないことがあります。その場合は
ModelSettings(include_usage=True)が必要です
previous_response_id.py
会話の 2 ターン目以降で、過去のやり取りを再送せずに会話を継続する example です。OpenAI Responses API はサーバー側に応答を保存しており、previous_response_id に前回の応答 ID を渡すだけで文脈を引き継げます。
非ストリーミング版の核心部分です。
async def main():
print("=== Non-streaming Example ===")
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant. be VERY concise.",
)
result = await Runner.run(agent, "What is the largest country in South America?")
print(result.final_output)
# Brazil
result = await Runner.run(
agent,
"What is the capital of that country?",
previous_response_id=result.last_response_id,
)
print(result.final_output)
# Brasilia
ストリーミング版(main_stream)も同じ考え方で、Runner.run_streamed(..., previous_response_id=result.last_response_id) を使います。
コード解説
- 2 ターン目の入力は「その国の首都は?」だけです。「その国」が何を指すかは、
previous_response_id=result.last_response_idによってサーバー側に保存された 1 ターン目の文脈から解決されます result.last_response_id: 直近のモデル応答の ID です。これを次の実行に渡すのがパターンの全てです- ファイル冒頭の docstring に重要な注意が 2 つあります。(1) この機能は OpenAI Responses API 専用で、他のモデルではこのパラメータは無視されます。(2) 応答の保存期間は執筆時点で 30 日のため、本番では応答 ID と有効期限をセットで保存し、期限切れの際は会話履歴の再送にフォールバックする設計が必要です
実行手順
OPENAI_API_KEYを設定しますexamples.auto_modeを import しているため、モジュール形式で実行します
uv run python -m examples.basic.previous_response_id
Run in stream mode? (y/n):と聞かれるので、n(通常実行)かy(ストリーミング実行)を入力します。EXAMPLES_INTERACTIVE_MODE=autoを付けると両方が連続実行されます
期待される動作・出力例
=== Non-streaming Example ===
Brazil
Brasília
2 ターン目が「Brasília」と答えられれば、履歴を再送せずに文脈が引き継がれたことの証明です。
つまずきポイント
- 会話履歴の引き継ぎには、この方式のほかに
result.to_input_list()で履歴をローカル管理する方法や、session機能(別章で解説)もあります。「サーバー側に任せるか、手元で持つか」の設計判断です
retry.py
LLM の API 呼び出しは、レート制限(429)やサーバーエラー(5xx)など一時的な失敗が起こりえます。この example は、SDK に組み込まれたリトライ(自動再試行)設定を ModelSettings(retry=...) で構成する方法を示します。
リトライポリシーの組み立て部分です。
apply_policies = retry_policies.any(
# On OpenAI-backed models, provider_suggested() follows provider retry advice,
# including fallback retryable statuses when x-should-retry is absent
# (for example 408/409/429/5xx).
retry_policies.provider_suggested(),
retry_policies.retry_after(),
retry_policies.network_error(),
retry_policies.http_status([408, 409, 429, 500, 502, 503, 504]),
)
リトライ設定本体と実行への適用です。
retry = ModelRetrySettings(
max_retries=4,
backoff={
"initial_delay": 0.5,
"max_delay": 5.0,
"multiplier": 2.0,
"jitter": True,
},
policy=policy,
)
# RunConfig-level model_settings are shared defaults for the run.
# If an Agent also defines model_settings, the Agent wins for overlapping
# keys, while nested objects like retry/backoff are merged.
run_config = RunConfig(model_settings=ModelSettings(retry=retry))
agent = Agent(
name="Assistant",
instructions="You are a concise assistant. Answer in 3 short bullet points at most.",
model_settings=ModelSettings(retry=retry),
)
コード解説
retry_policies.any(...): 複数の判定条件のうちどれか 1 つでも「リトライすべき」なら再試行する合成ポリシーです。部品として、プロバイダの推奨に従うprovider_suggested()、Retry-After ヘッダー(サーバーが指定する待機秒数)に従うretry_after()、ネットワークエラー全般のnetwork_error()、指定 HTTP ステータスのhttp_status([...])を組み合わせていますModelRetrySettings(max_retries=4, backoff={...}, policy=...): 最大 4 回まで再試行し、待ち時間は 0.5 秒から 2 倍ずつ(最大 5 秒)増やします。これを指数バックオフと呼び、jitter(待ち時間へのランダムなゆらぎ)は再試行の同時集中を避けるための工夫です- 自作の
policy関数は、合成ポリシーの判定結果(RetryDecision)を横取りして[retry] ...というログを出してから、そのまま判定を返しています。リトライの可視化テクニックです - コメントにある通り、
RunConfigのmodel_settingsは実行全体の既定値、Agent 側のmodel_settingsは個別上書きで、retry や backoff のようなネストした設定はマージされます
実行手順
OPENAI_API_KEYを設定します- 次を実行します
uv run python examples/basic/retry.py
期待される動作・出力例
Retry support is configured. You will only see [retry] logs if a transient failure happens.
Final output:
- Exponential backoff means waiting longer after each failed retry...
正常時は [retry] ログは出ません。一時的な障害(429 など)が起きたときだけ [retry] retry attempt 1/5 | waiting 0.52s | ... のようなログが挟まります。
つまずきポイント
- 「何も特別なログが出ない」のが正常です。リトライは保険であり、発動しないのが良い状態です
- 質問文自体が「指数バックオフを平易に説明して」なので、出力の内容がそのままこの example の仕組みの解説になっています
retry_litellm.py
retry.py とほぼ同一のコードで、モデル指定だけを LiteLLM(100 以上の LLM プロバイダを共通インターフェースで呼べるアダプタライブラリ)経由に変えた example です。リトライ設定がサードパーティアダプタ経由でも同じように機能することを示します。
差分は Agent の model 指定の 1 点です。
agent = Agent(
name="Assistant",
instructions="You are a concise assistant. Answer in 3 short bullet points at most.",
# Prefix with litellm/ to route this request through the LiteLLM adapter.
model="litellm/openai/gpt-4o-mini",
# This Agent repeats the same retry config for clarity. In real code you
# can keep shared defaults in RunConfig and only put per-agent overrides
# here when you need different retry behavior.
model_settings=ModelSettings(retry=retry),
)
コード解説
model="litellm/openai/gpt-4o-mini": モデル名をlitellm/で始めると、SDK は LiteLLM アダプタ経由でリクエストを送ります。litellm/の後ろは「プロバイダ名/モデル名」で、例えば Anthropic や Gemini のモデルにも同じ書式で切り替えられます- リトライ設定(
ModelRetrySettings/retry_policies)の書き方は retry.py と完全に共通です。モデルの接続先を替えても、信頼性まわりのコードは変えなくてよいことがこの example の要点です
実行手順
- LiteLLM は追加依存のため、extras 付きで同期します
uv sync --extra litellm
OPENAI_API_KEYを設定します(この例の接続先は LiteLLM 経由の OpenAI のため)- 次を実行します
uv run python examples/basic/retry_litellm.py
期待される動作・出力例
retry.py と同様に、指数バックオフの説明が箇条書き(3 点以内)で表示されます。障害時のみ [retry] ログが出ます。
つまずきポイント
uv sync --extra litellmを忘れると、LiteLLM モジュールが見つからない旨の ImportError になります- LiteLLM 経由では、プロバイダによってはトークン使用量が既定で返らないため、必要なら
ModelSettings(include_usage=True)を検討してください
まとめ
- SDK の最小サイクルは「
Agent(name, instructions)を作り、Runner.run(agent, 入力)してresult.final_outputを読む」の 3 手(hello_world.py)。モデルは文字列指定・モデルオブジェクト指定(Ollama 等のローカル LLM 含む)のどちらも可能です instructionsにもpromptにも関数を渡せて、コンテキスト(Runner.run(..., context=...)で持ち込む自作オブジェクト。LLM には送信されない)に応じた動的な指示が作れます- ストリーミングは
Runner.run_streamed()+stream_events()。トークン差分(raw_response_event)、アイテム完成(run_item_stream_event)、Agent 切り替え(agent_updated_stream_event)の 3 粒度を用途で使い分けます。WebSocket トランスポートと HITL 承認、Agent.as_tool()の組み合わせも可能です - フックは Agent 単位の
AgentHooksと実行全体のRunHooksの 2 段階。どのフックでもcontext.usageで累積使用量を観察できます @toolで Python 関数がそのまま LLM の道具になり、ツール単位の入出力 guardrail で「差し止めて続行(reject_content)」「例外で全停止(raise_exception)」を選べます。ツールはToolOutputImageで画像も返せます- 入力はリスト形式にすれば画像(
input_image)や PDF(input_file)を、URL でも Base64 埋め込みでも渡せます - 実務の周辺機能として、使用量集計(
result.context_wrapper.usage)、サーバー側での会話継続(previous_response_idとresult.last_response_id)、指数バックオフ付き自動リトライ(ModelRetrySettings+retry_policies)、strict 非対応スキーマへの対処(AgentOutputSchema(strict_json_schema=False)/AgentOutputSchemaBase)を押さえました
理解度チェック
Runner.run() と Runner.run_streamed() の違いを、戻り値の型と結果の受け取り方の観点から説明してください。
dynamic_system_prompt.py で使われた「コンテキスト」オブジェクト(Runner.run(..., context=...))は LLM に送信されるでしょうか。また、何のために使うものでしょうか。
tool_guardrails.py に登場した ToolGuardrailFunctionOutput.reject_content(...) と ToolGuardrailFunctionOutput.raise_exception(...) の挙動の違いを説明してください。
会話の 2 ターン目で「その国の首都は?」のような文脈依存の質問に答えさせたいとき、previous_response_id を使う方法の仕組みと、その利用上の制約を 2 つ挙げてください。
AgentHooks と RunHooks はどう使い分けますか。それぞれどこに取り付けるかも答えてください。
解答と解説
問1 の解答と解説
Runner.run() は await で完了を待つ非同期メソッドで、完成した結果を RunResult として一括で返します。Runner.run_streamed() は await せずに RunResultStreaming を即座に返し、result.stream_events() を async for で消費することで、生成中のイベント(テキスト差分やツール実行通知)を逐次受け取ります。ストリーミングでは、イテレータが終わるまで実行は完了扱いにならず、final_output も完了後に確定します。
問2 の解答と解説
送信されません。コンテキストは自分の Python コード側(ツール関数、フック、動的 instructions 関数など)でデータや依存オブジェクトを共有するための純粋にローカルな仕組みです。LLM に情報を見せたい場合は、instructions やユーザー入力に含めるか、ツールの戻り値として渡す必要があります。
問3 の解答と解説
reject_content(message=...) はそのツール呼び出し(または結果)だけを差し止め、指定したメッセージを LLM に伝えた上でエージェントの実行は継続します。LLM は「その操作はできなかった」前提で回答を続けます。一方 raise_exception(...) は ToolOutputGuardrailTripwireTriggered 例外を発生させ、実行全体を停止します。SSN のような重大な機密データ漏えいの防止など、続行自体が危険な場合に使います。
問4 の解答と解説
OpenAI Responses API はサーバー側に応答を保存しており、前回の result.last_response_id を次の実行の previous_response_id に渡すと、過去のメッセージを再送しなくても文脈が引き継がれます。制約は、(1) OpenAI Responses API 専用の機能であり、他のモデル/プロバイダではパラメータが無視されること、(2) 応答の保存期間が執筆時点で 30 日であるため、本番では応答 ID と有効期限を保存し、期限切れ時は履歴再送にフォールバックする設計が必要なこと、の 2 点です。
問5 の解答と解説
AgentHooks は特定の Agent の視点のイベント(その Agent の開始・終了・ツール・handoff)だけを受け取りたいときに使い、Agent(hooks=...) として Agent 定義に取り付けます。RunHooks は実行全体を横断するイベント(すべての Agent の開始/終了、LLM 呼び出しの開始/終了、ツール、handoff)を一元的に観察したいときに使い、Runner.run(..., hooks=...) として実行時に渡します。両者は併用できます。
参考リンク
- エージェントの実行(Running agents): https://openai.github.io/openai-agents-python/running_agents/ / 日本語版: https://openai.github.io/openai-agents-python/ja/running_agents/
- 実行結果(Results): https://openai.github.io/openai-agents-python/results/ / 日本語版: https://openai.github.io/openai-agents-python/ja/results/
- ストリーミング(Streaming): https://openai.github.io/openai-agents-python/streaming/ / 日本語版: https://openai.github.io/openai-agents-python/ja/streaming/
- エージェント(Agents): https://openai.github.io/openai-agents-python/agents/ / 日本語版: https://openai.github.io/openai-agents-python/ja/agents/
- コンテキスト管理(Context management): https://openai.github.io/openai-agents-python/context/ / 日本語版: https://openai.github.io/openai-agents-python/ja/context/
- 使用量(Usage): https://openai.github.io/openai-agents-python/usage/ / 日本語版: https://openai.github.io/openai-agents-python/ja/usage/
- ガードレール(Guardrails): https://openai.github.io/openai-agents-python/guardrails/ / 日本語版: https://openai.github.io/openai-agents-python/ja/guardrails/
- SDK の設定(Configuring the SDK): https://openai.github.io/openai-agents-python/config/ / 日本語版: https://openai.github.io/openai-agents-python/ja/config/
- コード例(Examples): https://openai.github.io/openai-agents-python/examples/ / 日本語版: https://openai.github.io/openai-agents-python/ja/examples/
第3章 組み込みツール(tools)
この章で学ぶこと
- hosted tools(OpenAI がサーバー側で実行するツール)と function tools(自作 Python 関数のツール)、そしてローカル実行ツールの違い
- WebSearchTool・FileSearchTool・CodeInterpreterTool・ImageGenerationTool など代表的な hosted tools の使い方
- ComputerTool と Playwright を組み合わせたブラウザ自動操作(computer use)の仕組み
- ShellTool・ApplyPatchTool による「モデルが指示し、手元で実行する」ツールと、承認(approval)フローの実装パターン
- ToolSearchTool による大量ツールの遅延読み込み(tool search)と、実験的な Codex ツールの概要
- ProgrammaticToolCallingTool による Programmatic Tool Calling(モデルが JavaScript を生成し、そこから複数ツールを並列に呼ぶ方式)
前提知識
この章を読む前に、以下を理解しておくことをおすすめします。
- 第1章: SDK のインストール(
git clone→uv sync)、OPENAI_API_KEYの設定、uv runによるスクリプト実行 - 第2章:
Agent(エージェントの定義体)とRunner(エージェントを実行するクラス)の基本、Runner.run()/Runner.run_streamed()の使い分け、result.final_outputとresult.new_itemsの読み方
また、この章のコードはすべて 非同期処理(async / await を使い、通信などの待ち時間中に他の処理を進められる Python の仕組み)で書かれています。asyncio.run(main()) で非同期関数を起動する形は第2章までと同じです。
概念解説
ツールとは何か
エージェントにとっての「ツール」とは、LLM(大規模言語モデル。文章を理解・生成する AI モデル)が 文章の生成以外の行動を起こすための手段 です。LLM 単体は「知っていることを話す」ことしかできませんが、ツールを渡すことで「Web を検索する」「コードを実行する」「ファイルを編集する」「ブラウザを操作する」といったアクションが可能になります。
比喩で言えば、LLM は非常に博識な「電話相談員」です。電話(テキスト)だけでは調べ物も作業もできませんが、手元に検索端末(WebSearchTool)や電卓付きパソコン(CodeInterpreterTool)、社内資料棚(FileSearchTool)を用意してあげると、実際に手を動かして正確な答えを返せるようになります。
hosted tools と function tools の違い(最重要ポイント)
OpenAI Agents SDK のツールは、「誰がどこで実行するか」 で大きく分類されます。この章の examples を理解する上で最も重要な区別です。
| 分類 | 実行場所 | 代表例 | あなたが書くコード |
|---|---|---|---|
| hosted tools(OpenAI ホスト型ツール) | OpenAI のサーバー側 | WebSearchTool, FileSearchTool, CodeInterpreterTool, ImageGenerationTool, HostedMCPTool, ToolSearchTool, ShellTool(コンテナモード) | ツールを tools=[...] に並べるだけ。実行ロジックは不要 |
| ローカル実行ツール(local runtime tools) | あなたのマシン(プロセス) | ComputerTool, ApplyPatchTool, ShellTool(ローカルモード), LocalShellTool(旧式) | 「実際に実行する部分」(executor や Computer 実装)を自分で書く |
| function tools(関数ツール) | あなたのマシン(プロセス) | @tool(別名 @function_tool)を付けた任意の Python 関数 | 関数本体そのもの |
| Programmatic Tool Calling | プログラム本体は OpenAI 側の V8、呼ばれるツールは各ツールの実行場所 | ProgrammaticToolCallingTool() + allowed_callers を許可したツール群 | モデルが生成した JavaScript |
- hosted tools は、レストランの「ルームサービス」のようなものです。注文(ツール呼び出し)を出すと、OpenAI のサーバー内の厨房で調理され、結果だけが返ってきます。あなたの手元では何も実行されないため、準備が簡単で安全ですが、中で何が起きるかは OpenAI 側の仕様に従います。hosted tools は Responses API を使う OpenAI モデル(
OpenAIResponsesModel)でのみ利用できます。 - ローカル実行ツール は「食材キットの宅配」に近い形です。モデルが「この操作をしてほしい」というリクエスト(レシピ)を返し、実際の調理(コマンド実行・ファイル編集・マウス操作)は あなたが書いたコードが手元で行います。何を実行するかをモデルが決め、実行そのものは自分の環境で制御できるため、承認フローを挟むといった安全対策が組み込めます。
- function tools は完全な「自炊」です。任意の Python 関数を
@toolデコレータ(function_toolの別名)でツール化し、モデルが引数を決めて呼び出します。function tools の基本は第2章(examples/basic/tools.py)で扱ったので、この章では主に hosted tools とローカル実行ツールを扱います。
公式ドキュメントは、この他に「Agents as tools(エージェントをツールとして使う)」「実験的な Codex ツール」を加えた 5 分類で説明しています。Agents as tools は第4章で扱い、Codex ツールはこの章の後半で紹介します。
この分類を図にすると次のようになります。
この章で頻出する用語
- トークン: LLM が文章を処理する際の最小単位(文字や単語の断片。文字数との比率は一定ではない)。API 利用料はトークン数に応じて課金されます。
- ストリーミング: モデルの応答を、完成を待たずに少しずつ受け取る仕組み。
Runner.run_streamed()で利用します。 - ベクトルストア(vector store): 文書を「意味を表す数値の並び(ベクトル)」に変換して保存し、キーワードの一致ではなく意味の近さで検索できる OpenAI 上のデータベース。FileSearchTool の裏側で使われます。
- サンドボックス: 外部への影響を遮断した、隔離された安全な実行環境。CodeInterpreterTool のコードはサンドボックス内で動きます。
- コンテナ: OpenAI サーバー上に用意される、独立した仮想の実行環境。hosted の ShellTool はこの中でコマンドを実行します。
- trace(トレーシング): エージェントの実行過程(どのツールをいつ呼んだか等)を記録する仕組み。
with trace("名前"):で囲むと、OpenAI ダッシュボードの Traces ページで実行の様子を確認できます。 - human-in-the-loop(HITL): 危険な操作の前に人間の承認を挟む設計パターン。「実行してよいですか? [y/N]」のような確認です。
各 example の解説
examples/tools ディレクトリには 15 個の Python ファイルと、シェル用スキルを定義した skills/csv-workbench サブディレクトリがあります。hosted tools → ローカル実行ツール → 応用(tool search・Codex)の順に見ていきます。
共通の前提: 以降の実行手順は、第1章の手順(
git clone https://github.com/openai/openai-agents-python.git→cd openai-agents-python→uv sync)が完了し、環境変数OPENAI_API_KEYが設定済みであることを前提とします。
web_search.py
v0.22.1:Web検索の画像結果
Web検索ツールの画像結果への対応が追加されました。既存のテキスト検索例は引き続き学習の入口になります。画像を利用する場合は出力がテキストだけという前提を置かず、利用するモデルとツールの仕様に合わせて処理します。公式リリース
hosted tools の最小構成です。WebSearchTool を渡すだけで、エージェントが OpenAI サーバー側で Web 検索を実行し、最新のニュースを要約して返します。
24 行の短いファイルなので全文を引用します。
import asyncio
from agents import Agent, Runner, WebSearchTool, trace
async def main():
agent = Agent(
name="Web searcher",
instructions="You are a helpful agent.",
tools=[WebSearchTool(user_location={"type": "approximate", "city": "New York"})],
)
with trace("Web search example"):
result = await Runner.run(
agent,
"search the web for 'local sports news' and give me 1 interesting update in a sentence.",
)
print(result.final_output)
# The New York Giants are reportedly pursuing quarterback Aaron Rodgers after his ...
if __name__ == "__main__":
asyncio.run(main())
コード解説
tools=[WebSearchTool(...)]— hosted tool はインスタンスをtoolsリストに入れるだけで使えます。検索処理の実装は一切書いていません。これが hosted tools の最大の特徴です。user_location={"type": "approximate", "city": "New York"}— 検索結果を特定の地域向けに調整するオプションです。「ローカルのスポーツニュース」という曖昧な依頼が、ニューヨークのニュースとして解釈されます。WebSearchToolはこの他にfilters(検索対象ドメインの制限)、search_context_size(検索に使うコンテキスト量。"low"/"medium"/"high")というオプションを持ちます(ソースコードsrc/agents/tool.pyの定義より)。with trace("Web search example"):— 実行記録に名前を付けています。OpenAI ダッシュボードの Traces で「Web search example」という名前のトレースとして確認できます。
実行手順
OPENAI_API_KEYが設定されていることを確認します。次のコマンドを実行します。
uv run python examples/tools/web_search.py
期待される動作・出力例
ニューヨークのスポーツニュースを 1 文で要約した文章が表示されます。ファイル内のコメントにある通り、The New York Giants are reportedly pursuing quarterback Aaron Rodgers after his ... のような、実行時点の最新ニュースに基づく文が出力されます(実行のたびに内容は変わります)。
つまずきポイント
WebSearchToolは OpenAI の Responses API 専用です。他社モデルや Chat Completions API 経由では使えません。- 検索は OpenAI 側で行われるため、社内プロキシの設定などは不要ですが、その分「どのサイトをどう検索したか」の細かい制御は限定的です(次の example で制御方法を学びます)。
web_search_filters.py
WebSearchTool の応用編です。検索対象ドメインを OpenAI の開発者向けサイトに限定(ドメインフィルタリング)し、さらに 回答に付いたインライン引用の URL と 検索時に実際に参照したソース URL の一覧 の 2 種類を取り出して表示します。
コード解説
中心となるのはエージェント定義です。
agent = Agent(
name="WebOAI website searcher",
model="gpt-5.6",
instructions=(
"You are a helpful agent that searches OpenAI developer documentation. Answer only "
"from the allowed official documentation sources and include inline citations. Cite "
"the official page for each model when comparing multiple models."
),
tools=[
WebSearchTool(
# https://platform.openai.com/docs/guides/tools-web-search?api-mode=responses#domain-filtering
filters=Filters(allowed_domains=ALLOWED_DOMAINS),
search_context_size="medium",
)
],
model_settings=ModelSettings(
reasoning=Reasoning(effort="low"),
tool_choice="required",
verbosity="low",
# https://platform.openai.com/docs/guides/tools-web-search?api-mode=responses#sources
response_include=["web_search_call.action.sources"],
),
)
filters=Filters(allowed_domains=ALLOWED_DOMAINS)— 検索対象をファイル冒頭で定義したALLOWED_DOMAINS = ["developers.openai.com"]の 1 ドメインに限定します。Filtersはopenaiパッケージ(openai.types.responses.web_search_tool)からインポートしている点に注意してください。SDK は OpenAI 公式 Python ライブラリの型をそのまま利用します。model_settings=ModelSettings(...)—ModelSettingsはモデルの動作を細かく調整する設定クラスです。response_include=["web_search_call.action.sources"]を指定すると、API の応答に「検索で参照したソース一覧」が含まれるようになります。これがこの example の鍵です。reasoning=Reasoning(effort="low")は推論(モデルが答えを出す前に内部で考える処理)の深さを浅めに、verbosity="low"は応答の冗長さを抑える設定です。tool_choice="required"は「必ずツール(ここでは Web 検索)を使ってから回答する」指定で、モデルが検索せずに記憶だけで答えてソース一覧が空になる事態を防ぎます。
実行後、URL の取り出しは共通モジュール examples/web_search_utils.py の 2 つのヘルパーに委譲されています。
citations = extract_url_citations(result.new_items)
cited_urls = _normalized_source_urls([citation.url for citation in citations])
retrieved_urls = _normalized_source_urls(extract_web_search_source_urls(result.new_items))
model_documentation_urls = [
url for url in retrieved_urls if "/api/docs/models/gpt-5.6-" in url
]
if not cited_urls:
raise RuntimeError("Expected at least one official inline citation in the final answer")
if not model_documentation_urls:
raise RuntimeError(
f"Expected GPT-5.6 model documentation in retrieved sources, got {retrieved_urls}"
)
result.new_itemsには、実行中に生成されたアイテム(ツール呼び出し、メッセージなど)が並びます。ここから 2 種類の URL を取り出します。extract_url_citations()— 最終メッセージ(type == "message")のoutput_textに付いたurl_citation注釈を集めます。つまり「モデルが回答本文で実際に引用として示した URL」です。extract_web_search_source_urls()— Web 検索呼び出し(type == "web_search_call")のaction.sourcesを集めます。つまり「検索で参照した URL の全量」で、引用されなかったものも含みます。
- どちらのヘルパーも
examples/web_search_utils.pyにあります。この共通モジュールは、同じ抽出処理を第13章の金融リサーチ example とも共有するために新設されました。from examples.web_search_utils import ...という形の import があるため、この example はパッケージとして(python -m examples.tools.web_search_filters)起動する必要があります - ファイル前半の
_normalize_source_url()/_normalized_source_urls()は、取得した URL から画像や CSS などの不要な URL を除外し、ホスト名が許可ドメイン配下かを検証し、重複を取り除くヘルパー関数です(LLM とは無関係の純粋な Python 処理)。 - 末尾の 2 つの
raise RuntimeError(...)は example 自身の自己検証です。引用が 1 件も取れなかった場合や、想定したモデルドキュメントが参照されなかった場合に黙って成功したことにせず落とすようになっています。CI で example を通しで流すための作りです
実行手順
OPENAI_API_KEYが設定されていることを確認します。次のコマンドを実行します。
uv run python -m examples.tools.web_search_filters共通モジュール
examples/web_search_utils.pyを import しているため、python examples/tools/web_search_filters.pyの形(単独スクリプト実行)ではModuleNotFoundErrorになります。-m付きのモジュール実行を使ってください。
期待される動作・出力例
### Cited sources ### に回答本文で引用された URL、### Retrieved model documentation ### に検索で参照した GPT-5.6 のモデルドキュメント URL(/api/docs/models/gpt-5.6-...)が並び、その後 ### Final output ### として、GPT-5.6 Sol と GPT-5.6 Terra を比較する 3 点の箇条書きと「どちらをいつ使うか」の説明が表示されます。
つまずきポイント
response_includeを指定し忘れると、sourcesが返らず URL 一覧は空になります。「ソースを取りたければ明示的にリクエストする」のが Responses API の流儀です。- ドメインフィルタリングの仕様は OpenAI プラットフォーム側の機能です。詳細はコード中のコメントにあるプラットフォームドキュメント(tools-web-search ガイド)を参照してください。
- モデルが許可ドメイン内で目的のページに到達できなかった場合、末尾の
raise RuntimeError(...)で異常終了します。エラーメッセージには実際に参照された URL 一覧が入るので、まずそこを見てください。
file_search.py
FileSearchTool を使い、ベクトルストア に保存した自社文書などから情報を検索して回答する example です。いわゆる RAG(Retrieval-Augmented Generation。検索で得た情報を根拠に回答を生成する手法)を最短の手数で実現します。
コード解説
FileSearchTool を使うには 事前にベクトルストアを作り、その ID を渡す必要があります。この example は親切なことに、実行のたびに使い捨てのベクトルストアをその場で作成し、終了時に finally で後始末までしてくれます。
vector_store_id: str | None = None
async with AsyncOpenAI() as client:
try:
print("### Preparing vector store:\n")
# Create a temporary vector store and index a file.
text = (
"Arrakis, the desert planet in Frank Herbert's 'Dune,' was inspired by the "
"scarcity of water as a metaphor for oil and other finite resources."
)
file_upload = await client.files.create(
file=("example.txt", text.encode("utf-8")),
purpose="assistants",
)
file_id = file_upload.id
print(f"File uploaded: {file_upload.to_dict()}")
vector_store = await client.vector_stores.create(
name="example-vector-store",
expires_after={"anchor": "last_active_at", "days": 1},
)
vector_store_id = vector_store.id
print(f"Vector store created: {vector_store.to_dict()}")
indexed = await client.vector_stores.files.create_and_poll(
vector_store_id=vector_store_id,
file_id=file_id,
)
print(f"Stored files in vector store: {indexed.to_dict()}")
- ここは Agents SDK ではなく OpenAI 公式ライブラリ(
openaiパッケージ)を直接使う準備コード です。手順は「①テキストをファイルとしてアップロード → ②ベクトルストアを作成 → ③ファイルをストアに登録して索引化を待つ(create_and_poll)」の 3 段階です。 async with AsyncOpenAI() as client:と非同期クライアントを使い、awaitで各 API を呼びます。以前は同期版のOpenAI()を使い、vector_store_idが未指定のときだけ準備コードを走らせる分岐がありましたが、現在は毎回使い捨てのストアを作る形に整理されました。expires_after={"anchor": "last_active_at", "days": 1}を付けてベクトルストアに有効期限を持たせ、さらにfinally節でvector_stores.delete()とfiles.delete()を必ず実行します。試し終わった後の後始末が example 側で完結するようになっています。
エージェント本体は次の通りです。
# Create an agent that can search the vector store.
agent = Agent(
name="File searcher",
instructions=(
"You are a helpful agent. "
"You answer only based on the information in the vector store."
),
tools=[
FileSearchTool(
max_num_results=3,
vector_store_ids=[vector_store_id],
include_search_results=True,
)
],
)
vector_store_ids=[...]— 検索対象のベクトルストア ID をリストで渡します。これが FileSearchTool の必須パラメータです。max_num_results=3— 検索結果を最大 3 件に制限します。include_search_results=True— 検索でヒットした本文を出力アイテムに含めます。実行後のresult.new_itemsで検索クエリとヒット内容を確認できます。- この他に
ranking_options(ランキング調整)とfilters(ファイル属性によるフィルタ)も指定できます(ソースコードの定義より)。
実行手順
OPENAI_API_KEYが設定されていることを確認します。次のコマンドを実行します。
uv run python examples/tools/file_search.py- 実行が終わると、作成されたベクトルストアとファイルは
finally節で自動的に削除されます(Deleted vector store: .../Deleted file: ...のログが出ます)。
期待される動作・出力例
まずベクトルストア作成のログ(File uploaded: ... など)が流れ、その後、登録した一文に基づく回答が表示されます。
Arrakis, the desert planet in Frank Herbert's "Dune," was inspired by the scarcity of water
as a metaphor for oil and other finite resources.
### Output items: には {"id":"...", "queries":["Arrakis"], "results":[...]} のような検索の生データが表示されます。
つまずきポイント
- この example を実行するたびに、あなたの OpenAI アカウントにファイルとベクトルストアが作成されます。ベクトルストアは保存容量に応じた課金対象になり得ますが、現在の example は
finally節で削除まで行い、加えてexpires_afterで 1 日後の自動失効も設定しています。途中で強制終了させた場合は後始末が走らないことがあるので、その時は OpenAI ダッシュボード(Storage ページ)で残骸がないか確認してください。 - ファイルの索引化には数秒かかります。
create_and_pollが完了を待ってくれますが、大きなファイルではその分時間がかかります。
FileSearchTool を使った一連の処理をまとめると、次の一本道になります。
code_interpreter.py
CodeInterpreterTool を使い、OpenAI サーバー上のサンドボックスで Python コードを実行して数値計算を解かせる example です。ストリーミング実行と組み合わせ、モデルが書いたコードをリアルタイムに表示します。
コード解説
agent = Agent(
name="Code interpreter",
# Note: using gpt-5-class models with streaming for this tool may require org verification.
# Code interpreter does not support gpt-5 minimal reasoning effort; use default effort.
model="gpt-5.6-sol",
instructions=(
"Always use the code interpreter tool to solve numeric problems, and show the code "
"you ran when possible."
),
tools=[
CodeInterpreterTool(
tool_config={"type": "code_interpreter", "container": {"type": "auto"}},
)
],
)
CodeInterpreterToolはtool_configという辞書で設定します。"container": {"type": "auto"}は「実行用のコンテナを自動で用意する」という意味です。- コメントにある通り、gpt-5 系モデルとストリーミングの組み合わせでは、組織の本人確認(org verification)が必要な場合があります。
実行部分はストリーミングです。
result = Runner.run_streamed(
agent,
(
"Use the code interpreter tool to calculate the square root of 273 * 312821 + "
"1782. Show the Python code you ran and then provide the numeric answer."
),
)
saw_code_interpreter_call = False
async for event in result.stream_events():
if event.type != "run_item_stream_event":
continue
item = event.item
if item.type == "tool_call_item":
raw_call = item.raw_item
if _get_field(raw_call, "type") == "code_interpreter_call":
saw_code_interpreter_call = True
code = _get_field(raw_call, "code")
if isinstance(code, str):
print(f"Code interpreter code:\n```\n{code}\n```\n")
continue
Runner.run_streamed()は実行しながらイベントを逐次受け取れる実行方法です(第2章のストリーミングの復習です)。- イベントの中から
type == "code_interpreter_call"のツール呼び出しを探し、そのcodeフィールド(モデルが書いた Python コード)を表示しています。「AI がどんなコードを書いて計算したか」を目視できるのがこの example の見どころです。
実行手順
OPENAI_API_KEYが設定されていることを確認します。次のコマンドを実行します。
uv run python examples/tools/code_interpreter.py
期待される動作・出力例
Solving math problem with the coding interpreter... の後、モデルが実行した Python コード(import math して平方根を計算するようなコード)が表示され、最後に Final output: として数値の答えを含む説明文が表示されます。
つまずきポイント
- 計算は OpenAI サーバーのサンドボックスで実行されるため、あなたのローカルファイルにはアクセスできません。ローカルで実行したい場合は後述の
ShellTool(ローカルモード)を使います。 - コンテナの起動・実行時間に応じた課金が発生します。
image_generator.py
v0.22.2:画像生成の設定を更新
公式exampleは ImageGenerationToolConfig にモデル、生成/編集の指定、画像サイズをまとめる形になりました。以下はその設定部分の抜粋です。自作コードでは from agents import ImageGenerationToolConfig も必要です。action は generate・edit・auto、output_format は png・webp・jpeg を指定できます。保存する拡張子も出力形式に合わせます。v0.22.2のサンプル
ImageGenerationTool で画像を生成し、返ってきた base64 データ(画像バイナリを文字列で表現したもの)を PNG ファイルに保存して開く example です。
コード解説
image_config: ImageGenerationToolConfig = {
"type": "image_generation",
"model": "gpt-image-2.5-flare",
"action": "generate",
"size": "1536x864",
"quality": "low",
}
agent = Agent(
name="Image generator",
instructions="Always use the image generation tool when the user asks for a new image.",
tools=[ImageGenerationTool(tool_config=image_config)],
)
tool_configで品質を"low"に設定しています。試すだけなら低品質で十分で、生成も速く料金も抑えられます。
生成結果の取り出しと保存は次の通りです。
for item in result.new_items:
if item.type != "tool_call_item":
continue
raw_call = item.raw_item
call_type = _get_field(raw_call, "type")
if call_type != "image_generation_call":
continue
img_result = _get_field(raw_call, "result")
if not isinstance(img_result, str):
continue
generated_image = True
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
tmp.write(base64.b64decode(img_result))
temp_path = tmp.name
- 画像は
image_generation_callタイプのツール呼び出しアイテムのresultフィールドに base64 文字列として入っています。base64.b64decode()でバイナリに戻して一時ファイルに書き出します。 - その後の
open_file()は OS ごとに適切なコマンド(macOS ならopen、Windows ならos.startfile、Linux ならxdg-open)で画像ビューアを開くヘルパーです。 from examples.auto_mode import is_auto_mode— examples 共通のユーティリティで、環境変数EXAMPLES_INTERACTIVE_MODE=autoが設定されていると対話やファイルオープンを省略します(CI などの自動実行用)。この import があるため、このファイルはモジュール形式で実行する必要があります。
実行手順
OPENAI_API_KEYが設定されていることを確認します。examples.auto_modeを import しているため、リポジトリルートから モジュール形式 で実行します。uv run python -m examples.tools.image_generator
期待される動作・出力例
Generating image, this may take a while... と表示され、しばらく待つと Saved generated image to: /tmp/....png と出力されて、「ピザを食べるカエル(コミック風)」の画像が既定のビューアで開きます。
つまずきポイント
- 画像生成は数十秒かかることがあります。フリーズではないので待ちましょう。
- OpenAI の画像生成モデルの利用には、アカウント側で組織の本人確認が求められる場合があります。API エラーになる場合はダッシュボードの組織設定を確認してください。
computer_use.py
ComputerTool を使った computer use(モデルが画面のスクリーンショットを見てマウス・キーボード操作を決め、ブラウザを自動操作する仕組み)の example です。hosted tools と異なり、ブラウザの操作はあなたのマシン上で Playwright(ブラウザ自動操作ライブラリ)が実行します。モデルは「座標 (x, y) をクリックせよ」といった操作指示を返すだけです。
コード解説
ファイル冒頭に実行方法が明記されています。
# How to run this example:
# uv run python -m playwright install chromium
# uv run -m examples.tools.computer_use
この example の主役は、AsyncComputer インターフェースを Playwright で実装した LocalPlaywrightComputer クラスです。
class LocalPlaywrightComputer(AsyncComputer):
"""A computer, implemented using a local Playwright browser."""
AsyncComputer は「コンピュータとはこういう操作ができるものだ」という約束事(インターフェース)で、以下のようなメソッドの実装が求められます。
@property
def dimensions(self) -> tuple[int, int]:
return (1024, 768)
async def screenshot(self) -> str:
"""Capture only the viewport (not full_page)."""
png_bytes = await self.page.screenshot(full_page=False)
return base64.b64encode(png_bytes).decode("utf-8")
async def click(
self, x: int, y: int, button: Button = "left", *, keys: list[str] | None = None
) -> None:
playwright_button: Literal["left", "middle", "right"] = "left"
# Playwright only supports left, middle, right buttons
if button in ("left", "right", "middle"):
playwright_button = button # type: ignore
async with self._hold_keys(keys):
await self.page.mouse.click(x, y, button=playwright_button)
screenshot()が返す画像をモデルが「見て」、次の操作(クリック位置など)を決めます。SDK が「モデルの指示 → あなたの実装の呼び出し → スクリーンショットをモデルに返す」というループを自動で回します。click/double_click/scroll/type/keypress/drag/move/waitがそれぞれ Playwright の対応 API に変換されています。CUA_KEY_TO_PLAYWRIGHT_KEY辞書は、モデルが使うキー名("ctrl"など)を Playwright のキー名("Control")に変換する対応表です。- 操作対象はファイル内に埋め込まれた「東京の天気デモページ」(
DEMO_PAGE_HTML)です。外部サイトに依存せず安全に試せるようになっています。環境変数COMPUTER_USE_START_URLを設定すると任意の URL を開けます。
エージェント定義とツール登録は次の通りです。
agent = Agent(
name="Browser user",
instructions=AGENT_INSTRUCTIONS,
tools=[ComputerTool(computer=computer_config)],
# GPT-5.4 uses the built-in Responses API computer tool.
model="gpt-5.5",
model_settings=ModelSettings(tool_choice="required"),
)
ComputerTool(computer=...)に自作の Computer 実装を渡します。tool_choice="required"は「必ずツールを使え」という強制設定です。
さらにこの example は、Computer インスタンスの管理方法を 2 パターン示しています。
async def singleton_computer() -> None:
# Use a shared computer when you do not expect to run multiple agents concurrently.
async with LocalPlaywrightComputer() as computer:
await run_agent(computer)
async def computer_per_request() -> None:
# Initialize a new computer per request to avoid sharing state between runs.
async def create_computer(*, run_context: RunContextWrapper[Any]) -> LocalPlaywrightComputer:
print(f"Creating computer for run context: {run_context}")
return await LocalPlaywrightComputer().open()
async def dispose_computer(
*,
run_context: RunContextWrapper[Any],
computer: LocalPlaywrightComputer,
) -> None:
print(f"Disposing computer for run context: {run_context}")
await computer.close()
await run_agent(
ComputerProvider[LocalPlaywrightComputer](
create=create_computer,
dispose=dispose_computer,
)
)
- singleton モード: 1 つのブラウザを使い回す方式。並行実行しないならこれで十分です。
- ComputerProvider モード(既定): 実行(run)のたびにブラウザを新規作成・破棄するファクトリ方式。複数の実行の間で状態を共有したくない場合に使います。コマンドライン引数
singletonを付けない限りこちらが動きます。
実行手順
OPENAI_API_KEYが設定されていることを確認します。事前準備(必須): Playwright 用の Chromium ブラウザをダウンロードします(Playwright ライブラリ自体は
uv syncで導入済みです)。uv run python -m playwright install chromiumexample を実行します(モジュール形式)。
uv run python -m examples.tools.computer_use1 つのブラウザを使い回す singleton モードで試す場合は引数を付けます。
uv run python -m examples.tools.computer_use singleton(任意)ブラウザの動きを画面で見たい場合は、ヘッドレスモード(画面を表示しない実行モード)を無効化します。
COMPUTER_USE_HEADLESS=0 uv run python -m examples.tools.computer_use
期待される動作・出力例
Creating computer for run context: ... に続き、モデルがスクリーンショットを解析して「Refresh forecast」ボタンをクリックし、ページに表示された東京の天気(22C、風速 37 km/h など)を要約した文章が最後に表示されます。
つまずきポイント
playwright install chromiumを忘れると起動時にエラーになります。ライブラリのインストール(uv sync)とブラウザ本体のダウンロードは別作業です。- computer use は「スクリーンショット → 操作 → スクリーンショット」のループを繰り返すため、実行に時間がかかり、トークン消費も多めです。
- モデルが座標を誤るなど、操作が常に成功するとは限りません。実運用では後述の安全チェックや承認フローとの組み合わせが重要です。
computer use のループを図示すると次のようになります。
shell.py
ShellTool の ローカルモード の example です。モデルが「実行したいシェルコマンド」を提案し、あなたが書いた executor がローカルで実行 します。実行前にターミナルで承認を求める安全設計になっています。
コード解説
executor は「コマンド実行の依頼(ShellCommandRequest)を受け取り、結果(ShellResult)を返す」呼び出し可能オブジェクトとして実装します。
class ShellExecutor:
"""Executes shell commands; approval is handled via ShellTool."""
def __init__(self, cwd: Path | None = None):
self.cwd = Path(cwd or Path.cwd())
async def __call__(self, request: ShellCommandRequest) -> ShellResult:
action = request.data.action
outputs: list[ShellCommandOutput] = []
for command in action.commands:
proc = await asyncio.create_subprocess_shell(
command,
cwd=self.cwd,
env=os.environ.copy(),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
request.data.action.commandsにモデルが提案したコマンドのリストが入っています。それをasyncio.create_subprocess_shellで 1 つずつ実行し、標準出力・標準エラー・終了コードをShellCommandOutputに詰めて返します。- タイムアウト処理(
action.timeout_ms)も実装されており、時間切れの場合はoutcomeが"timeout"になります。
承認フローは ShellTool のパラメータで宣言します。
agent = Agent(
name="Shell Assistant",
model=model,
instructions=(
"You can run shell commands using the shell tool. "
"Keep responses concise and include command output when helpful."
),
tools=[
ShellTool(
executor=ShellExecutor(),
needs_approval=True,
on_approval=on_shell_approval,
)
],
model_settings=ModelSettings(tool_choice="required"),
)
needs_approval=True— このツールの実行には承認が必要、という宣言です。on_approval=on_shell_approval— 承認が必要になった瞬間に呼ばれるコールバック関数です。この example では、実行予定のコマンドを表示してinput()でy/Nを尋ね、{"approve": True/False, "reason": ...}を返します。コールバック方式 なので、Runner.run()の呼び出しは 1 回のままで、承認処理が実行の内側で完結します(次の example と比較してください)。- 環境変数
SHELL_AUTO_APPROVE=1を設定すると、確認プロンプトをスキップして常に承認します。
実行手順
OPENAI_API_KEYが設定されていることを確認します。次のコマンドを実行します。
uv run python examples/tools/shell.pyプロンプトやモデルを変えたい場合は引数を渡せます。
uv run python examples/tools/shell.py --prompt "Show the current date." --model gpt-5.6-solShell command approval required:と表示されたら、実行されるコマンドを確認してyを入力します。- (任意)確認なしで自動承認する場合は
SHELL_AUTO_APPROVE=1を設定して実行します。
期待される動作・出力例
既定のプロンプトは「カレントディレクトリのファイル一覧を表示して」です。ls 系のコマンドが提案され、承認するとコマンドが実行され、Final response: としてファイル一覧の要約が表示されます。
つまずきポイント
- モデルが提案するコマンドは毎回変わり得ます。承認前に必ず内容を確認する習慣をつけてください。この example が承認フローをデフォルトにしているのは、シェル実行が本質的に危険な操作だからです。
nで拒否するとツール呼び出しは実行されず、その旨がモデルに伝わります。
shell_human_in_the_loop.py
shell.py と同じ ShellTool ローカル実行ですが、承認の方式が異なります。on_approval コールバックの代わりに、interruption(中断)方式 の human-in-the-loop を実装しています。実行が承認待ちでいったん停止し、承認・拒否を登録してから実行を再開する、より本格的なパターンです。
コード解説
ShellExecutor は shell.py とほぼ同一です。違いはツール定義と実行ループにあります。
tools=[
ShellTool(
executor=ShellExecutor(),
needs_approval=True,
)
],
needs_approval=Trueのみでon_approvalを渡していません。この場合、承認が必要になると 実行がそこで中断され、結果オブジェクトのinterruptionsに承認待ちアイテムが入ります。
result = await Runner.run(agent, prompt)
while result.interruptions:
print("\n== Pending approvals ==")
state = result.to_state()
for interruption in result.interruptions:
commands = _extract_commands(interruption)
approved, always = await prompt_shell_approval(commands)
if approved:
state.approve(interruption, always_approve=always)
else:
state.reject(interruption, always_reject=always)
result = await Runner.run(agent, state)
result.interruptionsが空でない間、承認待ちのループを回します。result.to_state()で実行状態(RunState)を取り出し、state.approve(...)/state.reject(...)で個々の承認待ちに対する判断を登録します。always_approve=Trueを渡すと「以降の同種の呼び出しはすべて承認」になります。Runner.run(agent, state)に 状態オブジェクトを渡して再実行 すると、中断地点から続きが実行されます。この「中断 → 状態保存 → 再開」の形は、Web アプリで「ユーザーの承認ボタンを待つ」ような非対話環境でも使える汎用パターンです。- 承認プロンプトには
examples.auto_modeのconfirm_with_fallback()を使っており、環境変数EXAMPLES_INTERACTIVE_MODE=autoのときは自動で既定の回答を返します。
実行手順
OPENAI_API_KEYが設定されていることを確認します。examples.auto_modeを import しているため、モジュール形式で実行します。uv run python -m examples.tools.shell_human_in_the_loop== Pending approvals ==と実行予定コマンドが表示されたらy/nを入力します。続けて「今後のシェル呼び出しをすべて承認するか」も聞かれます。
期待される動作・出力例
既定プロンプトは「ファイル一覧とカレントディレクトリを表示して」です。承認するとコマンドが実行され、Final response: に結果の要約が表示されます。拒否した場合は、モデルが「実行が拒否された」ことを踏まえた応答を返します。
つまずきポイント
on_approvalコールバック方式(shell.py)と interruption 方式(本ファイル)の使い分け: その場で同期的に判断できるなら前者、承認を別の画面・別のタイミングに委ねたいなら後者が向いています。while result.interruptions:のループを忘れると、承認待ちのまま最終出力が得られません。
2 つの承認方式の違いを図で比較すると次のようになります。
apply_patch.py
ApplyPatchTool を使って、モデルに ファイルの作成・更新・削除を diff(差分)形式で行わせる example です。エディタ実装(ApplyPatchEditor 相当)を自作し、ワークスペース外への書き込み防止と操作ごとの承認を組み込んでいます。
コード解説
エディタは create_file / update_file / delete_file の 3 メソッドを実装します。
def create_file(self, operation: ApplyPatchOperation) -> ApplyPatchResult:
relative = self._relative_path(operation.path)
self._require_approval(operation, relative)
target = self._resolve(operation.path, ensure_parent=True)
diff = operation.diff or ""
content = apply_diff("", diff, mode="create")
target.write_text(content, encoding="utf-8")
return ApplyPatchResult(output=f"Created {relative}")
operation.diffにはモデルが生成した差分テキストが入っています。SDK 提供のヘルパーapply_diff(元テキスト, diff)を使うと、差分を適用した後のテキストが得られます。新規作成時はmode="create"で空文字列に対して適用します。_resolve()は、パスがワークスペース(一時ディレクトリ)の外を指していないか検証するガードです。target.relative_to(self._root)が失敗したら例外を投げます。モデルにファイル操作をさせる場合、この種の境界チェックは必須の防御策です。_require_approval()は操作内容(種類・パス・diff)の SHA-256 ハッシュを「指紋」として記録し、同じ操作の再承認を省きつつ、新しい操作にはターミナルで承認を求めます。
エージェント側の設定で興味深いのは previous_response_id の利用です。
result = await Runner.run(
agent,
"Create tasks.md with a shopping checklist of 5 entries.",
previous_response_id=previous_response_id,
)
previous_response_id = result.last_response_id
previous_response_idは Responses API の会話継続機能で、前回の応答 ID を渡すことで会話履歴を再送信せずに文脈を引き継げます。1 回目(tasks.md の作成)の文脈を保ったまま、2 回目(最後の 2 項目にチェックを付ける更新)を依頼しています。- 2 回目のプロンプトでは、現在のファイル内容を
<BEGIN_FILES> ... <END_FILES>で囲んで渡しています。モデルが正確な diff を作るには元ファイルの内容が必要だからです。
実行手順
OPENAI_API_KEYが設定されていることを確認します。examples.auto_modeを import しているため、モジュール形式で実行します。uv run python -m examples.tools.apply_patch[apply_patch] approval requiredと diff プレビューが表示されたら、内容を確認してyを入力します(作成と更新で計 2 回程度)。(任意)承認を省略する場合は
--auto-approveフラグ、または環境変数APPLY_PATCH_AUTO_APPROVE=1を使います。uv run python -m examples.tools.apply_patch --auto-approve
期待される動作・出力例
一時ディレクトリに tasks.md が作成され、買い物チェックリスト 5 項目が書き込まれます。続いて 2 回目の実行で最後の 2 項目が [x] にチェックされ、最終的なファイル内容がターミナルに表示されます。ワークスペースは一時ディレクトリなので、終了後に自動で消えます。
つまずきポイント
- 操作対象は
tempfile.TemporaryDirectoryで作られる一時ディレクトリです。自分のプロジェクトのファイルが書き換わることはありません。 - モデルの diff が元ファイルと食い違うと適用に失敗することがあります。「更新前に必ず最新のファイル内容をプロンプトで渡す」のが安定運用のコツです。
skills/csv-workbench(シェル用スキルの定義)
次の 3 つの example が共通で使う「スキル」の定義ディレクトリです。スキルとは、エージェントに特定の作業手順を教えるための文書の束(SKILL.md と補助ファイル)で、シェル環境にマウント(配置)して使います。
SKILL.md は YAML フロントマター(ファイル冒頭のメタデータ)と手順書からなります。
---
name: csv-workbench
description: Analyze CSV files in /mnt/data and return concise numeric summaries.
---
# CSV Workbench
Use this skill when the user asks for quick analysis of tabular data.
## Workflow
1. Inspect the CSV schema first (`head`, `python csv.DictReader`, or both).
2. Compute requested aggregates with a short Python script.
3. Return concise results with concrete numbers and units when available.
## Constraints
- Prefer Python stdlib for portability.
- If data is missing or malformed, state assumptions clearly.
- Keep the final answer short and actionable.
同梱の playbook.md には、行数カウントやグループ集計のためのシェル・Python スニペット集が書かれています。エージェントはスキルを読み込むことで「CSV 分析はこの手順とスニペットでやる」という知識を獲得します。
local_shell_skill.py
上記の csv-workbench スキルを、ローカルの ShellTool に組み込む example です。スキルのファイルをローカルパスで指定し、モデルがスキルの手順書に従ってローカルでシェルコマンドを実行します。
コード解説
def build_local_skill() -> ShellToolLocalSkill:
return {
"name": SKILL_NAME,
"description": "Analyze CSV files and return concise numeric summaries.",
"path": str(SKILL_DIR),
}
- ローカルスキルは
name/description/path(スキルディレクトリのパス)の 3 項目を持つ辞書(ShellToolLocalSkill型)です。
tools=[
ShellTool(
environment={
"type": "local",
"skills": [local_skill],
},
executor=ShellExecutor(),
)
],
environment={"type": "local", "skills": [...]}— ShellTool の実行環境を「ローカル + スキル付き」と宣言します。ローカルモードではexecutorの指定が必須です(ソースコードの検証ロジックで、未指定だとUserErrorになります)。from examples.tools.shell import ShellExecutor— executor は shell.py のものを再利用しています。この import があるため モジュール形式での実行が必要 です。- example 後半では、スキルなしの 2 つ目のエージェントが
/tmp/test_orders.csvをlsで確認し、1 つ目のエージェントが作ったファイルがローカルに実在することを確かめています。
実行手順
OPENAI_API_KEYが設定されていることを確認します。モジュール形式で実行します。
uv run python -m examples.tools.local_shell_skill
期待される動作・出力例
エージェントが csv-workbench スキルの手順に従って /tmp/test_orders.csv を作成し、地域別の合計金額と失敗注文数を集計した要約を出力します。続けて 2 つ目のエージェントが ls -la /tmp/test_orders.csv の結果を 1 文で要約します。
つまずきポイント
- このモードではコマンドが 実際にあなたのマシンで実行され、/tmp にファイルが作られます。この example には承認フローがないので、内容を理解した上で実行してください。
container_shell_inline_skill.py
同じ csv-workbench スキルを、今度は OpenAI がホストするコンテナ内の ShellTool で使う example です。スキルディレクトリを ZIP に固めて base64 で埋め込み(インラインスキル)、リクエストと一緒にサーバーへ送ります。さらに、1 つ目の実行で作られたコンテナを 2 つ目のエージェントが 再利用 する方法も示します。
コード解説
def build_inline_skill() -> ShellToolInlineSkill:
bundle = build_skill_zip_bundle()
return {
"type": "inline",
"name": SKILL_NAME,
"description": "Analyze CSV files in /mnt/data and return concise numeric summaries.",
"source": {
"type": "base64",
"media_type": "application/zip",
"data": base64.b64encode(bundle).decode("ascii"),
},
}
build_skill_zip_bundle()がスキルディレクトリを ZIP 圧縮し、その中身を base64 文字列にしてsourceに埋め込みます。スキルの実体ごとサーバーに送る方式です。
tools=[
ShellTool(
environment={
"type": "container_auto",
"network_policy": {"type": "disabled"},
"skills": [inline_skill],
}
)
],
"type": "container_auto"— OpenAI 側でコンテナを自動作成するモードです。hosted モードなのでexecutorは書きません(公式ドキュメントも、hosted 環境ではexecutor/needs_approval/on_approvalを設定しないよう明記しています)。"network_policy": {"type": "disabled"}— コンテナからの外部ネットワークアクセスを遮断します。allowlistモードで特定ドメインだけ許可することもできます。
コンテナ再利用のための ID 抽出が次の関数です。
def extract_container_id(raw_responses: list[ModelResponse]) -> str | None:
for response in raw_responses:
for item in response.output:
if isinstance(item, ResponseFunctionShellToolCall) and isinstance(
item.environment, ResponseContainerReference
):
return item.environment.container_id
return None
- API の生応答(
result.raw_responses)からシェルツール呼び出しを探し、実行に使われたコンテナの ID(cntr_...)を取り出します。 - 2 つ目のエージェントは
environment={"type": "container_reference", "container_id": container_id}を指定して同じコンテナに接続し、ls -la /mnt/dataで 1 つ目のエージェントが作ったファイルが残っていることを確認します。コンテナ内の状態(ファイル)が実行をまたいで保持されることの実演です。
実行手順
OPENAI_API_KEYが設定されていることを確認します。次のコマンドを実行します。
uv run python examples/tools/container_shell_inline_skill.py
期待される動作・出力例
1 つ目のエージェントがコンテナ内の /mnt/data/orders.csv を作成して地域別集計を報告し、[info] Reusing container_id=cntr_... の後、2 つ目のエージェントが同じコンテナの /mnt/data の一覧を 1 文で要約します。
つまずきポイント
- コマンドは OpenAI のコンテナ内で実行されるため、あなたのマシンにファイルは作られません(ローカル版との対比を意識してください)。
- コンテナの生成・稼働には課金が発生し得ます。また、コンテナには有効期限があるため、時間を置いて
container_referenceで再接続するとエラーになることがあります。
同じスキルが 2 つの経路に分かれる様子を図にすると次のようになります。
container_shell_skill_reference.py
インラインスキルと対になる方式で、事前に OpenAI プラットフォームへ登録済みのスキルを ID で参照 して hosted コンテナにマウントする example です。
コード解説
DEFAULT_SKILL_REFERENCE: ShellToolSkillReference = {
"type": "skill_reference",
"skill_id": "skill_698bbe879adc81918725cbc69dcae7960bc5613dadaed377",
"version": "1",
}
def resolve_skill_reference() -> ShellToolSkillReference:
skill_id = os.environ.get(SHELL_SKILL_ID_ENV)
if not skill_id:
return DEFAULT_SKILL_REFERENCE
reference: ShellToolSkillReference = {"type": "skill_reference", "skill_id": skill_id}
skill_version = os.environ.get(SHELL_SKILL_VERSION_ENV)
if skill_version:
reference["version"] = skill_version
return reference
- スキル参照は
{"type": "skill_reference", "skill_id": "skill_...", "version": "..."}という形式の辞書です。ZIP を毎回送るインライン方式と違い、登録済みスキルを ID で指すだけなので軽量で、チームでの共有・バージョン管理に向きます。 - 環境変数
OPENAI_SHELL_SKILL_ID(と任意のOPENAI_SHELL_SKILL_VERSION)で自分のスキル ID に差し替えられます。ファイル内のデフォルト ID はサンプル値であり、あなたのアカウントからは参照できない可能性が高い 点に注意してください。 - エージェント定義・コンテナ再利用のコードは container_shell_inline_skill.py と同一構造です。
実行手順
OPENAI_API_KEYが設定されていることを確認します。- 事前準備: OpenAI プラットフォームに自分のスキルをアップロードし、スキル ID(
skill_...)を取得します(プラットフォームの Skills ガイド参照)。 取得した ID を環境変数で渡して実行します。
OPENAI_SHELL_SKILL_ID=skill_xxxxxxxx uv run python examples/tools/container_shell_skill_reference.py
期待される動作・出力例
[info] Using skill reference: skill_... (version 1) と表示された後の流れはインライン版と同じで、コンテナ内での CSV 作成・集計と、コンテナ再利用による ls の要約が出力されます。
つまずきポイント
- デフォルトのスキル ID のまま実行すると、スキルが見つからない旨の API エラーになることがあります。必ず自分で登録したスキル ID を環境変数で指定してください。
tool_search.py
ToolSearchTool による tool search(ツールの遅延読み込み) の example です。エージェントに大量のツールを持たせると、全ツールの定義(スキーマ)が毎回プロンプトに含まれてトークンを浪費します。tool search を使うと、ツールを「隠して」おき、モデルが必要になった時に検索して読み込む ことができます。
コード解説
ツール側は defer_loading=True を付けた function tool として定義します。
@tool(defer_loading=True)
def get_customer_profile(
customer_id: Annotated[str, "The CRM customer identifier to look up."],
) -> str:
"""Fetch a CRM customer profile."""
return json.dumps(CUSTOMER_PROFILES[customer_id], indent=2)
defer_loading=True— このツールは最初はモデルに見えず、ToolSearchTool経由で読み込まれるまで使えません。
関連ツールは 名前空間(namespace) でグループ化できます。
crm_tools = tool_namespace(
name="crm",
description="CRM tools for customer lookups.",
tools=[get_customer_profile, list_open_orders],
)
billing_tools = tool_namespace(
name="billing",
description="Billing tools for invoice lookups.",
tools=[get_invoice_status],
)
namespaced_agent = Agent(
name="Operations assistant",
model="gpt-5.6-sol",
instructions=(
"For customer questions in this example, load the full `crm` namespace with no query "
"filter before calling tools. "
"Do not search `billing` unless the user asks about invoices."
),
model_settings=ModelSettings(parallel_tool_calls=False),
tools=[*crm_tools, *billing_tools, ToolSearchTool()],
)
tool_namespace(name=..., description=..., tools=[...])— 「crm」「billing」のような業務単位でツールをまとめます。モデルは名前空間の説明だけを最初に見て、必要なグループをまとめて読み込みます。公式ドキュメントは「可能な限り名前空間を使う」ことと「1 名前空間は 10 個未満に保つ」ことを推奨しています。tools=[*crm_tools, *billing_tools, ToolSearchTool()]— 名前空間を展開して並べ、ToolSearchTool()を 1 つだけ 追加します。これが検索の入り口になります。- もう 1 つの
top_level_agentは、名前空間を使わずトップレベルの deferred ツール(get_shipping_etaなど)を個別に検索するパターンを示します。
実行結果の分析部分では、result.new_items の中の tool_search_output_item を調べて「どのツール/名前空間が実際に読み込まれたか」を集計し、registered(登録済み)/ loaded(読み込まれた)/ untouched(読み込まれなかった) を表示します。「billing は聞かれていないので読み込まれない」ことを確認するのが狙いです。
実行手順
OPENAI_API_KEYが設定されていることを確認します。tool search は OpenAI Responses モデル専用で、openaiパッケージ 2.25.0 以上が必要です(uv sync済みなら条件を満たしています)。次のコマンドを実行します。
uv run python examples/tools/tool_search.py名前空間の例だけ・トップレベルの例だけを実行することもできます。
uv run python examples/tools/tool_search.py namespace uv run python examples/tools/tool_search.py top-level
期待される動作・出力例
## Tool search with namespaces のセクションで、顧客 customer_42(Avery Chen)のプロフィールと注文一覧が表示され、Loaded paths に loaded: crm / untouched: billing のような集計が出ます。続く top-level の例では、追跡番号 ZX-123 の到着予定(2026-03-06 14:00 JST)が回答され、get_shipping_credit_balance が untouched のままであることが確認できます。
つまずきポイント
defer_loading=Trueのツールを使う場合、ToolSearchTool()を必ず一緒に登録してください。検索手段がないとそのツールは永遠に使えません。tool_choiceで名前空間名や deferred のみのツール名を直接指定することはできません(auto/required/ 実在するトップレベルのツール名を使います)。
tool search の仕組みを図にすると次のようになります。
programmatic_tool_calling.py
v0.19.0 で追加された example です。 モデルが JavaScript のプログラムを生成し、そのプログラムからツールを呼ぶという新しい呼び出し方式(Programmatic Tool Calling)を扱います。3 SKU × 3 ツール= 9 回のツール呼び出しを並列実行し、その場で在庫の発注量を計算させる在庫補充プランナーです。
従来の方式では「モデルがツールを 1 つ呼ぶ → 結果がモデルに戻る → モデルが次のツールを呼ぶ」とツール 1 回ごとにモデルとの往復(ラウンドトリップ)が発生しました。Programmatic Tool Calling では、モデルが一度だけプログラムを書き、そのプログラムがループ・分岐・並列呼び出し・途中計算をこなして、最終結果 1 個だけをモデルに返します。「呼ぶ順番と組み合わせ方が決まっている定型作業」で効きます。
生成されたプログラムは、OpenAI 側のまっさらな V8 環境で実行されます。Node.js の API もファイルシステムもネットワークもなく、プロセスも永続しません。プログラムが触れるのは、あなたが明示的に許可したツールだけです。
コード解説
ツール側は allowed_callers で「プログラムからのみ呼べる」ことを宣言します。
@tool(allowed_callers=["programmatic"])
def get_inventory(sku: Sku) -> InventoryOutput:
"""Return the currently available units for one SKU."""
print(f"[tool] get_inventory({sku})")
return InventoryOutput(sku=sku, available_units=inventory[sku])
allowed_callersは「そのツールを誰が呼べるか」の指定です。省略するとモデルの直接呼び出しのみ、["programmatic"]でプログラム専用、["direct", "programmatic"]で両方を許可します- 戻り値の型注釈に Pydantic モデル(ここでは
InventoryOutput)を使うと、strict なオブジェクト出力スキーマが自動生成され、返り値はプログラムに渡される前にそのスキーマで検証されます。注釈が使えない場合はoutput_type=...、既に strict なスキーマを持っている場合はoutput_json_schema={...}を使います(両者は排他)。str/Any/Noneの注釈ではスキーマは作られません - 使えるツール型は
FunctionTool/CustomTool/ShellTool/ApplyPatchTool/HostedMCPTool/CodeInterpreterToolです。前 4 つはallowed_callersを直接指定でき、hosted MCP と code interpreter はtool_configの中に書きます
エージェント側は ProgrammaticToolCallingTool() を 1 つ足し、tool_choice で方式を指定します。
agent = Agent(
name="Replenishment planner",
model="gpt-5.6",
instructions="""
<tool_orchestration>
Use Programmatic Tool Calling to prepare a replenishment plan for desk-lamp,
ergonomic-keyboard, and usb-c-dock. For every SKU, call get_inventory,
get_weekly_demand, and get_inbound_units. Create all nine tool-call promises
before awaiting them, then run them concurrently with one Promise.all call.
...
</tool_orchestration>
""".strip(),
model_settings=ModelSettings(tool_choice="programmatic_tool_calling"),
tools=[
get_inventory,
get_weekly_demand,
get_inbound_units,
ProgrammaticToolCallingTool(),
],
)
ProgrammaticToolCallingTool()は 1 エージェントにつき最大 1 つです。加えて、プログラムから呼べるツールが最低 1 つ必要です(あるいは名前空間・遅延ツール・遅延 hosted MCP を持つToolSearchTool())。中身のないToolSearchTool()だけの構成は拒否されますinstructionsが「9 個の promise を先に全部作ってからPromise.allで一度に待て」「安全在庫は 5、reorder_units = max(forecast + 5 - available - inbound, 0)で計算せよ」とプログラムの書き方まで指示している点に注目してください。生成されるのはコードなので、指示も「アルゴリズムの仕様書」として書きますtool_choice="programmatic_tool_calling"で、直接呼び出しではなくプログラム生成を強制しています
実行結果からは、生成されたプログラム本体とプログラム経由の呼び出し記録を取り出せます。
programmatic_calls: list[str] = []
for item in result.new_items:
if not isinstance(item, ToolCallItem):
continue
raw_item = item.raw_item
if isinstance(raw_item, Program):
print(f"\nGenerated program:\n{raw_item.code}\n")
elif (
isinstance(raw_item, ResponseFunctionToolCall)
and raw_item.caller is not None
and raw_item.caller.type == "program"
):
programmatic_calls.append(raw_item.name)
- 生成されたプログラムは
Program型のraw_itemとしてToolCallItemに入り、.codeで JavaScript のソースそのものを読めます。モデルが何を書いたかを人間が検査できるのがこの方式の重要な性質です - プログラムから呼ばれた子ツールは
raw_item.caller.type == "program"で判別できます。SDK は親プログラムと子呼び出しの関係を保持します - プログラム経由の呼び出しでも、Runner の通常のライフサイクルはそのまま効きます。ツールの入出力 guardrail・フック・タイムアウト・同時実行数制限・承認・セッション・
RunStateの中断/再開はすべて従来どおり動作します
実行手順
OPENAI_API_KEYが設定されていることを確認します。次のコマンドを実行します。
uv run python examples/tools/programmatic_tool_calling.py
期待される動作・出力例
[tool] get_inventory(desk-lamp) のようなツール実行ログが 9 行分(順不同・並列実行のため)流れ、続いて Generated program: としてモデルが書いた JavaScript が表示されます。最後に Programmatic calls: get_inventory, get_weekly_demand, ... と、発注推奨をまとめた最終回答が出ます。
つまずきポイント
- Responses API の対応モデル専用です。 Chat Completions 系のモデルや非 Responses バックエンドでは、
ProgrammaticToolCallingTool()とtool_choice="programmatic_tool_calling"は拒否されます ProgrammaticToolCallingTool()があると、モデルリクエストのリトライ条件が厳しくなります。プログラムが実行される前であっても、プロバイダー管理のリトライと WebSocket の事前イベントリトライは無効化されます。Runner のリトライポリシーは「プロバイダーが再送安全と明示した場合」だけ再試行し、retry_policies.network_error()単体ではこの境界を越えられません- 承認が要るツールや影響の大きいツールは、プログラムに渡さず直接呼び出しのままにしておくのが無難です。 プログラムの一部として一括実行されると、人間が 1 つずつ確認する余地がなくなります。なおプログラム経由の呼び出しが承認待ちで止まった場合も、
RunStateで中断を解決して元の実行を再開する通常の手順で対処できます - ツールの返り値スキーマがある場合、既定の失敗フォーマッタ(例外を自由文のテキストに変換する仕組み)は無効化されます。自由文はスキーマを満たさないためです。ハンドラの例外はそのまま伝播するので、スキーマに沿った JSON を返す
failure_error_functionを自分で用意してください - 公式プラットフォームガイド: Programmatic Tool Calling
この一連の流れを図にすると次のようになります。
codex.py
実験的機能 である Codex ツール(codex_tool)の example です。Codex CLI(OpenAI のコーディングエージェント用コマンドラインツール)をサブプロセスとして起動し、エージェントのツール呼び出しの中でワークスペース調査などのタスクを Codex に委任します。実験段階のため、API は今後変更される可能性があります。
コード解説
agent = Agent(
name="Codex Agent",
instructions=(
"Use the codex tool to inspect the workspace in read-only mode and answer the question. "
...
),
tools=[
# Run local Codex CLI as a sub process
codex_tool(
sandbox_mode="read-only",
default_thread_options=ThreadOptions(
model="gpt-5.5",
model_reasoning_effort="low",
network_access_enabled=True,
web_search_enabled=False,
approval_policy="never", # We'll update this example once the HITL is implemented
),
default_turn_options=TurnOptions(
# Abort Codex CLI if no events arrive within this many seconds.
idle_timeout_seconds=60,
),
on_stream=on_codex_stream,
)
],
)
codex_tool(...)はagents.extensions.experimental.codexからインポートします(agents直下ではありません)。sandbox_mode="read-only"— Codex にファイルの読み取りだけを許可します。書き込みを許すworkspace-writeなどのモードもあります。ThreadOptionsは Codex 側のセッション(スレッド)の既定設定(使用モデル、推論の深さ、ネットワークアクセス可否など)、TurnOptionsは 1 ターンごとの設定(アイドルタイムアウトなど)です。on_stream=on_codex_stream— Codex の実行中に発生するイベント(スレッド開始、推論、コマンド実行、ファイル変更、Web 検索など)を受け取るコールバックです。ファイル前半のon_codex_stream()は、イベントの型をisinstanceで判別してログ出力する網羅的なサンプルになっています。Codex が内部で何をしているかを可視化する仕組みとして参考になります。
main 処理では、リポジトリの pyproject.toml と AGENTS.md を Codex に読み取り専用で調査させ、対応 Python バージョンやローカル検証コマンドを要約させています。
実行手順
OPENAI_API_KEYが設定されていることを確認します(Codex 用にCODEX_API_KEYを別途設定することも可能です)。事前準備: Codex CLI が必要です。SDK は「環境変数
CODEX_PATH→ PATH 上のcodexコマンド → SDK 同梱のバイナリ」の順で実行ファイルを探します(ソースコードexec.pyの解決順より)。手元にcodexコマンドがない場合は、Node.js 環境で次のようにインストールできます。npm install -g @openai/codexリポジトリルートから実行します(Codex がカレントディレクトリのファイルを調査するため)。
uv run python examples/tools/codex.py
期待される動作・出力例
タイムスタンプ付きのログで codex thread started: ...、codex command item.completed: ... のようなイベントが流れ、最後に「対応 Python バージョンは 3.10 以上、テストコマンドは make check 系」といった要約と、codex resume <thread_id> でセッションを継続できる旨の案内が出力されます。
つまずきポイント
- 実験的機能 です。
agents.extensions.experimental配下の API は予告なく変わる可能性があります。 approval_policy="never"は「Codex 側の承認確認を行わない」設定です。この example は read-only なので安全ですが、書き込みモードで使う際は慎重に設定してください。
codex_same_thread.py
Codex ツールの応用で、複数回の Runner.run() をまたいで同じ Codex スレッド(会話セッション)を再利用する example です。1 回目の調査結果を踏まえて、2 回目に「さっきの内容を 1 文に要約して」と依頼できます。
コード解説
tools=[
codex_tool(
# Give each Codex tool a unique `codex_` name when you run multiple tools in one agent.
# Name-based defaults keep their run-context thread IDs separated.
name="codex_engineer",
sandbox_mode="read-only",
default_thread_options=ThreadOptions(
model="gpt-5.5",
model_reasoning_effort="low",
network_access_enabled=True,
web_search_enabled=False,
approval_policy="never",
),
on_stream=on_codex_stream,
# Reuse the same Codex thread across runs that share this context object.
use_run_context_thread_id=True,
)
],
use_run_context_thread_id=True— Codex のスレッド ID を run context(Runner.run(..., context=...)で渡す、実行間で共有できる状態オブジェクト)に保存・再利用します。name="codex_engineer"— ツール名を変えると、スレッド ID の保存キーもcodex_thread_id_engineerに変わります(既定名codexならcodex_thread_id)。1 つのエージェントに複数の Codex ツールを持たせるときの分離の仕組みです。
class MyContext(BaseModel):
something: str | None = None
# the default is "codex_thread_id"; missing this works as well
codex_thread_id_engineer: str | None = None # aligns with run_context_thread_id_key
context = MyContext()
- context は Pydantic モデルでも素の辞書でも構いません。1 回目の実行後にこのフィールドへスレッド ID が書き込まれ、2 回目の実行はその ID で既存スレッドに接続します。
- main の最後で、1 回目と 2 回目のスレッド ID が一致するか(
same thread reused: True)を検証しています。
実行手順
- codex.py と同じ準備(
OPENAI_API_KEY、Codex CLI)を整えます。 リポジトリルートから実行します。
uv run python examples/tools/codex_same_thread.py
期待される動作・出力例
Turn 1 で AGENTS.md の検証コマンド一覧が出力され、thread id after turn 1: <ID> が表示されます。Turn 2 では同じスレッドの文脈を使って 1 文の要約が返り、最後に same thread reused: True と表示されます。
つまずきポイント
- 2 回の
Runner.run()に 同じ context オブジェクト を渡すことが再利用の条件です。context を作り直すとスレッド ID が失われ、新しいスレッドが始まります。
まとめ
- ツールは「hosted tools(OpenAI サーバーで実行)」「ローカル実行ツール(モデルが指示し、あなたのコードが実行)」「function tools(自作 Python 関数)」に大別されます。
- hosted tools(
WebSearchTool/FileSearchTool/CodeInterpreterTool/ImageGenerationToolなど)はtools=[...]に置くだけで動きますが、OpenAI の Responses API 専用です。 FileSearchToolは ベクトルストア ID(vs_...)の事前準備が必須 です。example は OpenAI クライアントでストアを自動作成する手順も含んでいます。ComputerToolは Playwright のインストールとplaywright install chromiumが事前に必要 で、AsyncComputerインターフェース(screenshot / click / type など)を自分で実装します。ShellToolはローカル実行(executor 自作 +needs_approvalによる承認)と hosted コンテナ実行(container_auto/container_reference、スキルのマウント)の両モードを持ちます。承認には「on_approvalコールバック方式」と「interruption +state.approve()方式」の 2 パターンがあります。ApplyPatchToolは diff ベースのファイル編集をエージェントに任せる仕組みで、ワークスペース境界チェックと承認が安全運用の鍵です。ToolSearchTool+defer_loading=True+tool_namespace()で、大量ツールのスキーマを必要時のみ読み込み、トークンを節約できます。ProgrammaticToolCallingTool()+@tool(allowed_callers=["programmatic"])で、モデルが生成した JavaScript から複数ツールを並列に呼び、途中計算まで済ませて結果 1 個だけを返せます(v0.19.0 で追加)。ツール 1 回ごとのモデル往復がなくなる代わりに、Responses API 対応モデル限定・リトライ条件が厳格化・承認向きでない、という制約があります。codex_toolは Codex CLI をツールとして呼び出す実験的機能で、スレッド再利用による会話の継続もできます。
理解度チェック
WebSearchTool と、@tool で自作した検索関数の最も本質的な違いは何でしょうか。
FileSearchTool を使うために必ず渡さなければならないパラメータは何ですか。また、それを準備する 3 つのステップ(file_search.py の準備コード)を挙げてください。
examples/tools/computer_use.py を初めて実行する前に必要な準備コマンドは何ですか。また、computer use においてモデルとローカルコードはそれぞれ何を担当しますか。
ShellTool で needs_approval=True を指定したとき、on_approval コールバックを渡す場合と渡さない場合とで、承認フローはどう変わりますか。
エージェントに 50 個の function tools を持たせたところ、毎回のリクエストでトークン消費が大きくなりました。この章で学んだ仕組みでどう改善できますか。
解答と解説
問1 の解答と解説
答1. 実行場所と実装責任が異なります。WebSearchTool は hosted tool であり、検索処理は OpenAI のサーバー側で実行されるため、あなたは実装コードを一切書きません。一方 @tool の関数はあなたのマシン(Python プロセス)で実行され、検索ロジック・API 呼び出し・エラー処理をすべて自分で実装します。hosted tools は手軽ですが OpenAI モデル(Responses API)専用、function tools はどこでも動く代わりに実装が必要、というトレードオフです。
問2 の解答と解説
答2. 必須パラメータは vector_store_ids(ベクトルストア ID のリスト)です。準備は「① client.files.create() でファイルをアップロード → ② client.vector_stores.create() でベクトルストアを作成 → ③ client.vector_stores.files.create_and_poll() でファイルをストアに登録し索引化の完了を待つ」の 3 ステップです。
問3 の解答と解説
答3. uv run python -m playwright install chromium で Playwright 用の Chromium ブラウザをダウンロードしておく必要があります(Playwright ライブラリ自体は uv sync で導入済み)。役割分担は、モデルが「スクリーンショットを見て次の操作(クリック座標、入力文字など)を決める」、ローカルコード(AsyncComputer 実装 = LocalPlaywrightComputer)が「その操作を Playwright で実際に実行し、新しいスクリーンショットを返す」です。
問4 の解答と解説
答4. on_approval を渡した場合(shell.py)は、承認が必要になった瞬間にそのコールバックが呼ばれ、{"approve": bool, "reason": ...} を返せば実行が内側で継続します。Runner.run() は 1 回で完結します。渡さない場合(shell_human_in_the_loop.py)は実行が中断され、result.interruptions に承認待ちが入ります。result.to_state() で状態を取り出し、state.approve() / state.reject() を登録してから Runner.run(agent, state) で再開する必要があります。後者は承認を別画面・別タイミングに委ねられる汎用パターンです。
問5 の解答と解説
答5. tool search を使います。各ツールを @tool(defer_loading=True) で遅延読み込みにし、関連ツールを tool_namespace(name=..., description=..., tools=[...]) でグループ化(1 名前空間は 10 個未満が目安)した上で、エージェントの tools に ToolSearchTool() を 1 つ追加します。モデルは最初は名前空間の説明だけを見て、必要なグループだけを実行時に読み込むため、ツールスキーマのトークン消費を大幅に削減できます。
参考リンク
- Tools ガイド(英語): https://openai.github.io/openai-agents-python/tools/
- Tools ガイド(日本語): https://openai.github.io/openai-agents-python/ja/tools/
- Examples 一覧(英語): https://openai.github.io/openai-agents-python/examples/
- Examples 一覧(日本語): https://openai.github.io/openai-agents-python/ja/examples/
- 実行結果(RunResult)ガイド(英語): https://openai.github.io/openai-agents-python/results/
- 実行結果(RunResult)ガイド(日本語): https://openai.github.io/openai-agents-python/ja/results/
- Human-in-the-loop ガイド(英語): https://openai.github.io/openai-agents-python/human_in_the_loop/
- Human-in-the-loop ガイド(日本語): https://openai.github.io/openai-agents-python/ja/human_in_the_loop/
- ストリーミングガイド(英語): https://openai.github.io/openai-agents-python/streaming/
- ストリーミングガイド(日本語): https://openai.github.io/openai-agents-python/ja/streaming/
第4章 エージェント設計パターン(agent_patterns)
入力検査はチェーンの最初、出力検査は最終回答を出すエージェント、ツール検査は取り付けたツールが対象です。入力を検査してから本体を開始する変更例は次のとおりです(本章の math_guardrail のデコレータを置き換えます)。
@input_guardrail(run_in_parallel=False)
async def math_guardrail(context, agent, input):
result = await Runner.run(guardrail_agent, input, context=context.context)
return GuardrailFunctionOutput(
output_info=result.final_output,
tripwire_triggered=result.final_output.is_math_homework,
)検査用モデルの料金は発生します。承認で中断した場合は interruptions を確認し、result.to_state() に承認・拒否を記録して同じrunを再開します。新しいユーザーターンとして再送しません。公式:ガードレールと人による確認
この章で学ぶこと
- 複数のエージェントを組み合わせる「マルチエージェント設計」の代表的な 8 つのパターンと、それぞれを「いつ使うべきか」の判断基準
- コードでフローを制御する「決定的フロー(deterministic flow)」と、LLM に判断を任せる「ルーティング(routing)」の違い
- エージェントを別のエージェントから道具として呼び出す「agents as tools」パターンの基本と応用(条件付き有効化・ストリーミング・構造化入力)
asyncio.gatherによる並列実行と、生成結果を別の LLM に採点させる「LLM as a judge」パターン- 不正な入力・出力を自動検知して実行を止める guardrail(ガードレール)の仕組み
tool_choice="required"によるツール使用の強制と、人間の承認を挟む human-in-the-loop(HITL)フロー- サブエージェントの生成と統括を OpenAI のサーバー側に任せる実験的な hosted multi-agent 方式と、SDK の handoff / agents as tools との使い分け
前提知識
この章を読む前に、以下の内容を理解しておくことをおすすめします。
- 第1章: リポジトリのクローン、
uv syncによる環境構築、OPENAI_API_KEYの設定方法 - 第2章:
Agent(エージェントの定義)とRunner(エージェントの実行)の基本、output_typeによる構造化出力、Runner.run_streamedによるストリーミング実行 - 第3章:
function_toolデコレータによるツールの定義と、エージェントへのツールの登録方法
概念解説
一人の万能社員より、チームで分業する
ここまでの章では、1 つのエージェント(Agent。LLM に指示とツールを持たせた実行単位のこと)に仕事をさせる方法を学びました。しかし現実のアプリケーションでは、1 つのエージェントにすべてを任せると、指示文(instructions)が長くなりすぎて精度が落ちたり、間違った出力をそのままユーザーに返してしまったりする問題が起きます。
これは会社の仕事に似ています。一人の万能社員に「企画も執筆も校正も翻訳も全部やって」と頼むより、企画担当・執筆担当・校正担当に分業して、決まった手順で仕事を回す方が品質は安定します。マルチエージェント設計とは、この「分業と手順の設計」をエージェントで行うことです。
公式ドキュメント(docs/multi_agent.md)では、エージェントの協調(オーケストレーション)の方法を大きく 2 つに分類しています。
- コードによるオーケストレーション: どのエージェントをどの順番で動かすかを Python コードで固定する方法。速度・コスト・結果が予測しやすい
- LLM によるオーケストレーション: LLM 自身に「次に誰へ仕事を渡すか」を判断させる方法。柔軟だが結果は予測しにくい
この章の examples/agent_patterns ディレクトリは、この 2 つを組み合わせた「定石集」です。各パターンの位置づけを先にまとめておきます。
| パターン | 主な example | 制御の主体 | いつ使うべきか |
|---|---|---|---|
| 決定的フロー | deterministic.py | コード | 手順が固定で、各ステップの品質を確実に管理したいとき |
| ルーティング(handoff) | routing.py | LLM | 振り分け先の専門家に会話の主導権ごと渡したいとき |
| Agents as tools | agents_as_tools.py ほか | LLM(司令塔が保持) | 司令塔が最終回答に責任を持ち、専門家は下請けに徹するとき |
| 並列実行 | parallelization.py | コード | 互いに依存しないタスクを高速化したい、または複数案から選びたいとき |
| LLM as a judge | llm_as_a_judge.py | コード + LLM | 品質基準を満たすまで生成と評価を繰り返したいとき |
| ガードレール | input_guardrails.py ほか | SDK の仕組み | 不正な入力・出力を検知して実行を即座に止めたいとき |
| 強制ツール使用 | forcing_tool_use.py | 設定 | LLM の「自分で答えてしまう」挙動を禁止したいとき |
| Human-in-the-loop | human_in_the_loop.py ほか | 人間 | 送金・削除など、実行前に人間の承認が必須の操作があるとき |
この 8 パターンを「誰が制御するか」という軸で並べ直すと、次の図のようになります。
handoff と agents as tools の違い(この章の最重要ポイント)
似て非なる 2 つのパターンの違いを、電話窓口にたとえて説明します。
- handoff(ハンドオフ。会話の引き継ぎ): 総合受付が「その件は技術サポートにおつなぎします」と電話を転送するイメージです。転送後は技術サポートがあなたと直接話し、会話の主導権も履歴もそちらに移ります。
- agents as tools(エージェントのツール化): 総合受付が保留にして内線で専門家に確認し、自分の言葉であなたに回答するイメージです。会話の相手はずっと総合受付のままで、専門家は裏方として結果だけを返します。
公式ドキュメントの判断基準は明快です。専門家に会話そのものを引き継がせたいなら handoff、司令塔(オーケストレーター)が最終回答を統合・所有すべきなら agents as tools を使います。handoff の詳細は次の第5章で扱うため、この章では routing.py で全体像だけつかみ、agents as tools を深掘りします。
この違いを電話窓口の比喩のまま図にすると、「ユーザーとの対話がどちらへ繋がり続けるか」がはっきり見えます。
この章で登場する用語
- 非同期処理(async/await): 待ち時間の長い処理(API 呼び出しなど)の完了を待つ間に他の処理を進められる Python の仕組みです。examples はすべて
asyncio.run(main())で非同期関数を実行します - ストリーミング: LLM の応答を、全文完成を待たずに生成されたそばから少しずつ受け取る方式です
- トークン: LLM が文章を処理する最小単位(文字や単語の断片で、分割方法はモデルにより異なる)で、API 課金の単位でもあります
- トレース(trace): 一連のエージェント実行を 1 つの作業単位として記録する仕組みです。
with trace("名前"):で囲むと、OpenAI ダッシュボードの Traces 画面で複数エージェントの動きを 1 本の流れとして確認できます - コンテキスト(context): 実行時にエージェントやツールへ引き回されるアプリ独自のデータ(ユーザー情報や設定など)です
なお、多くの example は examples.auto_mode というリポジトリ内の共通ヘルパーを import しています。これは環境変数 EXAMPLES_INTERACTIVE_MODE=auto を設定したときに、キーボード入力の代わりに既定値を自動入力してくれる仕組みです(input_with_fallback は入力プロンプト、confirm_with_fallback は y/n 確認の自動化)。普通に実行すれば対話式に動くので、読者が特別な設定をする必要はありません。ただし、この import があるためファイルパス指定の直接実行ではなく python -m によるモジュール指定での実行が必要です(実行手順で後述します)。
各 example の解説
以降のすべての example は、リポジトリのルートディレクトリ(openai-agents-python/)で、環境変数 OPENAI_API_KEY を設定した状態で実行してください。
export OPENAI_API_KEY="sk-..." # 第1章参照
deterministic.py — 決定的フロー
物語の「アウトライン生成 → 品質チェック → 本文執筆」という 3 ステップを、コードで固定した順番どおりに実行する example です。各ステップを担当する 3 つのエージェントを、Python コードが順番に呼び出します。
いつ使うべきか: 業務の手順が事前に決まっていて、途中に「合格しなければ先に進ませない」品質ゲートを置きたいときに最適です。LLM に手順の判断を任せないため、実行時間・コスト・結果のばらつきが最小になります。
コード解説
まず 3 つのエージェントを定義します。ポイントは 2 番目のチェック担当エージェントに output_type(構造化出力。LLM の返答を自由文ではなく指定した型のデータとして受け取る機能)を指定していることです。
story_outline_agent = Agent(
name="story_outline_agent",
instructions="Generate a very short story outline based on the user's input.",
)
class OutlineCheckerOutput(BaseModel):
good_quality: bool
is_scifi: bool
outline_checker_agent = Agent(
name="outline_checker_agent",
instructions="Read the given story outline, and judge the quality. Also, determine if it is a scifi story.",
output_type=OutlineCheckerOutput,
)
story_agent = Agent(
name="story_agent",
instructions="Write a short story based on the given outline.",
output_type=str,
)
OutlineCheckerOutput は Pydantic(Python のデータ検証ライブラリ)のモデルで、「品質は良いか」「SF か」を 2 つの真偽値で返させます。自由文ではなく bool で受け取るからこそ、次の if 文でコードによる分岐が可能になる——これが決定的フローの核心です。
メイン処理では、前のエージェントの出力(final_output)を次のエージェントの入力として手渡ししていきます。
# Ensure the entire workflow is a single trace
with trace("Deterministic story flow"):
# 1. Generate an outline
outline_result = await Runner.run(
story_outline_agent,
input_prompt,
)
print("Outline generated")
# 2. Check the outline
outline_checker_result = await Runner.run(
outline_checker_agent,
outline_result.final_output,
)
# 3. Add a gate to stop if the outline is not good quality or not a scifi story
assert isinstance(outline_checker_result.final_output, OutlineCheckerOutput)
if not outline_checker_result.final_output.good_quality:
print("Outline is not good quality, so we stop here.")
exit(0)
if not outline_checker_result.final_output.is_scifi:
print("Outline is not a scifi story, so we stop here.")
exit(0)
print("Outline is good quality and a scifi story, so we continue to write the story.")
# 4. Write the story
story_result = await Runner.run(
story_agent,
outline_result.final_output,
)
print(f"Story: {story_result.final_output}")
with trace("Deterministic story flow"): で 3 回の Runner.run を囲むことで、3 つのエージェント実行がダッシュボード上で 1 つのワークフローとして記録されます。品質ゲート(ステップ 3)で不合格なら exit(0) で打ち切り、高価な本文執筆ステップを実行しません。
実行手順
リポジトリルートで以下を実行します。
uv run python -m examples.agent_patterns.deterministicWhat kind of story do you want?と聞かれるので、書いてほしい物語の内容(例:A sci-fi story about a robot)を入力します。
期待される動作・出力例
What kind of story do you want? A sci-fi story about a robot
Outline generated
Outline is good quality and a scifi story, so we continue to write the story.
Story: (生成された短編小説の本文)
SF 以外の題材(例: 恋愛小説)を入力すると、Outline is not a scifi story, so we stop here. と表示されて途中終了します。
つまずきポイント
- 入力を英語以外で書いても動作しますが、チェック担当の判定(
is_scifi)は LLM の解釈に依存するため、曖昧な題材では合否が実行のたびに変わることがあります。決定的なのは「フローの順序」であって「LLM の判定内容」ではない点に注意してください。
routing.py — ルーティング(handoff によるトリアージ)
最初に受け付ける「トリアージエージェント」(triage。緊急度や種別を見て振り分ける係のこと)が、ユーザーのメッセージの言語を判別し、フランス語・スペイン語・英語の専門エージェントへ handoff する example です。応答はストリーミングで表示されます。
いつ使うべきか: 「振り分け」自体が業務フローの一部で、振り分け先の専門エージェントがそのままユーザーと会話を続けるべきときに使います。カスタマーサポートの一次受付などが典型です。
コード解説
エージェント定義はシンプルで、トリアージエージェントの handoffs パラメータに委譲先の候補を並べるだけです。
french_agent = Agent(
name="french_agent",
instructions="You only speak French",
)
spanish_agent = Agent(
name="spanish_agent",
instructions="You only speak Spanish",
)
english_agent = Agent(
name="english_agent",
instructions="You only speak English",
)
triage_agent = Agent(
name="triage_agent",
instructions="Handoff to the appropriate agent based on the language of the request.",
handoffs=[french_agent, spanish_agent, english_agent],
)
メインループでは Runner.run_streamed でストリーミング実行し、テキストの断片(delta)が届くたびに画面へ出力します。
with trace("Routing example", group_id=conversation_id):
result = Runner.run_streamed(
agent,
input=inputs,
)
async for event in result.stream_events():
if not isinstance(event, RawResponsesStreamEvent):
continue
data = event.data
if isinstance(data, ResponseTextDeltaEvent):
print(data.delta, end="", flush=True)
elif isinstance(data, ResponseContentPartDoneEvent):
print("\n")
inputs = result.to_input_list()
print("\n")
if auto_mode:
break
user_msg = input_with_fallback("Enter a message: ", "Thanks!")
inputs.append({"content": user_msg, "role": "user"})
agent = result.current_agent
最後の 2 行が handoff の本質を示しています。result.to_input_list() でこれまでの会話履歴を取り出して次の入力とし、さらに result.current_agent(handoff 後に会話を担当しているエージェント)を次のターンの開始エージェントにする——つまり 2 回目以降の発言はトリアージを経由せず、引き継いだ専門エージェントに直接届きます。「会話の主導権が移る」という handoff の性質がコードにそのまま表れています。
実行手順
リポジトリルートで以下を実行します。
uv run python -m examples.agent_patterns.routing- 最初のメッセージ(例:
Hola, ¿cómo estás?)を入力します。 - 応答後、続けてメッセージを入力できます。終了は
Ctrl+Cです。
期待される動作・出力例
Hi! We speak French, Spanish and English. How can I help? Hola, ¿cómo estás?
¡Hola! Estoy muy bien, gracias. ¿Y tú? ...(スペイン語の応答がストリーミング表示される)
Enter a message:
スペイン語で話しかけると spanish_agent に handoff され、以降はスペイン語で会話が続きます。
つまずきポイント
- 2 回目以降に別の言語で話しかけても、開始エージェントがすでに専門エージェントに切り替わっているため、自動では別言語の担当に振り直されません。常にトリアージへ戻したい場合は、専門エージェント側にもトリアージへの handoff を持たせる設計(第5章で解説)が必要です。
agents_as_tools.py — エージェントをツールとして使う
司令塔(orchestrator)エージェントが、スペイン語・フランス語・イタリア語の翻訳エージェントをツールとして呼び出し、最後に別の統合(synthesizer)エージェントが結果を検品・整形する example です。
いつ使うべきか: 「複数の専門家の成果を 1 つの回答に統合したい」「会話の窓口は 1 つのエージェントに固定したい」ときに使います。handoff と違って複数の専門家を 1 ターン内で何度でも呼べるため、「複数言語へ同時に翻訳」のような並列的な依頼に向きます。
コード解説
核心は Agent.as_tool() メソッドです。エージェントを、他のエージェントから呼び出せる関数ツール(FunctionTool)に変換します。
orchestrator_agent = Agent(
name="orchestrator_agent",
instructions=(
"You are a translation agent. You use the tools given to you to translate."
"If asked for multiple translations, you call the relevant tools in order."
"You never translate on your own, you always use the provided tools."
),
tools=[
spanish_agent.as_tool(
tool_name="translate_to_spanish",
tool_description="Translate the user's message to Spanish",
),
french_agent.as_tool(
tool_name="translate_to_french",
tool_description="Translate the user's message to French",
),
italian_agent.as_tool(
tool_name="translate_to_italian",
tool_description="Translate the user's message to Italian",
),
],
)
as_tool() のソースコード(src/agents/agent.py)のコメントには、handoff との違いが明記されています。(1)handoff では新しいエージェントが会話履歴全体を受け取るのに対し、ツール化されたエージェントは呼び出し元 LLM が生成した入力文字列だけを受け取る、(2)handoff では会話の主導権が移るが、ツール呼び出しでは元のエージェントが会話を続ける、の 2 点です。
instructions に「You never translate on your own(自分では絶対に翻訳しない)」と念押ししている点にも注目してください。LLM は放っておくと自力で翻訳してしまうため、ツールへの委譲を指示文で強制するのが定石です。
メイン処理では、司令塔の実行結果から中間のツール出力を表示し、その全履歴を統合エージェントへ渡します。
with trace("Orchestrator evaluator"):
orchestrator_result = await Runner.run(orchestrator_agent, msg)
for item in orchestrator_result.new_items:
if isinstance(item, MessageOutputItem):
text = ItemHelpers.text_message_output(item)
if text:
print(f" - Translation step: {text}")
synthesizer_result = await Runner.run(
synthesizer_agent, orchestrator_result.to_input_list()
)
print(f"\n\nFinal response:\n{synthesizer_result.final_output}")
new_items は実行中に生成されたアイテム(メッセージ、ツール呼び出し、ツール結果など)のリストで、ItemHelpers.text_message_output はその中からテキストメッセージ部分を取り出すユーティリティです。
実行手順
リポジトリルートで以下を実行します。
uv run python -m examples.agent_patterns.agents_as_tools- 翻訳したい文と言語(例:
Translate 'Hello, world!' to French and Spanish.)を入力します。
期待される動作・出力例
Hi! What would you like translated, and to which languages? Translate 'Hello, world!' to French and Spanish.
- Translation step: Bonjour, le monde !
- Translation step: ¡Hola, mundo!
Final response:
French: Bonjour, le monde !
Spanish: ¡Hola, mundo!
つまずきポイント
- ツール化されたエージェントには会話履歴が渡らないため、「さっきの文を訳して」のような文脈依存の依頼は失敗します。文脈を渡したい場合は、司令塔がツール引数に必要な情報をすべて含めるよう instructions を工夫するか、後述の
parametersによる構造化入力を使います。
agents_as_tools_conditional.py — 条件付きツール有効化 + 承認
ユーザーの言語設定(コンテキスト)に応じて、司令塔が使える翻訳ツールを動的に増減させる example です。as_tool(is_enabled=...) によるツールの出し分けと、needs_approval=True による承認フロー(human-in-the-loop)の組み合わせを学べます。
いつ使うべきか: ユーザーの権限・プラン・設定によって「LLM に見せるツールの選択肢そのもの」を変えたいときに使います。instructions で「このツールは使うな」と指示するより、そもそも見せない方が確実です。
コード解説
まずアプリ独自のコンテキスト型と、有効化判定の関数を定義します。
class AppContext(BaseModel):
language_preference: str = "spanish_only" # "spanish_only", "french_spanish", "european"
def french_spanish_enabled(ctx: RunContextWrapper[AppContext], agent: AgentBase) -> bool:
"""Enable for French+Spanish and European preferences."""
return ctx.context.language_preference in ["french_spanish", "european"]
def european_enabled(ctx: RunContextWrapper[AppContext], agent: AgentBase) -> bool:
"""Only enable for European preference."""
return ctx.context.language_preference == "european"
RunContextWrapper は、Runner.run(..., context=...) で渡したアプリ独自データを包んで各所へ引き回す SDK のラッパーです。判定関数は実行時に呼ばれ、True を返したツールだけが LLM に提示されます。
司令塔のツール定義では、この判定関数を is_enabled に渡します。スペイン語ツールには needs_approval=True も付いています。
tools=[
spanish_agent.as_tool(
tool_name="respond_spanish",
tool_description="Respond to the user's question in Spanish",
is_enabled=True, # Always enabled
needs_approval=True, # HITL
),
french_agent.as_tool(
tool_name="respond_french",
tool_description="Respond to the user's question in French",
is_enabled=french_spanish_enabled,
),
italian_agent.as_tool(
tool_name="respond_italian",
tool_description="Respond to the user's question in Italian",
is_enabled=european_enabled,
),
],
さらに spanish_agent 自身も内部に承認必須のツール get_user_name を持ち、ModelSettings(tool_choice="required") で必ずツールを使うよう設定されています(tool_choice は後述の forcing_tool_use.py で詳しく解説します)。
@tool(needs_approval=True)
async def get_user_name() -> str:
print("Getting the user's name...")
return "Kaz"
メイン処理では、実行前に get_all_tools() で「今のコンテキストで LLM から見えるツール一覧」を確認できます。
context = RunContextWrapper(AppContext(language_preference=language_preference))
available_tools = await orchestrator.get_all_tools(context)
tool_names = [tool.name for tool in available_tools]
実行後は、承認待ちがあれば result.interruptions に入っているので、承認(state.approve)または拒否(state.reject)してから再実行します。
result = await Runner.run(
starting_agent=orchestrator,
input=user_request,
context=context.context,
)
while result.interruptions:
async def confirm(question: str) -> bool:
return confirm_with_fallback(f"{question} (y/n): ", default=True)
state = result.to_state()
for interruption in result.interruptions:
prompt = f"\nDo you approve this tool call: {interruption.name} with arguments {interruption.arguments}?"
confirmed = await confirm(prompt)
if confirmed:
state.approve(interruption)
print(f"✓ Approved: {interruption.name}")
else:
state.reject(interruption)
print(f"✗ Rejected: {interruption.name}")
result = await Runner.run(orchestrator, state)
注目すべきは、入れ子になったエージェント(spanish_agent の中の get_user_name)の承認要求も、外側の実行(orchestrator)の interruptions に浮上してくる点です。公式ドキュメント(docs/human_in_the_loop.md)にあるとおり、承認はどの階層で発生しても常に最上位の実行で処理し、最上位のエージェントで再開します。
実行手順
リポジトリルートで以下を実行します。
uv run python -m examples.agent_patterns.agents_as_tools_conditional- 言語設定を
1〜3で選びます(3を選ぶと 3 ツールすべてが有効になります)。 - 質問(例:
How do you say good morning?)を入力します。 - 承認を求められたら
yまたはnを入力します。
期待される動作・出力例
Choose language preference:
1. Spanish only (1 tool)
2. French and Spanish (2 tools)
3. European languages (3 tools)
Select option (1-3): 2
Language preference: french_spanish
Available tools: respond_spanish, respond_french
The LLM will only see and can use these 2 tools
...
Do you approve this tool call: respond_spanish with arguments {...}? (y/n): y
✓ Approved: respond_spanish
...
Response:
(スペイン語とフランス語の回答)
つまずきポイント
Runner.runのcontext引数にはRunContextWrapperそのものではなく、中身(context.context)を渡している点に注意してください。RunContextWrapperを自分で作っているのは、実行前にget_all_tools(context)を呼ぶためです。- 承認ループの終了条件は
result.interruptionsが空になることです。承認・拒否のどちらを選んでも実行は最後まで進みます(拒否時はその旨が LLM に伝わり、LLM が代替の回答を組み立てます)。
agents_as_tools_streaming.py — 入れ子エージェントのストリーミング観測
エージェントをツール化すると、その内部の動きは通常ブラックボックスになります。この example は as_tool(on_stream=...) コールバックを使い、入れ子で動く請求(billing)エージェントの内部イベントをリアルタイムに観測する方法を示します。
いつ使うべきか: ツール化したエージェントの進捗をユーザーに見せたいとき(「調査中…」のような UI 表示)や、内部動作のデバッグ・ロギングをしたいときに使います。
コード解説
このファイルは対話入力なしで完結します。まず、ツール名と説明を明示した関数ツールを定義します(name_override / description_override は関数名や docstring の代わりに任意の名前・説明を指定するオプションです)。
@tool(
name_override="billing_status_checker",
description_override="Answer questions about customer billing status.",
)
def billing_status_checker(customer_id: str | None = None, question: str = "") -> str:
"""Return a canned billing answer or a fallback when the question is unrelated."""
normalized = question.lower()
if "bill" in normalized or "billing" in normalized:
return f"This customer (ID: {customer_id})'s bill is $100"
return "I can only answer questions about billing."
次にストリーミングイベントを受け取るコールバックです。AgentToolStreamEvent は辞書型(TypedDict)で、event(ストリームイベント本体)、agent(イベントを発した入れ子エージェント)、tool_call(呼び出し元のツールコール情報)の 3 つのキーを持ちます。
def handle_stream(event: AgentToolStreamEvent) -> None:
"""Print streaming events emitted by the nested billing agent."""
stream = event["event"]
tool_call = event.get("tool_call")
tool_call_info = tool_call.call_id if tool_call is not None else "unknown"
print(f"[stream] agent={event['agent'].name} call={tool_call_info} type={stream.type} {stream}")
組み立て部分では、billing_agent をツール化する際に on_stream=handle_stream を渡します。ソースコードの仕様どおり、on_stream を指定すると入れ子エージェントは自動的にストリーミングモードで実行されます。
billing_agent_tool = billing_agent.as_tool(
tool_name="billing_agent",
tool_description="You are a billing agent that answers billing questions.",
on_stream=handle_stream,
)
main_agent = Agent(
name="Customer Support Agent",
instructions=(
"You are a customer support agent. Always call the billing agent to answer billing "
"questions and return the billing agent response to the user."
),
tools=[billing_agent_tool],
)
result = await Runner.run(
main_agent,
"Hello, my customer ID is ABC123. How much is my bill for this month?",
)
外側の Runner.run は通常の(非ストリーミングの)実行のままで、入れ子エージェントのイベントだけがコールバック経由で流れてくる、という構造です。
実行手順
リポジトリルートで以下を実行します(対話入力は不要です)。
uv run python -m examples.agent_patterns.agents_as_tools_streaming
期待される動作・出力例
[stream] agent=Billing Agent call=call_abc123 type=raw_response_event ...
[stream] agent=Billing Agent call=call_abc123 type=run_item_stream_event ...
(多数のイベント行が続く)
Final response:
Your bill for this month is $100 (customer ID: ABC123).
つまずきポイント
- イベントは生データがそのまま print されるため出力が大量になります。実用時は
stream.typeでフィルタし、必要なイベント(テキスト delta やツール呼び出し)だけを扱うのが普通です。
agents_as_tools_structured.py — 構造化入力によるツール化
エージェントツールの引数を、既定の「自由なテキスト 1 個」ではなく、Pydantic モデルで定義した構造化パラメータ(翻訳対象テキスト・元言語・先言語)にする example です。
いつ使うべきか: ツール化したエージェントに渡すべき情報が複数の要素からなるとき(翻訳の 3 要素、検索のクエリ+件数+期間など)に使います。呼び出し元 LLM が必要な情報を漏れなく渡すことを、型スキーマの力で強制できます。
コード解説
ファイルはコメント込みで 65 行と短いため、主要部を通して見ます。
class TranslationInput(BaseModel):
text: str = Field(description="Text to translate.")
source: str = Field(description="Source language code or name.")
target: str = Field(description="Target language code or name.")
translator = Agent(
name="translator",
instructions=(
"Translate the input text into the target language. "
"If the target is not clear, ask the user for clarification."
),
)
orchestrator = Agent(
name="orchestrator",
instructions=(
"You are a task dispatcher. Always call the tool with sufficient input. "
"Do not handle the translation yourself."
),
tools=[
translator.as_tool(
tool_name="translate_text",
tool_description=(
"Translate text between languages. Provide text, source language, "
"and target language."
),
parameters=TranslationInput,
# By default, the input schema will be included in a simpler format.
# Set include_input_schema to true to include the full JSON Schema:
# include_input_schema=True,
# Build a custom prompt from structured input data:
# input_builder=lambda options: (
# f'Translate the text "{options["params"]["text"]}" '
# f'from {options["params"]["source"]} to {options["params"]["target"]}.'
# ),
)
],
)
as_tool(parameters=TranslationInput) により、呼び出し元 LLM に提示されるツール定義の引数が text / source / target の 3 フィールドになります。ソースコード上、parameters には Pydantic モデルまたは dataclass を指定でき、コメントアウトされた 2 つのオプションも用意されています。
include_input_schema=True: 入れ子エージェントへの入力に完全な JSON Schema(データ構造を記述する標準形式)を含めるinput_builder: 構造化データから入れ子エージェントへ渡すプロンプト文字列を自作する関数
メイン処理は、同じ質問を「translator を直接実行した場合」と「orchestrator 経由でツールとして実行した場合」で比較します。
async def main() -> None:
query = 'Translate "Hola" from Spanish to French.'
response1 = await Runner.run(translator, query)
print(f"Translator agent direct run: {response1.final_output}")
response2 = await Runner.run(orchestrator, query)
print(f"Translator agent as tool: {response2.final_output}")
実行手順
リポジトリルートで以下を実行します(対話入力は不要です)。
uv run python -m examples.agent_patterns.agents_as_tools_structured
期待される動作・出力例
Translator agent direct run: Bonjour
Translator agent as tool: The translation of "Hola" from Spanish to French is "Bonjour".
直接実行では翻訳結果そのもの、ツール経由では orchestrator が結果を文章にまとめて返す、という違いが観察できます。
つまずきポイント
parametersに指定できるのは Pydantic モデルか dataclass だけです。それ以外の型を渡すとTypeError(Agent tool parameters must be a dataclass or Pydantic model type.)が発生します。
parallelization.py — 並列実行
同じ翻訳エージェントを 3 回並列に実行して 3 通りのスペイン語訳を作り、別の「選定エージェント」に最良の 1 つを選ばせる example です。
いつ使うべきか: (1)互いに依存しない複数タスクを同時に走らせて待ち時間を短縮したいとき、(2)LLM の出力のばらつきを逆手に取り、複数案を生成してベストを選ぶ「best-of-N」で品質を上げたいとき、の 2 つの場面で使います。SDK 特有の機能は使わず、Python 標準の asyncio.gather だけで実現できるのがポイントです。
コード解説
エージェントは「翻訳担当」と「選定担当」の 2 つだけです。
spanish_agent = Agent(
name="spanish_agent",
instructions="You translate the user's message to Spanish",
)
translation_picker = Agent(
name="translation_picker",
instructions="You pick the best Spanish translation from the given options.",
)
メイン処理では asyncio.gather(複数の非同期処理を同時に開始し、全部の完了を待つ関数)で同一エージェントを 3 回走らせます。
with trace("Parallel translation"):
res_1, res_2, res_3 = await asyncio.gather(
Runner.run(
spanish_agent,
msg,
),
Runner.run(
spanish_agent,
msg,
),
Runner.run(
spanish_agent,
msg,
),
)
outputs = [
ItemHelpers.text_message_outputs(res_1.new_items),
ItemHelpers.text_message_outputs(res_2.new_items),
ItemHelpers.text_message_outputs(res_3.new_items),
]
translations = "\n\n".join(outputs)
print(f"\n\nTranslations:\n\n{translations}")
best_translation = await Runner.run(
translation_picker,
f"Input: {msg}\n\nTranslations:\n{translations}",
)
3 つの結果を文字列として連結し、元の入力と一緒に選定エージェントへ渡す——後段の入力をコードで組み立てる点は決定的フローと同じ発想です。LLM は同じ入力でも毎回少しずつ違う出力を返す性質があるため、3 案はたいてい互いに異なります。
実行手順
リポジトリルートで以下を実行します。
uv run python -m examples.agent_patterns.parallelization- 翻訳したいメッセージ(例:
Good morning!)を入力します。
期待される動作・出力例
Hi! Enter a message, and we'll translate it to Spanish.
Good morning!
Translations:
¡Buenos días!
¡Buen día!
Buenos días.
-----
Best translation: ¡Buenos días!
つまずきポイント
- 並列実行は速度を上げますが、API 呼び出し回数(= コストとレート制限の消費)は実行回数分だけ増えます。best-of-N は品質とコストのトレードオフであることを意識してください。
asyncio.gather で 3 本の実行が同時に走り、結果がそろってから選定エージェントへ渡る流れは次の図のとおりです。
llm_as_a_judge.py — LLM を審査員にする
物語のアウトラインを生成するエージェントと、それを採点してフィードバックを返す「審査員」エージェントを while ループで交互に実行し、審査員が合格(pass)を出すまで改善を繰り返す example です。
いつ使うべきか: 出力の品質基準が「一発生成では満たしにくいが、評価はしやすい」ときに有効です。文章の推敲、コードレビュー、要件チェックなどが典型です。README が示唆するとおり、生成側に小さく安価なモデル、審査側に大きく賢いモデルを使うコスト最適化も可能です。
コード解説
審査員の出力は dataclass による構造化出力で、3 段階のスコアとフィードバック文を返します(Literal 型は「この 3 つの文字列のどれか」に値を制限する型ヒントです)。
@dataclass
class EvaluationFeedback:
feedback: str
score: Literal["pass", "needs_improvement", "fail"]
evaluator = Agent[None](
name="evaluator",
instructions=(
"You evaluate a story outline and decide if it's good enough. "
"If it's not good enough, you provide feedback on what needs to be improved. "
"Never give it a pass on the first try. After 5 attempts, you can give it a pass if the story outline is good enough - do not go for perfection"
),
output_type=EvaluationFeedback,
)
instructions の「Never give it a pass on the first try(初回では絶対に合格を出すな)」「5 回試したら完璧を求めず合格させてよい」という指示は、実際の運用でも重要なテクニックです。審査員が甘すぎるとループの意味がなく、厳しすぎると無限ループするため、合格基準と打ち切り条件をプロンプトに明示します。
改善ループの本体は以下です。
with trace("LLM as a judge"):
while True:
story_outline_result = await Runner.run(
story_outline_generator,
input_items,
)
input_items = story_outline_result.to_input_list()
latest_outline = ItemHelpers.text_message_outputs(story_outline_result.new_items)
print("Story outline generated")
evaluator_result = await Runner.run(evaluator, input_items)
result: EvaluationFeedback = evaluator_result.final_output
print(f"Evaluator score: {result.score}")
if result.score == "pass":
print("Story outline is good enough, exiting.")
break
不合格の場合は、フィードバックを user ロールのメッセージとして履歴に追加し、生成エージェントへ再投入します。
print("Re-running with feedback")
input_items.append({"content": f"Feedback: {result.feedback}", "role": "user"})
生成エージェント側の instructions に「If there is any feedback provided, use it to improve the outline.(フィードバックがあればそれを使って改善せよ)」とあるため、2 周目以降は指摘を反映したアウトラインが生成されます。
実行手順
リポジトリルートで以下を実行します。
uv run python -m examples.agent_patterns.llm_as_a_judge- 聞きたい物語の内容(例:
A detective story in space.)を入力します。
期待される動作・出力例
What kind of story would you like to hear? A detective story in space.
Story outline generated
Evaluator score: needs_improvement
Re-running with feedback
Story outline generated
Evaluator score: needs_improvement
Re-running with feedback
Story outline generated
Evaluator score: pass
Story outline is good enough, exiting.
Final story outline: (改善を重ねたアウトライン)
つまずきポイント
- ループ 1 周ごとに生成 + 審査で最低 2 回の API 呼び出しが発生します。トークン消費が周回数に比例して増えるため、実運用では最大周回数の上限をコード側にも入れるのが安全です(この example では対話モード時のコード上の上限はなく、審査員のプロンプトで打ち切りを制御しています)。
生成と審査を交互に繰り返すこの改善ループを図にすると次のようになります。
input_guardrails.py — 入力ガードレール
カスタマーサポートエージェントに「数学の宿題を解かせようとする入力」を検知する入力 guardrail(ガードレール。入出力を検査する安全柵の仕組み)を取り付け、検知したら本体の実行を中断して定型の断り文を返す example です。
いつ使うべきか: 高価で賢いモデルを使う本体エージェントを、規約違反・目的外利用・悪意ある入力から守りたいときに使います。検査には高速で安価なモデルを使えるため、「不正入力に高いモデルの料金を払わない」というコスト防御の意味もあります。
コード解説
guardrail 自体も小さなエージェントとして実装できます。検査結果は構造化出力で受け取ります。
class MathHomeworkOutput(BaseModel):
reasoning: str
is_math_homework: bool
guardrail_agent = Agent(
name="Guardrail check",
instructions="Check if the user is asking you to do their math homework.",
output_type=MathHomeworkOutput,
)
@input_guardrail
async def math_guardrail(
context: RunContextWrapper[None], agent: Agent, input: str | list[TResponseInputItem]
) -> GuardrailFunctionOutput:
"""This is an input guardrail function, which happens to call an agent to check if the input
is a math homework question.
"""
result = await Runner.run(guardrail_agent, input, context=context.context)
final_output = result.final_output_as(MathHomeworkOutput)
return GuardrailFunctionOutput(
output_info=final_output,
tripwire_triggered=final_output.is_math_homework,
)
@input_guardrail デコレータを付けた関数は、(1)コンテキスト、(2)対象エージェント、(3)エージェントに渡されたのと同じ入力、を受け取り、GuardrailFunctionOutput を返します。なお v0.19.0 以降、examples は input_guardrail / output_guardrail を agents.decorators からインポートしています(from agents import input_guardrail でも同じものが得られます。agents.decorators は tool / input_guardrail / output_guardrail / tool_input_guardrail / tool_output_guardrail を 1 か所に集めた公開モジュールです)。tripwire_triggered(トリップワイヤー。侵入を検知すると作動する仕掛け線のこと)が True になると、SDK は InputGuardrailTripwireTriggered 例外を送出して実行を停止します。
本体エージェントへの取り付けは input_guardrails パラメータ、例外処理は通常の try/except です。
agent = Agent(
name="Customer support agent",
instructions="You are a customer support agent. You help customers with their questions.",
input_guardrails=[math_guardrail],
)
try:
result = await Runner.run(agent, input_data)
print(result.final_output)
# If the guardrail didn't trigger, we use the result as the input for the next run
input_data = result.to_input_list()
except InputGuardrailTripwireTriggered:
# If the guardrail triggered, we instead add a refusal message to the input
message = "Sorry, I can't help you with your math homework."
print(message)
input_data.append(
{
"role": "assistant",
"content": message,
}
)
例外を捕捉したら、断り文を assistant ロールとして会話履歴に追加し、次のターンへ進みます。こうすることで「断った」事実も会話の文脈に残ります。
公式ドキュメント(docs/guardrails.md)の重要な補足を 2 点挙げます。
- 入力 guardrail はチェーンの最初のエージェントでのみ実行され、出力 guardrail は最終出力を出すエージェントでのみ実行されます
- 既定では guardrail は本体エージェントと並行に実行されます(レイテンシ最優先)。
run_in_parallel=Falseを指定すると本体の開始前に guardrail を完了させる「ブロッキング実行」になり、検査で拒否された入力について、本体の開始を防ぎます。検査用モデルのトークン消費は残り、検査の見逃しや別経路のツール実行まで防ぐ保証ではありません
実行手順
リポジトリルートで以下を実行します。
uv run python -m examples.agent_patterns.input_guardrails- まず普通の質問(例:
What's the capital of California?)を入力し、次に数学の宿題(例:Can you help me solve for x: 2x + 5 = 11)を入力して挙動の違いを確認します。
期待される動作・出力例
ファイル末尾のコメントに実行例が記載されています。
Enter a message: What's the capital of California?
The capital of California is Sacramento.
Enter a message: Can you help me solve for x: 2x + 5 = 11
Sorry, I can't help you with your math homework.
つまずきポイント
- 既定の並行実行モードでは、guardrail が作動したときすでに本体エージェントが動き始めておりトークンを消費している可能性があります。本体の先行実行を避けたい場合はブロッキング実行を選びます。検査そのものの費用は発生し、外部操作にはツール単位の検証と承認も必要です。
既定の並行実行とブロッキング実行(run_in_parallel=False)の違いを図で比較します。
output_guardrails.py — 出力ガードレール
v0.22.1:拒否された最終ツール出力の置換文
ツールの結果が最終出力になる設定で出力ガードレールに拒否された場合、再生用状態に保持するツール出力は機密データを含まない置換文になります。RunConfig(output_guardrail_blocked_message="出力はポリシーにより非表示です。") で文面を指定できます。ガードレールは既に行われた外部操作を取り消しません。検証済みの呼び出し引数などのメタデータが保持される場合もあります。出力ガードレールの仕様
エージェントの最終出力に電話番号(この例では「650」という市外局番)が含まれていないかを検査し、含まれていたら OutputGuardrailTripwireTriggered 例外で止める example です。個人情報などの機微データの流出防止を模しています。
いつ使うべきか: 「ユーザーに見せてはいけない情報(個人情報、社外秘、不適切表現)が出力に混ざるリスク」を最後の関門で摘み取りたいときに使います。入力 guardrail が「門番」なら、出力 guardrail は「検品係」です。
コード解説
エージェントの出力型と guardrail 関数を定義します。今回は LLM を使わず、単純な文字列判定です。
class MessageOutput(BaseModel):
reasoning: str = Field(description="Thoughts on how to respond to the user's message")
response: str = Field(description="The response to the user's message")
user_name: str | None = Field(description="The name of the user who sent the message, if known")
@output_guardrail
async def sensitive_data_check(
context: RunContextWrapper, agent: Agent, output: MessageOutput
) -> GuardrailFunctionOutput:
phone_number_in_response = "650" in output.response
phone_number_in_reasoning = "650" in output.reasoning
return GuardrailFunctionOutput(
output_info={
"phone_number_in_response": phone_number_in_response,
"phone_number_in_reasoning": phone_number_in_reasoning,
},
tripwire_triggered=phone_number_in_response or phone_number_in_reasoning,
)
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant.",
output_type=MessageOutput,
output_guardrails=[sensitive_data_check],
)
@output_guardrail の関数は第 3 引数にエージェントの最終出力(ここでは MessageOutput 型のオブジェクト)を受け取ります。response だけでなく reasoning(思考過程)フィールドも検査している点が実践的です。ユーザーに直接見せないフィールドにも機微データは漏れ得ます。
メイン処理は安全な質問と危険な質問を 1 回ずつ実行して比較します。
async def main():
# This should be ok
await Runner.run(agent, "What's the capital of California?")
print("First message passed")
# This should trip the guardrail
try:
result = await Runner.run(
agent, "My phone number is 650-123-4567. Where do you think I live?"
)
print(
f"Guardrail didn't trip - this is unexpected. Output: {json.dumps(result.final_output.model_dump(), indent=2)}"
)
except OutputGuardrailTripwireTriggered as e:
print(f"Guardrail tripped. Info: {e.guardrail_result.output.output_info}")
例外オブジェクトの e.guardrail_result.output.output_info から、guardrail 関数が output_info に詰めた診断情報を取り出せます。
実行手順
リポジトリルートで以下を実行します(対話入力は不要です)。
uv run python -m examples.agent_patterns.output_guardrails
期待される動作・出力例
First message passed
Guardrail tripped. Info: {'phone_number_in_response': True, 'phone_number_in_reasoning': False}
(LLM が電話番号を reasoning にも書いた場合は phone_number_in_reasoning も True になります。)
つまずきポイント
- 出力 guardrail は最終出力の完成後に実行されるため、入力 guardrail と違いトークン消費の節約にはなりません。目的は「漏らさないこと」です。
- 2 番目の質問で LLM が応答に「650」を含めなかった場合、想定どおり guardrail が作動しないことがあります(
Guardrail didn't trip - this is unexpected.と表示されます)。LLM の出力は確率的である、という教訓も含んだ example です。
streaming_guardrails.py — ストリーミング中のガードレール
出力 guardrail は「全部できてから検品」ですが、ストリーミングでは応答が長いほど不適切な内容が画面に流れ続けてしまいます。この example は、ストリーミング中に約 300 文字ごとに途中経過を検査し、問題を検知したら配信を打ち切る自前の実装パターンを示します。
いつ使うべきか: チャット UI のようにストリーミング表示するアプリで、不適切な出力を「流れきる前に」止めたいときに使います。SDK の guardrail 機能ではなく asyncio を使った手組みのパターンである点に注意してください。
コード解説
本体エージェントはあえて冗長な長文を書く設定、検査エージェントは「10 歳児に理解できる文章か」を判定する設定です。検査側には明示的に軽量モデル gpt-4o-mini を指定しています。
guardrail_agent = Agent(
name="Checker",
instructions=(
"You will be given a question and a response. Your goal is to judge whether the response "
"is simple enough to be understood by a ten year old."
),
output_type=GuardrailOutput,
model="gpt-4o-mini",
)
async def check_guardrail(text: str) -> GuardrailOutput:
result = await Runner.run(guardrail_agent, text)
return result.final_output_as(GuardrailOutput)
メインループでは、テキスト delta を受け取り画面に流しつつ、蓄積文字数がしきい値を超えたら検査をバックグラウンドタスクとして起動します(asyncio.create_task は非同期処理を「待たずに」開始する関数です。検査中もストリーミング表示は止まりません)。
async for event in result.stream_events():
if event.type == "raw_response_event" and isinstance(event.data, ResponseTextDeltaEvent):
print(event.data.delta, end="", flush=True)
current_text += event.data.delta
# Check if it's time to run the guardrail check
# Note that we don't run the guardrail check if there's already a task running. An
# alternate implementation is to have N guardrails running, or cancel the previous
# one.
if len(current_text) >= next_guardrail_check_len and not guardrail_task:
print("Running guardrail check")
guardrail_task = asyncio.create_task(check_guardrail(current_text))
next_guardrail_check_len += 300
# Every iteration of the loop, check if the guardrail has been triggered
if guardrail_task and guardrail_task.done():
guardrail_result = guardrail_task.result()
if not guardrail_result.is_readable_by_ten_year_old:
print("\n\n================\n\n")
print(f"Guardrail triggered. Reasoning:\n{guardrail_result.reasoning}")
break
ループを抜けた後に最終テキスト全体をもう一度検査する「最終チェック」も入っており、途中検査をすり抜けた場合の保険になっています。
実行手順
リポジトリルートで以下を実行します(質問はコード内に固定されており、対話入力は不要です)。
uv run python -m examples.agent_patterns.streaming_guardrails
期待される動作・出力例
ブラックホールについての専門的な長文がストリーミング表示され、途中で Running guardrail check が挟まり、検査が「10 歳児には難しい」と判定した時点で打ち切られます。
A black hole is a region of spacetime where gravity is so intense that ...(長文が流れる)
Running guardrail check
...(続きが流れる)
================
Guardrail triggered. Reasoning:
The response uses technical terms like "spacetime curvature" and "event horizon" that ...
つまずきポイント
- 検査は非同期に走るため、「問題を検知した瞬間」までに数百文字が既に表示されています。ゼロ露出が必要なら、表示前にバッファして検査を通過した分だけ画面に出す、より保守的な設計が必要です。
- しきい値(300 文字)と検査の並行数は品質とコストのトレードオフです。コメントにあるとおり、複数検査を並行させる・古い検査をキャンセルするなどの変種が考えられます。
forcing_tool_use.py — ツール使用の強制
LLM は「ツールを使うかどうか」を自分で判断しますが、天気 API のように必ずツールを使ってほしい場合があります。この example は ModelSettings(tool_choice="required") でツール使用を強制し、その後の挙動を tool_use_behavior で 3 通りに制御する方法を示します。
いつ使うべきか: 「LLM がツールを使わず記憶で答えてしまう」事故を防ぎたいとき、およびツールの結果をそのまま最終出力にして LLM の再呼び出しを省略(高速化・コスト削減)したいときに使います。
コード解説
ファイル冒頭のコメントが 3 つのモードを説明しています。
"""
This example shows how to force the agent to use a tool. It uses `ModelSettings(tool_choice="required")`
to force the agent to use any tool.
You can run it with 3 options:
1. `default`: The default behavior, which is to send the tool output to the LLM. In this case,
`tool_choice` is not set, because otherwise it would result in an infinite loop - the LLM would
call the tool, the tool would run and send the results to the LLM, and that would repeat
(because the model is forced to use a tool every time.)
2. `first_tool_result`: The first tool result is used as the final output.
3. `custom`: A custom tool use behavior function is used. The custom function receives all the tool
results, and chooses to use the first tool result to generate the final output.
"""
ツールは天気を返すだけの単純なものです。カスタム動作関数は、全ツール結果のリストを受け取り、最終出力にするかどうかを ToolsToFinalOutputResult で返します。
@tool
def get_weather(city: str) -> Weather:
print("[debug] get_weather called")
return Weather(city=city, temperature_range="14-20C", conditions="Sunny with wind")
async def custom_tool_use_behavior(
context: RunContextWrapper[Any], results: list[FunctionToolResult]
) -> ToolsToFinalOutputResult:
weather: Weather = results[0].output
return ToolsToFinalOutputResult(
is_final_output=True, final_output=f"{weather.city} is {weather.conditions}."
)
エージェントの組み立てでは、モードに応じて tool_use_behavior と tool_choice を切り替えます。
agent = Agent(
name="Weather agent",
instructions="You are a helpful agent.",
tools=[get_weather],
tool_use_behavior=behavior,
model_settings=ModelSettings(
tool_choice="required" if tool_use_behavior != "default" else None
),
)
tool_use_behavior の意味(ソースコード src/agents/agent.py の定義より)は次のとおりです。
"run_llm_again"(既定): ツール実行後、結果を LLM に渡してもう一度応答を生成させる"stop_on_first_tool": 最初のツール呼び出しの出力をそのまま最終結果とし、LLM には戻さない- 関数を渡す: 実行コンテキストとツール結果のリストを受け取り、
ToolsToFinalOutputResultで「最終出力にするか、LLM に続けさせるか」を自分で決める - このほか
StopAtTools(指定した名前のツールが呼ばれたら停止する辞書型設定)も指定できます
tool_choice="required" と既定の "run_llm_again" を組み合わせると、「毎ターンツール使用を強制 → 結果を LLM へ → また強制…」の無限ループになり得る、というコメントの警告は重要です。実際には SDK に reset_tool_choice(ツール実行後に tool_choice を既定へ戻す設定。既定で有効)という安全装置がありますが、強制と再呼び出しの組み合わせには常に注意が必要です。
実行手順
リポジトリルートで、3 つのモードをそれぞれ試します。
uv run python -m examples.agent_patterns.forcing_tool_use -t default uv run python -m examples.agent_patterns.forcing_tool_use -t first_tool uv run python -m examples.agent_patterns.forcing_tool_use -t custom
期待される動作・出力例
# -t default: ツール結果を LLM が文章化する
[debug] get_weather called
The weather in Tokyo is sunny with wind, with temperatures ranging from 14 to 20°C.
# -t first_tool: ツールの生の戻り値(Weather オブジェクト)がそのまま最終出力になる
[debug] get_weather called
city='Tokyo' temperature_range='14-20C' conditions='Sunny with wind'
# -t custom: カスタム関数が整形した文字列が最終出力になる
[debug] get_weather called
Tokyo is Sunny with wind.
つまずきポイント
stop_on_first_toolでは LLM による文章化が入らないため、ツールの戻り値の型・体裁がそのままユーザーへの出力になります。表示用の整形が必要ならカスタム関数方式を選びます。tool_use_behaviorの設定が効くのは自作の関数ツール(FunctionTool)だけで、Web 検索などのホスト型ツールの結果は常に LLM が処理します(ソースコードの NOTE より)。
human_in_the_loop.py — 人間の承認(HITL)の基本
エージェントが特定のツールを呼ぼうとしたら実行を一時停止し、人間が承認(approve)または拒否(reject)してから再開する human-in-the-loop の基本形です。実行状態(RunState)を JSON ファイルに保存・復元するデモも兼ねています。
いつ使うべきか: 送金、データ削除、メール送信、本番環境への変更など、「間違えたら取り返しがつかない操作」の前に必ず人間の目を入れたいときに使います。
コード解説
承認要否はツール定義時に needs_approval で宣言します。True 固定のほか、引数の内容を見て動的に判断する関数も渡せます。
async def _needs_temperature_approval(_ctx, params, _call_id) -> bool:
"""Check if temperature tool needs approval."""
return "Oakland" in params.get("city", "")
@tool(
# Dynamic approval: only require approval for Oakland
needs_approval=_needs_temperature_approval
)
async def get_temperature(city: str) -> str:
...
この例では「都市名に Oakland が含まれるときだけ承認が必要」です。判定関数はコンテキスト・解析済みパラメータ・ツール呼び出し ID の 3 つを受け取ります。
承認フローの本体は「interruptions(割り込み)を確認 → 状態を保存/復元 → 承認/拒否 → 再開」のループです。
result = await Runner.run(
agent,
"What is the weather and temperature in Oakland?",
)
has_interruptions = len(result.interruptions) > 0
while has_interruptions:
...
# Storing state to file (demonstrating serialization)
state = result.to_state()
state_json = state.to_json()
RESULT_PATH.parent.mkdir(parents=True, exist_ok=True)
with RESULT_PATH.open("w") as f:
json.dump(state_json, f, indent=2)
...
state = await RunState.from_json(agent, stored_state_json)
# Process each interruption
for interruption in result.interruptions:
print("\nTool call details:")
print(f" Agent: {interruption.agent.name}")
print(f" Tool: {interruption.name}")
print(f" Arguments: {interruption.arguments}")
confirmed = await confirm("\nDo you approve this tool call?")
if confirmed:
print(f"✓ Approved: {interruption.name}")
state.approve(interruption)
else:
print(f"✗ Rejected: {interruption.name}")
state.reject(interruption)
# Resume execution with the updated state
print("\nResuming agent execution...")
result = await Runner.run(agent, state)
has_interruptions = len(result.interruptions) > 0
流れを整理すると次のとおりです。
- 承認が必要なツール呼び出しが発生すると、実行が一時停止し
result.interruptionsにToolApprovalItem(エージェント名・ツール名・引数を持つ承認要求)が入る result.to_state()で実行状態をRunStateに変換し、to_json()でシリアライズ(オブジェクトを保存可能なデータ形式に変換すること)できる。つまり承認待ちのままプロセスを終了し、別プロセス・別サーバーで後から再開することも可能RunState.from_json(agent, ...)で復元し、state.approve(...)/state.reject(...)で決定を記録するRunner.run(agent, state)に入力の代わりに state を渡すと、続きから実行が再開される。新たな承認要求が出ればまたループする
実行手順
リポジトリルートで以下を実行します。
uv run python -m examples.agent_patterns.human_in_the_loopDo you approve this tool call? (y/n):と聞かれたらy(承認)またはn(拒否)を入力します。- 実行後、
.cache/agent_patterns/human_in_the_loop/result.jsonに保存された実行状態を覗いてみると、何が永続化されるのか理解が深まります。
期待される動作・出力例
================================================================================
Run interrupted - tool approval required
================================================================================
State saved to .cache/agent_patterns/human_in_the_loop/result.json
Loading state from .cache/agent_patterns/human_in_the_loop/result.json
Tool call details:
Agent: Weather Assistant
Tool: get_temperature
Arguments: {"city":"Oakland"}
Do you approve this tool call? (y/n): y
✓ Approved: get_temperature
Resuming agent execution...
================================================================================
Final Output:
================================================================================
The weather in Oakland is sunny and the temperature is 20° Celsius.
n で拒否した場合は、エージェントが「気温の取得は承認されなかった」ことを踏まえた回答(天気のみの回答など)を生成します。
つまずきポイント
get_weatherにはneeds_approvalが付いていないため、承認なしで即実行されます。止まるのは Oakland 宛のget_temperatureだけです。- 再開時の
Runner.runに渡すのは「新しい入力」ではなく state です。ここで元の質問文字列をもう一度渡すと、続きではなく新規実行になってしまいます。
中断から再開までの一連のやり取りを、アプリコード・Runner・人間の3者間のシーケンス図として整理すると次のとおりです。
human_in_the_loop_stream.py — ストリーミング版 HITL
human_in_the_loop.py と同じ承認フローを、Runner.run_streamed によるストリーミング実行で行う example です。
いつ使うべきか: チャット UI などリアルタイム表示のアプリに承認フローを組み込むときの雛形です。
コード解説
ツール定義(Oakland のみ承認必須の get_temperature と承認不要の get_weather)は基本版と同じです。違いは実行と再開の両方がストリーミングである点です。
# Run the agent with streaming
result = Runner.run_streamed(
main_agent,
"What is the weather and temperature in Oakland?",
)
async for _ in result.stream_events():
pass # Process streaming events silently or could print them
# Handle interruptions
while len(result.interruptions) > 0:
...
state = result.to_state()
for interruption in result.interruptions:
...
if confirmed:
state.approve(interruption)
else:
state.reject(interruption)
# Resume execution with streaming
print("\nResuming agent execution...")
result = Runner.run_streamed(main_agent, state)
async for _ in result.stream_events():
pass # Process streaming events silently or could print them
重要なのは、stream_events() を最後まで消費し切ってから result.interruptions を確認するという順序です(公式ドキュメント docs/human_in_the_loop.md にも明記されています)。ストリームの途中では承認要求がまだ確定していません。この example ではイベントを pass で読み捨てていますが、実アプリではここで delta を画面表示します。再開も Runner.run_streamed(main_agent, state) で行い、再開後の出力もストリーミングされます。
実行手順
リポジトリルートで以下を実行します。
uv run python -m examples.agent_patterns.human_in_the_loop_stream- 承認を求められたら
yまたはnを入力します。
期待される動作・出力例
基本版とほぼ同じ出力になります(この example はイベントを表示しないため、見た目上の違いは小さいです)。
================================================================================
Human-in-the-loop: approval required for the following tool calls:
================================================================================
Tool call details:
Agent: Weather Assistant
Tool: get_temperature
Arguments: {"city":"Oakland"}
Do you approve this tool call? (y/n): y
✓ Approved: get_temperature
Resuming agent execution...
================================================================================
Final Output:
================================================================================
The weather in Oakland is sunny and the temperature is 20°C.
Done!
つまずきポイント
stream_events()の消費を忘れる(async forを書かない)と、実行が完了せずinterruptionsも正しく取得できません。ストリーミング版では「ストリームを飲み干す → interruptions を見る → 再開」の 3 拍子を崩さないでください。
human_in_the_loop_custom_rejection.py — 拒否メッセージのカスタマイズ
承認を拒否したとき、LLM には既定で「Tool execution was not approved.」という定型文が渡ります。この example は、その文言を(1)実行全体のフォールバックとして RunConfig.tool_error_formatter で、(2)個別の拒否として state.reject(..., rejection_message=...) で、2 層でカスタマイズする方法を示します。
いつ使うべきか: 拒否理由をエージェントの次の応答に正確に反映させたいとき(「レビュー担当者が否認した」等、業務上の文脈を伝えたいとき)に使います。
コード解説
まず実行全体のフォールバックとなるフォーマッタ関数です。args.kind で「承認拒否」のケースだけを対象にし、それ以外は None を返して既定動作に任せます。
async def tool_error_formatter(args: ToolErrorFormatterArgs[None]) -> str | None:
"""Build the universal fallback output message for rejected tool calls."""
if args.kind != "approval_rejected":
return None
# The default message is "Tool execution was not approved."
return "Publish action was canceled because approval was rejected."
エージェントは「お知らせを公開する」ツールを必ず呼ぶよう、tool_choice にツール名そのものを指定しています("required" が「どれかのツールを必ず使え」なのに対し、ツール名指定は「このツールを必ず使え」です)。
@tool(needs_approval=True)
async def publish_announcement(title: str, body: str) -> str:
"""Simulate publishing an announcement to users."""
return f"Published announcement '{title}' with body: {body}"
agent = Agent(
name="Operations Assistant",
instructions=(
"When a user asks to publish an announcement, call the publish_announcement tool directly. "
"Do not ask the user for approval in plain text; runtime approvals handle that. "
"If the tool call is rejected, respond with the exact rejection message and nothing else."
),
model_settings=ModelSettings(tool_choice="publish_announcement"),
tools=[publish_announcement],
)
run_config = RunConfig(tool_error_formatter=tool_error_formatter)
拒否時には、フォーマッタより優先される呼び出し単位の rejection_message を渡します。
if approved:
state.approve(interruption)
else:
# This per-call rejection message takes precedence over ``tool_error_formatter``.
state.reject(
interruption,
rejection_message=(
"Publish action was canceled because the reviewer denied approval."
),
)
result = await Runner.run(agent, state, run_config=run_config)
優先順位は「state.reject の rejection_message(個別)> RunConfig.tool_error_formatter(全体フォールバック)> SDK 既定文」です。実行の最後には、ツール呼び出し結果アイテム(tool_call_output_item)から実際に LLM へ渡った文言を取り出して表示します。
実行手順
リポジトリルートで以下を実行します。
uv run python -m examples.agent_patterns.human_in_the_loop_custom_rejectionApprove this tool call? [y/N]:に対してn(または Enter のみ。既定は拒否)を入力すると、カスタム拒否メッセージの効果を確認できます。
期待される動作・出力例
拒否した場合の例です。
Approval required:
- Tool: publish_announcement
Arguments: {"title":"Office maintenance","body":"The office will close at 6 PM today."}
Approve this tool call? [y/N]: n
Formatter output:
Publish action was canceled because the reviewer denied approval.
Final output:
Publish action was canceled because the reviewer denied approval.
y で承認すると、ツールが実行されて公開完了のメッセージが最終出力になります。
つまずきポイント
- この example の確認プロンプトは既定が拒否(
default=False)です。他の HITL example(既定が承認)と逆なので、Enter 連打での挙動が異なります。 - instructions で「拒否されたら拒否メッセージだけをそのまま返せ」と指示しているため最終出力が拒否文と一致しますが、この指示がない場合、LLM は拒否メッセージを踏まえた自由な文章で応答します。
hosted_multi_agent_beta.py — OpenAI ホスト型マルチエージェント(実験的)
v0.18.2 で追加された example です。 これまでの節はすべて「あなたのプロセスが複数のエージェントを組み立てて動かす」設計でしたが、この example は サブエージェントの生成と統括を OpenAI のサーバー側に任せるという別方式(hosted multi-agent ベータ)を扱います。ルートのモデルがサーバー上でサブエージェントを作り、それらを並行に走らせ、結果をまとめます。あなたのアプリケーションはサブエージェントを作りもスケジュールもしません。
一方で、開発者が定義した function tool は従来どおりあなたの手元で実行されます。サーバー側のサブエージェントが「このツールを呼びたい」と言うと、SDK の通常の Runner がローカルで関数を実行し、同じ call ID の function_call_output を WebSocket 上の実行中レスポンスへ注入(inject)して、呼び出し元のホスト型エージェントを再開させます。「統括はサーバー、ツール実行は手元」という役割分担です。
コード解説
エージェントの model に専用のモデルクラスを渡すだけで有効になります。
from agents.extensions.experimental.hosted_multi_agent import (
OpenAIHostedMultiAgentModel,
get_hosted_agent_metadata,
)
def build_agent() -> Agent[Any]:
return Agent(
name="Hosted proposal coordinator",
instructions=(
"Create two subagents. Ask one to inspect proposal alpha and the other to inspect "
"proposal beta. Each subagent must call get_proposal for its assigned proposal. "
"Wait for both results, then return one concise comparison from the root agent."
),
model=OpenAIHostedMultiAgentModel(
model="gpt-5.6-sol",
config={"max_concurrent_subagents": 3},
),
tools=[get_proposal],
)
OpenAIHostedMultiAgentModelを構築するとmulti_agent.enabledが有効になり、OpenAI-Beta: responses_multi_agent=v1というヘッダーが WebSocket に送られます。max_concurrent_subagentsを省略するとサービス側の既定値が使われますinstructionsは「サブエージェントを 2 つ作れ」「それぞれに alpha / beta を見させろ」「両方の結果を待ってからルートエージェントが 1 つの比較を返せ」と、サブエージェントの編成そのものを日本語ならぬ自然言語で指示している点に注目してください。編成表がコードではなくプロンプトに書かれるのが、この方式の最大の特徴です
ツール側は get_hosted_agent_metadata() で「どのホスト型エージェントから呼ばれたか」を知ることができます。
@tool
def get_proposal(ctx: ToolContext[Any], proposal: str) -> dict[str, object]:
"""Return deterministic details for one proposal."""
metadata = get_hosted_agent_metadata(ctx)
caller = metadata.agent_name if metadata else "unknown"
print(f"local tool caller={caller} call_id={ctx.tool_call_id} proposal={proposal}")
if proposal not in PROPOSALS:
raise ValueError(f"Unknown proposal: {proposal}")
return PROPOSALS[proposal]
- 第 1 引数に
ToolContextを取ると、SDK が実行文脈を渡してくれます。get_hosted_agent_metadata(ctx)が返すagent_nameが呼び出し元のホスト型エージェント名です(ルートは/root) - ホスト型エージェント名は観測用のメタデータであって、ローカルのルーティング機構ではありません。 出力の振り分けには SDK が渡す call ID を使ってください。副作用のあるツールでは、その call ID を冪等キーとして使い、必要な認可はアプリケーション側で実装します
ストリーミング時は、ホスト型のアイテムを raw イベントから拾えます。
def log_hosted_event(event: object) -> None:
if getattr(event, "type", None) != "response.output_item.done":
return
item = getattr(event, "item", None)
item_type = item.get("type") if isinstance(item, Mapping) else getattr(item, "type", None)
metadata = get_hosted_agent_metadata(item)
is_hosted_item = item_type in {
"agent_message",
"multi_agent_call",
"multi_agent_call_output",
}
agent_message/multi_agent_call/multi_agent_call_outputがホスト型オーケストレーションの記録です- 最終出力になるのは、
/rootに帰属し phase がfinal_answerのメッセージだけです。サブエージェントのメッセージとオーケストレーション記録は高レベルのRunResultからは除外されます(SDK がそれらをローカル関数として実行することもありません)
実行手順
OPENAI_API_KEYが設定されていることを確認します。非ストリーミング版は次のコマンドです。
uv run -m examples.agent_patterns.hosted_multi_agent_betaストリーミング版は
--mode streamを付けます。uv run -m examples.agent_patterns.hosted_multi_agent_beta --mode stream
期待される動作・出力例
local tool caller=... call_id=... proposal=alpha のようなログが 2 回(alpha / beta)出て、最後に Final response: として 2 案の比較が表示されます。ストリーミング版では加えて hosted item type=multi_agent_call agent=/root のような、サーバー側オーケストレーションの実況が流れます。
つまずきポイント
- 実験的機能です。 インターフェースとベータのアイテムスキーマは一般提供までに変わり得ます。
client.beta.responses.connectを公開するopenai[realtime]2.45.0 以降のビルドが必要で、通信は Responses の WebSocket トランスポートを使います(ローカル関数の出力をresponse.injectで実行中のホスト型エージェントへ返すため) - SDK の handoff とは併用できません。 すべてのホスト型エージェントが同じ handoff ツールを受け取ってしまい所有権が衝突するため、handoff を渡すと
UserErrorで弾かれます。agents-as-tools は使えますが、クライアント側とサーバー側の入れ子オーケストレーションになるので、レイテンシ・コスト・ツール露出を意識して選んでください - ツール承認(approval)は使えません。
needs_approvalがFalse以外の function tool は、リクエスト送信前に拒否されます。認可が要るなら、承認機構ではなくアプリケーションコード側で実装してください - 現状の制限として、
reasoning.summary・max_tool_calls・呼び出し側からのmulti_agent/betasの上書きは拒否されます。Responses の/compactエンドポイントも未対応です(ただしサービスが各ホスト型エージェントの文脈を独立に自動圧縮するため、明示的なcontext_management.compact_thresholdは使えます) - ツールの引数と出力は Responses API の境界を越えて流れます。機密データを扱うツールでは、この点を踏まえて設計してください
「統括はサーバー、ツール実行は手元」という役割分担を図にすると次のとおりです。
まとめ
- マルチエージェント設計には「コードで制御」と「LLM に判断させる」の 2 軸があり、手順が固定なら決定的フロー、統合責任を 1 エージェントに置くなら agents as tools、会話ごと専門家に渡すなら handoff が基本の選び分けです
Agent.as_tool()はエージェントを関数ツール化する仕組みで、is_enabled(条件付き有効化)、on_stream(内部イベント観測)、parameters(構造化入力)、needs_approval(承認必須化)などのオプションで実運用の要件に対応できます- 並列実行は
asyncio.gatherだけで実現でき、速度向上のほか「複数案生成 + LLM as a judge による選定・改善ループ」で品質を高める用途にも使えます - guardrail は入力用(最初のエージェントで実行、コスト防御が主目的)と出力用(最後のエージェントで実行、漏えい防止が主目的)があり、tripwire が作動すると例外で実行が止まります。ストリーミング中の途中検査は自前実装のパターンで補います
ModelSettings(tool_choice="required")とtool_use_behaviorの組み合わせで「必ずツールを使う」「ツール結果を最終出力にして LLM 再呼び出しを省く」といった制御ができますneeds_approvalを付けたツールは実行前に停止してinterruptionsに承認要求が現れ、to_state()→approve/reject→Runner.run(agent, state)で再開します。RunStateは JSON にシリアライズでき、承認待ちを別プロセスに持ち越せますOpenAIHostedMultiAgentModel(実験的、v0.18.2 で example 追加)を使うと、サブエージェントの生成・並行実行・統括を OpenAI のサーバー側が担い、function tool だけが手元で実行されます。編成をコードではなくプロンプトで指示する方式で、SDK handoff とは併用できず、ツール承認も使えません
理解度チェック
handoff と agents as tools の違いとして正しいものはどれですか。
- (a) handoff は会話履歴を渡さないが、agents as tools は渡す
- (b) handoff では委譲先が会話の主導権を持つが、agents as tools では元のエージェントが会話を続ける
- (c) agents as tools は 1 つのエージェントしか呼べない
- (d) 両者は内部的に同じ動作で、名前が違うだけである
deterministic.py で、アウトラインのチェック担当エージェントに output_type=OutlineCheckerOutput を指定している理由を説明してください。
入力 guardrail の既定の実行モード(並行実行)には、どんな弱点がありますか。また、それを避ける方法は何ですか。
tool_choice="required" と tool_use_behavior="run_llm_again"(既定)を単純に組み合わせると、どんな問題が起き得ますか。
human_in_the_loop.py で、承認後に実行を再開するときの Runner.run の第 2 引数には何を渡しますか。元の質問文字列を渡すとどうなりますか。
解答と解説
問1 の解答と解説
handoff は「電話の転送」で、委譲先が会話履歴ごと引き継いで以降の会話を担当します。agents as tools は「内線での確認」で、専門エージェントは生成された入力だけを受け取って結果を返し、会話の窓口は元のエージェントのままです。この違いは src/agents/agent.py の as_tool() の docstring にも明記されています。
問2 の解答と解説
問2.
チェック結果を good_quality: bool と is_scifi: bool という構造化データで受け取ることで、if not ... : exit(0) のような Python コードによる確実な分岐(品質ゲート) が書けるからです。自由文で「良いと思います」と返されても、コードでは合否を機械的に判定できません。構造化出力は「LLM の判断をコードの制御構造に接続する」ための要です。
問3 の解答と解説
問3.
並行実行モードでは guardrail と本体エージェントが同時に走り始めるため、guardrail が作動した時点で本体がすでにトークンを消費し、ツールを実行してしまっている可能性があります。これを避けるには run_in_parallel=False(ブロッキング実行)を指定し、guardrail の完了を待ってから本体を開始します。レイテンシと安全性のトレードオフです。
問4 の解答と解説
問4.
無限ループの恐れがあります。ツール使用が毎ターン強制されるため、「ツール実行 → 結果を LLM へ → また強制的にツール実行 → …」が繰り返されます。forcing_tool_use.py では default モードのときだけ tool_choice を設定しないことで回避しており、SDK 側にも reset_tool_choice(ツール実行後に強制を解除する設定。既定で有効)という安全装置があります。
問5 の解答と解説
問5.
result.to_state() から得た(そして approve/reject を記録した)RunState オブジェクトを渡します。これにより中断地点から実行が再開されます。元の質問文字列を渡してしまうと、承認情報を持たないまったく新しい実行が最初から始まり、また同じ承認要求で止まることになります。
参考リンク
- エージェントのオーケストレーション(英語): https://openai.github.io/openai-agents-python/multi_agent/ /(日本語): https://openai.github.io/openai-agents-python/ja/multi_agent/
- ガードレール(英語): https://openai.github.io/openai-agents-python/guardrails/ /(日本語): https://openai.github.io/openai-agents-python/ja/guardrails/
- Human-in-the-loop(英語): https://openai.github.io/openai-agents-python/human_in_the_loop/ /(日本語): https://openai.github.io/openai-agents-python/ja/human_in_the_loop/
- ツール(
Agent.as_tool()を含む)(英語): https://openai.github.io/openai-agents-python/tools/ /(日本語): https://openai.github.io/openai-agents-python/ja/tools/ - エージェントの実行と結果(英語): https://openai.github.io/openai-agents-python/running_agents/ ・ https://openai.github.io/openai-agents-python/results/ /(日本語): https://openai.github.io/openai-agents-python/ja/running_agents/ ・ https://openai.github.io/openai-agents-python/ja/results/
- コード例一覧(英語): https://openai.github.io/openai-agents-python/examples/ /(日本語): https://openai.github.io/openai-agents-python/ja/examples/
第5章 ハンドオフ(handoffs)
この章で学ぶこと
- handoff(ハンドオフ)の概念 — エージェントが会話ごと別のエージェントに引き継ぐ仕組み
- handoff が LLM からは「ツール」として見えることと、
handoff()関数によるカスタマイズ方法 - input filter(入力フィルター)で、引き継ぎ先エージェントに渡す会話履歴を加工する方法
HandoffInputDataの構造と、既製フィルターhandoff_filters.remove_all_toolsの使い方- handoff と agents-as-tools(第4章)の違いと使い分け
- ストリーミング実行時に handoff と input filter を使う際の注意点
前提知識
この章を読む前に、以下を理解していることを前提とします。
- 第1章: リポジトリのクローンと
uv syncによるセットアップ、OPENAI_API_KEYの設定 - 第2章:
AgentとRunner.run()の基本、実行結果(RunResult)からto_input_list()で会話履歴を取り出して次の実行につなげる方法 - 第3章:
@tool(別名@function_tool)デコレータによるツール(LLM が呼び出せる関数)の定義 - 第4章: agents-as-tools パターン(エージェントをツールとして別のエージェントから呼び出す設計)。この章で handoff との違いを比較します
概念解説
handoff とは何か — 「電話窓口の転送」の比喩
handoff(ハンドオフ)とは、あるエージェントが会話の主導権ごと別のエージェントに引き継ぐ仕組みです。
コールセンターに電話をかけた場面を想像してください。最初に応対するのは総合受付のオペレーターです。あなたが「返金について相談したい」と言うと、受付は「返金担当におつなぎします」と言って電話を転送します。転送された後は、返金担当者があなたと直接会話を続けます。総合受付はもう会話に参加しません。そして重要なのは、転送先の担当者がそれまでの会話内容(あなたの名前や要件)を引き継いだ状態で応対を始めることです。
これがまさに handoff です。SDK の世界では、
- 総合受付 = 振り分け役のエージェント(triage agent、トリアージエージェントとも呼びます)
- 返金担当 = 専門エージェント
- 電話の転送 = handoff
- 「それまでの会話内容」= 会話履歴(これまでのユーザー発言と AI 応答の記録)
に対応します。カスタマーサポートアプリなら、注文状況・返金・FAQ をそれぞれ専門に扱うエージェントを用意し、受付エージェントが適切な担当へ handoff する、という構成が典型例です。
handoff は LLM からは「ツール」として見える
handoff は、LLM(大規模言語モデル。ChatGPT の中身のような文章生成 AI)に対してはツールとして表現されます。たとえば Refund Agent という名前のエージェントへの handoff があると、LLM には transfer_to_refund_agent という名前のツールが見えます。LLM が「この会話は返金担当に任せるべきだ」と判断してこのツールを呼び出すと、SDK が実際の引き継ぎ処理を実行します。
つまり「誰に引き継ぐかの判断」は LLM が行い、「引き継ぎの実務(履歴の受け渡しや実行エージェントの切り替え)」は SDK が行う、という役割分担です。
handoff の作り方
すべての Agent には handoffs パラメータがあり、引き継ぎ先を 2 通りの書き方で指定できます。公式ドキュメント(docs/handoffs.md)の例を引用します。
from agents import Agent, handoff
billing_agent = Agent(name="Billing agent")
refund_agent = Agent(name="Refund agent")
triage_agent = Agent(name="Triage agent", handoffs=[billing_agent, handoff(refund_agent)])
billing_agentのようにAgentをそのまま渡す: 最も簡単な方法です。エージェントにhandoff_description(引き継ぎ先としての説明文)を設定しておくと、その内容がツールの説明に追記され、LLM が「いつこの引き継ぎ先を選ぶべきか」を判断しやすくなりますhandoff(refund_agent)のようにhandoff()関数でラップして渡す: 挙動を細かくカスタマイズしたいときに使います
handoff() 関数でカスタマイズできること
handoff() 関数には以下のオプションがあります(公式ドキュメントより)。
| 引数 | 役割 |
|---|---|
agent | 引き継ぎ先のエージェント |
tool_name_override | ツール名の上書き。デフォルトは transfer_to_<agent_name> |
tool_description_override | ツール説明文の上書き |
on_handoff | handoff が発動した瞬間に実行されるコールバック関数。引き継ぎ先で必要になるデータの先読みなどに使えます |
input_type | handoff のツール呼び出し引数のスキーマ(データ構造の定義)。LLM に「引き継ぎ理由」などのメタデータを生成させ、on_handoff で受け取れます |
input_filter | 引き継ぎ先エージェントが受け取る会話履歴を加工する関数(この章の主役。後述) |
is_enabled | この handoff の有効/無効。真偽値または真偽値を返す関数を渡せ、実行時に動的に切り替えられます |
nest_handoff_history | 実行設定(RunConfig)レベルの履歴ネスト設定をこの handoff だけ上書きするオプション |
on_handoff と input_type を組み合わせると、LLM に引き継ぎ時のメタデータを生成させられます。公式ドキュメントの例です。
from pydantic import BaseModel
from agents import Agent, handoff, RunContextWrapper
class EscalationData(BaseModel):
reason: str
async def on_handoff(ctx: RunContextWrapper[None], input_data: EscalationData):
print(f"Escalation agent called with reason: {input_data.reason}")
agent = Agent(name="Escalation agent")
handoff_obj = handoff(
agent=agent,
on_handoff=on_handoff,
input_type=EscalationData,
)
エスカレーション(上位担当への引き上げ)用エージェントに引き継ぐ際、LLM に「理由(reason)」を出力させ、ログに残す、という使い方です。注意点として、input_type はあくまで handoff というツール呼び出しの引数であって、引き継ぎ先エージェントへの入力(会話履歴)を置き換えるものではありません。履歴を変えたい場合は次に説明する input_filter を使います。
input filter — 引き継ぐ会話履歴を加工する
handoff が発生すると、新しいエージェントが会話を丸ごと引き継ぎ、それまでの会話履歴全体を見られる状態になります。これがデフォルトの挙動です。
しかし、履歴をそのまま渡したくない場合もあります。たとえば、
- 前のエージェントが使ったツールの呼び出し記録は、引き継ぎ先には無関係なノイズなので消したい
- 履歴が長すぎるので古い部分を削って、LLM に渡すトークン(LLM が文章を処理する際の最小単位。課金や入力上限の単位でもあります)を節約したい
- 個人情報を含む発言を引き継ぎ先に見せたくない
こうしたときに使うのが input filter です。input filter は HandoffInputData を受け取り、加工した新しい HandoffInputData を返す関数です。
HandoffInputData には以下が含まれます(SDK ソース src/agents/handoffs/__init__.py の定義に基づく)。
| フィールド | 内容 |
|---|---|
input_history | 今回の Runner.run(...) を開始する前からあった入力履歴(文字列または項目のタプル) |
pre_handoff_items | 今回の実行中、handoff が発動したターンより前に生成された項目 |
new_items | handoff が発動した現在のターンで生成された項目(handoff のツール呼び出しとその結果を含む) |
input_items | (省略可能)new_items の代わりに次のエージェントへ転送する項目。セッション履歴には new_items を残しつつ、モデルへの入力だけを加工したいときに使います |
run_context | handoff 発動時点の実行コンテキスト(アプリ側で共有している状態への参照) |
HandoffInputData には clone(**kwargs) メソッドがあり、一部のフィールドだけ差し替えたコピーを簡単に作れます。
よくある加工パターンはあらかじめ agents.extensions.handoff_filters に実装されています。代表が remove_all_tools で、履歴からツール関連の項目(関数呼び出しとその結果、Web 検索・ファイル検索の記録など)をすべて取り除きます。
from agents import Agent, handoff
from agents.extensions import handoff_filters
agent = Agent(name="FAQ agent")
handoff_obj = handoff(
agent=agent,
input_filter=handoff_filters.remove_all_tools,
)
なお、handoff とその実行全体の設定 RunConfig.handoff_input_filter の両方にフィルターが定義されている場合は、handoff 個別の input_filter が優先されます。
推奨プロンプト
LLM に handoff を正しく理解させるため、SDK は推奨のプロンプト接頭辞を提供しています。agents.extensions.handoff_prompt.RECOMMENDED_PROMPT_PREFIX を instructions の先頭に付けるか、prompt_with_handoff_instructions() 関数で自動付与します。
from agents import Agent
from agents.extensions.handoff_prompt import RECOMMENDED_PROMPT_PREFIX
billing_agent = Agent(
name="Billing agent",
instructions=f"""{RECOMMENDED_PROMPT_PREFIX}
<Fill in the rest of your prompt here>.""",
)
この接頭辞には「あなたはマルチエージェントシステムの一部であり、transfer_to_<agent_name> という関数で会話を引き継げる。引き継ぎのことをユーザーとの会話で話題にしないこと」といった内容が含まれています。
handoff と agents-as-tools の使い分け
第4章で学んだ agents-as-tools(Agent.as_tool() でエージェントをツール化して呼び出すパターン)と handoff は、どちらも「エージェントが別のエージェントに仕事を任せる」仕組みですが、性質が大きく異なります。
| 観点 | handoff | agents-as-tools(第4章) |
|---|---|---|
| 会話の主導権 | 引き継ぎ先に完全に移る。以後の応答は引き継ぎ先が生成し、元のエージェントには戻らない | 呼び出し元が保持したまま。子エージェントは結果を返すだけ |
| 渡る情報 | 会話履歴全体(input filter で加工可能) | ツール呼び出しの引数として渡された入力のみ |
| 結果の扱い | 引き継ぎ先の応答がそのままユーザーへの応答になる | 呼び出し元が結果を材料にして自分の応答を組み立てる |
| LLM から見た形 | transfer_to_<agent_name> というツール | エージェントごとに定義したツール |
| 向いている場面 | 窓口の切り替え(言語別対応、部門別サポートなど、以後の会話を専門家に任せたい場合) | 部分作業の委託(複数の翻訳結果を集めて統合する等、司令塔が結果を取りまとめたい場合) |
比喩で言えば、handoff は「電話の転送」、agents-as-tools は「部下への調べ物の依頼」です。転送すれば以後は転送先が話し相手になりますが、調べ物の依頼では依頼した本人が回答を受け取り、自分の言葉でユーザーに答えます。
補足として、handoff は 1 回の実行(run)の内部で完結します。入力ガードレール(不正な入力をブロックする安全チェック。別章で詳しく扱います)は連鎖の最初のエージェントにのみ適用され、出力ガードレールは最終出力を生成したエージェントにのみ適用される点も覚えておいてください。
各 example の解説
examples/handoffs ディレクトリには 2 つのファイルがあります。どちらも「input filter で会話履歴を加工してから handoff する」流れを示すもので、2 つ目はストリーミング実行版です。
message_filter.py
英語のアシスタントからスペイン語専門アシスタントへ handoff する際に、input filter で「ツール呼び出しの記録」と「履歴の先頭 2 件」を削除して引き継ぐ example です。フィルターが実際に効いたことを、最終的な会話履歴を出力して確認します。
コード解説
まず import とツール定義です。
from __future__ import annotations
import json
import random
from agents import (
Agent,
HandoffInputData,
Runner,
handoff,
trace,
)
from agents.decorators import tool
from agents.extensions import handoff_filters
from agents.models import is_gpt_5_default
@tool
def random_number_tool(max: int) -> int:
"""Return a random integer between 0 and the given maximum."""
return random.randint(0, max)
random_number_tool は乱数を返すだけの単純なツールです。これは「履歴にツール呼び出しの記録を残す」ために用意されています。後でフィルターがこの記録を削除する様子を観察します。
次が本章の主役、input filter 関数です。
def spanish_handoff_message_filter(handoff_message_data: HandoffInputData) -> HandoffInputData:
if is_gpt_5_default():
print("gpt-5 is enabled, so we're not filtering the input history")
# when using gpt-5, removing some of the items could break things, so we do this filtering only for other models
return HandoffInputData(
input_history=handoff_message_data.input_history,
pre_handoff_items=tuple(handoff_message_data.pre_handoff_items),
new_items=tuple(handoff_message_data.new_items),
)
# First, we'll remove any tool-related messages from the message history
handoff_message_data = handoff_filters.remove_all_tools(handoff_message_data)
# Second, we'll also remove the first two items from the history, just for demonstration
history = (
tuple(handoff_message_data.input_history[2:])
if isinstance(handoff_message_data.input_history, tuple)
else handoff_message_data.input_history
)
# or, you can use the HandoffInputData.clone(kwargs) method
return HandoffInputData(
input_history=history,
pre_handoff_items=tuple(handoff_message_data.pre_handoff_items),
new_items=tuple(handoff_message_data.new_items),
)
ポイントを順に見ます。
is_gpt_5_default()の分岐: デフォルトモデルが GPT-5 系のとき、履歴の一部項目(推論の記録など)を削除するとモデル呼び出しが壊れる可能性があるため、フィルタリングをスキップしてそのまま返します。この関数はデフォルトモデル名(環境変数OPENAI_DEFAULT_MODEL、未設定なら SDK 組み込みの既定値)が GPT-5 系かどうかを判定しますhandoff_filters.remove_all_tools(...): SDK 既製のフィルターで、input_history・pre_handoff_items・new_itemsからツール関連の項目(関数呼び出し・関数結果・handoff 呼び出しの記録など)をすべて除去した新しいHandoffInputDataを返します- 先頭 2 件の削除:
input_history[2:]で履歴の最初の 2 項目(最初のユーザー発言とアシスタント応答)をデモとして切り落とします。input_historyは文字列の場合もあるため、タプルのときだけスライスしています - 戻り値: 加工済みの
HandoffInputDataを組み立てて返します。コメントにあるとおり、handoff_message_data.clone(input_history=history)と書いても同じことができます
続いてエージェントの定義です。
first_agent = Agent(
name="Assistant",
instructions="Be extremely concise.",
tools=[random_number_tool],
)
spanish_agent = Agent(
name="Spanish Assistant",
instructions="You only speak Spanish and are extremely concise.",
handoff_description="A Spanish-speaking assistant.",
)
second_agent = Agent(
name="Assistant",
instructions=(
"Be a helpful assistant. If the user speaks Spanish, handoff to the Spanish assistant."
),
handoffs=[handoff(spanish_agent, input_filter=spanish_handoff_message_filter)],
)
first_agent: 乱数ツールを持つアシスタント。履歴の「前半」を作る役ですspanish_agent: スペイン語しか話さない専門エージェント。handoff_descriptionにより、LLM に見えるツール説明へ「スペイン語話者向けアシスタント」という補足が付きますsecond_agent: ユーザーがスペイン語を話したら handoff するよう指示されたエージェント。handoff(spanish_agent, input_filter=spanish_handoff_message_filter)により、この handoff が発動した瞬間に先ほどのフィルターが呼ばれます
最後に main 関数です。会話を 4 ステップに分けて積み上げていきます。
async def main():
# Trace the entire run as a single workflow
with trace(workflow_name="Message filtering"):
# 1. Send a regular message to the first agent
result = await Runner.run(first_agent, input="Hi, my name is Sora.")
print("Step 1 done")
# 2. Ask it to generate a number
result = await Runner.run(
first_agent,
input=result.to_input_list()
+ [{"content": "Can you generate a random number between 0 and 100?", "role": "user"}],
)
print("Step 2 done")
# 3. Call the second agent
result = await Runner.run(
second_agent,
input=result.to_input_list()
+ [
{
"content": "I live in New York City. Whats the population of the city?",
"role": "user",
}
],
)
print("Step 3 done")
# 4. Cause a handoff to occur
result = await Runner.run(
second_agent,
input=result.to_input_list()
+ [
{
"content": "Por favor habla en español. ¿Cuál es mi nombre y dónde vivo?",
"role": "user",
}
],
)
print("Step 4 done")
with trace(workflow_name="Message filtering"): トレーシング(実行の記録を OpenAI のダッシュボードで可視化する仕組み)のブロックです。複数回のRunner.run()を 1 つのワークフローとしてまとめて記録します- ステップ 1〜2:
first_agentに名前を名乗り("my name is Sora")、乱数生成を依頼します。result.to_input_list()は「これまでの入力+生成された項目」を次の実行の入力リストに変換するメソッドで、これにより会話履歴が引き継がれます。ステップ 2 でツール呼び出しの記録が履歴に入ります - ステップ 3:
second_agentに切り替えて英語で質問します。英語なので handoff は起きません - ステップ 4: スペイン語で「私の名前と住んでいる場所は?」と尋ねます。
second_agentは指示に従いtransfer_to_spanish_assistantツールを呼び出し、handoff が発動 →spanish_handoff_message_filterが呼ばれ → 加工済み履歴を持ったspanish_agentが応答を生成します
最後に、フィルターの効果を確認するために最終履歴を JSON(データ交換用のテキスト形式)で出力します。
print("\n===Final messages===\n")
# 5. That should have caused spanish_handoff_message_filter to be called, which means the
# output should be missing the first two messages, and have no tool calls.
# Let's print the messages to see what happened
for message in result.to_input_list():
print(json.dumps(message, indent=2))
実行手順
- リポジトリのルートディレクトリ(
openai-agents-python)にいることを確認します(第1章のとおりgit cloneとuv syncは完了している前提です) OPENAI_API_KEYを設定します:export OPENAI_API_KEY=sk-...- フィルターの動作を観察したい場合は、デフォルトモデルを GPT-5 系以外に切り替えます(理由は後述の「つまずきポイント」参照):
export OPENAI_DEFAULT_MODEL=gpt-4.1 - 実行します:
uv run python examples/handoffs/message_filter.py
期待される動作・出力例
Step 1 done 〜 Step 4 done が順に表示された後、最終履歴が JSON で出力されます。ソースコード内のコメントに実行例が残されており、要点は次のとおりです。
- 履歴の先頭が「乱数を生成して」というユーザー発言から始まっている(最初の 2 件 —「Hi, my name is Sora.」とその応答 — がフィルターで削除された)
- 乱数ツールの呼び出し・結果の項目が存在しない(
remove_all_toolsの効果) - 最後のスペイン語の質問に対し、スペイン語エージェントが「あなたの名前は分かりませんが、ニューヨークに住んでいると言っていましたね」という趣旨の応答を返す
{
"content": "Por favor habla en español. ¿Cuál es mi nombre y dónde vivo?",
"role": "user"
}
{
...
"text": "No tengo acceso a esa información personal, solo sé lo que me has contado: vives en Nueva York.",
...
}
名前(Sora)を答えられないのは、名乗った発言がフィルターで履歴から消えたためです。一方、ニューヨーク在住であることは履歴に残っているので答えられます。「何を消したら何を忘れるか」が応答にそのまま表れる、input filter の効果が一目で分かる仕掛けです。
つまずきポイント
- デフォルト設定のままではフィルターが働きません。 この SDK バージョン(v0.22.2)のデフォルトモデル
gpt-5.6-lunaは GPT-5 系のため、is_gpt_5_default()が真になり、フィルター関数は冒頭で「gpt-5 is enabled, so we're not filtering the input history」と表示して履歴を無加工で返します。フィルタリングの動作を確認したいときは、環境変数OPENAI_DEFAULT_MODELに GPT-5 系以外のモデル(例:gpt-4.1)を設定してください - 出力される名前や乱数、応答の文言は実行のたびに変わります。コメント内の実行例と完全一致しなくても正常です
trace()でまとめた実行記録は、OpenAI ダッシュボードの Traces ページ(https://platform.openai.com/traces)で「Message filtering」というワークフロー名で確認できます。handoff の発生箇所やフィルター適用後の入力を視覚的に追えるので、ぜひ見てみてください
message_filter_streaming.py
message_filter.py とほぼ同じ内容を、最後の handoff 発生ステップだけストリーミング実行(応答を完成後にまとめて受け取るのではなく、生成されるそばから逐次受け取る実行方式)に変えた example です。input filter がストリーミングでも同じように機能することを確認します。
コード解説
ツール定義・フィルター関数・エージェント定義・ステップ 1〜3 は message_filter.py と同一です(トレース名だけ "Streaming message filter" に変わっています)。違いはステップ 4 と最終出力の部分だけなので、そこを引用します。
# 4. Cause a handoff to occur
stream_result = Runner.run_streamed(
second_agent,
input=result.to_input_list()
+ [
{
"content": "Por favor habla en español. ¿Cuál es mi nombre y dónde vivo?",
"role": "user",
}
],
)
async for _ in stream_result.stream_events():
pass
print("Step 4 done")
Runner.run_streamed(...):Runner.run()の代わりに使うストリーミング版の実行メソッドです。戻り値はストリーミング結果オブジェクトで、awaitせずに受け取りますasync for _ in stream_result.stream_events(): 非同期イテレーション(結果が届くたびに 1 件ずつ処理を進める書き方)でイベントを消費します。この example ではイベントの中身を使わないため_で読み捨てていますが、実際のアプリではここで応答テキストの断片を画面に逐次表示する、といった処理を書きます。ストリームを最後まで消費しないと実行が完了しないため、読み捨てでもループは必須です
最終履歴の出力もストリーミング結果オブジェクトから行います。
print("\n===Final messages===\n")
# 5. That should have caused spanish_handoff_message_filter to be called, which means the
# output should be missing the first two messages, and have no tool calls.
# Let's print the messages to see what happened
for item in stream_result.to_input_list():
print(json.dumps(item, indent=2))
ストリーミング実行でも to_input_list() で会話履歴を取り出せる点は通常実行と同じです。
実行手順
- リポジトリのルートディレクトリにいることを確認します
OPENAI_API_KEYを設定します:export OPENAI_API_KEY=sk-...- フィルターの動作を観察する場合はデフォルトモデルを切り替えます:
export OPENAI_DEFAULT_MODEL=gpt-4.1 - 実行します:
uv run python examples/handoffs/message_filter_streaming.py
期待される動作・出力例
message_filter.py と同様に Step 1 done 〜 Step 4 done が表示され、最終履歴の JSON が出力されます。確認ポイントも同じです。
- 履歴の先頭 2 件(名乗りとその応答)が消えている
- ツール呼び出しの項目が消えている
- スペイン語エージェントは「名前は分からないが、ニューヨーク在住なのは知っている」と答える
ソースコード内のコメントに残された実行例では、最終応答は次のようになっています。
"No sé tu nombre, pero me dijiste que vives en Nueva York."
つまずきポイント
- input filter による履歴の加工結果はストリーミングイベントとして流れてきません。 SDK の
Handoff.input_filterの仕様として、フィルター適用より前に生成された項目はすでにストリーミング済みであり、フィルターの結果として何かが再送されることはありません。「フィルターで消したはずの項目がイベントとしては流れてきた」ように見えても、最終的な履歴(to_input_list())には反映されています message_filter.pyと同じく、デフォルトモデルが GPT-5 系の場合はフィルタリングがスキップされます- サーバー管理の会話機能(
conversation_idやprevious_response_idを使って会話履歴を OpenAI 側に保存する方式)と handoff の input filter は併用できません。この example のようにクライアント側でto_input_list()により履歴を管理する方式で使ってください
まとめ
- handoff は、エージェントが会話の主導権ごと別のエージェントに引き継ぐ仕組みで、LLM からは
transfer_to_<agent_name>というツールとして見えます - 引き継ぎ先は
Agent.handoffsにAgentを直接渡すか、handoff()関数でラップして渡します。handoff()ではツール名・説明・コールバック(on_handoff)・入力スキーマ(input_type)・有効/無効(is_enabled)などをカスタマイズできます - デフォルトでは引き継ぎ先が会話履歴全体を見られます。input filter に
HandoffInputDataを加工する関数を渡すことで、ツール記録の削除や古い履歴の切り落としなどが可能です - よくある加工は
agents.extensions.handoff_filtersに既製実装があり、代表がremove_all_toolsです - handoff は「電話の転送」(主導権が移る)、agents-as-tools は「部下への調べ物の依頼」(主導権は呼び出し元に残る)。以後の会話を専門家に任せたいなら handoff、結果だけ受け取って統合したいなら agents-as-tools を選びます
- ストリーミング実行(
Runner.run_streamed)でも handoff と input filter は機能しますが、フィルターの加工結果自体はストリーミングされません
理解度チェック
handoff は LLM に対してどのような形で提示されますか。Spanish Assistant という名前のエージェントへの handoff を例に答えてください。
handoff と agents-as-tools(第4章)の最も本質的な違いを、「会話の主導権」と「引き継がれる情報」の 2 つの観点から説明してください。
HandoffInputData の input_history・pre_handoff_items・new_items は、それぞれどの時点の情報を指しますか。
message_filter.py を実行したとき、スペイン語エージェントが「ユーザーの名前」を答えられず「住んでいる街」は答えられるのはなぜですか。
message_filter.py をデフォルト設定のまま実行するとフィルタリングが行われないことがあります。その理由と、フィルタリングを観察するための対処法を答えてください。
解答と解説
問1 の解答と解説
handoff はツールとして提示され、ツール名はデフォルトで transfer_to_<agent_name>(エージェント名を関数名スタイルに変換したもの)になります。Spanish Assistant への handoff なら transfer_to_spanish_assistant です。LLM がこのツールを呼び出すと SDK が引き継ぎ処理を実行します。ツール名は handoff() の tool_name_override で変更できます。
問2 の解答と解説
handoff では会話の主導権が引き継ぎ先エージェントに完全に移り、以後の応答は引き継ぎ先が生成します。また、デフォルトで会話履歴全体が引き継がれます(input filter で加工可能)。一方 agents-as-tools では主導権は呼び出し元に残り、子エージェントにはツール引数として渡した入力だけが伝わり、呼び出し元が結果を受け取って自分の応答を組み立てます。
問3 の解答と解説
input_history は今回の Runner.run(...) を開始する前からあった入力履歴、pre_handoff_items は今回の実行中で handoff が発動したターンより前に生成された項目、new_items は handoff が発動した現在のターンで生成された項目(handoff のツール呼び出しと、その結果を表す項目を含む)です。
問4 の解答と解説
input filter spanish_handoff_message_filter が履歴の先頭 2 件を削除したためです。先頭 2 件は「Hi, my name is Sora.」というユーザーの名乗りとそれへの応答なので、引き継ぎ先のスペイン語エージェントは名前を知り得ません。一方「I live in New York City...」の発言は削除されずに残っているため、住んでいる街には答えられます。
問5 の解答と解説
フィルター関数の冒頭に is_gpt_5_default() による分岐があり、デフォルトモデルが GPT-5 系の場合は「履歴の一部項目を削除するとモデル呼び出しが壊れる可能性がある」ため、フィルタリングせずに履歴をそのまま返すからです。この SDK バージョンのデフォルトモデルは GPT-5 系なので、そのまま実行するとスキップされます。環境変数 OPENAI_DEFAULT_MODEL に GPT-5 系以外のモデル(例: gpt-4.1)を設定してから実行すると、フィルタリングの動作を観察できます。
参考リンク
Handoffs(公式ドキュメント): https://openai.github.io/openai-agents-python/handoffs/
- 日本語版: https://openai.github.io/openai-agents-python/ja/handoffs/
Running agents(
Runner.run/run_streamedの解説): https://openai.github.io/openai-agents-python/running_agents/- 日本語版: https://openai.github.io/openai-agents-python/ja/running_agents/
Results(
to_input_list()など実行結果の扱い): https://openai.github.io/openai-agents-python/results/- 日本語版: https://openai.github.io/openai-agents-python/ja/results/
Streaming(ストリーミングイベントの詳細): https://openai.github.io/openai-agents-python/streaming/
- 日本語版: https://openai.github.io/openai-agents-python/ja/streaming/
Tracing(
trace()とダッシュボードでの可視化): https://openai.github.io/openai-agents-python/tracing/- 日本語版: https://openai.github.io/openai-agents-python/ja/tracing/
Examples 一覧: https://openai.github.io/openai-agents-python/examples/
- 日本語版: https://openai.github.io/openai-agents-python/ja/examples/
第6章 MCP 連携(mcp)
この章で学ぶこと
- MCP(Model Context Protocol)とは何か、なぜ AI エージェント開発で重要なのかを理解する
- stdio / SSE / Streamable HTTP という 3 つのトランスポート(通信方式)の違いと使い分けを理解する
MCPServerStdio、MCPServerSse、MCPServerStreamableHttpを使って Agent に外部ツールを接続する方法を学ぶ- ツールフィルタリング(
tool_filter)と承認フロー(require_approval)で MCP ツールの利用を制御する方法を学ぶ - MCP プロンプト機能で Agent の instructions を動的に生成する方法を学ぶ
MCPServerManagerで複数の MCP サーバーの接続ライフサイクルを管理する方法を学ぶ
前提知識
v0.22.1:MCPサーバー単位のガードレール
MCPServerStdio・MCPServerSse・MCPServerStreamableHttp に tool_input_guardrails と tool_output_guardrails を設定できるようになりました。フィルター後に残ったツールへ適用され、入力側はMCP呼び出しの前、出力側は変換済みの結果をモデルへ渡す前に検査します。第7章の HostedMCPTool へクライアント側の検査を追加する機能ではありません。MCPの公式仕様
この章を読む前に、以下の内容を理解しておいてください。
- 第1章: SDK のセットアップ(
git clone→uv sync)、OPENAI_API_KEYの設定、AgentとRunnerの基本 - 第2章: function tool の仕組み(LLM がツールを選んで呼び出す流れ)
- 第3章: ストリーミングと非同期処理(
async/await)の基本
また、この章の一部の example では Python 以外のツールが必要です。
| 前提ツール | 必要な example | インストール方法 |
|---|---|---|
Node.js(npx コマンド) | filesystem_example、tool_filter_example、get_all_mcp_tools_example | Node.js 公式サイト からインストール(npx は Node.js に同梱) |
uvx コマンド | git_example | uv をインストール済みなら同梱されています(第1章参照) |
| インターネット接続 | streamable_http_remote_example | 外部の公開 MCP サーバー(DeepWiki)に接続するため |
概念解説
MCP(Model Context Protocol)とは
MCP(Model Context Protocol)は、アプリケーションが LLM(大規模言語モデル)にツールやコンテキスト(文脈情報)を提供する方法を標準化したオープンなプロトコルです。公式ドキュメントでは次のように説明されています。
MCP は、アプリケーションが LLM にコンテキストを提供する方法を標準化するオープンプロトコルです。MCP は AI アプリケーションにおける USB-C ポートのようなものだと考えてください。USB-C がさまざまな周辺機器やアクセサリーにデバイスを接続する標準化された方法を提供するように、MCP は AI モデルをさまざまなデータソースやツールに接続する標準化された方法を提供します。
日常の比喩で考えてみましょう。海外旅行に行くとき、国ごとにコンセントの形状が違うと、家電ごとに専用の変換プラグが必要になり大変です。しかし USB-C のような統一規格があれば、どのメーカーの充電器でも、どのデバイスでも同じケーブルで接続できます。
AI エージェント開発でも同じ問題がありました。「ファイルを読むツール」「Git を操作するツール」「データベースを検索するツール」を Agent に持たせたいとき、従来はエージェントフレームワークごとに個別のツール実装(変換プラグ)を書く必要がありました。MCP はこの「接続部分」を標準化したことで、誰かが一度 MCP サーバーとして公開したツールは、MCP に対応したどのアプリケーションからでもそのまま使えるようになりました。
MCP サーバーと MCP クライアント
MCP の世界には 2 つの登場人物がいます。
- MCP サーバー: ツール(機能)を提供する側。例えば「ファイルシステム操作サーバー」は
read_file(ファイルを読む)やlist_directory(ディレクトリ一覧を取得する)といったツールを公開します - MCP クライアント: ツールを利用する側。この章では OpenAI Agents SDK で作った Agent がクライアントになります
レストランに例えると、MCP サーバーは「メニュー(ツール一覧)を提示し、注文(ツール呼び出し)を受けて料理(実行結果)を返す厨房」、Agent は「メニューを見て注文する客」です。そしてメニューの書式や注文の伝え方(プロトコル)が世界共通なので、どの厨房(サーバー)とどの客(クライアント)を組み合わせても会話が成立します。
MCP サーバーはツール以外に、プロンプト(Agent への指示文のテンプレート。後述の prompt_server で扱います)やリソース(読み取り可能なデータ)も提供できます。
この関係を図にすると次のようになります。
SDK での統合の流れ
OpenAI Agents SDK では、MCP サーバーへの接続を表すオブジェクトを作り、Agent の mcp_servers 引数に渡すだけで統合できます。内部の流れは次のとおりです。
- MCP サーバーに接続する(stdio の場合はサブプロセスとして起動する。サブプロセスとは、プログラムの中から起動される別のプログラムのことです)
- Agent の実行のたびに、SDK がサーバーの
list_tools()を呼び出してツール一覧を取得する - 取得したツールは LLM から見ると通常の function tool と同じように見える
- LLM が MCP ツールの使用を選択すると、SDK がサーバーの
call_tool()を呼び出して実行し、結果を LLM に返す
つまり読者のみなさんは「サーバーへの接続」さえ書けば、あとは第2章で学んだ function tool とまったく同じ感覚で使えます。この4ステップを図にすると次のとおりです。
3 つのトランスポートの違い
MCP クライアントとサーバーの間の通信方式をトランスポートと呼びます。SDK は 3 種類のトランスポートをサポートしており、それぞれに対応するクラスが agents.mcp モジュールに用意されています。
| 項目 | stdio | SSE(HTTP with Server-Sent Events) | Streamable HTTP |
|---|---|---|---|
| SDK のクラス | MCPServerStdio | MCPServerSse | MCPServerStreamableHttp |
| 通信経路 | 標準入出力(stdin/stdout) | HTTP(/sse エンドポイント) | HTTP(/mcp エンドポイント) |
| サーバーの場所 | ローカルのサブプロセスとして SDK が起動 | ローカルまたはリモートの HTTP サーバー | ローカルまたはリモートの HTTP サーバー |
| サーバーの起動 | SDK が自動で起動・終了 | 自分で起動しておく必要がある | 自分で起動しておく必要がある |
| ネットワーク越しの利用 | 不可(同一マシンのみ) | 可能 | 可能 |
| 現在の位置づけ | ローカルツールの標準 | 非推奨(deprecated)。既存サーバーとの互換性のためだけに残されている | HTTP 系の推奨方式 |
| 典型的な用途 | npx や uvx で配布される既製サーバー、手元のツール | レガシーな SSE サーバーへの接続 | 自社インフラ上のサーバー、リモート公開サーバー |
- stdio: サーバーをローカルのサブプロセスとして起動し、標準入出力(キーボード入力や画面出力に使われる、プログラムの基本的な入出力経路)でやり取りします。「同じ部屋にいる相手と直接会話する」イメージです。ネットワーク設定が不要で、最も手軽です
- SSE(Server-Sent Events): サーバーからクライアントへ一方向にデータをプッシュし続ける HTTP の仕組みです。「ラジオ放送を聞きながら、別の電話で注文する」ような二本立ての通信になります。MCP プロジェクトではすでに非推奨となっており、新規開発では次の Streamable HTTP を使うべきです
- Streamable HTTP: 単一の HTTP エンドポイントで双方向のやり取りとストリーミング(データを一括ではなく少しずつ順次送る方式)を実現する、現在推奨されている HTTP トランスポートです
なお、これらはすべて「自分のコードがクライアントとして接続を管理する」方式です。これに対して、接続管理ごと OpenAI 側のインフラに任せる「ホスト型 MCP(Hosted MCP)」もあり、それは次の第7章で扱います。
3 つのトランスポートの通信経路を並べると、次のようになります。
この章の examples の全体像
examples/mcp ディレクトリには 11 個の example があります。前半(6.1〜6.7)はトランスポート別の基本形、後半(6.8〜6.11)は応用機能(プロンプト、フィルタリング、事前取得、ライフサイクル管理)です。
| 節 | ディレクトリ | トランスポート | テーマ |
|---|---|---|---|
| 6.1 | filesystem_example | stdio | ファイルシステム操作サーバー(npx) |
| 6.2 | git_example | stdio | Git 操作サーバー(uvx)とツール一覧のキャッシュ |
| 6.3 | sse_example | SSE | ローカル SSE サーバーの自動起動と接続 |
| 6.4 | sse_remote_example | SSE | リモート SSE サーバーへの接続(URL 切り替え) |
| 6.5 | streamablehttp_example | Streamable HTTP | ローカル Streamable HTTP サーバーの自動起動と接続 |
| 6.6 | streamable_http_remote_example | Streamable HTTP | 公開リモートサーバー(DeepWiki)への接続とリトライ設定 |
| 6.7 | streamablehttp_custom_client_example | Streamable HTTP | カスタム HTTP クライアント(httpx)の注入 |
| 6.8 | prompt_server | Streamable HTTP | MCP プロンプトで instructions を動的生成 |
| 6.9 | tool_filter_example | stdio | 静的ツールフィルタと承認フロー |
| 6.10 | get_all_mcp_tools_example | stdio | ツールの事前取得と Human-in-the-loop |
| 6.11 | manager_example | Streamable HTTP | MCPServerManager + FastAPI での運用 |
すべての example は、リポジトリのルートディレクトリ(git clone して uv sync を済ませた openai-agents-python ディレクトリ)で実行する前提です。また、LLM を呼び出す example はすべて環境変数(OS がプログラムに渡す設定値)OPENAI_API_KEY の設定が必要です。
export OPENAI_API_KEY=sk-...
各 example の解説
filesystem_example
MCP の最も基本的な example です。Model Context Protocol 公式が配布しているファイルシステム操作用 MCP サーバー(@modelcontextprotocol/server-filesystem)を npx 経由でサブプロセスとして起動し、Agent にローカルファイルの読み取り能力を与えます。
コード解説
main.py の中心部分は、MCPServerStdio でサーバーを起動して Agent に渡す箇所です。
async def main():
current_dir = os.path.dirname(os.path.abspath(__file__))
samples_dir = os.path.join(current_dir, "sample_files")
async with MCPServerStdio(
name="Filesystem Server, via npx",
params={
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", samples_dir],
},
) as server:
trace_id = gen_trace_id()
with trace(workflow_name="MCP Filesystem Example", trace_id=trace_id):
print(f"View trace: https://platform.openai.com/logs/trace?trace_id={trace_id}\n")
await run(server)
MCPServerStdioのparamsには、サーバーを起動するコマンドラインをそのまま指定します。ここでは「npx -y @modelcontextprotocol/server-filesystem <sample_filesディレクトリ>」を実行しています。-yは npx の確認プロンプトを省略するオプション、最後の引数はサーバーがアクセスを許可されるディレクトリです。サーバーにはsample_filesディレクトリしか見せていない点に注目してください。MCP サーバーに渡す権限は最小限に絞るのが安全ですasync with(非同期コンテキストマネージャ。ブロックを抜けるときに後片付けを自動実行する構文)を使うことで、ブロック終了時にサブプロセスの終了とパイプのクローズが自動で行われますgen_trace_id()とtrace()はトレーシング(実行の記録。どのツールがいつ呼ばれたかを OpenAI のダッシュボードで可視化する機能)の設定です。表示された URL を開くと、MCP ツールの呼び出し履歴を確認できます
Agent 側は驚くほどシンプルです。
async def run(mcp_server: MCPServer):
agent = Agent(
name="Assistant",
instructions="Use the tools to read the filesystem and answer questions based on those files.",
mcp_servers=[mcp_server],
)
# List the files it can read
message = "Read the files and list them."
print(f"Running: {message}")
result = await Runner.run(starting_agent=agent, input=message)
print(result.final_output)
mcp_servers=[mcp_server] にサーバーを渡すだけで、サーバーが公開する list_directory() や read_file() などのツールが Agent から使えるようになります。ツールを 1 つも自分で定義していないのに、Agent がファイル操作できるようになるのが MCP の威力です。
ファイル末尾では、実行前に npx の存在チェックをしています。
if __name__ == "__main__":
# Let's make sure the user has npx installed
if not shutil.which("npx"):
raise RuntimeError("npx is not installed. Please install it with `npm install -g npx`.")
asyncio.run(main())
実行手順
- Node.js をインストールし、
npx --versionでコマンドが使えることを確認します OPENAI_API_KEYを設定します- リポジトリルートで以下を実行します(初回は npx がサーバーパッケージをダウンロードするため少し時間がかかります)
uv run python examples/mcp/filesystem_example/main.py
期待される動作・出力例
3 つの質問が順に実行されます。sample_files には favorite_books.txt、favorite_cities.txt、favorite_songs.txt が入っているため、次のような出力になります。
View trace: https://platform.openai.com/logs/trace?trace_id=trace_...
Running: Read the files and list them.
The files are: favorite_books.txt, favorite_cities.txt, favorite_songs.txt ...
Running: Read favorite_books.txt and tell me my #1 favorite book.
Your #1 favorite book is "To Kill a Mockingbird" by Harper Lee.
Running: Read favorite_songs.txt and suggest one new song that I might like.
(好みの曲リストを踏まえたおすすめ曲の提案)
つまずきポイント
npx is not installedエラーが出る場合は Node.js がインストールされていません- 企業ネットワーク等で npm レジストリにアクセスできない環境では、初回のパッケージダウンロードに失敗します
async with MCPServerStdio(...) のブロックが、サブプロセスの一生(起動から終了まで)とちょうど重なっている様子を図にすると次のとおりです。
git_example
公式の Git 操作用 MCP サーバー(mcp-server-git)を uvx 経由で起動し、任意のローカル Git リポジトリについて質問できる Agent を作る example です。uvx は uv に同梱されているコマンドで、Python 製ツールをインストールなしで一時的に実行できます(Node.js における npx の Python 版と考えてください)。
コード解説
async def main():
# Ask the user for the directory path
directory_path = input_with_fallback(
"Please enter the path to the git repository: ",
".",
)
async with MCPServerStdio(
cache_tools_list=True, # Cache the tools list, for demonstration
params={"command": "uvx", "args": ["mcp-server-git"]},
) as server:
with trace(workflow_name="MCP Git Example"):
await run(server, directory_path)
- 起動時にユーザーへ Git リポジトリのパスを尋ねます。
input_with_fallbackは examples 共通のヘルパー関数で、環境変数EXAMPLES_INTERACTIVE_MODE=autoが設定されているときは入力を求めずに既定値(ここではカレントディレクトリ.)を使います。CI などの自動実行のための仕組みで、通常の学習では普通に入力すれば大丈夫です cache_tools_list=Trueが新しいポイントです。SDK は Agent の実行のたびにサーバーのlist_tools()を呼びますが、このオプションを有効にするとツール一覧を初回取得後にキャッシュ(結果を保存して再利用)し、2 回目以降の問い合わせを省略します。ツール構成が変わらないサーバーでは実行のたびの待ち時間を減らせます。キャッシュを破棄したいときはserver.invalidate_tools_cache()を呼びます
Agent の instructions には、ユーザーが入力したリポジトリパスを埋め込みます。
async def run(mcp_server: MCPServer, directory_path: str):
agent = Agent(
name="Assistant",
instructions=f"Answer questions about the git repository at {directory_path}, use that for repo_path",
mcp_servers=[mcp_server],
)
message = "Who's the most frequent contributor?"
Git サーバーのツール(git_log など)は引数に repo_path を要求するため、instructions で「このパスを repo_path に使え」とあらかじめ指示しているのがコツです。
実行手順
uvがインストール済みであることを確認します(uvx --versionが通れば OK)OPENAI_API_KEYを設定します- リポジトリルートで以下を実行します
uv run python examples/mcp/git_example/main.py
Please enter the path to the git repository:と聞かれたら、調べたい Git リポジトリの絶対パス(例: いま作業中のopenai-agents-pythonのパス)を入力します
期待される動作・出力例
「最も頻繁なコントリビューターは誰か」「最後の変更を要約して」という 2 つの質問に対して、Agent が git_log 等の MCP ツールでコミット履歴を取得し、回答します。
----------------------------------------
Running: Who's the most frequent contributor?
The most frequent contributor to this repository is ... with NNN commits.
----------------------------------------
Running: Summarize the last change in the repository.
The most recent change is commit ... which ...
つまずきポイント
- コード内のエラーメッセージには
pip install uvxとありますが、実際にはuvxはuv本体に同梱されています。uvを公式手順でインストールすれば別途インストールは不要です - Git リポジトリではないパスを入力すると、ツール呼び出しがエラーになります
sse_example
SSE トランスポートの example です。server.py(自作の小さな MCP サーバー)をサブプロセスとして自動起動し、MCPServerSse で接続します。「MCP サーバーを自分で作る」側のコードが初めて登場します。
コード解説(サーバー側: server.py)
サーバーは公式 MCP Python SDK の MCPServer を使うと、デコレーター(関数に機能を追加する @ 記法)だけで作れます。42 行の全体像から主要部を引用します。
バージョン注記: MCP Python SDK v1 では、このクラスの名前は
FastMCP(from mcp.server.fastmcp import FastMCP)でした。SDK v2 でMCPServer(from mcp.server.mcpserver import MCPServer)に改称され、ホスト・ポートはMCPServer()のコンストラクタ引数ではなくmcp.run()の引数で渡す形に変わっています。openai-agents は v0.20.0 で MCP Python SDK v1 と v2 の両方をサポートし(依存はmcp>=1.19.0,<3)、examples は v2 の書き方に更新されました。v1 のままにしたい場合はmcp<2にピン留めできます
from mcp.server.mcpserver import MCPServer
SSE_HOST = os.getenv("SSE_HOST", "127.0.0.1")
SSE_PORT = int(os.getenv("SSE_PORT", "8000"))
# Create server
mcp = MCPServer("Echo Server")
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two numbers"""
print(f"[debug-server] add({a}, {b})")
return a + b
@mcp.tool()
def get_secret_word() -> str:
print("[debug-server] get_secret_word()")
return random.choice(["apple", "banana", "cherry"])
if __name__ == "__main__":
mcp.run(transport="sse", host=SSE_HOST, port=SSE_PORT)
@mcp.tool()を付けた Python 関数が、そのまま MCP ツールとして公開されます。関数の型ヒントと docstring からツールのスキーマ(引数の仕様)が自動生成されますmcp.run(transport="sse", host=..., port=...)で SSE トランスポートのサーバーとして起動します。MCP SDK v1(FastMCP)ではホスト・ポートをコンストラクタで渡していましたが、v2 ではrun()側に移りました- このほか
get_current_weather(city)ツールも定義されており、東京・サンフランシスコ・ニューヨークの固定の天気を返します(オフラインでも安定動作するように実データは取得しません)
コード解説(クライアント側: main.py)
main.py は空いているポートを自動選択し(環境変数 SSE_PORT があればそれを優先)、subprocess.Popen でサーバーを起動してから Agent を実行します。
process: subprocess.Popen[Any] | None = None
try:
this_dir = os.path.dirname(os.path.abspath(__file__))
server_file = os.path.join(this_dir, "server.py")
print(f"Starting SSE server at {SSE_URL} ...")
# Run `uv run server.py` to start the SSE server
env = os.environ.copy()
env.setdefault("SSE_HOST", SSE_HOST)
env.setdefault("SSE_PORT", str(SSE_PORT))
process = subprocess.Popen(["uv", "run", server_file], env=env)
# Give it 3 seconds to start
time.sleep(3)
接続部分は URL を指定するだけです。stdio と違い、サーバーの起動コマンドではなく接続先 URL を渡す点が本質的な違いです。
async def main():
async with MCPServerSse(
name="SSE Python Server",
params={
"url": SSE_URL,
},
) as server:
Agent には ModelSettings(tool_choice="required") が指定されています。これは「必ず何らかのツールを使って回答せよ」というモデルへの強制で、デモとして確実にツール呼び出しを観察するための設定です。
agent = Agent(
name="Assistant",
instructions="Use the tools to answer the questions.",
mcp_servers=[mcp_server],
model_settings=ModelSettings(tool_choice="required"),
)
実行手順
OPENAI_API_KEYを設定します- リポジトリルートで以下を実行します(サーバーの起動・終了はスクリプトが自動で行うため、ターミナルは 1 つで完結します)
uv run python examples/mcp/sse_example/main.py
- ポートを固定したい場合は
SSE_PORT=8000 SSE_HOST=127.0.0.1を指定して実行します
期待される動作・出力例
「7 + 22 を計算して」「東京の天気は?」「秘密の言葉は?」の 3 つの質問に、それぞれ add、get_current_weather、get_secret_word ツールを使って回答します。サーバー側のログ([debug-server] add(7, 22) など)も同じターミナルに混ざって表示されます。
Starting SSE server at http://127.0.0.1:xxxxx/sse ...
SSE server started. Running example...
Running: Add these numbers: 7 and 22.
[debug-server] add(7, 22)
The sum of 7 and 22 is 29.
Running: What's the weather in Tokyo?
The weather in Tokyo is sunny with a light breeze and 20°C.
Running: What's the secret word?
The secret word is "banana".
つまずきポイント
- SSE トランスポートは MCP プロジェクトで非推奨になっています。この example は既存 SSE サーバーとの接続方法を知るためのもので、新規にサーバーを作るなら Streamable HTTP(6.5)を選んでください
- サーバー起動の待機は
time.sleep(3)の固定 3 秒です。低速なマシンで接続エラーになる場合は、この秒数を伸ばすと解決することがあります
sse_remote_example
SSE サーバーが「リモートにある」場合の接続パターンを示す example です。環境変数 MCP_SSE_REMOTE_URL が設定されていればそのリモートサーバーへ、なければ 6.3 の sse_example/server.py をローカルで自動起動して接続します。
コード解説
接続時にタイムアウト(応答を待つ制限時間)関連のパラメータを指定しているのが 6.3 との違いです。
async def run(url: str, name: str) -> None:
async with MCPServerSse(
name=name,
params={
"url": url,
"timeout": 5,
"sse_read_timeout": 30,
},
) as server:
timeoutは HTTP 接続のタイムアウト秒数、sse_read_timeoutは SSE ストリームの読み取りタイムアウト秒数です。ネットワーク越しの接続では、こうした値を明示しておくと障害時の切り分けが楽になります
メイン処理は URL の有無で分岐します。
async def main() -> None:
if REMOTE_SSE_URL:
print(f"Connecting to remote SSE server at {REMOTE_SSE_URL} ...", flush=True)
await run(REMOTE_SSE_URL, "Remote SSE Server")
return
print(
"MCP_SSE_REMOTE_URL is not set; using the bundled local SSE server for this demo.",
flush=True,
)
with local_sse_server() as url:
await run(url, "Local SSE Server")
local_sse_server() はコンテキストマネージャとして実装されており、隣の sse_example/server.py をサブプロセス起動し、終了時には terminate() → 5 秒待っても終わらなければ kill() という丁寧な後始末をしています。実運用でサブプロセスを管理する際の良いお手本です。
実行手順
OPENAI_API_KEYを設定します- ローカルサーバーで試す場合は、そのまま実行します
uv run python examples/mcp/sse_remote_example/main.py
- 手元にある互換の SSE サーバー(
addツールを持つもの)に接続したい場合は、URL を指定します
export MCP_SSE_REMOTE_URL=http://<ホスト>:<ポート>/sse
uv run python examples/mcp/sse_remote_example/main.py
期待される動作・出力例
「MCP の add ツールで 7 + 22 を計算して」という 1 つの質問を実行し、29 を含む回答が表示されます。
MCP_SSE_REMOTE_URL is not set; using the bundled local SSE server for this demo.
Starting local SSE server at http://127.0.0.1:xxxxx/sse ...
View trace: https://platform.openai.com/logs/trace?trace_id=trace_...
7 + 22 = 29.
つまずきポイント
MCP_SSE_REMOTE_URLに指定するサーバーはaddツールを公開している必要があります。ツール名が異なるサーバーでは質問文と噛み合わず、期待した動作になりません
streamablehttp_example
現在推奨されている Streamable HTTP トランスポートの基本 example です。構成は 6.3 の sse_example とほぼ同じで、server.py を自動起動して MCPServerStreamableHttp で接続します。SSE 版と見比べると、トランスポートを差し替えても Agent 側のコードがほとんど変わらないことがよく分かります。
コード解説
サーバー側(server.py)の違いは、起動時のトランスポート指定と既定ポートだけです。
STREAMABLE_HTTP_HOST = os.getenv("STREAMABLE_HTTP_HOST", "127.0.0.1")
STREAMABLE_HTTP_PORT = int(os.getenv("STREAMABLE_HTTP_PORT", "18080"))
# Create server
mcp = MCPServer("Echo Server")
if __name__ == "__main__":
mcp.run(
transport="streamable-http",
host=STREAMABLE_HTTP_HOST,
port=STREAMABLE_HTTP_PORT,
)
このサーバーの get_current_weather は、実際に天気情報サイト(wttr.in)へ HTTP アクセスを試み、失敗したらフォールバック文字列を返す実装になっています(SSE 版は固定データでした)。
クライアント側は、クラス名と URL のパス(/sse → /mcp)が変わっただけです。
async def main():
async with MCPServerStreamableHttp(
name="Streamable HTTP Python Server",
params={
"url": STREAMABLE_HTTP_URL,
},
) as server:
trace_id = gen_trace_id()
with trace(workflow_name="Streamable HTTP Example", trace_id=trace_id):
print(f"View trace: https://platform.openai.com/logs/trace?trace_id={trace_id}\n")
await run(server)
URL は http://<ホスト>:<ポート>/mcp という形式で、Streamable HTTP サーバーは慣例的に /mcp パスを使います。
実行手順
OPENAI_API_KEYを設定します- リポジトリルートで以下を実行します(サーバーは自動起動されます)
uv run python examples/mcp/streamablehttp_example/main.py
- ポートを固定したい場合は
STREAMABLE_HTTP_PORT、バインドアドレスを変えたい場合はSTREAMABLE_HTTP_HOSTを設定します
期待される動作・出力例
sse_example と同じ 3 つの質問(足し算・天気・秘密の言葉)が実行されます。天気の質問では wttr.in のテキストアート形式の天気予報が返ることもあれば、オフライン時はフォールバックメッセージが返ります。
つまずきポイント
- 新規に HTTP 系の MCP 統合を作るときは、SSE ではなくこの Streamable HTTP を選んでください
- 3 秒待機で接続できない場合の対処は sse_example と同じです
streamable_http_remote_example
インターネット上に公開されている実在の MCP サーバー DeepWiki(https://mcp.deepwiki.com/mcp。GitHub リポジトリの内容を調べられるサービス)に Streamable HTTP で接続する example です。自前のサーバー起動が一切不要で、「他者が運用する MCP サーバーをそのまま使う」体験ができます。ファイルは 38 行と短いので全文を引用します。
コード解説
import asyncio
from agents import Agent, Runner, gen_trace_id, trace
from agents.mcp import MCPServerStreamableHttp
async def main():
async with MCPServerStreamableHttp(
name="DeepWiki MCP Streamable HTTP Server",
params={
"url": "https://mcp.deepwiki.com/mcp",
# Allow more time for remote tool responses.
"timeout": 15,
"sse_read_timeout": 300,
},
# Retry slow/unstable remote calls a couple of times.
max_retry_attempts=2,
retry_backoff_seconds_base=2.0,
client_session_timeout_seconds=15,
) as server:
agent = Agent(
name="DeepWiki Assistant",
instructions="Use the tools to respond to user requests.",
mcp_servers=[server],
)
trace_id = gen_trace_id()
with trace(workflow_name="DeepWiki Streamable HTTP Example", trace_id=trace_id):
print(f"View trace: https://platform.openai.com/logs/trace?trace_id={trace_id}\n")
result = await Runner.run(
agent,
"For the repository openai/codex, tell me the primary programming language.",
)
print(result.final_output)
if __name__ == "__main__":
asyncio.run(main())
リモートサーバーならではの堅牢化オプションが 3 つ登場します。
max_retry_attempts=2:list_tools()やcall_tool()が失敗したとき最大 2 回まで自動リトライ(再試行)しますretry_backoff_seconds_base=2.0: リトライ間隔の基準秒数です。指数バックオフ(失敗のたびに待ち時間を倍々に伸ばす方式)の底として使われますclient_session_timeout_seconds=15: MCP セッションの読み取りタイムアウトです
ローカルサーバーでは不要だったこれらの設定が、不安定になりがちなリモート接続では重要になります。
実行手順
OPENAI_API_KEYを設定します- インターネットに接続できる環境で、リポジトリルートで以下を実行します
uv run python examples/mcp/streamable_http_remote_example/main.py
期待される動作・出力例
Agent が DeepWiki のツールで openai/codex リポジトリを調査し、主要なプログラミング言語(Rust)を回答します。
View trace: https://platform.openai.com/logs/trace?trace_id=trace_...
The primary programming language of the openai/codex repository is Rust.
つまずきポイント
- DeepWiki は外部サービスのため、応答に時間がかかったり一時的に失敗したりすることがあります。リトライ設定はそのための保険です
- プロキシ必須のネットワーク環境では接続に失敗します。その場合は次の 6.7 のカスタム HTTP クライアントが解決策になります
streamablehttp_custom_client_example
MCPServerStreamableHttp の httpx_client_factory パラメータを使って、MCP 通信に使う HTTP クライアント(Python の HTTP ライブラリ httpx の AsyncClient)を自分で構成する example です。
バージョン注記: この example は openai-agents v0.20.0 の MCP Python SDK v2 対応にあわせて
httpxからhttpx2へ移行しました。カスタム HTTP 認証やクライアントファクトリを使うアプリケーションは、インストールされている MCP のメジャーバージョンが持つ HTTP 型(v2 ならhttpx2、v1 ならhttpx)を使う必要があります。移行したくない場合はmcp<2にピン留めしてください。v0.20.0 で唯一「破壊的になりうる」とアナウンスされた箇所です。SSL 証明書、カスタムヘッダー、タイムアウト、プロキシなど、企業ネットワークや本番運用で必要になる細かな HTTP 設定を注入できます。
コード解説
中心は、カスタム設定済みの httpx2.AsyncClient を返すファクトリ関数(オブジェクトを生成して返す関数)です。
def create_custom_http_client(
headers: dict[str, str] | None = None,
timeout: httpx2.Timeout | None = None,
auth: httpx2.Auth | None = None,
) -> httpx2.AsyncClient:
"""Create a custom HTTP client with specific configurations."""
if headers is None:
headers = {
"X-Custom-Client": "agents-mcp-example",
"User-Agent": "OpenAI-Agents-MCP/1.0",
}
if timeout is None:
timeout = httpx2.Timeout(60.0, read=120.0)
if auth is None:
auth = None
return httpx2.AsyncClient(
# Disable SSL verification for testing (not recommended for production)
verify=False,
# Set custom timeout
timeout=httpx2.Timeout(60.0, read=120.0),
# Add custom headers that will be sent with every request
headers=headers,
)
verify=Falseは SSL 証明書検証の無効化です。コメントにあるとおりテスト専用で、本番では絶対に使わないでください。証明書検証を無効にすると中間者攻撃(MITM。通信経路上の第三者による盗聴・改ざん)を検出できなくなります。開発環境で自己署名証明書を使う場合も、検証を無効化するのではなく、verify="/path/to/ca.pem"のように CA 証明書を信頼対象として指定するか、正規に発行された証明書を使うのが正しい対処ですheadersに指定した内容は、MCP サーバーへのすべての HTTP リクエストに付与されます。API キーによる認証ヘッダーの追加などに応用できます
このファクトリ関数を params に渡すだけで、SDK が MCP 通信にこのクライアントを使うようになります。
async with MCPServerStreamableHttp(
name="Streamable HTTP with Custom Client",
params={
"url": STREAMABLE_HTTP_URL,
"httpx_client_factory": create_custom_http_client,
},
) as server:
サーバー側(server.py、27 行)は add と get_secret_word の 2 ツールだけの最小構成で、6.5 と同様に main.py がサブプロセスとして自動起動します。
実行手順
OPENAI_API_KEYを設定します- README の手順どおり、example のディレクトリに移動して実行します
cd examples/mcp/streamablehttp_custom_client_example
uv run main.py
(リポジトリルートから uv run python examples/mcp/streamablehttp_custom_client_example/main.py でも実行できます)
期待される動作・出力例
カスタムクライアント経由で「7 + 22」の足し算が実行され、29 を含む回答が表示されます。見た目の動作は 6.5 と同じですが、裏側の HTTP リクエストにはカスタムヘッダーが載っています。
つまずきポイント
- ここで学ぶべきは「動くこと」より「どこを差し替えられるか」です。社内プロキシ経由の接続、独自 CA 証明書、Bearer トークン認証などが必要になったら、この example の
create_custom_http_clientを書き換える形で対応します
prompt_server
MCP サーバーはツールだけでなくプロンプト(Agent への指示文テンプレート)も提供できます。この example では、コードレビュー用の instructions を生成するプロンプトを持つサーバーを立て、Agent の instructions を MCP 経由で動的に組み立てます。「Agent の人格・専門性をサーバー側で管理する」パターンです。
コード解説(サーバー側: server.py)
ツールの @mcp.tool() の代わりに @mcp.prompt() デコレーターを使います。
# Instruction-generating prompts (user-controlled)
@mcp.prompt()
def generate_code_review_instructions(
focus: str = "general code quality", language: str = "python"
) -> str:
"""Generate agent instructions for code review tasks"""
print(f"[debug-server] generate_code_review_instructions({focus}, {language})")
return f"""You are a senior {language} code review specialist. Your role is to provide comprehensive code analysis with focus on {focus}.
...
"""
focus(レビューの観点)と language(対象言語)という 2 つのパラメータを受け取り、それを埋め込んだ長文の instructions を返します。呼び出し側がパラメータを変えるだけで、「セキュリティ特化のレビュアー」「パフォーマンス特化のレビュアー」を同じテンプレートから作り分けられます。
コード解説(クライアント側: main.py)
プロンプト関連の API は 2 つです。まず list_prompts() で利用可能なプロンプトを列挙します。
async def show_available_prompts(mcp_server: MCPServer):
"""Show available prompts for user selection"""
print("=== AVAILABLE PROMPTS ===")
prompts_result = await mcp_server.list_prompts()
print("User can select from these prompts:")
for i, prompt in enumerate(prompts_result.prompts, 1):
print(f" {i}. {prompt.name} - {prompt.description}")
print()
次に get_prompt(名前, 引数) で具体的なプロンプト本文を取得します。
try:
prompt_result = await mcp_server.get_prompt(prompt_name, kwargs)
content = prompt_result.messages[0].content
if hasattr(content, "text"):
instructions = content.text
else:
instructions = str(content)
取得した文字列を、そのまま Agent の instructions に渡します。
instructions = await get_instructions_from_prompt(
mcp_server,
"generate_code_review_instructions",
focus="security vulnerabilities",
language="python",
)
agent = Agent(
name="Code Reviewer Agent",
instructions=instructions, # Instructions from MCP prompt
model_settings=ModelSettings(tool_choice="auto"),
)
そしてこの Agent に、わざと脆弱なコード(ユーザー入力をそのまま os.system に渡すコマンドインジェクションの典型例)をレビューさせます。
実行手順
OPENAI_API_KEYを設定します- リポジトリルートで以下を実行します(プロンプトサーバーは自動起動されます)
uv run python examples/mcp/prompt_server/main.py
期待される動作・出力例
まず利用可能なプロンプトの一覧が表示され、続いてセキュリティ観点のコードレビュー結果が出力されます。
=== AVAILABLE PROMPTS ===
User can select from these prompts:
1. generate_code_review_instructions - Generate agent instructions for code review tasks
=== CODE REVIEW DEMO ===
Getting instructions from prompt: generate_code_review_instructions
Generated instructions
Running: Please review this code: ...
1. Overall Assessment ...
(コマンドインジェクション脆弱性の指摘と修正案が続く)
つまずきポイント
- プロンプト取得(
get_prompt)は Agent 実行前に「自分のコードで」明示的に呼ぶ点に注意してください。ツールと違って LLM が自動で選ぶものではなく、ユーザー(開発者)が制御する機能です
list_prompts() から get_prompt() を経て instructions が組み上がるまでの流れは、次のように整理できます。
tool_filter_example
MCP サーバーが公開するツールのうち一部だけを Agent に見せるツールフィルタリングの example です。ファイルシステムサーバーは書き込み系ツールも持っていますが、読み取り系だけを許可し、書き込みを遮断します。あわせて、ツール実行前に承認を要求する require_approval も登場します。
コード解説
サーバー構築時に tool_filter と require_approval を指定します。
async with MCPServerStdio(
name="Filesystem Server with filter",
params={
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", samples_dir],
"cwd": samples_dir,
},
require_approval="always",
tool_filter=create_static_tool_filter(
allowed_tool_names=["read_file", "list_directory"],
blocked_tool_names=["write_file"],
),
) as server:
create_static_tool_filterは静的(実行中に変化しない)な許可リスト・拒否リストを作るヘルパー関数です。allowed_tool_namesとblocked_tool_namesを両方指定した場合、先に許可リストで絞り込み、その結果からさらに拒否リストのツールを除外します。結果として、この Agent からはread_fileとlist_directoryしか見えませんrequire_approval="always"は「すべてのツール呼び出しの前に承認を要求する」設定です。承認が必要になると Agent の実行は一時停止し、結果のinterruptions(中断情報)に承認待ちのツール呼び出しが入ります
承認待ちを処理するループが次の関数です。
async def run_with_auto_approval(agent: Agent[Any], message: str) -> str | None:
"""Run and auto-approve interruptions."""
result = await Runner.run(agent, message)
while result.interruptions:
state = result.to_state()
for interruption in result.interruptions:
print(f"Approving a tool call... (name: {interruption.name})")
state.approve(interruption, always_approve=True)
result = await Runner.run(agent, state)
return cast(str | None, result.final_output)
result.interruptionsが空でない間、result.to_state()で実行状態を取り出し、state.approve(...)で承認してからRunner.run(agent, state)で中断地点から再開します。always_approve=Trueは「同名ツールは以後自動承認」の指定です- この example では機械的に全承認していますが、
approveの代わりにrejectを呼べば拒否もできます。人間に判断を仰ぐ本格的な例は次の 6.10 で扱います
メイン処理では、まず許可されている「一覧表示」を実行し、次にわざと「ファイル作成」を依頼して、フィルタで遮断されることを確認します。
実行手順
- Node.js(
npx)をインストールします OPENAI_API_KEYを設定します- リポジトリルートで以下を実行します
uv run python examples/mcp/tool_filter_example/main.py
期待される動作・出力例
View trace: https://platform.openai.com/logs/trace?trace_id=trace_...
Approving a tool call... (name: list_directory)
The allowed directory contains: books.txt, favorite_songs.txt
Attempting to write a file (should be blocked):
I cannot create the file because write operations are blocked by the tool filter. ...
1 回目の依頼ではツール実行前に承認ログが出てからファイル一覧が表示され、2 回目の依頼では「書き込みツールが使えないため実行できない」という説明が返ります。
つまずきポイント
- フィルタは「LLM にツールの存在自体を見せない」仕組みです。遮断されたツールについて Agent は「使えるが拒否された」のではなく「そもそも知らない」状態になるため、instructions で「使えないツールを求められたらフィルタで遮断されている旨を説明せよ」と指示しています
tool_filter による絞り込みと require_approval による承認は、それぞれ独立した制御の二段構えになっています。
get_all_mcp_tools_example
これまでの example は mcp_servers に渡して「実行のたびにツール一覧を取得」していましたが、この example では MCPUtil.get_all_function_tools で MCP ツールを事前に取得(プリフェッチ)し、通常の tools として Agent に渡すパターンを示します。さらに、特定ツールだけに承認を要求し、人間が y/n で承認する Human-in-the-loop(HITL。処理の途中に人間の判断を挟む仕組み)の完全な流れも実演します。
コード解説
ツールの事前取得は次の関数で行います。
async def list_tools(server: MCPServer, *, convert_to_strict: bool) -> list[Any]:
"""Fetch all MCP tools from the server."""
run_context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={})
agent = Agent(name="ToolFetcher", instructions="Prefetch MCP tools.", mcp_servers=[server])
return await MCPUtil.get_all_function_tools(
[server],
convert_schemas_to_strict=convert_to_strict,
run_context=run_context,
agent=agent,
)
MCPUtil.get_all_function_toolsは、MCP サーバー群のツールを SDK の function tool オブジェクトのリストに変換して返します。convert_schemas_to_strict=Trueを指定すると、ツールの引数スキーマを strict JSON schema(OpenAI のモデルが引数を厳密に守るための形式)へ可能な範囲で変換します- 取得したツールは
Agent(tools=all_tools)のように通常のツールとして渡せます。mcp_serversを使わないため、実行のたびのlist_tools()呼び出しが発生しません
サーバー構築時の承認設定は、6.9 より細かい「特定ツールのみ」の形式です。
async with MCPServerStdio(
name="Filesystem Server",
params={
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", samples_dir],
"cwd": samples_dir,
},
require_approval={"always": {"tool_names": ["read_text_file"]}},
) as server:
require_approval は "always" / "never" の一括指定のほか、このような {"always": {"tool_names": [...]}} 形式や、{"ツール名": "always"} のようなツール別指定も受け付けます。ここでは read_text_file(ファイルシステムサーバーのファイル読み取りツール)だけが承認対象です。
承認は今回、本物の人間に問い合わせます。
while True:
user_input = input(f"Approve tool call '{interruption_name}'? (y/n): ").strip().lower()
if user_input == "y":
return True
if user_input == "n":
return False
print("Please enter 'y' or 'n'.")
そして resolve_interruptions 関数が、承認なら state.approve(interruption)、拒否なら state.reject(interruption) を呼んで実行を再開します。
後半では、実行中のサーバーに対して動的にフィルタを適用し、ツール一覧を取り直します。
# Apply a static tool filter and refetch tools.
server.tool_filter = create_static_tool_filter(
allowed_tool_names=["read_file", "list_directory"]
)
filtered_tools = await list_tools(server, convert_to_strict=False)
tool_filter はサーバーオブジェクトの属性として後から差し替えられることが分かります。フィルタ後のツールで作った Agent に「ファイルを作成して」と依頼し、作成系ツールがないため断られる、という流れは 6.9 と同様です。
実行手順
- Node.js(
npx)をインストールします OPENAI_API_KEYを設定します- リポジトリルートで以下を実行します
uv run python examples/mcp/get_all_mcp_tools_example/main.py
- 実行中に
Approve tool call 'read_text_file'? (y/n):と聞かれたらyを入力して承認します(nで拒否した場合の挙動も試してみてください) - 対話なしで動かしたい場合は
EXAMPLES_INTERACTIVE_MODE=autoを設定すると自動承認されます
期待される動作・出力例
=== Fetching all tools with strict schemas ===
Found NN tool(s):
- read_file: Read the complete contents of a file ...
- write_file: ...
(ファイルシステムサーバーの全ツールが列挙される)
Running: List files in this allowed directory: ... Then read one of those files.
Approve tool call 'read_text_file'? (y/n): y
Approving a tool call... (name: read_text_file)
(ファイル一覧と、読んだファイルの内容の説明)
=== After applying tool filter ===
Found 2 tool(s):
- read_file
- list_directory
Running: Create a file named "..." with the text "hello". ...
(作成ツールがないため実行できない旨の説明)
つまずきポイント
- 事前取得したツールは取得時点のスナップショットです。サーバー側のツール構成が変わっても自動では追従しないため、必要に応じて取得し直してください
- 一覧に出るツール名(
read_text_fileなど)はファイルシステムサーバーのバージョンによって増減・変更されることがあります
manager_example
実運用に最も近い example です。Web アプリケーション(FastAPI。Python の Web フレームワーク)の中で MCPServerManager を使い、複数の MCP サーバーの接続・切断を一元管理します。この example だけは、MCP サーバーとアプリを別々のターミナルで手動起動します。
構成は 3 ファイルです。
mcp_server.py:addとechoの 2 ツールを持つ Streamable HTTP サーバー(26 行)app.py:MCPServerManagerを組み込んだ FastAPI アプリsmoke_test.py: モデルを呼ばずに統合を検証するテストスクリプト
コード解説(mcp_server.py)
全 26 行の最小サーバーです。
import os
from mcp.server.mcpserver import MCPServer
STREAMABLE_HTTP_HOST = os.getenv("STREAMABLE_HTTP_HOST", "127.0.0.1")
STREAMABLE_HTTP_PORT = int(os.getenv("STREAMABLE_HTTP_PORT", "8000"))
mcp = MCPServer("FastAPI Example Server")
@mcp.tool()
def add(a: int, b: int) -> int:
return a + b
@mcp.tool()
def echo(message: str) -> str:
return f"echo: {message}"
if __name__ == "__main__":
mcp.run(
transport="streamable-http",
host=STREAMABLE_HTTP_HOST,
port=STREAMABLE_HTTP_PORT,
)
コード解説(app.py)
FastAPI の lifespan(アプリの起動から終了までの寿命に合わせて資源を管理する仕組み)の中で MCPServerManager を使います。
@asynccontextmanager
async def lifespan(app: FastAPI):
server = MCPServerStreamableHttp({"url": MCP_SERVER_URL})
inactive_server = MCPServerStreamableHttp({"url": INACTIVE_MCP_SERVER_URL})
servers = [server, inactive_server]
if USE_MCP_MANAGER:
async with MCPServerManager(
servers=servers,
connect_in_parallel=True,
) as manager:
app.state.mcp_manager = manager
app.state.mcp_servers = servers
yield
return
重要なのは、わざと存在しないサーバー(http://localhost:8001/mcp、inactive_server)も登録している点です。MCPServerManager は次の振る舞いをします。
- 登録された全サーバーへの接続を試み、成功したものだけを
manager.active_serversとして公開する(既定のdrop_failed_servers=Trueの動作) - 失敗したサーバーは
manager.failed_serversとmanager.errorsに記録される。既定(strict=False)では一部の接続失敗でアプリ全体は落ちない connect_in_parallel=Trueでサーバーごとの接続を並行実行するreconnect(failed_only=True)で失敗したサーバーだけ再接続を試みられる
つまり「10 個の MCP サーバーのうち 1 個が落ちていてもサービスは残り 9 個で動き続け、後から復旧を試みる」という、実運用に必須の耐障害性が手に入ります。
エンドポイントでは active_servers を Agent に渡します。
agent = Agent(
name="FastAPI Agent",
instructions="Use the MCP tools when needed.",
mcp_servers=servers,
model_settings=ModelSettings(tool_choice="auto"),
)
result = await Runner.run(starting_agent=agent, input=req.input)
return {"output": result.final_output}
また /add エンドポイントでは、Agent を介さずサーバーのツールを直接呼ぶ方法も示しています。
result = await active_servers[0].call_tool("add", {"a": req.a, "b": req.b})
call_tool(ツール名, 引数の辞書) は、LLM を使わずに MCP ツールを直接実行する低レベル API です。
実行手順
FastAPI は uv sync で開発依存として導入済みのため、追加インストールは不要です。ターミナルを 2 つ使います。
- ターミナル 1: MCP サーバーを起動します(
http://localhost:8000/mcpで待ち受けます)
uv run python examples/mcp/manager_example/mcp_server.py
- ターミナル 2: FastAPI アプリを起動します(
http://127.0.0.1:9001で待ち受けます)
uv run python examples/mcp/manager_example/app.py
- ターミナル 3(またはターミナル 2 を空けて): 各エンドポイントを curl で叩きます
curl http://127.0.0.1:9001/health
curl http://127.0.0.1:9001/tools
curl -X POST http://127.0.0.1:9001/add \
-H 'Content-Type: application/json' \
-d '{"a": 2, "b": 3}'
- LLM を使う
/runを試す場合は、ターミナル 2 でOPENAI_API_KEYを設定してからアプリを起動し、次を実行します
curl -X POST http://127.0.0.1:9001/run \
-H 'Content-Type: application/json' \
-d '{"input": "Add 4 and 9."}'
- 失敗中のサーバーの再接続を試すには次を実行します
curl -X POST http://127.0.0.1:9001/reconnect \
-H 'Content-Type: application/json' \
-d '{"failed_only": true}'
- モデルなしで統合全体を検証したい場合はスモークテストを実行します(サーバーの起動も自動で行われるため、これは単独で動きます)
uv run python -m examples.mcp.manager_example.smoke_test
期待される動作・出力例
/health のレスポンスで、接続に成功したサーバーと失敗したサーバーが区別されていることを確認できます。
{"connected_servers": ["streamable_http: http://localhost:8000/mcp"], "failed_servers": ["streamable_http: http://localhost:8001/mcp"]}
/add は MCP ツールの生の実行結果(5 を含むコンテンツ)を返し、/run は Agent が add ツールを使って導いた自然文の回答(13)を返します。スモークテストは最後に MCP manager example smoke test completed successfully. と表示します。
つまずきポイント
- アプリより先に MCP サーバーを起動してください。逆順だと、起動時接続に失敗して
failed_serversに入ります(その場合も/reconnectで復旧できます。これ自体が良い実験になります) - ポート 8000 や 9001 が他のプロセスに使われていると起動に失敗します。
STREAMABLE_HTTP_PORTとMCP_SERVER_URLを揃えて変更してください USE_MCP_MANAGER=0を設定するとマネージャーなしの素朴な接続管理に切り替わり、比較ができます
MCPServerManager が複数サーバーの接続状態をどう管理するかをまとめると、次のようになります。
まとめ
- MCP は「AI アプリケーションの USB-C」と呼ばれる標準プロトコルで、一度作られた MCP サーバーのツールをどのクライアントからでも再利用できます
- SDK では
MCPServerStdio(ローカルサブプロセス)、MCPServerSse(SSE。非推奨)、MCPServerStreamableHttp(HTTP 系の推奨方式)の 3 クラスでサーバーに接続し、Agentのmcp_serversに渡すだけで統合できます - stdio 系 example では
npx(Node.js)やuvx(uv 同梱)で公式配布のサーバーを起動します。前提ツールの有無を最初に確認しましょう - リモート接続では
timeout/sse_read_timeout/max_retry_attempts/retry_backoff_seconds_baseなどの堅牢化オプションが重要です。特殊なネットワーク要件にはhttpx_client_factoryで対応します tool_filter(create_static_tool_filter)でツールの公開範囲を絞り、require_approval+interruptions/approve/rejectで人間の承認を挟めます。安全な MCP 運用の基本形です- MCP サーバーはツールのほかプロンプトも提供でき、
list_prompts()/get_prompt()で Agent の instructions を動的に生成できます - 複数サーバーの本番運用では
MCPServerManagerが接続の成否を管理し、一部サーバーの障害時もサービスを継続できます
理解度チェック
MCP の 3 つのトランスポート(stdio / SSE / Streamable HTTP)について、新しく HTTP ベースの MCP サーバーを立てる場合に選ぶべきものはどれですか。また、その理由を説明してください。
MCPServerStdio に渡す params と、MCPServerStreamableHttp に渡す params の最も本質的な違いは何ですか。
create_static_tool_filter(allowed_tool_names=["read_file", "list_directory"], blocked_tool_names=["read_file"]) を指定した場合、Agent から見えるツールはどれですか。
require_approval="always" を設定した Agent の実行結果 result に interruptions が入っていた場合、実行を再開するために必要な手順を順番に説明してください。
MCPServerManager を使うと、登録した MCP サーバーの一部が接続に失敗した場合にどのような挙動になりますか。既定の設定(drop_failed_servers=True、strict=False)を前提に答えてください。
解答と解説
問1 の解答と解説
Streamable HTTP(MCPServerStreamableHttp)を選ぶべきです。SSE トランスポートは MCP プロジェクトですでに非推奨(deprecated)とされており、MCPServerSse はレガシーな既存サーバーとの互換性維持のために残されているだけだからです。Streamable HTTP は単一の /mcp エンドポイントで双方向のやり取りとストリーミングを実現する、現在の推奨方式です。
問2 の解答と解説
MCPServerStdio の params にはサーバーを起動するコマンドライン(command と args)を渡し、SDK 自身がサーバーをサブプロセスとして起動・終了します。一方 MCPServerStreamableHttp の params にはすでに稼働しているサーバーの接続先 URL を渡します。つまり前者は「サーバーの起動から面倒を見る」、後者は「起動済みサーバーに接続するだけ」という違いです。
問3 の解答と解説
list_directory だけです。両方指定した場合は、先に許可リスト(read_file と list_directory に絞る)が適用され、その結果から拒否リストのツール(read_file)が除外されるためです。
問4 の解答と解説
(1) result.to_state() で実行状態オブジェクトを取り出します。(2) result.interruptions の各要素について、承認するなら state.approve(interruption)、拒否するなら state.reject(interruption) を呼びます(always_approve=True を渡すと同名ツールを以後自動承認できます)。(3) Runner.run(agent, state) を state を入力にして呼び、直前の中断地点から実行を再開します。新しい結果にまだ interruptions が残っていれば、なくなるまで同じ処理を繰り返します。
問5 の解答と解説
接続に成功したサーバーだけが manager.active_servers に含まれ、失敗したサーバーは manager.failed_servers と manager.errors に記録されます。strict=False のため一部の接続失敗で例外にはならず、アプリケーションは接続できたサーバーだけで動作を継続できます。その後 manager.reconnect(failed_only=True) を呼べば、失敗したサーバーだけ再接続を試みて active_servers を更新できます。
参考リンク
第7章 ホスト型 MCP(hosted_mcp)
この章で学ぶこと
- ローカル MCP(第6章)とホスト型 MCP(
HostedMCPTool)の違い — 「誰が MCP サーバーに接続するのか」の観点で理解する HostedMCPToolのtool_configの書き方(server_label/server_url/require_approval)- approval(承認)フローの 3 パターン — 承認なし・コールバックによる自動承認・interruption(中断)を使った人間の承認
- OpenAI connectors(Google カレンダー等)を
connector_idとアクセストークンで利用する方法 - 承認待ちで中断した実行を
to_state()/approve()/reject()で再開するテクニック
前提知識
この章を読む前に、以下の章の内容を理解しておいてください。
- 第1章: 環境構築(
git clone→uv sync)とOPENAI_API_KEYの設定方法 - 第2章:
AgentとRunnerの基本的な使い方(Runner.run()/Runner.run_streamed()) - 第6章: MCP(Model Context Protocol)の基本概念と、ローカル MCP サーバー(
MCPServerStdio等)をAgent(mcp_servers=[...])で接続する方法
特に第6章の「MCP は AI アプリケーションにとっての USB-C ポートのような共通規格」というイメージは、この章でもそのまま使います。
概念解説
ローカル MCP とホスト型 MCP — 「誰が電話をかけるか」の違い
第6章で学んだローカル MCP では、あなたの Python プログラムが MCP サーバーに接続していました。プログラムがサーバーを起動(または接続)し、ツール一覧を取得し、モデルがツールを使いたいと言うたびにあなたのプログラムがツールを呼び出して結果をモデルに返す、という往復が発生します。
一方、この章で扱うホスト型 MCP(hosted MCP)では、接続するのは OpenAI のサーバー側です。あなたのプログラムは HostedMCPTool というツール定義を通じて「このラベルの MCP サーバーをこの URL で使ってください」という設定情報だけを OpenAI の Responses API(OpenAI のモデル呼び出し API。エージェント向けの新しい API 形式です)に渡します。すると、ツール一覧の取得もツールの実行も、すべて OpenAI のインフラ内で完結します。あなたの Python プロセスへの折り返し(round trip)は発生しません。
日常の比喩で言えば、ローカル MCP は「自分で専門家に電話をかけて質問し、回答をアシスタントに伝える」方式です。ホスト型 MCP は「アシスタントに専門家の電話番号を渡して、アシスタント自身に電話をかけてもらう」方式です。自分は取り次ぎ作業から解放されますが、その代わり会話の中身は直接には見えなくなります。
これを図にすると次のようになります。
SDK のソースコード(src/agents/tool.py)でも、HostedMCPTool は次のように説明されています。
A tool that allows the LLM to use a remote MCP server. The LLM will automatically list and call tools, without requiring a round trip back to your code.
両者の違いを表にまとめます。
| 観点 | ローカル MCP(第6章) | ホスト型 MCP(この章) |
|---|---|---|
| MCP サーバーへの接続元 | あなたの Python プロセス | OpenAI のサーバー |
| Agent への渡し方 | Agent(mcp_servers=[server]) | Agent(tools=[HostedMCPTool(...)]) |
| サーバーの場所 | ローカルプロセス(stdio)や自社ネットワーク内でも可 | インターネット上から公開到達可能な URL が必要 |
| ツール呼び出しの往復 | 毎回あなたのコードを経由 | OpenAI 内で完結(低レイテンシ) |
| 使えるモデル | 任意 | Responses API のホスト型 MCP 統合をサポートする OpenAI モデル |
| 向いている場面 | 社内ツール・ファイルシステム等の非公開リソース | DeepWiki のような公開 MCP サーバー、OpenAI connectors |
表の最後の行にある「DeepWiki」と「OpenAI connectors」は、tool_config の書き方も少し異なります。図にすると次のとおりです。
重要な注意点として、ホスト型 MCP では Agent の mcp_servers 引数は使いません。HostedMCPTool は WebSearchTool などと同じ「OpenAI がホストするツール」の一種なので、tools リストに追加します。
approval(承認)フロー — 勝手にツールを実行させないための仕組み
ホスト型 MCP ではツール実行が OpenAI 側で完結するため、放っておくとモデルが外部サーバーのツールをあなたの確認なしに実行できてしまいます。信頼できるサーバーなら問題ありませんが、機密性の高い操作(カレンダーの書き換え、ファイルの削除など)ができるサーバーでは危険です。
そこで tool_config の require_approval で承認ポリシーを設定します。この章の example は、承認の扱い方の 3 パターンをちょうど 1 つずつ見せてくれます。
- 承認なし(
"require_approval": "never") — 信頼できるサーバー専用。ツールは即座に実行されます(simple.py/connectors.py) - コールバックで承認(
"always"+on_approval_request) — 承認が必要になるたびに、あなたが用意した Python 関数(コールバック。あるイベントが起きたときに呼び出してもらう関数のことです)が呼ばれ、その戻り値で承認/拒否が決まります(on_approval.py) - interruption(中断)で承認 — コールバックを渡さない場合、実行は承認待ちの状態でいったん停止します。返ってきた結果の
interruptionsを確認し、approve()/reject()を呼んでから実行を再開します(human_in_the_loop.py)
require_approval には "always" / "never" のほか、ツール名ごとにポリシーを変える辞書も指定できます(公式ドキュメント docs/mcp.md 参照)。
3 パターンを図にまとめると、次のようになります。
connectors — Google カレンダー等の「認証つき既製サーバー」
ホスト型 MCP のもう 1 つの形態が OpenAI connectors(コネクタ)です。Google カレンダー・Gmail・Dropbox のような主要 SaaS について、OpenAI が MCP サーバー相当の接続口をあらかじめ用意してくれています。この場合は server_url の代わりに connector_id(例: connector_googlecalendar)と、そのサービスにアクセスするためのアクセストークン(本人に代わって API を呼び出す許可証となる文字列)を authorization に渡します。
比喩で言えば、通常のホスト型 MCP が「専門家の電話番号を渡す」方式だとすると、connectors は「OpenAI が既に提携している専門家窓口を、あなたの会員証(アクセストークン)を添えて利用する」方式です。認証まわりの面倒な処理は Responses API が引き受けてくれます。
各 example の解説
この章の example は examples/hosted_mcp/ ディレクトリにあります。全 example に共通して、公開 MCP サーバーの実例として DeepWiki(https://mcp.deepwiki.com/mcp)が使われています。DeepWiki は GitHub リポジトリの内容について質問に答えてくれる公開 MCP サーバーで、API キー等の認証は不要です。
また、全 example が --stream フラグ(ストリーミング: 結果を一括ではなく、生成されるそばから少しずつ受け取る方式)と --verbose フラグ(実行中の項目を詳細表示)を持つ、共通のコマンドライン構造になっています。
simple.py
承認なし(require_approval: "never")でホスト型 MCP を使う、最小構成の example です。DeepWiki に「このリポジトリは何の言語で書かれていますか?」と質問します。
コード解説
エージェント定義の中心部分です。
from agents import Agent, HostedMCPTool, ModelSettings, Runner, RunResult, RunResultStreaming
async def main(verbose: bool, stream: bool, repo: str):
question = f"Which language is the repository {repo} written in?"
agent = Agent(
name="Assistant",
instructions=f"You can use the DeepWiki hosted MCP server to inspect {repo}.",
model_settings=ModelSettings(tool_choice="required"),
tools=[
HostedMCPTool(
tool_config={
"type": "mcp",
"server_label": "deepwiki",
"server_url": "https://mcp.deepwiki.com/mcp",
"require_approval": "never",
}
)
],
)
HostedMCPToolはtoolsリストに追加します。tool_configは Responses API に REST で送る JSON と同じ形式の辞書で、"type": "mcp"が必須ですserver_labelはこのサーバーを識別するラベル、server_urlは公開 URL です"require_approval": "never"は「承認不要」の宣言です。ソースコード冒頭のコメントに "You should only use this for trusted MCP servers."(信頼できる MCP サーバーに対してのみ使うこと)とある通り、信頼できるサーバー限定の設定ですModelSettings(tool_choice="required")は「モデルは必ず何らかのツールを使うこと」という指定です。これがないと、モデルがツールを使わずに記憶だけで答えてしまう可能性があります
実行部分は、ストリーミングの有無で分岐します。
run_result: RunResult | RunResultStreaming
if stream:
run_result = Runner.run_streamed(agent, question)
async for event in run_result.stream_events():
if event.type == "run_item_stream_event":
print(f"Got event of type {event.item.__class__.__name__}")
print(f"Done streaming; final result: {run_result.final_output}")
else:
run_result = await Runner.run(agent, question)
print(run_result.final_output)
# The repository is primarily written in Python...
ホスト型 MCP はストリーミングにも通常のツールと同じように対応しており、run_item_stream_event として MCP のツール一覧取得やツール呼び出しのイベントが流れてきます。
コマンドライン引数として --repo で調査対象リポジトリを変更できます(デフォルトは https://github.com/openai/openai-agents-python)。
実行手順
- リポジトリのルートディレクトリにいることを確認します(第1章の手順で
uv sync済みの前提です) - 環境変数
OPENAI_API_KEYを設定します:export OPENAI_API_KEY=sk-... - 次のコマンドで実行します
uv run python -m examples.hosted_mcp.simple
- ストリーミングで実行する場合は
--stream、実行項目の詳細を見る場合は--verboseを付けます
uv run python -m examples.hosted_mcp.simple --stream
uv run python -m examples.hosted_mcp.simple --repo https://github.com/openai/codex
期待される動作・出力例
DeepWiki への問い合わせが OpenAI 側で実行され、承認プロンプトなしで最終回答が表示されます。コード中のコメントにある通り、デフォルトのリポジトリでは次のような出力になります。
The repository is primarily written in Python...
--stream を付けた場合は、ToolCallItem や MessageOutputItem などのイベント型名が順に表示された後、最終結果が表示されます。
つまずきポイント
- 実行にはインターネット接続が必要です。DeepWiki サーバーや対象リポジトリの状態によっては応答に時間がかかることがあります
uv run python examples/hosted_mcp/simple.pyでも動作しますが、この章では他の example(examples.auto_modeを import するためパッケージとして実行する必要があるもの)と合わせて-m形式に統一しています
on_approval.py
require_approval: "always" と on_approval_request コールバックを組み合わせ、ツール実行のたびに Python 関数で承認可否を判断する example です。実行の流れを止めずに、プログラム側で承認を処理できます。
コード解説
まず承認コールバック関数です。
from agents import (
Agent,
HostedMCPTool,
MCPToolApprovalFunctionResult,
MCPToolApprovalRequest,
Runner,
RunResult,
RunResultStreaming,
)
from examples.auto_mode import confirm_with_fallback
def prompt_approval(request: MCPToolApprovalRequest) -> MCPToolApprovalFunctionResult:
params: object = request.data.arguments or {}
approved = confirm_with_fallback(
f"Approve running tool (mcp: {request.data.name}, params: {json.dumps(params)})? (y/n) ",
default=True,
)
result: MCPToolApprovalFunctionResult = {"approve": approved}
if not approved:
result["reason"] = "User denied"
return result
MCPToolApprovalRequestは承認リクエストを表すクラスで、request.data.name(呼び出そうとしているツール名)とrequest.data.arguments(引数)が参照できます- 戻り値の
MCPToolApprovalFunctionResultは{"approve": bool}形式の辞書(正確にはTypedDict)で、拒否時には任意で"reason"(拒否理由。モデルに伝わります)を添えられます confirm_with_fallbackは examples 共通のヘルパーで、通常はターミナルで y/n の入力を求め、環境変数EXAMPLES_INTERACTIVE_MODE=autoが設定されているときは自動でデフォルト値(ここでは承認)を返します- コールバックは同期関数でも非同期関数(
async def)でも構いません
エージェント定義では、このコールバックを HostedMCPTool に渡します。
agent = Agent(
name="MCP Assistant",
instructions=(
"You must always use the MCP tools to answer questions. "
"Use the DeepWiki hosted MCP server to answer questions and do not ask the user for "
"additional configuration."
),
tools=[
HostedMCPTool(
tool_config={
"type": "mcp",
"server_label": "deepwiki",
"server_url": "https://mcp.deepwiki.com/mcp",
"allowed_tools": ["ask_question"],
"require_approval": require_approval,
},
on_approval_request=prompt_approval,
)
],
)
require_approval には "always" が入っています。ポイントは、承認ポリシーは tool_config の中(API に送られる設定)、コールバックは HostedMCPTool の引数(SDK がローカルで処理)という役割分担です。承認が要求されるたびに SDK が prompt_approval を呼び、その結果を API に返して実行を継続します。また "allowed_tools": ["ask_question"] は、サーバーが公開する複数のツールのうち ask_question だけをモデルに見せる絞り込みです。承認デモが余計なツール呼び出しに逸れないようにする指定で、承認ポリシーと同じく API に送られる tool_config 側の設定です。
非ストリーミング側の実行部分には、念のための再開ループも書かれています。
else:
run_result = await Runner.run(agent, question)
while run_result.interruptions:
run_result = await Runner.run(agent, run_result.to_state())
print(run_result.final_output)
コールバックを渡している場合、承認は実行中に処理されるため通常このループは回りませんが、コールバックで処理されなかった承認要求が interruptions として残った場合に、状態を引き継いで再実行する保険として機能します(この仕組み自体は次の human_in_the_loop.py で詳しく学びます)。
実行手順
- 環境変数
OPENAI_API_KEYを設定します - 次のコマンドで実行します
uv run python -m examples.hosted_mcp.on_approval
- ツール呼び出しごとに
Approve running tool (mcp: ask_question, params: {...})? (y/n)と聞かれるので、yを入力して承認します - 対話なしで自動実行したい場合は、環境変数を付けて実行します
EXAMPLES_INTERACTIVE_MODE=auto uv run python -m examples.hosted_mcp.on_approval
期待される動作・出力例
「openai/codex リポジトリは何の言語で書かれているか」という質問に対し、DeepWiki のツール呼び出し前に承認プロンプトが表示されます。承認すると最終回答(Rust が主要言語である旨)が表示されます。n で拒否すると {"approve": False, "reason": "User denied"} がモデルに伝わり、モデルはツール結果なしで応答を組み立てようとします。
つまずきポイント
- この example は
from examples.auto_mode import confirm_with_fallbackとパッケージ内 import をしているため、必ずリポジトリルートから-m形式で実行してください。uv run python examples/hosted_mcp/on_approval.pyはModuleNotFoundError: No module named 'examples'で失敗します --stream指定時も承認プロンプトはコールバック経由で出ます。ストリーミング出力の途中にプロンプトが混ざって見えることがありますが、動作としては正常です
human_in_the_loop.py
コールバックを使わず、interruption(中断)ベースで人間の承認を挟む example です。human-in-the-loop(ヒューマン・イン・ザ・ループ)とは、自動処理の途中に人間の判断ポイントを組み込む設計パターンのことです。承認待ちで実行がいったん「停止」し、承認/拒否を記録してから「再開」する、という 2 段階の流れを学びます。
コード解説
エージェント定義には、これまでにない要素が 2 つあります。
from agents.model_settings import MCPToolChoice
agent = Agent(
name="MCP Assistant",
instructions=(
"You must always use the MCP tools to answer questions. "
"Use the DeepWiki hosted MCP server to answer questions and do not ask the user for "
"additional configuration."
),
model_settings=ModelSettings(
tool_choice=MCPToolChoice(server_label="deepwiki", name="ask_question")
),
tools=[
HostedMCPTool(
tool_config={
"type": "mcp",
"server_label": "deepwiki",
"server_url": "https://mcp.deepwiki.com/mcp",
"require_approval": require_approval,
}
)
],
)
resume_config = RunConfig(model_settings=ModelSettings(tool_choice="auto"))
MCPToolChoice(server_label="deepwiki", name="ask_question")は、"required"(何かしらのツールを使え)よりさらに踏み込んで、「deepwiki サーバーのask_questionという特定のツールを使え」と強制する指定ですHostedMCPToolにon_approval_requestを渡していない点がon_approval.pyとの違いです。require_approval: "always"なのにコールバックがないため、承認が必要になった時点で実行が中断しますresume_configは再開時に使うRunConfig(実行単位の設定)で、tool_choice="auto"に緩めています。承認後の再開時にもツール強制が残っていると、モデルが回答を出す代わりに同じツールを何度も呼ぼうとしてしまうためです(コード中のコメントにもその旨が書かれています)
中断と再開のループが、この example の核心です(非ストリーミング側)。
run_result = await Runner.run(agent, question, max_turns=100)
while run_result.interruptions:
state = run_result.to_state()
for interruption in run_result.interruptions:
approved = prompt_for_interruption(interruption.name, interruption.arguments)
if approved:
state.approve(interruption)
else:
state.reject(interruption)
run_result = await Runner.run(
agent,
state,
max_turns=100,
run_config=resume_config,
)
print(run_result.final_output)
流れを整理します。
Runner.run()を普通に実行します。承認が必要になると、実行はそこで止まり、結果のrun_result.interruptionsに承認待ちのツール呼び出し(ToolApprovalItemのリスト)が入りますrun_result.to_state()で、実行の途中状態を表すRunStateオブジェクトを取り出します- 各 interruption について、
state.approve(interruption)またはstate.reject(interruption)で判断を記録します Runner.run(agent, state, ...)に入力文字列の代わりにstateを渡すと、中断した箇所から実行が再開されます- 再開後にまた承認が必要になるかもしれないので、
interruptionsが空になるまでwhileで繰り返します
ストリーミング版(Runner.run_streamed)でもまったく同じパターンで、stream_result.to_state() → approve/reject → Runner.run_streamed(agent, state, ...) と再開します。
承認プロンプト用のヘルパー prompt_for_interruption は、ツール名と引数(JSON 文字列の場合はパースして)を表示して y/n を尋ねる関数です。EOFError / KeyboardInterrupt(Ctrl+C 等)が起きた場合は拒否扱いにする防御的な実装になっています。
実行手順
- 環境変数
OPENAI_API_KEYを設定します - 次のコマンドで実行します(パッケージ内 import があるため
-m形式必須)
uv run python -m examples.hosted_mcp.human_in_the_loop
Approve running tool (mcp: ask_question, params: {...})? (y/n)と表示されたらyを入力します- 対話なしで試す場合は
EXAMPLES_INTERACTIVE_MODE=autoを付けます
EXAMPLES_INTERACTIVE_MODE=auto uv run python -m examples.hosted_mcp.human_in_the_loop --stream
期待される動作・出力例
DeepWiki の ask_question ツール呼び出し前に実行が中断し、承認プロンプトが表示されます。承認すると実行が再開され、「openai/codex は主に Rust で書かれている」という趣旨の最終回答が表示されます。--stream の場合は回答テキストが少しずつ流れた後、Done streaming; final result: ... が表示されます。
つまずきポイント
on_approval.py(コールバック方式)とこの example(interruption 方式)の使い分け: コールバック方式は「その場で機械的に判断できる」場合(許可リスト照合など)に向き、interruption 方式は「状態をいったん保存して、別の場所・別のタイミングで人間が判断する」場合(Web アプリで承認画面を出す等)に向きます- 再開時に
run_config=resume_configを渡し忘れると、MCPToolChoiceによるツール強制が効いたままになり、モデルがツール呼び出し→承認→ツール呼び出し…を繰り返してmax_turns(実行ループの上限回数。この example では 100 に引き上げられています)に達するおそれがあります state.approve()にはalways_approve=Trueという引数もあり、同じツールを以後ずっと自動承認にできます(src/agents/run_state.py参照)
この中断・再開の流れを図にすると次のようになります。
connectors.py
OpenAI connectors を使って、Google カレンダーにホスト型 MCP としてアクセスする example です。「今日の予定は?」という質問にエージェントが答えます。
コード解説
HostedMCPTool の設定が、これまでの server_url 方式と異なります。
async def main(verbose: bool, stream: bool):
# 1. Visit https://developers.google.com/oauthplayground/
# 2. Input https://www.googleapis.com/auth/calendar.events as the required scope
# 3. Grab the access token starting with "ya29."
authorization = os.environ["GOOGLE_CALENDAR_AUTHORIZATION"]
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant that can help a user with their calendar.",
tools=[
HostedMCPTool(
tool_config={
"type": "mcp",
"server_label": "google_calendar",
# see https://developers.openai.com/api/docs/guides/tools-connectors-mcp#connectors
"connector_id": "connector_googlecalendar",
"authorization": authorization,
"require_approval": "never",
}
)
],
)
server_urlの代わりにconnector_id(ここでは Google カレンダー用のconnector_googlecalendar)を指定します。利用可能な connector の一覧は、コメントにある OpenAI 公式ガイド(tools-connectors-mcp)に掲載されていますauthorizationには Google のアクセストークンを渡します。このトークンを使って、Responses API があなたの代わりに Google カレンダー API と通信します- トークンはコードに直書きせず、環境変数
GOOGLE_CALENDAR_AUTHORIZATIONから読み込んでいます。認証情報をソースコードに含めないのは、実務でも必ず守るべき習慣です
質問文は実行時の日付から動的に組み立てます。
today = datetime.now().strftime("%Y-%m-%d")
run_result: RunResult | RunResultStreaming
if stream:
run_result = Runner.run_streamed(agent, f"What is my schedule for {today}?")
async for event in run_result.stream_events():
if event.type == "raw_response_event":
if event.data.type.startswith("response.output_item"):
print(json.dumps(event.data.to_dict(), indent=2))
if event.data.type.startswith("response.mcp"):
print(json.dumps(event.data.to_dict(), indent=2))
if event.data.type == "response.output_text.delta":
print(event.data.delta, end="", flush=True)
print()
else:
run_result = await Runner.run(agent, f"What is my schedule for {today}?")
print(run_result.final_output)
ストリーミング側では、これまでの example より一段低いレイヤーの raw_response_event(Responses API から届く生イベント)を観察しています。response.mcp で始まるイベント(MCP ツール一覧の取得やツール呼び出しの進行状況)を JSON で表示するので、ホスト型 MCP の内部動作を学ぶのに便利です。
実行手順
この example だけは、Google のアクセストークンという事前準備が必要です。コード内のコメントに沿って進めます。
- ブラウザで Google OAuth 2.0 Playground を開きます
- Step 1 のスコープ入力欄に
https://www.googleapis.com/auth/calendar.eventsを入力し、「Authorize APIs」で自分の Google アカウントを認可します - Step 2 の「Exchange authorization code for tokens」を押し、表示される
ya29.で始まるアクセストークンをコピーします - 環境変数を設定します
export OPENAI_API_KEY=sk-...
export GOOGLE_CALENDAR_AUTHORIZATION=ya29....
- 実行します
uv run python -m examples.hosted_mcp.connectors
uv run python -m examples.hosted_mcp.connectors --stream
期待される動作・出力例
エージェントが Google カレンダーの connector 経由で当日の予定を検索し、「今日の予定は 10 時からのミーティングが 1 件です」のような自然文で答えます。予定が何もない日であれば「本日の予定は登録されていません」といった回答になります。--stream では response.mcp_list_tools や response.mcp_call 系のイベント JSON が先に流れ、最後に回答テキストが表示されます。
つまずきポイント
- アクセストークンは短時間(通常 1 時間程度)で失効します。実行時に認証エラーが出たら、OAuth Playground でトークンを取得し直してください。OAuth Playground はあくまで学習・検証用であり、本番アプリでは正式な OAuth フロー(refresh token による更新等)を実装する必要があります
GOOGLE_CALENDAR_AUTHORIZATIONが未設定だと、os.environ[...]の時点でKeyErrorになります- スコープを間違える(例:
calendar.readonlyにする)と、connector が必要とする操作が許可されずエラーになることがあります。コメント指定どおりcalendar.eventsを使ってください - デバッグしたいときは、ファイル冒頭のコメントアウトされた
logging.basicConfig(level=logging.DEBUG)を有効にすると通信の詳細が見えます
connectors の認証仲介を図にすると次のとおりです。
まとめ
- ホスト型 MCP(
HostedMCPTool)は、MCP サーバーへの接続とツール実行を OpenAI のサーバー側に任せる方式です。ローカル MCP(第6章)と違い、あなたのコードへの往復なしにツールが実行されます HostedMCPToolはAgent(tools=[...])に追加し、tool_configに"type": "mcp"、server_label、server_url(またはconnector_id+authorization)、require_approvalを指定します- 承認フローは 3 パターン: 承認なし(
"never"、信頼できるサーバー限定)、コールバック承認(on_approval_requestで{"approve": bool}を返す)、interruption 承認(result.interruptions→to_state()→approve()/reject()→ state を渡して再実行) - connectors は Google カレンダー等の既製サービス向けで、
connector_idとアクセストークンを渡すと Responses API が認証を仲介してくれます。トークンは環境変数で渡し、コードに直書きしません ModelSettingsのtool_choiceは"required"(何かツールを使う)だけでなく、MCPToolChoice(server_label=..., name=...)で特定 MCP ツールの使用を強制できます。中断からの再開時はRunConfigで"auto"に戻すのが定石です
理解度チェック
ローカル MCP(Agent(mcp_servers=[...]))とホスト型 MCP(HostedMCPTool)の最も本質的な違いは何ですか。
require_approval: "always" を設定したとき、on_approval_request コールバックを「渡した場合」と「渡さなかった場合」で、それぞれ承認はどのように処理されますか。
human_in_the_loop.py では、承認後に実行を再開する際 RunConfig(model_settings=ModelSettings(tool_choice="auto")) を渡しています。これを渡さないとどのような問題が起こり得ますか。
Google カレンダー connector を使うために必要な tool_config のキーを、DeepWiki のような URL 指定のホスト型 MCP と比較して答えてください。
社内ネットワーク内でしかアクセスできない自作 MCP サーバーをエージェントに使わせたい場合、ホスト型 MCP とローカル MCP のどちらを選ぶべきですか。理由も述べてください。
解答と解説
問1 の解答と解説
MCP サーバーに接続してツールを実行する主体が異なります。ローカル MCP ではあなたの Python プロセスがサーバーに接続し、ツール呼び出しのたびにコードを経由します。ホスト型 MCP では OpenAI の Responses API がサーバーに直接接続し、ツール一覧の取得も実行も OpenAI 側で完結するため、あなたのコードへの往復が発生しません。
問2 の解答と解説
コールバックを渡した場合、承認が必要になるたびに SDK がその関数を呼び出し、戻り値({"approve": True} 等)に基づいて実行がそのまま継続されます。渡さなかった場合、実行は承認待ちで中断し、結果の interruptions に ToolApprovalItem が入ります。to_state() で状態を取り出して approve()/reject() を記録し、その state を Runner.run()(または run_streamed())に渡し直すことで再開します。
問3 の解答と解説
エージェント本体には MCPToolChoice(server_label="deepwiki", name="ask_question") という「特定ツールの使用を強制する」設定が付いています。再開時にこれが効いたままだと、モデルは最終回答を出す代わりに再び同じツールを呼ぼうとし、承認→ツール呼び出し→承認…のループに陥って max_turns の上限に達するおそれがあります。再開用の RunConfig で tool_choice="auto" に緩めることで、承認後にモデルが自由に回答を生成できるようにしています。
問4 の解答と解説
URL 指定のホスト型 MCP では server_url(例: https://mcp.deepwiki.com/mcp)を指定します。connector の場合は server_url の代わりに connector_id(例: connector_googlecalendar)と、対象サービスのアクセストークンを入れた authorization を指定します。"type": "mcp"、server_label、require_approval は両者で共通です。
問5 の解答と解説
ローカル MCP を選ぶべきです。ホスト型 MCP は OpenAI のサーバーが接続元になるため、MCP サーバーがインターネット上から公開到達可能である必要があります。社内ネットワーク内・VPC 内・ローカルプロセス(stdio)でしか動かないサーバーには接続できないので、第6章で学んだ MCPServerStdio や MCPServerStreamableHttp を使い、Agent(mcp_servers=[...]) で接続します。
参考リンク
- Model context protocol (MCP) — ホスト型 MCP・承認フロー・connectors の公式解説(日本語版)
- Tools —
HostedMCPToolを含む OpenAI ホスト型ツールの一覧(日本語版) - Examples — hosted_mcp カテゴリの位置づけ(日本語版)
- Running agents —
Runner.run/run_streamedとRunConfigの解説(日本語版) - OpenAI 公式ガイド: Connectors and MCP servers — 利用可能な
connector_idの一覧
第8章 セッションメモリ(memory)
継続方法は、手動の to_input_list()、SDKの session、Conversations、previous_response_id から会話ごとに選びます。サーバー側で履歴を管理する場合は新しい入力とIDを渡し、同じ履歴全体を重ねて再送しません。承認待ちのrunは保存したstateから再開します。handoff後に同じ担当を継続させるときは last_agent も引き継ぎます。SDKセッション、Agents APIセッション、サンドボックスはそれぞれ別のリソースです。公式:実行と継続 · 結果と状態
この章で学ぶこと
v0.22.1 / v0.22.2:再開と圧縮の修正
承認後の再開中にsession保存が失敗した場合の復元や、圧縮中の並行書き込みが修正されました。v0.22.2では pop_item() 後に圧縮用response chainがリセットされます。過去状態から再開するアプリは、承認・保存失敗・履歴の取り消しを組み合わせて確認してください。外部関数の二重実行を防ぐ仕組みは、アプリ側でも必要です。v0.22.2。API側がセッションを持つ構成は第18章で比較します。
- LLM が「ステートレス」であるとはどういうことか、なぜ会話履歴の管理(メモリ)が必要になるのか
- Agents SDK の Session(セッション)機能が会話履歴の保存・復元をどのように自動化するか
- SQLite / SQLAlchemy / Redis / MongoDB / Dapr / OpenAI Conversations API など、各セッションバックエンドの特徴と使い分け
- EncryptedSession(暗号化)と OpenAIResponsesCompactionSession(履歴の自動圧縮)という「ラッパー型」セッションの使い方
- セッションと human-in-the-loop(ツール実行の人間承認)を組み合わせ、中断・再開できる会話を作る方法
Sessionプロトコルに従って自作のセッションバックエンドを実装する方法
前提知識
- 第1章: リポジトリのクローン、
uv syncによる環境構築、OPENAI_API_KEYの設定、example の実行方法 - 第2章:
Agentの作成とRunner.run()による実行、result.final_outputの読み方 - ツール(
function_tool)の基本。エージェントに Python 関数を「道具」として渡す仕組みで、本章の後半(HITL 系 example)で登場します - Python の
async/awaitの基本。非同期処理(処理の完了を待つ間に他の処理を進められる書き方)は本章の全 example で使われます
概念解説
LLM は「記憶喪失」— なぜメモリが必要か
LLM(大規模言語モデル。大量のテキストから学習した文章生成 AI)には、実は「記憶」がありません。API 呼び出しは 1 回 1 回が完全に独立していて、前回何を話したかをモデル側は一切覚えていないのです。この性質を ステートレス(状態を持たない)と呼びます。
日常の比喩で言えば、LLM は「非常に優秀だが、電話を切るたびに記憶がリセットされるオペレーター」です。あなたが 1 本目の電話で「ゴールデンゲートブリッジはどの都市にありますか?」と聞き、2 本目の電話で「それはどの州にありますか?」と聞いても、オペレーターは「『それ』とは何のことですか?」と困ってしまいます。
では ChatGPT のようなチャットアプリはなぜ文脈を覚えているように見えるのでしょうか。答えは単純で、毎回の呼び出しで過去の会話履歴を丸ごと添付し直しているからです。オペレーターに電話をかけるたびに「これまでの通話記録のメモ」を最初に読み上げているイメージです。
Agents SDK でこれを手動でやろうとすると、result.to_input_list() で前回の実行結果を入力リストに変換し、次の入力に連結して……という定型作業を毎ターン繰り返すことになります。これを肩代わりしてくれるのが本章の主役、Session(セッションメモリ) です。
Session の仕組み
Runner.run() に session=... を渡すと、SDK は次の 2 つを自動で行います。
- 実行前: セッションに保存された会話履歴を取り出し、今回の入力の前に連結する
- 実行後: 今回の実行で生まれた新しいアイテム(ユーザー入力、アシスタントの応答、ツール呼び出しなど)をセッションに保存する
つまり開発者は「同じ session オブジェクトを渡し続ける」だけで、複数ターンの会話が成立します。
from agents import Agent, Runner, SQLiteSession
agent = Agent(name="Assistant", instructions="Reply very concisely.")
session = SQLiteSession("conversation_123")
result = await Runner.run(agent, "What city is the Golden Gate Bridge in?", session=session)
# → "San Francisco"
result = await Runner.run(agent, "What state is it in?", session=session)
# → "California"(前のターンを覚えている!)
なお、セッションは OpenAI サーバー側の会話継続機能(conversation_id / previous_response_id / auto_previous_response_id)と同じ実行で併用できません。どちらか一方を選びます。
実行前と実行後で何が起きているかを図にすると次のとおりです。
Session はプロトコル — バックエンドは差し替え自由
セッションの正体は、次の 4 メソッドを持つ Session プロトコル(Python における「この形のメソッドを持っていればよい」という契約)です。
get_items(limit)— 履歴の取得add_items(items)— 新しいアイテムの追加pop_item()— 最新アイテムの削除(直前の発言の取り消しに便利)clear_session()— 履歴の全削除
この契約さえ満たせば保存先は何でもよいため、SDK にはさまざまなバックエンド実装が用意されています。「どこにメモを保管するか」の違いだと考えてください。机の引き出し(SQLite)、会社の共有キャビネット(Redis / MongoDB)、外部の貸金庫サービス(OpenAI Conversations API)、といった具合です。
バックエンド比較表 — どれを選ぶか
| セッション実装 | 保存先 | 追加依存 | 向いている用途 |
|---|---|---|---|
SQLiteSession | ローカルの SQLite(メモリ or ファイル) | なし(組み込み) | ローカル開発、CLI ツール、単一プロセスの小規模アプリ |
AdvancedSQLiteSession | ローカルの SQLite | なし(extensions) | SQLite に加えて会話の分岐(ブランチ)・トークン使用量分析が欲しい場合 |
SQLAlchemySession | SQLAlchemy が対応する任意の DB(PostgreSQL、MySQL 等) | sqlalchemy extra | 既存のリレーショナル DB を持つ本番アプリ |
RedisSession | Redis サーバー | redis extra + Redis サーバー | 複数ワーカー・複数サービス間で共有する低遅延メモリ |
MongoDBSession | MongoDB | mongodb extra + MongoDB サーバー | すでに MongoDB を使っているアプリ、マルチプロセスの水平スケール |
DaprSession | Dapr が対応する 30 種以上のステートストア | dapr extra + Dapr サイドカー | Kubernetes 等のクラウドネイティブ環境、バックエンドを後から差し替えたい場合 |
OpenAIConversationsSession | OpenAI のサーバー(Conversations API) | なし | 履歴管理を OpenAI 側に任せたい場合。自前の DB 不要 |
OpenAIResponsesCompactionSession | ラッパー(下位セッションに保存) | なし | 長い会話の履歴を自動圧縮してトークン消費を抑えたい場合 |
EncryptedSession | ラッパー(下位セッションに保存) | encrypt extra | 会話履歴を暗号化して保存したい場合、TTL で自動失効させたい場合 |
選択に迷ったら、次のフローチャートで用途から辿ってください。
選び方の目安は次のとおりです。
- まず試すなら
SQLiteSession。依存ゼロで動きます - 本番の Web アプリ で既存 DB があるなら
SQLAlchemySession、複数サーバーで共有するならRedisSessionかMongoDBSession - Kubernetes / マルチクラウド なら
DaprSession - 自前でデータを持ちたくない なら
OpenAIConversationsSession - 暗号化・履歴圧縮 は、上のどれかを選んだ上で
EncryptedSession/OpenAIResponsesCompactionSessionを重ねる(ラッパー型)
この章の example で共通して使う環境
読者はすでにリポジトリを clone して uv sync 済みという前提です(第1章参照)。全 example で環境変数 OPENAI_API_KEY(OpenAI の API 認証キー)の設定が必要です。
export OPENAI_API_KEY="sk-..."
一部の example は追加依存やサーバー(Docker コンテナ等)が必要です。それぞれの実行手順に明記します。
各 example の解説
sqlite_session_example.py
セッションメモリの最も基本的な example です。SQLiteSession を使って 3 ターンの会話を行い、エージェントが文脈を覚えていることを確認します。
コードの核心部分を見てみましょう。
from agents import Agent, Runner, SQLiteSession
async def main():
# Create an agent
agent = Agent(
name="Assistant",
instructions="Reply very concisely.",
)
# Create a session instance that will persist across runs
session_id = "conversation_123"
session = SQLiteSession(session_id)
SQLiteSession(session_id) のようにセッション ID(会話を識別する名前)だけを渡すと、インメモリ(プロセス終了とともに消える)の SQLite データベースが使われます。ソースコード上、第 2 引数 db_path のデフォルトが ":memory:" になっているためです。ファイルに永続化したい場合は SQLiteSession("conversation_123", "conversations.db") のようにファイルパスを渡します。
result = await Runner.run(
agent,
"What city is the Golden Gate Bridge in?",
session=session,
)
print(f"Assistant: {result.final_output}")
1 ターン目は普通の実行ですが、session=session を渡している点が重要です。これで実行結果が自動保存されます。
result = await Runner.run(agent, "What state is it in?", session=session)
2 ターン目では「What state is it in?」と代名詞で聞いていますが、セッションが 1 ターン目の履歴を自動で前置してくれるため、エージェントは「it = ゴールデンゲートブリッジのある都市(サンフランシスコ)」だと理解できます。
最後に、セッションの中身を直接のぞく方法も示されています。
# Demonstrate the limit parameter - get only the latest 2 items
latest_items = await session.get_items(limit=2)
...
# Get all items to show the difference
all_items = await session.get_items()
print(f"Total items in session: {len(all_items)}")
get_items(limit=2) で「最新 2 件だけ」を取得できます。長い会話で履歴の一部だけを確認したいときに便利です。
実行手順
export OPENAI_API_KEY="sk-..."を設定しますリポジトリルートで次を実行します
uv run python examples/memory/sqlite_session_example.py
期待される動作・出力例
3 ターンの会話が順に表示され、2 ターン目で「California」、3 ターン目でカリフォルニア州の人口が返ります。最後に「Latest 2 items」として直近のユーザー発言とアシスタント応答の 2 件、および「Total items in session: 6」(3 ターン × 2 アイテム)のような集計が表示されます。
つまずきポイント
- この example はインメモリ DB なので、スクリプトを再実行すると履歴はゼロから始まります。「前回の実行を覚えていない」のは正常な動作です
sqlalchemy_session_example.py
SQLAlchemySession を使う example です。SQLAlchemy は Python で最も広く使われるデータベースツールキットで、PostgreSQL や MySQL など多様な DB に同じコードで接続できます。会話の流れは SQLite 版と同じで、バックエンドだけが違います。
from agents.extensions.memory.sqlalchemy_session import SQLAlchemySession
拡張系のセッションは agents 直下ではなく agents.extensions.memory 配下からインポートします。
# Create a session instance with a session ID.
# This example uses an in-memory SQLite database.
# The `create_tables=True` flag is useful for development and testing.
session = SQLAlchemySession.from_url(
"conversation_123",
url="sqlite+aiosqlite:///:memory:",
create_tables=True,
)
from_url()は接続 URL からセッションを作るファクトリメソッドです。URL の書式は SQLAlchemy 標準で、sqlite+aiosqlite:///:memory:は「非同期ドライバ aiosqlite 経由のインメモリ SQLite」を意味します。本番ではpostgresql+asyncpg://user:pass@localhost/dbのように書き換えるだけで PostgreSQL に切り替わりますcreate_tables=Trueは必要なテーブルを自動作成するフラグで、開発・テスト向けです。本番ではマイグレーション(DB スキーマの計画的な変更管理)で管理するのが一般的です- v0.18.0 で
ensure_ascii引数が追加されました。デフォルト(True)では日本語などの非 ASCII 文字が\uXXXX形式にエスケープされて保存されます(従来どおりの保存形式)。ensure_ascii=Falseを渡すと文字がそのまま保存されるため、DB を直接開いて会話履歴を確認するときに読みやすくなります
実行手順
追加依存をインストールします(SQLAlchemy 本体。aiosqlite は
uv syncの開発依存に含まれています)uv sync --extra sqlalchemyexport OPENAI_API_KEY="sk-..."を設定しますリポジトリルートで次を実行します
uv run python examples/memory/sqlalchemy_session_example.py
期待される動作・出力例
sqlite_session_example.py と同じ 3 ターン会話が成立し、最後に get_items(limit=2) と全件数が表示されます。
つまずきポイント
- ファイル末尾のコメントに
pip install "agents[sqlalchemy]"とありますが、この教科書の前提環境では上記のuv sync --extra sqlalchemyが対応するコマンドです - PostgreSQL に切り替える場合は接続先の DB サーバーが起動している必要があります
advanced_sqlite_session_example.py
AdvancedSQLiteSession の総合デモです。通常のセッション機能に加えて、(1) トークン使用量の記録と集計、(2) ターン単位の構造化クエリ、(3) 会話のブランチ(分岐) という 3 つの高度な機能を実演します。ブランチとは、Git のブランチのように「会話のターン 2 の直前まで巻き戻して、そこから別の会話の流れを試す」機能です。
from agents import (
Agent,
Runner,
)
from agents.decorators import tool
from agents.extensions.memory import AdvancedSQLiteSession
@tool
async def get_weather(city: str) -> str:
if city.strip().lower() == "new york":
return f"The weather in {city} is cloudy."
return f"The weather in {city} is sunny."
ツール呼び出しも履歴として追跡できることを示すため、簡単な天気ツールを定義しています。
session = AdvancedSQLiteSession(
session_id="conversation_comprehensive",
create_tables=True,
)
create_tables=True で分析用の追加テーブルも自動作成されます。
使用量トラッキング: 各ターンの後に store_run_usage() を呼ぶと、そのターンのトークン数(LLM が処理するテキストの単位。課金の基準)が記録されます。
result = await Runner.run(
agent,
"What city is the Golden Gate Bridge in?",
session=session,
)
print(f"Assistant: {result.final_output}")
print(f"Usage: {result.context_wrapper.usage.total_tokens} tokens")
# Store usage data automatically
await session.store_run_usage(result)
集計は get_session_usage()(セッション全体)と get_turn_usage()(ターン別)で取得できます。
session_usage = await session.get_session_usage()
if session_usage:
print("Session Usage (aggregated from turns):")
print(f" Total requests: {session_usage['requests']}")
print(f" Total tokens: {session_usage['total_tokens']}")
構造化クエリ: get_conversation_by_turns() はターン番号をキーに、各ターンに含まれるアイテム(メッセージ、ツール呼び出し等)を返します。get_tool_usage() はどのツールが何回使われたかを返します。
ブランチ機能: この example の目玉です。
# Create a branch from turn 2
print("\nCreating new branch from turn 2...")
branch_id = await session.create_branch_from_turn(2)
print(f"Created branch: {branch_id}")
create_branch_from_turn(2) は「ターン 2 の直前」から新しいブランチを作ります。新ブランチにはターン 1 の履歴だけが引き継がれ、以降の Runner.run() は新ブランチ側に記録されます。example ではここで「Actually, what's the weather in New York instead?」と、元の会話とは別の質問を投げ、会話が分岐したことを確認します。
# Switch back to main branch
await session.switch_to_branch("main")
switch_to_branch() でブランチを行き来でき、list_branches() で一覧できます。元の会話(main ブランチ)と新ブランチは完全に独立しています。「会話のやり直し」「複数の回答パターンの比較」を実装したいときに強力です。
実行手順
export OPENAI_API_KEY="sk-..."を設定しますリポジトリルートで次を実行します
uv run python examples/memory/advanced_sqlite_session_example.py
期待される動作・出力例
PART 1〜4 の見出しとともに、3 ターンの会話 → トークン使用量の集計(例: Turn 1: 137 tokens)→ ターン構造の一覧 → ブランチ作成とブランチ上での新会話 → ブランチ切り替えの様子が順に表示されます。最後に main ブランチと新ブランチのアイテム数が別々に集計され、独立性が示されます。
つまずきポイント
store_run_usage()は自動では呼ばれません。使用量分析をしたい場合は各Runner.run()の後に自分で呼ぶ必要があります- ブランチ機能は
AdvancedSQLiteSession固有です。他のバックエンドにはありません
ブランチ作成から切り替えまでの流れを図にすると次のとおりです。
encrypted_session_example.py
EncryptedSession は任意のセッションを包んで(ラップして)、保存内容を透過的に暗号化するラッパーです。「透過的」とは、使う側のコードは暗号化を意識しなくてよい(読み書きの API は同じまま、保存時に暗号化・取得時に復号される)という意味です。
from agents import Agent, Runner, SQLiteSession
from agents.extensions.memory import EncryptedSession
from agents.extensions.memory.encrypt_session import EncryptedEnvelope
# Create an underlying session (SQLiteSession in this example)
session_id = "conversation_123"
underlying_session = SQLiteSession(session_id)
# Wrap with encrypted session for automatic encryption and TTL
session = EncryptedSession(
session_id=session_id,
underlying_session=underlying_session,
encryption_key="my-secret-encryption-key",
ttl=3600, # 1 hour TTL for messages
)
underlying_sessionに「実際の保存先」を渡します。ここでは SQLite ですが、SQLAlchemy や Redis でも構いませんencryption_keyは任意の文字列で OK です。内部では HKDF という鍵導出アルゴリズムでセッションごとに固有の暗号鍵が作られ、Fernet 方式(Python の cryptography ライブラリの対称暗号)で暗号化されますttl=3600は TTL(Time To Live、データの有効期限)で、3600 秒 = 1 時間を過ぎたアイテムは復号時に静かにスキップされます
会話部分はこれまでと同じです。面白いのは最後の「暗号化の確認」です。
# Show that underlying storage is encrypted
raw_items = await underlying_session.get_items()
print("Raw encrypted items in underlying storage:")
for i, item in enumerate(raw_items, 1):
if isinstance(item, dict) and item.get("__enc__") == 1:
enc_item = cast(EncryptedEnvelope, item)
print(
f" {i}. Encrypted envelope: __enc__={enc_item['__enc__']}, "
f"payload length={len(enc_item['payload'])}"
)
ラッパーを介さず下位セッションを直接読むと、{"__enc__": 1, "payload": "..."} という暗号化封筒(envelope)しか見えません。つまり DB を直接のぞかれても会話内容は読めない、ということが確認できます。
実行手順
追加依存をインストールします(cryptography ライブラリ)
uv sync --extra encryptexport OPENAI_API_KEY="sk-..."を設定しますリポジトリルートで次を実行します
uv run python examples/memory/encrypted_session_example.py
期待される動作・出力例
3 ターンの会話後、get_items() では復号済みの平文が表示される一方、「Encryption Demo」セクションでは下位ストレージのアイテムがすべて Encrypted envelope: __enc__=1, payload length=... と表示されます。
つまずきポイント
encryption_keyを変えると過去の履歴は復号できなくなります(エラーではなく「履歴が空」として扱われます)。鍵の管理は慎重に- TTL が短すぎると、会話の途中で古いターンが失効して文脈が失われることがあります
redis_session_example.py
RedisSession を使う example です。Redis はメモリ上でデータを保持する高速なデータストアで、複数のプロセス・サーバーから同じセッションにアクセスしたい場合(例: ロードバランサー配下の複数ワーカー)に向いています。
from agents.extensions.memory import RedisSession
DEFAULT_REDIS_URL = "redis://localhost:6379/0"
接続先は環境変数 REDIS_URL で上書きできます(デフォルトはローカルの 6379 番ポート)。
session_id = "redis_conversation_123"
try:
session = RedisSession.from_url(
session_id,
url=redis_url,
)
# Test Redis connectivity
if not await session.ping():
print("Redis server is not available!")
print("Please start Redis server and try again.")
return
from_url() で URL から接続し、ping() で疎通確認をしてから使う、という実務的なパターンです。本編は 3 ターン会話(例によってゴールデンゲートブリッジ)に加え、次の 3 つのデモがあります。
- セッション分離: 別のセッション ID(
different_conversation_456)を作ると履歴が完全に独立していることを確認 - TTL:
RedisSession.from_url(..., ttl=3600)で 1 時間後に自動失効するセッションを作成 - マルチテナント向けキープレフィックス(
demonstrate_advanced_features()内):
tenant_session = RedisSession.from_url(
"user_123",
url=os.environ.get("REDIS_URL", DEFAULT_REDIS_URL),
key_prefix="tenant_abc:sessions", # Custom prefix for isolation
)
key_prefix を変えることで、同じ Redis サーバー上でテナント(顧客企業など)ごとにキー空間を分離できます。
実行手順
Docker で Redis サーバーを起動します(Docker のインストールが前提です)
docker run -d --name redis -p 6379:6379 redis:7-alpine追加依存をインストールします
uv sync --extra redisexport OPENAI_API_KEY="sk-..."を設定します(別サーバーを使う場合はREDIS_URLも設定)リポジトリルートで次を実行します
uv run python examples/memory/redis_session_example.py終わったらコンテナを停止・削除します
docker rm -f redis
期待される動作・出力例
「Connected to Redis successfully!」の後に 3 ターン会話、Total messages stored in Redis: 6、セッション分離のデモ(Original session has 6 items / New session has 2 items)、TTL デモ、カスタムプレフィックスのデモが順に表示されます。
つまずきポイント
- Redis が起動していないと
Redis server is not available!と表示されて終了します。docker psでコンテナが動いているか確認してください - この example は冒頭で
clear_session()を呼んで履歴を消します。本番コードでは既存履歴を保持したい場合が多いので、そのままコピーしないよう注意してください
mongodb_session_example.py
MongoDBSession を使う example です。MongoDB はドキュメント指向データベースで、すでに MongoDB を採用しているアプリや、複数プロセスからの書き込み順序を保証したい場合(内部で単調増加のシーケンス番号を使用)に向いています。この example のポイントは 1 つの AsyncMongoClient を複数セッションで共有するという本番向けパターンです。
ファイルが短いので主要部を引用します。
from pymongo.asynchronous.mongo_client import AsyncMongoClient
from agents import Agent, Runner
from agents.extensions.memory import MongoDBSession
MONGO_URI = "mongodb://localhost:27017"
DATABASE = "agents_example"
async def main():
agent = Agent(
name="Assistant",
instructions="Reply very concisely.",
)
# One client shared across all sessions (production pattern).
client: AsyncMongoClient[Any] = AsyncMongoClient(MONGO_URI)
try:
await client.admin.command("ping")
except Exception:
print("MongoDB is not available on localhost:27017")
print("Start it with: docker run -d -p 27017:27017 mongo")
return
session_a = MongoDBSession("conversation_a", client=client, database=DATABASE)
session_b = MongoDBSession("conversation_b", client=client, database=DATABASE)
データベース接続はコストが高いため、接続プール(使い回し用の接続の束)を持つクライアントを 1 つだけ作り、全セッションに client= で渡すのが定石です。逆に MongoDBSession.from_uri() を使うとセッションがクライアントを所有し、session.close() で一緒に閉じられます。
本編ではセッション A で 3 ターン会話、セッション B で独立した 1 ターン会話を行い、Session A items: 6, Session B items: 2 のようにセッション分離を確認して、最後に両セッションを clear_session() で掃除します。
実行手順
Docker で MongoDB を起動します
docker run -d --name mongo -p 27017:27017 mongo追加依存をインストールします(pymongo)
uv sync --extra mongodbexport OPENAI_API_KEY="sk-..."を設定しますリポジトリルートで次を実行します
uv run python examples/memory/mongodb_session_example.py終わったらコンテナを削除します
docker rm -f mongo
期待される動作・出力例
Session A の 3 ターン、Session B の 1 ターンが表示され、最後に両セッションのアイテム数(6 と 2)が出力されます。MongoDB が起動していない場合は起動コマンドの案内が表示されて終了します。
つまずきポイント
- MongoDB Atlas(クラウド版)を使う場合は
MONGO_URIをmongodb+srv://...形式に変えるだけで動きます - コレクション名はデフォルトで
agent_sessions/agent_messagesです。既存コレクションと衝突する場合は引数で変更できます
dapr_session_example.py
DaprSession を使う、この章で最も大がかりな example です。Dapr(https://dapr.io)はマイクロサービス開発を支援するランタイムで、サイドカー(アプリの隣で動く補助プロセス)として動作し、Redis・PostgreSQL・MongoDB・Cosmos DB など 30 種類以上のデータストアを統一 API で扱えるようにします。つまり「アプリのコードを一切変えずに、設定ファイル(コンポーネント YAML)の差し替えだけで保存先を変えられる」のが最大の魅力です。
ファイル冒頭の docstring に、どんなときに Dapr を選ぶべきかが明記されています。
WHEN TO USE DaprSession:
- Horizontally scaled deployments (multiple agent instances behind a load balancer)
- Multi-region requirements (agents run in different geographic regions)
- Existing Dapr adoption (your team already uses Dapr for other services)
- Backend flexibility (switch state stores without code changes)
- Enterprise governance (centralized control over state management policies)
WHEN TO CONSIDER ALTERNATIVES:
- Use SQLiteSession for single-instance agents (desktop app, CLI tool)
- Use Session (in-memory) for quick prototypes or short-lived sessions
セッションの作成はコンテキストマネージャ(async with)を使うのが推奨パターンです。
async with DaprSession.from_address(
session_id,
state_store_name=DEFAULT_STATE_STORE,
dapr_address=f"localhost:{grpc_port}",
) as session:
# Test Dapr connectivity
if not await ping_with_retry(session, timeout_seconds=5.0, interval_seconds=0.5):
print("Dapr sidecar is not available!")
state_store_nameは Dapr のコンポーネント YAML で定義したストア名(デフォルトstatestore、環境変数DAPR_STATE_STOREで変更可)dapr_addressはサイドカーの gRPC アドレス(デフォルトポート 50001、環境変数DAPR_GRPC_PORTで変更可)async withを使うと終了時に自動でクリーンアップされます
本編の 3 ターン会話とセッション分離デモの後、demonstrate_advanced_features() で TTL、一貫性レベル、マルチテナントの 3 つを実演します。
async with DaprSession.from_address(
"strong_session",
state_store_name=DEFAULT_STATE_STORE,
dapr_address=f"localhost:{grpc_port}",
consistency=DAPR_CONSISTENCY_STRONG,
) as strong_session:
一貫性レベルとは「書き込んだデータが読み取りに反映されるタイミングの保証」のことで、DAPR_CONSISTENCY_EVENTUAL(結果整合。速いが反映に僅かな遅れがありうる)と DAPR_CONSISTENCY_STRONG(強整合。書いた直後の読み取りを保証) を選べます。
さらに demonstrate_multi_store() では、Redis 用と PostgreSQL 用の 2 つのステートストアを同じアプリから同時に使い分けられることを示します。これが「設定だけでバックエンドを差し替えられる」という Dapr の思想の実演です。
この example には環境構築を自動化する --setup-env オプションが組み込まれており、コンポーネント YAML の生成と Redis / PostgreSQL コンテナの起動を代行してくれます。
実行手順
- Dapr CLI をインストールします(公式手順: https://docs.dapr.io/getting-started/install-dapr-cli/ 。macOS なら
brew install dapr/tap/dapr-cli) - Docker をインストール・起動しておきます
追加依存をインストールします
uv sync --extra daprexport OPENAI_API_KEY="sk-..."を設定しますヘルパーで環境を準備します。
./componentsに Redis / PostgreSQL / デフォルトの 3 つのコンポーネント YAML が生成され、dapr_redis(ポート 6379)とdapr_postgres(ポート 5432)のコンテナが起動しますuv run python examples/memory/dapr_session_example.py --setup-env --only-setup別ターミナルで Dapr サイドカーを起動します(
--dapr-http-port 3500の指定を忘れないでください)dapr run --app-id openai-agents-example --dapr-http-port 3500 --dapr-grpc-port 50001 --resources-path ./components元のターミナルで example を実行します
uv run python examples/memory/dapr_session_example.py終わったらサイドカーを Ctrl+C で停止し、コンテナを削除します
docker rm -f dapr_redis dapr_postgres
期待される動作・出力例
セットアップ手順の解説が表示された後、「Connected to Dapr successfully!」に続いて 3 ターン会話、セッション分離デモ、TTL / 一貫性レベル / マルチテナントの高度な機能デモ、最後に Redis と PostgreSQL の 2 ストア併用デモ(statestore-redis: 1 items; example: Hello from Redis など)が表示されます。
つまずきポイント
- 「Health check connection refused (port 3500)」が出る場合、サイドカー起動時の
--dapr-http-port 3500が抜けています。あるいはexport DAPR_HTTP_ENDPOINT="http://localhost:3500"を設定してください - 「State store not found」はコンポーネント YAML が
--resources-pathのディレクトリにない場合に出ます - daprd を動かしたまま PostgreSQL コンテナを作り直すと、古い接続プールが残って
relation "state" does not existエラーになることがあります。daprd を再起動するか、コンポーネント YAML を touch して再読込させてください
openai_session_example.py
OpenAIConversationsSession を使う example です。これまでのバックエンドと違い、会話履歴を自分のマシンではなく OpenAI のサーバー(Conversations API)に保存します。銀行の貸金庫にメモを預けるイメージで、自前の DB を運用する必要がなくなります。
from agents import Agent, OpenAIConversationsSession, Runner
# Create a session instance that will persist across runs
session = OpenAIConversationsSession()
引数なしで作ると、初回実行時に新しい会話(conversation)が OpenAI 側に作成されます。ソースコードを確認すると、conversation_id= を渡せば既存の会話を再開できます。
# Optionally resume a previous conversation by passing a conversation ID
# session = OpenAIConversationsSession(conversation_id="conv_123")
会話部分は sqlite_session_example.py と全く同じ 3 ターン + get_items(limit=2) のデモです。バックエンドを差し替えてもコードがほぼ変わらないことこそが、Session プロトコルの設計の利点です。
実行手順
export OPENAI_API_KEY="sk-..."を設定しますリポジトリルートで次を実行します
uv run python examples/memory/openai_session_example.py
期待される動作・出力例
3 ターンの会話が成立し、最後にアイテム数が表示されます。ローカルにはファイルも DB も作られません。
つまずきポイント
- 履歴が OpenAI 側に残るため、データの取り扱いポリシー(社内規定等)によっては利用可否を確認してください
- セッションと
conversation_id/previous_response_idの実行時指定は併用できません(概念解説参照)
compaction_session_example.py
OpenAIResponsesCompactionSession を使い、長くなった会話履歴を自動で圧縮(コンパクション)する example です。会話が長くなるほど毎ターン送る履歴(= 入力トークン)が増え、料金と遅延が膨らみます。コンパクションは Responses API の responses.compact 機能を使って古い履歴を要約済みの「compaction アイテム」に置き換え、文脈を保ちながらトークン消費を抑える仕組みです。ノートがいっぱいになったら「これまでのあらすじ」1 ページに書き直すイメージです。
from agents import Agent, OpenAIResponsesCompactionSession, Runner, SQLiteSession
async def main():
# Create an underlying session for storage
underlying = SQLiteSession(":memory:")
# Wrap with compaction session - will automatically compact when threshold hit
session = OpenAIResponsesCompactionSession(
session_id="demo-session",
underlying_session=underlying,
model="gpt-4.1",
# Custom compaction trigger (default is 10 candidates)
should_trigger_compaction=lambda ctx: len(ctx["compaction_candidate_items"]) >= 4,
)
- これも
EncryptedSessionと同じラッパー型で、実際の保存はunderlying_session(ここではインメモリ SQLite)が担います should_trigger_compactionは「いつ圧縮するか」を決めるフック関数です。引数ctxのcompaction_candidate_items(圧縮候補のアイテム一覧)が 4 件以上になったら圧縮する、という設定です。ソースコード上のデフォルトは 10 件(DEFAULT_COMPACTION_THRESHOLD = 10)で、この example ではデモを短くするため 4 に下げています
5 つの質問を連続で投げた後、セッションの中身を表示します。
# Show session state after automatic compaction (if triggered)
items = await session.get_items()
print("=== Session State (Auto Compaction) ===")
print(f"Total items: {len(items)}")
圧縮が走ると、履歴の中に compaction という種類のアイテムが現れます(中身は暗号化されており人間には読めません)。さらに手動での強制圧縮も実演します。
# Manual compaction after inspecting the auto-compacted state.
print("=== Manual Compaction ===")
await session.run_compaction({"force": True})
run_compaction({"force": True}) は閾値に関係なく即座に圧縮します。公式ドキュメントによると、自動圧縮はターン完了時に走るためストリーミング(出力を少しずつ受け取る方式)の応答終了を遅らせることがあり、低遅延が必要なら should_trigger_compaction=lambda _: False で自動圧縮を止め、アイドル時間に手動で run_compaction() を呼ぶ設計が推奨されています。
実行手順
export OPENAI_API_KEY="sk-..."を設定しますリポジトリルートで次を実行します
uv run python examples/memory/compaction_session_example.py
期待される動作・出力例
エベレストに関する 5 ターンの会話の後、セッション内容の一覧に - compaction (encrypted content) と - message (...) が混在して表示されます。手動圧縮後は、アイテムがさらに集約されて件数が減ります。
つまずきポイント
OpenAIConversationsSessionをこのラッパーで包んではいけません。両者は履歴管理の方式が異なるためです(公式ドキュメント明記)- compaction アイテムの中身は暗号化されていて直接読めません。「履歴が壊れた」わけではありません
圧縮前後で履歴がどう変わるかを図示すると次のとおりです。
compaction_session_stateless_example.py
前の example の変形で、ModelSettings(store=False)(OpenAI サーバーに応答を保存しないステートレス設定)と組み合わせた場合のコンパクションを示します。
通常、コンパクションは直前の応答 ID(previous_response_id)を辿るサーバー側の情報を活用できますが、store=False ではサーバーに応答が残らないため辿れません。その場合、compaction_mode="auto"(デフォルト)は自動的にローカルに保存された入力アイテムから圧縮リクエストを組み立てる方式(input ベース)にフォールバックします。
# Wrap with compaction session in auto mode. When store=False, this will
# compact using the locally stored input items.
session = OpenAIResponsesCompactionSession(
session_id="demo-session",
underlying_session=underlying,
model="gpt-4.1",
compaction_mode="auto",
should_trigger_compaction=lambda ctx: len(ctx["compaction_candidate_items"]) >= 3,
)
agent = Agent(
name="Assistant",
instructions="Reply concisely. Keep answers to 1-2 sentences.",
model_settings=ModelSettings(store=False),
)
compaction_mode には 3 つの値があります。
"previous_response_id": Responses API の応答 ID チェーンを使う(応答がサーバーに保存されている前提)"input": セッション内のアイテムからリクエストを再構築する(セッションの中身を真実の源としたいときや、応答チェーンが使えないとき)"auto"(デフォルト): 状況に応じて安全な方を選ぶ
会話は 4 ターンで、閾値 3 で自動圧縮が発火し、最後に手動圧縮も行います。
実行手順
export OPENAI_API_KEY="sk-..."を設定しますリポジトリルートで次を実行します
uv run python examples/memory/compaction_session_stateless_example.py
期待される動作・出力例
前の example と同様、会話の後にセッション内容が表示され、compaction アイテムが含まれます。store=False でも圧縮が機能していることが確認できます。
つまずきポイント
store=Falseはプライバシー要件などで「OpenAI 側にデータを残したくない」場合の設定です。その場合でもローカルのセッションには履歴が残る、という役割分担を理解しておきましょう
EncryptedSession と OpenAIResponsesCompactionSession はいずれも既存セッションを包むラッパー型です。入れ子構造を図で整理すると次のとおりです。
file_session.py
これは実行する example ではなく、Session プロトコルの自作実装のお手本となるライブラリモジュールです。会話履歴を JSON ファイルとしてディスクに保存し、プロセスをまたいで再開できるようにします。後述の file_hitl_example.py と hitl_session_scenario.py から import されて使われます。
クラス定義と中核メソッドを見てみましょう。
from agents.memory.session import Session
from agents.memory.session_settings import SessionSettings
class FileSession(Session):
"""Persist session items to a JSON file on disk."""
session_settings: SessionSettings | None = None
def __init__(self, *, dir: str | Path | None = None, session_id: str | None = None) -> None:
self._dir = Path(dir) if dir is not None else Path.cwd() / ".agents-sessions"
self.session_id = session_id or ""
# Ensure the directory exists up front so subsequent file operations do not race.
self._dir.mkdir(parents=True, exist_ok=True)
保存先ディレクトリ(デフォルト .agents-sessions/)に <セッションID>.json を作ります。session_id を省略すると、タイムスタンプ + ランダム ID が自動生成されます。
プロトコルの 4 メソッドはすべて実装されています。例えば add_items は次のとおりです。
async def add_items(self, items: list[Any]) -> None:
if not items:
return
session_id = await self._ensure_session_id()
current = await self._read_items(session_id)
# Deep-copy via JSON to avoid persisting live references that might mutate later.
cloned = json.loads(json.dumps(items))
await self._write_items(session_id, current + cloned)
2 つの実装テクニックに注目してください。
- ファイル I/O(読み書き)は
asyncio.to_thread(...)で別スレッドに逃がし、非同期イベントループを塞がないようにしています json.loads(json.dumps(items))でディープコピーし、呼び出し元でリストが後から書き換えられても保存済みデータに影響しないようにしています
さらにプロトコル外の独自メソッドとして、load_state_json() / save_state_json() を持ちます。これは次の example で使う RunState(実行状態。承認待ちで中断した実行のスナップショット) を履歴と並べて <セッションID>-state.json に保存するためのものです。
実行手順
このファイル単体では実行しません。次の file_hitl_example.py / hitl_session_scenario.py の一部として動きます。
つまずきポイント
- 本番用というより教材です。ファイルロックがないため、同じセッションファイルへの並行書き込みには耐えられません。本番では SQLite 以上のバックエンドを使ってください
memory_session_hitl_example.py
ここからは HITL(human-in-the-loop、人間参加型) とセッションの組み合わせに入ります。HITL とは、エージェントがツールを実行する前に人間の承認を挟む仕組みです。「AI 秘書が経費精算を送信する前に、上司が『承認』ボタンを押す」イメージです。
この example は、インメモリ SQLite セッション + 承認必須の天気ツールによる対話型チャットです。
async def _needs_approval(_ctx, _params, _call_id) -> bool:
"""Always require approval for weather tool."""
return True
@tool(needs_approval=_needs_approval)
def get_weather(location: str) -> str:
function_tool の needs_approval に関数(または True)を渡すと、そのツールの呼び出しは即実行されず、実行が中断(interruption)されます。
メインループの中断処理が本題です。
# Run the agent
result = await Runner.run(agent, user_message, session=session)
# Handle interruptions (tool approvals)
while result.interruptions:
# Get the run state
state = result.to_state()
for interruption in result.interruptions:
tool_name = interruption.name or "Unknown tool"
args = interruption.arguments or "(no arguments)"
approved = await prompt_yes_no(
f"Agent {interruption.agent.name} wants to call '{tool_name}' with {args}. Approve?"
)
if approved:
state.approve(interruption)
print("Approved tool call.")
else:
state.reject(interruption)
print("Rejected tool call.")
# Resume the run with the updated state
result = await Runner.run(agent, state, session=session)
流れを整理すると次のとおりです。
Runner.run()の結果にresult.interruptions(承認待ちのツール呼び出し一覧)があるか確認する- あれば
result.to_state()で RunState を取り出す - 各 interruption について
state.approve(...)かstate.reject(...)を呼ぶ - 入力の代わりに state を渡して
Runner.run(agent, state, session=session)で再開する
重要なのは、再開時にも同じ session を渡すことです。こうすることで、中断をまたいだ一連のやり取りが 1 つの会話履歴として正しく保存されます。
なお、この example は環境変数 EXAMPLES_INTERACTIVE_MODE=auto を設定すると対話入力なしで自動実行されます(examples/auto_mode.py のヘルパーが「What's the weather in Oakland?」という入力と「承認」を自動供給します)。
実行手順
export OPENAI_API_KEY="sk-..."を設定しますexamplesパッケージ内のモジュールを import するため、モジュール形式で実行しますuv run python -m examples.memory.memory_session_hitl_exampleYou:と表示されたら「What's the weather in San Francisco?」などと入力し、承認プロンプトにy/nで答えます。空行で終了します- 自動実行したい場合は
EXAMPLES_INTERACTIVE_MODE=auto uv run python -m examples.memory.memory_session_hitl_example
期待される動作・出力例
天気を聞くと Agent HITL Assistant wants to call 'get_weather' with {"location":"San Francisco"}. Approve? (y/n): と承認を求められ、y なら「Foggy, 58°F」を踏まえた応答、n なら「ツールを使えなかった」旨の応答が返ります。
つまずきポイント
uv run python examples/memory/memory_session_hitl_example.pyのようにスクリプト直接指定で実行すると、from examples.auto_mode import ...の解決に失敗します。必ず-mのモジュール形式で実行してください
中断から再開までの一連の流れを図にすると次のとおりです。
openai_session_hitl_example.py
前の example とロジックはほぼ同一で、セッションだけを OpenAIConversationsSession に差し替えたバージョンです。
# Create a session instance that will persist across runs
session = OpenAIConversationsSession()
ツール定義(needs_approval 付き get_weather)、承認ループ(result.interruptions → state.approve/reject → Runner.run(agent, state, session=session))はすべて共通です。HITL の中断・再開が、ローカル保存でも OpenAI サーバー保存でも同じコードで動くことを示すのがこの example の狙いです。
実行手順
export OPENAI_API_KEY="sk-..."を設定しますモジュール形式で実行します
uv run python -m examples.memory.openai_session_hitl_example- 対話・承認の操作は前の example と同じです。自動実行は
EXAMPLES_INTERACTIVE_MODE=autoを付けます
期待される動作・出力例
memory_session_hitl_example.py と同様の対話ですが、履歴は OpenAI の Conversations API 側に保存されます。
つまずきポイント
- こちらも
-m形式必須です - スクリプトを再実行すると
OpenAIConversationsSession()が新しい会話を作るため、前回の続きにはなりません。続きから再開したい場合は conversation_id を保存しておきOpenAIConversationsSession(conversation_id=...)で復元します(次の hitl_session_scenario.py で実演されます)
file_hitl_example.py
file_session.py の FileSession を使った HITL チャットで、この章で最も実践的な example です。目玉は「承認待ちのままプロセスを終了しても、次回起動時に承認フローの続きから再開できる」ことです。JS 版 SDK の file-hitl.ts サンプルの移植です。
エージェントは顧客サポート支援という設定で、顧客名簿を引く承認必須ツールを持ちます。
lookup_customer_profile = create_lookup_customer_profile_tool(directory=customer_directory)
instructions = (
"You assist support agents. For every user turn you must call lookup_customer_profile. "
"If a tool reports a transient failure, request approval and retry the same call once before "
"responding. Keep responses under three sentences."
)
ツール側では、RunContextWrapper 経由で実行時コンテキスト({"user_id": "101"})から顧客 ID を読み取ります。
@tool(
name_override="lookup_customer_profile",
description_override="Look up stored profile details for a customer by their internal id.",
needs_approval=True,
)
def lookup_customer_profile(ctx: RunContextWrapper[Any]) -> str:
return directory.get(ctx.context.get("user_id"), missing_customer_message)
そして再開処理です。起動直後に保存済みの RunState があるかを確認します。
saved_state = await session.load_state_json()
if saved_state:
print("Found saved run state. Resuming pending interruptions before new input.")
try:
state = await RunState.from_json(agent, saved_state, context_override=user_context)
result = await Runner.run(agent, state, session=session)
RunState.from_json() は JSON 化された実行状態を復元する API です。復元した state で Runner.run() を呼ぶと、前回中断したまさにその承認待ちから再開されます。通常ループの側では、毎ターンの最後に状態を保存しています。
result = await Runner.run(agent, user_message, session=session, context=user_context)
while result.interruptions:
...
result = await Runner.run(agent, state, session=session)
await session.save_state_json(result.to_state().to_json())
会話履歴(items)は FileSession が、実行状態(RunState)は save_state_json() が、それぞれ examples/memory/tmp/ 配下の JSON に永続化されます。「履歴」と「中断中の実行状態」は別物であり、両方を保存して初めてプロセス再起動をまたぐ HITL が成立する、というのがこの example の教訓です。
実行手順
export OPENAI_API_KEY="sk-..."を設定しますモジュール形式で実行します(
.file_sessionの相対 import があるため必須)uv run python -m examples.memory.file_hitl_example- 「Summarize the customer profile.」などと入力し、承認プロンプトに答えます。空行で終了します
- 途中で Ctrl+C で強制終了してから再度同コマンドを実行すると、再開の動作を確認できます。自動実行は
EXAMPLES_INTERACTIVE_MODE=autoを付けます
期待される動作・出力例
起動時に Session id: 20260702...-abc123 のような ID が表示され、examples/memory/tmp/ に <ID>.json(履歴)と <ID>-state.json(実行状態)が作られます。承認して進めると顧客 101(Kaz S.)のプロフィール要約が返ります。保存済み状態がある状態で起動すると「Found saved run state. Resuming pending interruptions before new input.」と表示され、承認フローの続きが始まります。
つまずきポイント
- 実行のたびに
FileSession(dir="examples/memory/tmp")は新しいセッション ID を生成します。過去の会話履歴そのものを引き継ぐわけではなく、「保存された RunState の再開」を示すデモである点に注意してください examples/memory/tmp/に JSON ファイルが溜まっていくので、不要になったら削除して構いません
プロセスを再起動しても承認待ちから再開できる仕組みを図で整理すると次のとおりです。
hitl_session_scenario.py
この章の総仕上げにあたる example で、対話なしで自動実行される検証シナリオです。FileSession と OpenAIConversationsSession の両方に対して「承認 → 再構築(rehydration)→ 拒否」の 3 ターンを機械的に流し、セッション履歴が期待どおりに記録されているかを assert(検証)します。テストコードに近い書き味で、実運用での検証パターンの参考になります。
シナリオの構成要素は ScenarioStep というデータクラスで宣言されます。
@dataclass(frozen=True)
class ScenarioStep:
name: str
message: str
tool_name: str
approval: str
expected_output: str
各ステップでは、確実に狙ったツールが呼ばれるよう tool_choice(モデルに特定ツールの使用を強制する設定)と tool_use_behavior="stop_on_first_tool"(最初のツール実行結果をそのまま最終出力にする設定)を使います。
agent = Agent(
name=f"{label} HITL scenario",
instructions=(
f"You must call {step.tool_name} exactly once before responding. "
"Pass the user input as the 'query' argument."
),
tools=[approval_echo, approval_note],
model=model,
model_settings=ModelSettings(
tool_choice=step.tool_name, reasoning=Reasoning(effort="none")
),
tool_use_behavior="stop_on_first_tool",
)
実行後は session.get_items() で履歴を取り出し、「ユーザー入力が保存されているか」「ツール呼び出し(function_call)とその結果(function_call_output)の call_id が対応しているか」「出力が期待値と一致するか」を細かく検査します。1 つでも崩れていれば RuntimeError で失敗します。
「rehydration(再構築)」のステップでは、同じ保存先を指す新しいセッションインスタンスを作り直して 2 ターン目を実行します。
rehydrated_session = FileSession(dir=temp_dir, session_id=session_id)
print(f"[FileSession] rehydrated session id: {session_id}")
OpenAI 側でも同様に、会話 ID から復元します。
rehydrated_session = OpenAIConversationsSession(conversation_id=session_id)
これは「Web サーバーが再起動してもセッション ID さえあれば会話を続行できる」ことの検証にあたります。3 ターン目は承認を拒否し、ツール結果として Tool execution was not approved. が履歴に記録されることまで確認します。
実行手順
export OPENAI_API_KEY="sk-..."を設定します(未設定だとメッセージを表示して終了します)モジュール形式で実行します
uv run python -m examples.memory.hitl_session_scenario必要に応じて環境変数で挙動を変えられます
HITL_MODEL: 使用モデルの上書き(デフォルトgpt-5.6-sol)OPENAI_SESSION_ID: 既存の OpenAI 会話 ID を再利用KEEP_OPENAI_SESSION: 実行後に OpenAI 側の会話を削除せず残す
期待される動作・出力例
[FileSession turn 1] final output: approved:Fetch profile for customer 104. (items: 4) のようなログが FileSession → OpenAIConversationsSession の順に 3 ターンずつ流れ、各ターンで session summary: items=4 (message=2 function_call=1 function_call_output=1) といった履歴内訳が表示されます。すべての検証を通過すれば正常終了し、一時ファイルと OpenAI 側の会話は削除されます。
つまずきポイント
- 途中の検証に失敗すると
RuntimeErrorで止まります。モデルの応答形式に依存する検証を含むため、HITL_MODELを変えると失敗することがあります - FileSession 用の一時ディレクトリはカレントディレクトリ直下の
tmp/に作られ、終了時に削除されます
まとめ
- LLM はステートレスであり、複数ターンの会話には毎回履歴を渡し直す必要がある。Session はこの定型作業(実行前の履歴取得と実行後の保存)を自動化する
Runner.run(agent, input, session=session)と同じセッションインスタンスを渡し続けるだけで会話が継続する。get_items/add_items/pop_item/clear_sessionで履歴を直接操作することもできる- バックエンドは用途で選ぶ: ローカル開発は
SQLiteSession、既存 RDB があればSQLAlchemySession、複数ワーカー共有はRedisSession/MongoDBSession、クラウドネイティブはDaprSession、サーバー管理を任せるならOpenAIConversationsSession EncryptedSession(暗号化 + TTL)とOpenAIResponsesCompactionSession(履歴圧縮)は他のセッションを包むラッパー型。既存コードをほぼ変えずに機能を足せるAdvancedSQLiteSessionはトークン使用量の分析と会話のブランチ(分岐・切り替え)を提供する- HITL では
result.interruptionsを確認し、result.to_state()→approve/reject→Runner.run(agent, state, session=session)で再開する。再開時も同じセッションを渡すこと。プロセスをまたぐ再開には RunState の JSON 保存(to_json/RunState.from_json)を併用する Sessionプロトコルの 4 メソッドを実装すれば自作バックエンドも作れる(file_session.py が好例)
理解度チェック
LLM が「ステートレス」であるとはどういう意味ですか。また、それにもかかわらずチャットアプリが文脈を保てるのはなぜですか。
SQLiteSession("my_chat") と SQLiteSession("my_chat", "conversations.db") の違いを説明してください。
次の要件に最も適したセッションバックエンドをそれぞれ選んでください。
(a) ロードバランサー配下の複数ワーカーで同じ会話を共有したい (b) 自前のデータベースを一切運用せず、履歴管理を OpenAI に任せたい (c) Kubernetes 上で動かし、保存先 DB を後から設定変更だけで差し替えたい (d) 会話履歴を暗号化し、1 時間で自動失効させたい
OpenAIResponsesCompactionSession は何を解決するための仕組みですか。また、OpenAIConversationsSession と組み合わせてはいけないのはなぜですか。
HITL で承認待ちの中断が発生したとき、実行を再開する正しい手順を result.interruptions / to_state() / approve() / Runner.run() を使って説明してください。
解答と解説
問1 の解答と解説
答1. API 呼び出しごとに完全に独立しており、モデル自身は過去のやり取りを一切保持しない、という意味です。チャットアプリが文脈を保てるのは、アプリ側が過去の会話履歴を保存しておき、毎回の呼び出しで入力に含め直しているためです。Agents SDK ではこの管理を Session が自動化します。
問2 の解答と解説
答2. 前者は db_path がデフォルトの ":memory:" になるためインメモリ SQLite となり、プロセス終了とともに履歴が消えます。後者はファイル conversations.db に永続化されるため、プロセスを再起動しても同じセッション ID で履歴を再開できます。
問3 の解答と解説
答3. (a) RedisSession(または MongoDBSession)。複数プロセスから共有できる外部ストアが必要です。 (b) OpenAIConversationsSession。履歴は OpenAI の Conversations API に保存されます。 (c) DaprSession。Dapr のコンポーネント YAML を差し替えるだけで 30 種以上のステートストアを切り替えられます。 (d) EncryptedSession(encryption_key と ttl=3600 を指定)。下位セッションは SQLite でも SQLAlchemy でも構いません。
問4 の解答と解説
答4. 会話が長くなると毎ターン送信する履歴が肥大し、トークン消費(コスト)と遅延が増える問題を解決します。閾値を超えた履歴を Responses API の responses.compact で要約済みの compaction アイテムに置き換え、文脈を保ちつつ履歴を小さくします。OpenAIConversationsSession と組み合わせてはいけないのは、両者が履歴を異なる方式で管理しており(サーバー管理 vs ローカルセッションの書き換え)、二重管理が衝突するためです。
問5 の解答と解説
答5. (1) Runner.run() の戻り値で result.interruptions が空でないか確認する。(2) 空でなければ state = result.to_state() で実行状態を取り出す。(3) 各 interruption に対して state.approve(interruption) または state.reject(interruption) を呼ぶ。(4) 入力文字列の代わりに state を渡して result = await Runner.run(agent, state, session=session) で再開する。承認待ちが複数回続く可能性があるため while ループで繰り返し、再開時にも必ず同じセッションを渡します。
参考リンク
セッション(公式ドキュメント): https://openai.github.io/openai-agents-python/sessions/
- 日本語版: https://openai.github.io/openai-agents-python/ja/sessions/
Advanced SQLite Sessions: https://openai.github.io/openai-agents-python/sessions/advanced_sqlite_session/
- 日本語版: https://openai.github.io/openai-agents-python/ja/sessions/advanced_sqlite_session/
Encrypted Sessions: https://openai.github.io/openai-agents-python/sessions/encrypted_session/
- 日本語版: https://openai.github.io/openai-agents-python/ja/sessions/encrypted_session/
SQLAlchemy Sessions: https://openai.github.io/openai-agents-python/sessions/sqlalchemy_session/
- 日本語版: https://openai.github.io/openai-agents-python/ja/sessions/sqlalchemy_session/
エージェントの実行(running agents): https://openai.github.io/openai-agents-python/running_agents/
- 日本語版: https://openai.github.io/openai-agents-python/ja/running_agents/
- Dapr 公式ドキュメント(State Management): https://docs.dapr.io/developing-applications/building-blocks/state-management/
- OpenAI Conversations API リファレンス: https://platform.openai.com/docs/api-reference/conversations
第9章 OpenAI 以外のモデルを使う(model_providers)
この章で学ぶこと
- OpenAI Agents SDK が OpenAI 以外の LLM(大規模言語モデル)でも動作する仕組みを理解する
- OpenAI 互換 API を提供するプロバイダーを「Agent 単位」「実行(run)単位」「グローバル」の 3 段階で組み込む方法を使い分けられるようになる
ModelProviderとRunConfigを使って、1 回の実行だけカスタムプロバイダーに切り替える方法を学ぶ- アダプターライブラリ LiteLLM / any-llm 経由で、Anthropic や Google など OpenAI 互換 API を持たないプロバイダーのモデルも呼び出せるようになる
- OpenAI の API キーを持っていない場合に必要なトレーシング無効化(
set_tracing_disabled)の理由と方法を理解する
前提知識
この章を読む前に、以下の内容を理解しておいてください。
- 第1章: リポジトリのクローンと
uv syncによるセットアップ、OPENAI_API_KEYなどの環境変数の設定方法 - 第2章(basic):
Agent(エージェントの定義)とRunner(エージェントの実行エンジン)の基本的な使い方、Runner.run()の呼び出し方 - 第3章(tools):
@tool(別名@function_tool)デコレーターで Python 関数をツール(エージェントが呼び出せる外部機能)として登録する方法 - Python の環境変数の読み書き(
os.getenv/exportコマンド)
概念解説
なぜ「OpenAI 以外のモデル」を使いたくなるのか
OpenAI Agents SDK はその名の通り OpenAI が開発した SDK で、デフォルトでは OpenAI のモデル(GPT シリーズ)を呼び出します。しかし実際の開発現場では、次のような理由で他社モデルを使いたい場面が頻繁にあります。
- コストや性能の理由で Anthropic の Claude や Google の Gemini を使いたい
- 社内ポリシーで特定のクラウド(Azure、AWS Bedrock など)経由でしか LLM を呼べない
- Ollama などでローカルマシン上のモデルを使い、API 費用ゼロで開発・検証したい
- OpenRouter のような「マルチプロバイダー中継サービス」で複数モデルを 1 つの API キーで試したい
SDK はこうしたニーズに応えるため、モデル呼び出し部分を差し替え可能な設計になっています。この章で扱う examples/model_providers ディレクトリは、その差し替え方法のカタログです。
比喩で理解する: 「電源プラグと変換アダプター」
モデルプロバイダーの切り替えは、海外旅行での電化製品の使い方に似ています。
- OpenAI 互換 API を持つプロバイダー(OpenRouter、Ollama、多くの推論サービス)は、「同じ形のコンセント」を持つ国のようなものです。プラグ(SDK のクライアント)はそのままで、差し込み先の住所(ベース URL)と鍵(API キー)を変えるだけで動きます。これが
custom_example_*.pyの 3 つの example が示す方法です。 - OpenAI 互換 API を持たないプロバイダー(Anthropic のネイティブ API など)は、「コンセントの形が違う国」です。この場合は変換アダプターが必要で、それが LiteLLM や any-llm というライブラリです。これが
litellm_*.py/any_llm_*.pyの 4 つの example が示す方法です。
ここで用語を整理します。
- OpenAI 互換 API: OpenAI の Chat Completions API と同じリクエスト・レスポンス形式を採用した API のこと。互換 API を持つサービスなら、OpenAI 公式の Python クライアント(
AsyncOpenAI)の接続先 URL を変えるだけで呼び出せます - ベース URL(base_url): API リクエストの送信先となる基点の URL。例えば OpenAI なら
https://api.openai.com/v1が既定値で、これを他サービスの URL に差し替えます - アダプター: SDK と各社の API 仕様の違いを吸収する変換レイヤーとなるライブラリ。LiteLLM と any-llm は 100 以上のプロバイダーの API 差異を吸収してくれます
3 段階の切り替えスコープ
OpenAI 互換 API のプロバイダーを組み込む方法は、適用範囲(スコープ)の広さで 3 段階に分かれます。公式ドキュメントでも次の整理がされています。
| 方法 | スコープ | 使いどころ | 対応する example |
|---|---|---|---|
Agent(model=Model オブジェクト) | Agent 単位(最も狭い) | エージェントごとに異なるプロバイダーを混在させたい | custom_example_agent.py |
RunConfig(model_provider=...) | 実行(run)単位 | 特定の Runner.run() 呼び出しだけカスタムプロバイダーにしたい | custom_example_provider.py |
set_default_openai_client(...) | グローバル(最も広い) | アプリ全体で 1 つの互換エンドポイントをデフォルトにしたい | custom_example_global.py |
「一部の部屋だけ変換プラグを使う(Agent 単位)」「今日の外出中だけ使う(run 単位)」「家中の配線を丸ごと切り替える(グローバル)」という違いだとイメージしてください。狭いスコープの設定ほど優先されるため、グローバル設定をベースにしつつ特定エージェントだけ別モデルにする、といった組み合わせも可能です。
Responses API と Chat Completions API
OpenAI の API には 2 世代あります。
- Responses API: OpenAI の新しい API。SDK はデフォルトでこちらを使います
- Chat Completions API: 従来からの API。他社の OpenAI 互換サービスの多くはこちらの形式にのみ対応しています
そのため、他社プロバイダーを使うときは Chat Completions 形式でリクエストを送る OpenAIChatCompletionsModel という Model 実装を使うか、set_default_openai_api("chat_completions") でデフォルト API を切り替えるのが定石です。これを忘れると、存在しないエンドポイントにリクエストが飛んで 404 エラーになります。
トレーシングとの関係
トレーシング(tracing)とは、エージェントの実行過程(どのモデルを呼び、どのツールを使ったか)を記録し、OpenAI のダッシュボードで可視化する仕組みです。トレースデータは OpenAI のサーバーにアップロードされるため、OpenAI の API キーが必要です。
他社モデルだけを使う構成では OpenAI の API キーを持っていないことも多く、その場合トレースのアップロードが 401 エラー(認証エラー)になります。この章のすべての example が冒頭で set_tracing_disabled(disabled=True) を呼んでいるのはこのためです。OpenAI の API キーを持っている場合は、無効化する代わりに set_tracing_export_api_key() でトレース専用のキーを設定することもできます。
この章の example で使うサービス: OpenRouter
カテゴリ README にある通り、この章のアダプター系 example(any_llm_*.py / litellm_*.py)はデフォルトで OpenRouter を利用します。OpenRouter は 1 つの API キーで OpenAI・Anthropic・Google など多数のモデルを呼び出せる中継サービスで、環境変数は次の 1 つだけ用意すれば足ります。
export OPENROUTER_API_KEY="..."
一方、custom_example_*.py の 3 つは接続先を固定しておらず、次の 3 つの環境変数で任意の OpenAI 互換エンドポイントを指定します。
EXAMPLE_BASE_URL: 互換 API のベース URLEXAMPLE_API_KEY: そのサービスの API キーEXAMPLE_MODEL_NAME: 使用するモデル名
例えば OpenRouter を指す場合は EXAMPLE_BASE_URL=https://openrouter.ai/api/v1、ローカルの Ollama を指す場合は EXAMPLE_BASE_URL=http://localhost:11434/v1 のように設定します(値は利用するサービスのドキュメントで確認してください)。
各 example の解説
examples/model_providers には 7 つの example があります。前半 3 つ(custom_example_*)が「OpenAI 互換 API の直接利用」、後半 4 つ(litellm_* / any_llm_*)が「アダプター経由の利用」です。
なお、7 つすべてに共通して、天気を返すダミーツールが登場します。
@tool
def get_weather(city: str):
print(f"[debug] getting weather for {city}")
return f"The weather in {city} is sunny."
これは「他社モデルでもツール呼び出し(tool calling、モデルが関数の実行を指示する機能)がきちんと動くこと」を確認するための仕掛けです。また、instructions="You only respond in haikus."(俳句だけで答えよ)という指示も共通で、モデルが指示に従っているかを一目で確認できます。
custom_example_agent.py
Agent 単位でカスタムプロバイダーを使う example です。OpenAIChatCompletionsModel に自作の AsyncOpenAI クライアントを渡し、それを特定の Agent の model に設定します。
コード解説
まず、環境変数から接続情報を読み込み、未設定なら即座にエラーにします。
BASE_URL = os.getenv("EXAMPLE_BASE_URL") or ""
API_KEY = os.getenv("EXAMPLE_API_KEY") or ""
MODEL_NAME = os.getenv("EXAMPLE_MODEL_NAME") or ""
if not BASE_URL or not API_KEY or not MODEL_NAME:
raise ValueError(
"Please set EXAMPLE_BASE_URL, EXAMPLE_API_KEY, EXAMPLE_MODEL_NAME via env var or code."
)
次に、接続先を差し替えた AsyncOpenAI クライアントを作り、トレーシングを無効化します。AsyncOpenAI は OpenAI 公式 Python ライブラリの非同期クライアント(非同期=処理の完了を待つ間に他の処理を進められる方式)で、base_url を変えるだけで互換サービスに接続できます。
client = AsyncOpenAI(base_url=BASE_URL, api_key=API_KEY)
set_tracing_disabled(disabled=True)
ソースコード中のコメントには、この example の手順が簡潔にまとまっています。
"""This example uses a custom provider for a specific agent. Steps:
1. Create a custom OpenAI client.
2. Create a `Model` that uses the custom client.
3. Set the `model` on the Agent.
...
"""
核心部は main() の中です。Agent の model 引数には通常 "gpt-5.6-sol" のような文字列を渡しますが、ここでは OpenAIChatCompletionsModel という Model オブジェクトを渡しています。文字列を渡すと SDK が既定のプロバイダー経由でモデルを解決するのに対し、Model オブジェクトを渡すと「このエージェントはこのクライアント・このモデルで固定」という指定になります。
async def main():
# This agent will use the custom LLM provider
agent = Agent(
name="Assistant",
instructions="You only respond in haikus.",
model=OpenAIChatCompletionsModel(model=MODEL_NAME, openai_client=client),
tools=[get_weather],
)
result = await Runner.run(agent, "What's the weather in Tokyo?")
print(result.final_output)
この方法の利点はエージェントごとに違うプロバイダーを混在できることです。例えば「振り分け役の軽量エージェントはローカルモデル、本処理のエージェントは OpenAI」という構成が自然に書けます。
実行手順
利用したい OpenAI 互換サービスの接続情報を環境変数に設定します。
export EXAMPLE_BASE_URL="https://openrouter.ai/api/v1" # 例: OpenRouter の場合 export EXAMPLE_API_KEY="..." # そのサービスの API キー export EXAMPLE_MODEL_NAME="openai/gpt-5.4-mini" # そのサービスでのモデル名実行します(
OPENAI_API_KEYは不要です。トレーシングは無効化済みです)。uv run python examples/model_providers/custom_example_agent.py
期待される動作・出力例
モデルが get_weather ツールを呼び、その結果を踏まえて俳句形式で回答します。
[debug] getting weather for Tokyo
Tokyo skies are clear
Sunshine warms the bustling streets
A perfect spring day
(俳句の内容はモデルと実行ごとに変わります)
つまずきポイント
- 3 つの環境変数のどれかが未設定だと、起動直後に
ValueErrorで止まります。エラーメッセージに設定すべき変数名が書かれているので確認してください EXAMPLE_MODEL_NAMEは接続先サービスにおけるモデル名です。OpenRouter ならopenai/gpt-5.4-miniのようにプロバイダー名を含む形式、Ollama ならllama3.2のような形式と、サービスごとに命名規則が異なります
custom_example_provider.py
実行(run)単位でカスタムプロバイダーを使う example です。ModelProvider を自作し、Runner.run() に渡す RunConfig で指定します。「普段は OpenAI、この呼び出しだけ他社モデル」という使い分けができます。
コード解説
環境変数の読み込みとクライアント作成は custom_example_agent.py と同じです。ソースコードのコメントが手順を示しています。
"""This example uses a custom provider for some calls to Runner.run(), and direct calls to OpenAI for
others. Steps:
1. Create a custom OpenAI client.
2. Create a ModelProvider that uses the custom client.
3. Use the ModelProvider in calls to Runner.run(), only when we want to use the custom LLM provider.
...
"""
この example の主役は ModelProvider のサブクラスです。ModelProvider は「モデル名(文字列)を受け取り、実際の Model オブジェクトを返す工場」の役割を持つ抽象クラスで、実装すべきメソッドは get_model の 1 つだけです。
class CustomModelProvider(ModelProvider):
def get_model(self, model_name: str | None) -> Model:
return OpenAIChatCompletionsModel(model=model_name or MODEL_NAME, openai_client=client)
CUSTOM_MODEL_PROVIDER = CustomModelProvider()
model_name or MODEL_NAME という書き方に注目してください。Agent 側で model="..." が指定されていればその名前を、指定がなければ環境変数のモデル名をデフォルトとして使う、という柔軟な動きになります。
実行時には、Runner.run() の run_config 引数に RunConfig(model_provider=...) を渡します。RunConfig は「この 1 回の実行全体に適用する設定」をまとめるオブジェクトです。
async def main():
agent = Agent(name="Assistant", instructions="You only respond in haikus.", tools=[get_weather])
# This will use the custom model provider
result = await Runner.run(
agent,
"What's the weather in Tokyo?",
run_config=RunConfig(model_provider=CUSTOM_MODEL_PROVIDER),
)
print(result.final_output)
# If you uncomment this, it will use OpenAI directly, not the custom provider
# result = await Runner.run(
# agent,
# "What's the weather in Tokyo?",
# )
# print(result.final_output)
コメントアウトされた 2 つ目の Runner.run() が、この方式の本質を物語っています。同じ Agent オブジェクトでも、run_config を付けなければ通常どおり OpenAI に接続されます。Agent の定義を一切変えずに、呼び出し側の判断で接続先を切り替えられるのがこのパターンの強みです。
なお、run 単位の指定が重要なもう 1 つの理由があります。エージェントが handoff(別のエージェントへの処理の引き継ぎ)を行うマルチエージェント構成では、RunConfig の model_provider がその実行に登場するすべてのエージェントに適用されます。エージェント 1 つ 1 つに Model オブジェクトを設定して回る必要がありません。
実行手順
custom_example_agent.pyと同じ 3 つの環境変数を設定します。export EXAMPLE_BASE_URL="https://openrouter.ai/api/v1" export EXAMPLE_API_KEY="..." export EXAMPLE_MODEL_NAME="openai/gpt-5.4-mini"実行します。
uv run python examples/model_providers/custom_example_provider.py
期待される動作・出力例
custom_example_agent.py と同様に、ツール呼び出しのデバッグ出力に続いて俳句が表示されます。
[debug] getting weather for Tokyo
Sunny Tokyo
warm light on the city streets
gentle winds blow through
つまずきポイント
- コメントアウトされた 2 つ目の
Runner.run()を有効にすると、今度は本物の OpenAI に接続しに行くため、OPENAI_API_KEYが設定されていないと認証エラーになります。「run_config を外すと OpenAI 直行になる」ことを体感するための仕掛けなので、試す場合は OpenAI の API キーを用意してください RunConfig(model_provider=...)を渡し忘れると、静かにデフォルトプロバイダー(OpenAI)が使われます。「なぜか他社モデルに切り替わらない」ときは、まずrun_configの渡し忘れを疑ってください
custom_example_global.py
グローバル(プロセス全体)でカスタムプロバイダーをデフォルトにする example です。set_default_openai_client() で SDK が使う OpenAI クライアント自体を差し替えます。アプリ内のほぼすべてのエージェントを同じ互換エンドポイントに向けたい場合の最短ルートです。
コード解説
ソースコードのコメントに、この方式で行う 3 つの設定がまとまっています。
"""This example uses a custom provider for all requests by default. We do three things:
1. Create a custom client.
2. Set it as the default OpenAI client, and don't use it for tracing.
3. Set the default API as Chat Completions, as most LLM providers don't yet support Responses API.
...
"""
その 3 つの設定を実際に行っているのが次の部分です。
client = AsyncOpenAI(
base_url=BASE_URL,
api_key=API_KEY,
)
set_default_openai_client(client=client, use_for_tracing=False)
set_default_openai_api("chat_completions")
set_tracing_disabled(disabled=True)
1 行ずつ確認しましょう。
set_default_openai_client(client=client, use_for_tracing=False): SDK 全体のデフォルトクライアントを自作クライアントに差し替えます。use_for_tracing=Falseは「このクライアント(の API キー)をトレースのアップロードには使わない」という指定です。互換サービスの API キーを OpenAI のトレース基盤に送っても認証できないため、必ず分離しますset_default_openai_api("chat_completions"): SDK のデフォルト API を Responses API から Chat Completions API に切り替えます。前述の通り、多くの互換プロバイダーは Responses API 未対応のため、この 1 行がないと 404 エラーになりますset_tracing_disabled(disabled=True): トレーシング自体を無効化します
グローバル設定が済んでいるため、Agent の定義は驚くほど普通です。model にはただの文字列を渡すだけで、それがカスタムエンドポイントのモデルとして解決されます。
async def main():
agent = Agent(
name="Assistant",
instructions="You only respond in haikus.",
model=MODEL_NAME,
tools=[get_weather],
)
result = await Runner.run(agent, "What's the weather in Tokyo?")
print(result.final_output)
3 つの方式を並べると、コードの変更箇所の違いがよく分かります。
- agent 版: Agent の
model引数を変える(Agent 定義に手を入れる) - provider 版:
Runner.run()のrun_configを変える(呼び出し側に手を入れる) - global 版: プログラム冒頭で 3 行のセットアップを行う(Agent も呼び出しもそのまま)
実行手順
他の 2 つと同じ 3 つの環境変数を設定します。
export EXAMPLE_BASE_URL="https://openrouter.ai/api/v1" export EXAMPLE_API_KEY="..." export EXAMPLE_MODEL_NAME="openai/gpt-5.4-mini"実行します。
uv run python examples/model_providers/custom_example_global.py
期待される動作・出力例
[debug] getting weather for Tokyo
Bright sun in Tokyo
The city basks in warm light
Clear skies all day long
つまずきポイント
set_default_openai_api("chat_completions")の呼び忘れが典型的なハマりどころです。SDK のデフォルトは Responses API なので、互換サービスに Responses 形式のリクエストが飛んで 404 や 400 エラーになります- グローバル設定はそのプロセス内のすべてのエージェントに影響します。「一部だけ OpenAI に戻したい」場合は、そのエージェントにだけ Model オブジェクトを個別指定する(agent 版との併用)ことになります
set_default_openai_clientなどの設定関数は、エージェントを実行する前に呼ぶ必要があります。モジュールの先頭(import 直後)で呼ぶのが安全です
litellm_provider.py
アダプターライブラリ LiteLLM の Model 実装 LitellmModel を直接使う example です。OpenAI 互換 API を持たないプロバイダー(Anthropic ネイティブ API など)を含む、LiteLLM が対応する多数のモデルを呼び出せます。
コード解説
ソースコードのコメントに実行方法が書かれています。カテゴリ README の「Direct-model examples let you override the target model」に対応する example です。
"""This example uses the LitellmModel directly, to hit any model provider.
You can run it like this:
uv run examples/model_providers/litellm_provider.py --model openrouter/openai/gpt-5.4-mini
or
uv run examples/model_providers/litellm_provider.py --model openrouter/anthropic/claude-4.5-sonnet
Find more providers here: https://docs.litellm.ai/docs/providers
"""
LitellmModel は agents.extensions.models.litellm_model からインポートします。extensions(拡張)配下にあるのは、本体とは別のオプション依存(LiteLLM ライブラリ)を必要とするためです。
from agents.extensions.models.litellm_model import LitellmModel
Agent への組み込み方は custom_example_agent.py の Model オブジェクト方式とまったく同じ形です。OpenAIChatCompletionsModel の代わりに LitellmModel を渡すだけで、モデル名の解釈とプロバイダーへの接続は LiteLLM が引き受けます。
async def main(model: str, api_key: str):
if api_key == "dummy":
print("Skipping run because no valid OPENROUTER_API_KEY was provided.")
return
agent = Agent(
name="Assistant",
instructions="You only respond in haikus.",
model=LitellmModel(model=model, api_key=api_key),
tools=[get_weather],
)
result = await Runner.run(agent, "What's the weather in Tokyo?")
print(result.final_output)
モデル名は openrouter/anthropic/claude-4.5-sonnet のように「LiteLLM のプロバイダー名 / そのプロバイダーでのモデル指定」という LiteLLM 独自の階層形式です。対応プロバイダーの一覧はコメントにあるリンク(LiteLLM 公式ドキュメント)で確認できます。
ファイル末尾では、コマンドライン引数と環境変数からモデル名・API キーを決めています。--model / --api-key 引数が優先され、なければ環境変数 LITELLM_MODEL / OPENROUTER_API_KEY、それもなければデフォルト値が使われます。
if __name__ == "__main__":
# Prefer non-interactive defaults in auto mode to avoid blocking.
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--model", type=str, required=False)
parser.add_argument("--api-key", type=str, required=False)
args = parser.parse_args()
model = args.model or os.environ.get("LITELLM_MODEL", "openrouter/openai/gpt-5.4-mini")
api_key = args.api_key or os.environ.get("OPENROUTER_API_KEY", "dummy")
if not args.model:
print(f"Using default model: {model}")
if not args.api_key:
print("Using OPENROUTER_API_KEY from environment (or dummy placeholder).")
asyncio.run(main(model, api_key))
API キーが見つからない場合は "dummy" という目印の値になり、main() の冒頭で実行をスキップします。キー未設定でもエラーで落ちず、静かに終了する作りです。
実行手順
LiteLLM はオプション依存のため、追加インストールが必要です。
uv sync --extra litellm(pip 利用時は
pip install "openai-agents[litellm]"に相当します)OpenRouter の API キーを設定します(https://openrouter.ai/ で取得できます)。
export OPENROUTER_API_KEY="..."カテゴリ README の指定に従って実行します。
uv run examples/model_providers/litellm_provider.py別のモデルを試すときは
--modelで上書きします(README 記載のコマンド)。uv run examples/model_providers/litellm_provider.py --model openrouter/openai/gpt-5.4-mini uv run examples/model_providers/litellm_provider.py --model openrouter/anthropic/claude-4.5-sonnet
期待される動作・出力例
Using default model: openrouter/openai/gpt-5.4-mini
Using OPENROUTER_API_KEY from environment (or dummy placeholder).
[debug] getting weather for Tokyo
Tokyo shines bright
Sunny skies over the town
Warmth upon the streets
OPENROUTER_API_KEY が未設定の場合は、次の表示だけで終了します。
Skipping run because no valid OPENROUTER_API_KEY was provided.
つまずきポイント
uv sync --extra litellmを忘れると、LitellmModelの import 時にエラーになります- キー未設定時はエラーではなくスキップされます。「実行したのに何も起きない」ように見えたら、
OPENROUTER_API_KEYの設定を確認してください - LiteLLM のモデル名形式(
openrouter/...など)は、OpenAI のモデル名や OpenRouter 単体でのモデル名とは異なる LiteLLM 独自の規則です。名前を間違えると LiteLLM 側のエラーになります
litellm_auto.py
LitellmModel を明示的に組み立てる代わりに、モデル名文字列の litellm/ プレフィックス(接頭辞)だけで LiteLLM 経由の呼び出しを指示する、より手軽な example です。
コード解説
40 行台の短いファイルなので、要点をブロックごとに見ます。冒頭のコメントとセットアップは次の通りです。
"""This example uses the built-in support for LiteLLM through OpenRouter.
Set OPENROUTER_API_KEY before running it.
"""
set_tracing_disabled(disabled=True)
この example では、エージェントの最終出力の型を定義する Pydantic モデル(Python のデータ検証ライブラリ Pydantic で定義する型付きデータクラス)も登場します。
class Result(BaseModel):
output_text: str
tool_results: list[str]
そして核心の Agent 定義です。
async def main():
agent = Agent(
name="Assistant",
instructions="You only respond in haikus.",
# We prefix with litellm/ to tell the Runner to use the LitellmModel
model="litellm/openrouter/openai/gpt-5.4-mini",
tools=[get_weather],
model_settings=ModelSettings(tool_choice="required"),
output_type=Result,
)
result = await Runner.run(agent, "What's the weather in Tokyo?")
print(result.final_output)
3 つの新しい要素があります。
model="litellm/openrouter/openai/gpt-5.4-mini":LitellmModelオブジェクトを作らず、文字列にlitellm/を前置するだけです。これが動くのは、RunnerのデフォルトのモデルプロバイダーがMultiProviderという「プレフィックスでプロバイダーを振り分ける仕組み」になっているためです。ソースコード(src/agents/models/multi_provider.py)によれば、既定の振り分けは「プレフィックスなし・openai/→ OpenAI」「litellm/→ LiteLLM」「any-llm/→ any-llm」で、未知のプレフィックスはエラーになりますmodel_settings=ModelSettings(tool_choice="required"):ModelSettingsはモデル呼び出しの詳細設定をまとめるオブジェクトで、tool_choice="required"は「モデルは必ずいずれかのツールを呼ばなければならない」という強制です。モデルがツールを使わずに直接答えてしまうのを防ぎ、他社モデルでのツール呼び出し動作を確実に検証できますoutput_type=Result: 最終出力を自由テキストではなくResult型の構造化出力(JSON スキーマに従った型付きの出力)にします。他社モデルが構造化出力に対応しているかのチェックも兼ねています
実行手順
LiteLLM の追加依存をインストールします(
litellm_provider.pyと共通)。uv sync --extra litellmOpenRouter の API キーを設定します。
export OPENROUTER_API_KEY="..."README の指定に従って実行します(このファイルはモデルが文字列で固定されているため、
--model引数はありません)。uv run examples/model_providers/litellm_auto.py
期待される動作・出力例
output_type=Result を指定しているため、最終出力は文字列ではなく Result オブジェクトになります。
[debug] getting weather for Tokyo
output_text='Sunny Tokyo skies / Warm light falls on quiet streets / A bright day unfolds' tool_results=['The weather in Tokyo is sunny.']
つまずきポイント
OPENROUTER_API_KEY未設定の場合、このファイルは(litellm_provider.pyと違い)起動直後にValueErrorを送出しますlitellm/プレフィックスによる振り分けはRunnerのデフォルトプロバイダー(MultiProvider)の機能です。RunConfig(model_provider=...)で独自プロバイダーに差し替えている場合、そのプロバイダーがプレフィックスを解釈しない限り動きません- 構造化出力(
output_type)は接続先モデルの対応状況に依存します。JSON スキーマ指定に非対応のプロバイダーでは 400 エラーや不正な JSON が返ることがあります(公式ドキュメントの「Structured outputs support」参照)
any_llm_provider.py
LiteLLM と並ぶもう 1 つのアダプター any-llm の Model 実装 AnyLLMModel を直接使う example です。構成は litellm_provider.py とほぼ対になっています。
コード解説
冒頭のコメントに実行方法があります。
"""This example uses the AnyLLMModel directly.
You can run it like this:
uv run examples/model_providers/any_llm_provider.py --model openrouter/openai/gpt-5.4-mini
or
uv run examples/model_providers/any_llm_provider.py --model openrouter/anthropic/claude-4.5-sonnet
"""
インポート元はこちらも extensions 配下です。
from agents.extensions.models.any_llm_model import AnyLLMModel
Agent への組み込みは LitellmModel と同じパターンです。
async def main(model: str, api_key: str):
if api_key == "dummy":
print("Skipping run because no valid OPENROUTER_API_KEY was provided.")
return
agent = Agent(
name="Assistant",
instructions="You only respond in haikus.",
model=AnyLLMModel(model=model, api_key=api_key),
tools=[get_weather],
)
result = await Runner.run(agent, "What's the weather in Tokyo?")
print(result.final_output)
LiteLLM との違いとして、any-llm はプロバイダーの経路によって Responses API・Chat Completions 互換 API・プロバイダー固有の互換レイヤーを使い分けます。どちらの API 面を使うかを明示的に固定したい場合は、コンストラクターの api 引数に "responses" または "chat_completions" を渡せます(ソースコード src/agents/extensions/models/any_llm_model.py で確認できる仕様です)。
末尾の引数処理は litellm_provider.py と同型で、デフォルトモデルの環境変数名だけ ANY_LLM_MODEL に変わります。
model = args.model or os.environ.get("ANY_LLM_MODEL", "openrouter/openai/gpt-5.4-mini")
api_key = args.api_key or os.environ.get("OPENROUTER_API_KEY", "dummy")
実行手順
any-llm の追加依存をインストールします。any-llm は Python 3.11 以上が必要です(SDK 自体は 3.10 以上ですが、
pyproject.tomlのany-llmエクストラにpython_version >= '3.11'の条件が付いています)。uv sync --extra any-llm(pip 利用時は
pip install "openai-agents[any-llm]"に相当します)OpenRouter の API キーを設定します。
export OPENROUTER_API_KEY="..."README の指定に従って実行します。
uv run examples/model_providers/any_llm_provider.pyモデルを変えたい場合は
--modelで上書きします(README 記載のコマンド)。uv run examples/model_providers/any_llm_provider.py --model openrouter/openai/gpt-5.4-mini uv run examples/model_providers/any_llm_provider.py --model openrouter/anthropic/claude-4.5-sonnet
期待される動作・出力例
Using default model: openrouter/openai/gpt-5.4-mini
Using OPENROUTER_API_KEY from environment (or dummy placeholder).
[debug] getting weather for Tokyo
Sunlight on Tokyo
Streets aglow in gentle warmth
Skies without a cloud
キー未設定時は litellm_provider.py と同様、スキップメッセージのみで終了します。
つまずきポイント
- Python 3.10 環境では any-llm エクストラがインストールされず、import エラーになります。
python --versionで 3.11 以上であることを確認してください - LiteLLM と any-llm は役割が重なるアダプターです。公式ドキュメントはどちらも「ベストエフォートのベータ統合」と位置づけており、構造化出力・ツール呼び出し・利用量(usage)レポートなどに依存する場合は、デプロイ予定のプロバイダー経路で実際に検証することを推奨しています
any_llm_auto.py
any-llm/ プレフィックスでの自動振り分け版です。litellm_auto.py の any-llm 対応版で、コード構造は完全に対になっています。
コード解説
51 行の短いファイルです。litellm_auto.py との差分はモデル名の 1 行だけと言ってよく、Agent 定義は次の通りです。
async def main():
agent = Agent(
name="Assistant",
instructions="You only respond in haikus.",
model="any-llm/openrouter/openai/gpt-5.4-mini",
tools=[get_weather],
model_settings=ModelSettings(tool_choice="required"),
output_type=Result,
)
result = await Runner.run(agent, "What's the weather in Tokyo?")
print(result.final_output)
model="any-llm/openrouter/openai/gpt-5.4-mini" の any-llm/ プレフィックスを MultiProvider が検出し、AnyLLMProvider に振り分けます。残りの openrouter/openai/gpt-5.4-mini が any-llm におけるモデル指定として解釈されます。ModelSettings(tool_choice="required") によるツール呼び出しの強制と、Result 型による構造化出力の検証も litellm_auto.py と共通です。
末尾のガードも同じパターンです。
if __name__ == "__main__":
import os
if os.getenv("OPENROUTER_API_KEY") is None:
raise ValueError(
"OPENROUTER_API_KEY is not set. Please set the environment variable and try again."
)
asyncio.run(main())
実行手順
any-llm の追加依存をインストールします(Python 3.11 以上)。
uv sync --extra any-llmOpenRouter の API キーを設定します。
export OPENROUTER_API_KEY="..."README の指定に従って実行します。
uv run examples/model_providers/any_llm_auto.py
期待される動作・出力例
[debug] getting weather for Tokyo
output_text='Tokyo in sun / Golden light on every street / Clear skies overhead' tool_results=['The weather in Tokyo is sunny.']
つまずきポイント
- プレフィックスは
any-llm/(ハイフン区切り)です。any_llm/(アンダースコア)と書くとMultiProviderが未知のプレフィックスとしてUserErrorを送出します litellm_auto.pyと同じく、キー未設定時は起動直後にValueErrorになります
まとめ
- OpenAI Agents SDK は、モデル呼び出し層を差し替えることで OpenAI 以外の LLM でもそのまま動作します。手段は大きく「OpenAI 互換 API の直接利用」と「アダプター(LiteLLM / any-llm)経由」の 2 系統です
- OpenAI 互換 API の組み込みは 3 段階のスコープから選びます。Agent 単位(
Agent(model=OpenAIChatCompletionsModel(...)))、run 単位(RunConfig(model_provider=CustomModelProvider()))、グローバル(set_default_openai_client()+set_default_openai_api("chat_completions"))です - 多くの互換プロバイダーは Responses API 未対応のため、Chat Completions 系の Model / API 設定を使うのが定石です
- OpenAI の API キーを持たない構成では、トレースのアップロードが 401 エラーになるため
set_tracing_disabled(True)で無効化します(またはトレース専用キーを別途設定します) - アダプター経由では、
LitellmModel/AnyLLMModelを直接インスタンス化する方法と、モデル名にlitellm//any-llm/プレフィックスを付けてMultiProviderに自動振り分けさせる方法があります - LiteLLM は
uv sync --extra litellm、any-llm はuv sync --extra any-llm(Python 3.11 以上)の追加インストールが必要です。両者とも公式には「ベストエフォートのベータ統合」であり、構造化出力やツール呼び出しの対応状況はプロバイダーごとに検証が必要です
理解度チェック
custom_example_agent.py(Agent 単位)、custom_example_provider.py(run 単位)、custom_example_global.py(グローバル)のうち、「振り分け役のエージェントはローカルの軽量モデル、本処理のエージェントは OpenAI のモデル」という混在構成に最も適した方法はどれですか。
この章のすべての example が冒頭で set_tracing_disabled(disabled=True) を呼んでいるのはなぜですか。また、OpenAI の API キーを持っている場合の代替手段を 1 つ挙げてください。
custom_example_global.py にあって custom_example_agent.py にはない設定として set_default_openai_api("chat_completions") があります。この呼び出しが global 版で必要になる理由を説明してください。
model="litellm/openrouter/openai/gpt-5.4-mini" という文字列指定だけで LiteLLM 経由の呼び出しになるのは、SDK のどのコンポーネントのどのような仕組みによるものですか。
litellm_provider.py を OPENROUTER_API_KEY 未設定のまま実行した場合と、litellm_auto.py を同条件で実行した場合とで、挙動はどう異なりますか。
解答と解説
問1 の解答と解説
custom_example_agent.py の Agent 単位の方法です。Agent の model 引数に Model オブジェクト(例: OpenAIChatCompletionsModel(model=..., openai_client=...))を直接渡すと、そのエージェントだけが指定プロバイダーを使うため、エージェントごとに異なるプロバイダーを混在させられます。run 単位はその実行に登場する全エージェントに同じプロバイダーが適用され、グローバルはプロセス全体に及ぶため、混在構成には不向きです。
問2 の解答と解説
トレーシングは実行記録を OpenAI のサーバーにアップロードする機能であり、OpenAI の API キーが必要だからです。他社モデルだけを使う読者は OpenAI のキーを持っていない前提のため、無効化しないとトレースのアップロードが 401 エラー(認証エラー)になります。代替手段としては、set_tracing_export_api_key() で platform.openai.com のキーをトレース専用に設定する方法(または OPENAI_API_KEY 環境変数の設定、非 OpenAI のトレースプロセッサーの利用)があります。
問3 の解答と解説
global 版は Agent の model に文字列を渡し、モデルの解決を SDK のデフォルト経路に任せます。SDK のデフォルト API は Responses API ですが、多くの互換プロバイダーは Responses API に対応していないため、set_default_openai_api("chat_completions") でデフォルトを Chat Completions API に切り替えないと 404 エラーなどになります。一方 agent 版は OpenAIChatCompletionsModel という Chat Completions 専用の Model オブジェクトを直接渡しているため、API の切り替え設定が不要です。
問4 の解答と解説
Runner のデフォルトのモデルプロバイダーである MultiProvider の、モデル名プレフィックスによる振り分け機能です。MultiProvider は「プレフィックスなし・openai/ → OpenAI プロバイダー」「litellm/ → LiteLLM」「any-llm/ → any-llm」という既定のマッピングを持ち、litellm/ を検出すると残りの部分(openrouter/openai/gpt-5.4-mini)をモデル名として LitellmModel に渡します。未知のプレフィックスは既定では UserError になります。
問5 の解答と解説
litellm_provider.py は API キーのデフォルト値として "dummy" を使い、main() 冒頭の判定で「Skipping run because no valid OPENROUTER_API_KEY was provided.」と表示して正常終了(スキップ)します。一方 litellm_auto.py は if os.getenv("OPENROUTER_API_KEY") is None: の判定で ValueError を送出して異常終了します。前者はエラーにならないため、「実行しても何も起きない」と感じたらキー設定を確認する必要があります。
参考リンク
Models(モデル設定・非 OpenAI プロバイダー・サードパーティアダプター): https://openai.github.io/openai-agents-python/models/
- 日本語版: https://openai.github.io/openai-agents-python/ja/models/
LiteLLM 連携(Models ページのサードパーティアダプター節へ統合): https://openai.github.io/openai-agents-python/models/litellm/
- 日本語版: https://openai.github.io/openai-agents-python/ja/models/litellm/
Examples 一覧(model_providers カテゴリの位置づけ): https://openai.github.io/openai-agents-python/examples/
- 日本語版: https://openai.github.io/openai-agents-python/ja/examples/
Configuring the SDK(
set_default_openai_client/set_default_openai_api/ トレーシング設定): https://openai.github.io/openai-agents-python/config/- 日本語版: https://openai.github.io/openai-agents-python/ja/config/
Tracing(トレーシングの仕組みとカスタムプロセッサー): https://openai.github.io/openai-agents-python/tracing/
- 日本語版: https://openai.github.io/openai-agents-python/ja/tracing/
- LiteLLM 対応プロバイダー一覧(外部サイト): https://docs.litellm.ai/docs/providers
第10章 推論コンテンツ(reasoning_content)
OpenAIのホスト型モデルの生の推論トークンは公開されません。読める要約が必要なら対応モデルで summary="auto" などを明示します。現行Responses APIでは store=False またはZDRのステートレス実行で暗号化された encrypted_content が既定で返ります。従来の include=["reasoning.encrypted_content"] は互換性のため受理されますが必須ではありません。暗号化データは表示用の要約ではなく、次の呼び出しへ渡す継続用データです。公式:Reasoning
この章で学ぶこと
- 推論モデル(reasoning model)とは何か、通常のモデルと何が違うのかを理解する
- モデルが返す「推論コンテンツ(reasoning content)」と「通常の回答」の違いを理解する
ModelSettingsのreasoningパラメータで推論の深さ(effort)と要約(summary)を制御する方法を学ぶRunnerの実行結果やストリーミングイベントから推論コンテンツを取り出す方法を学ぶ- gpt-oss などのオープンウェイトモデルを OpenRouter 経由で使い、生の推論テキストをストリーミング表示する方法を学ぶ
前提知識
この章に進む前に、以下を理解している必要があります。
- 第1章: 開発環境の構築(リポジトリの clone と
uv sync、OPENAI_API_KEYの設定) - Agent と Runner の基本的な使い方(
Runner.run()/Runner.run_streamed()) - ストリーミングの基礎(
stream_events()でイベントを逐次受け取る仕組み) - 第9章: 他社モデル利用(
OpenAIChatCompletionsModelとbase_urlの差し替え、set_tracing_disabled())
特に gpt_oss_stream.py は第9章で学んだ「OpenAI 互換 API を持つ別プロバイダへの接続」パターンをそのまま使うため、第9章を先に読むことを強くおすすめします。
概念解説
推論モデル(reasoning model)とは
推論モデルとは、回答を出力する前に「頭の中で考える」工程を明示的に持つ LLM のことです。OpenAI の o 系モデル(o1、o3 など)や GPT-5 系モデル、DeepSeek-R1、そして OpenAI がオープンウェイト(モデルの重みデータが公開されている形態)で公開した gpt-oss 系モデルなどがこれに当たります。
日常の比喩で考えてみましょう。数学のテストで、いきなり答えだけを書く生徒と、余白に途中式をびっしり書いてから答えを書く生徒がいるとします。従来のモデルは前者に近く、質問を受け取るとすぐに回答テキストを生成し始めます。一方、推論モデルは後者のように、まず「途中式」に相当する思考をトークン(モデルが扱う文字列の最小単位)として大量に生成し、その思考を踏まえて最終回答を作ります。この「途中式」の部分が 推論コンテンツ(reasoning content) です。
推論コンテンツは Chain-of-Thought(思考の連鎖)とも呼ばれ、複雑な計算・論理パズル・多段階の意思決定などで回答品質を大きく向上させます。その代わり、思考トークンの分だけ応答時間と料金が増えるというトレードオフがあります。
「途中式」はどこまで見えるのか — summary と生テキスト
ここで重要な注意点があります。モデルやプロバイダによって、推論コンテンツの見え方が異なります。
| 提供形態 | 見えるもの | 代表例 |
|---|---|---|
| OpenAI の Responses API | 生の思考そのものではなく、その 要約(summary) | gpt-5.6、gpt-5.5、o 系モデル |
| オープンウェイト / サードパーティ | 生の推論テキスト がそのまま返ることが多い | gpt-oss(OpenRouter 経由)、DeepSeek-R1 |
OpenAI のホスト型モデルでは、生の思考過程は安全性などの理由で公開されず、ModelSettings(reasoning=Reasoning(summary="detailed")) のように 要約の生成を明示的にリクエスト すると、思考の要約が「reasoning item」という独立した出力アイテムとして返ってきます。一方、gpt-oss や DeepSeek-R1 のようなモデルは、思考のテキストそのものをレスポンスの追加フィールド(reasoning や reasoning_content)として返します。
この章の 3 つの example は、まさにこの違いを体験するための構成になっています。
main.py— Model クラスを直接使う低レベル API で、要約型の推論コンテンツを取得(ストリーミング / 非ストリーミング両方)runner_example.py— 実務で最も使うRunnerAPI 経由で同じことを行うgpt_oss_stream.py— OpenRouter 経由の gpt-oss で 生の推論テキスト をストリーミング表示する
SDK 内部でのイベントの統一
SDK は、どのプロバイダの推論コンテンツも Responses API 形式のイベントに変換して統一的に扱えるようにしています。ソースコード(src/agents/models/chatcmpl_stream_handler.py)を確認すると、Chat Completions 形式のストリーミング応答に対して次の変換が行われています。
- デルタに
reasoning_contentフィールドがある場合(DeepSeek 系)→response.reasoning_summary_text.deltaイベントに変換 - デルタに
reasoningフィールドがある場合(OpenRouter などのサードパーティ)→response.reasoning_text.deltaイベントに変換
つまり、購読すべきイベント名は「要約」なら response.reasoning_summary_text.delta、「生の推論テキスト」なら response.reasoning_text.delta です。example のコードでこの使い分けが登場するので、頭の片隅に置いておいてください。
Reasoning 設定オブジェクト
推論の挙動は、openai パッケージが提供する Reasoning オブジェクトを ModelSettings(モデル呼び出し時の温度やトークン上限などをまとめて指定する設定クラス)の reasoning に渡して制御します。
effort— 推論にかける労力。"low"/"medium"/"high"などの段階があり、高いほど深く考えますが時間と料金が増えます(GPT-5 系では"none"を指定して推論を無効化することもできます)summary— 推論要約の詳しさ。"auto"/"concise"/"detailed"から選びます。これを指定しないと、OpenAI モデルでは推論要約が返ってきません
from openai.types.shared import Reasoning
from agents import ModelSettings
ModelSettings(reasoning=Reasoning(effort="medium", summary="detailed"))
この後の各 example が、どの抽象度で推論コンテンツを扱うかを図で整理しておきます。
各 example の解説
この章の example ディレクトリは examples/reasoning_content/ です。カテゴリ用の README は無く、3 つのスクリプトで構成されています。いずれも単独実行できるスクリプトです。
main.py
Agent や Runner を使わず、SDK の低レベル API である Model クラスを直接呼び出して、推論要約を取得する example です。ストリーミング版と非ストリーミング版の両方を実演します。
コード解説
まずモデルの準備部分です。
import asyncio
import os
from openai.types.responses import (
ResponseOutputMessage,
ResponseOutputRefusal,
ResponseOutputText,
ResponseReasoningItem,
)
from openai.types.shared.reasoning import Reasoning
from agents import ModelSettings
from agents.models.interface import ModelTracing
from agents.models.openai_provider import OpenAIProvider
MODEL_NAME = os.getenv("REASONING_MODEL_NAME") or "gpt-5.6"
OpenAIProviderは「モデル名を渡すと、対応する Model オブジェクトを返してくれる工場」のようなクラスです。普段はRunnerが内部で使っているものを、この example では直接使いますMODEL_NAMEは環境変数(OS に設定しておく変数)REASONING_MODEL_NAMEで差し替え可能で、未設定ならgpt-5.6になります(v0.19.0 でgpt-5.5から変更されました)。推論要約に対応したモデルを指定する必要があります
次にストリーミング版です。ストリーミングとは、応答が完成するのを待たず、生成されたそばから小さな断片(デルタ)を受け取る方式のことです。
async def stream_with_reasoning_content():
"""
Example of streaming a response from a model that provides reasoning content.
The reasoning content will be emitted as separate events.
"""
provider = OpenAIProvider()
model = provider.get_model(MODEL_NAME)
print("\n=== Streaming Example ===")
print("Prompt: Write a haiku about recursion in programming")
reasoning_content = ""
regular_content = ""
output_text_already_started = False
async for event in model.stream_response(
system_instructions="You are a helpful assistant that writes creative content.",
input="Write a haiku about recursion in programming",
model_settings=ModelSettings(reasoning=Reasoning(effort="high", summary="auto")),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
):
if event.type == "response.reasoning_summary_text.delta":
# Yellow for reasoning content
print(f"\033[33m{event.delta}\033[0m", end="", flush=True)
reasoning_content += event.delta
elif event.type == "response.output_text.delta":
if not output_text_already_started:
print("\n")
output_text_already_started = True
# Green for regular content
print(f"\033[32m{event.delta}\033[0m", end="", flush=True)
regular_content += event.delta
if not reasoning_content:
raise RuntimeError(f"Model {MODEL_NAME} returned no reasoning summary deltas.")
if not regular_content:
raise RuntimeError(f"Model {MODEL_NAME} returned no output text deltas.")
print("\n")
model.stream_response(...)は低レベル API なので、Runnerが普段裏側で埋めてくれる引数(tools、output_schema、handoffsなど)をすべて自分で渡す必要があります。ツールもハンドオフも使わないので空のリスト、トレーシング(実行記録を OpenAI のダッシュボードに送る機能)はModelTracing.DISABLEDで無効化しています- ポイントは
ModelSettings(reasoning=Reasoning(effort="high", summary="auto"))です。summaryを指定することで、推論要約のイベントが流れてくるようになります(以前の版はeffort="medium"/summary="detailed"でしたが、要約が安定して返るようにhigh/autoへ変更されました) - イベントの
typeを見て振り分けます。response.reasoning_summary_text.deltaが推論要約の断片、response.output_text.deltaが通常回答の断片です \033[33m ... \033[0mは ANSI エスケープシーケンスと呼ばれる端末の色指定で、推論要約を黄色、通常回答を緑色で表示し、視覚的に区別できるようにしています- 末尾の 2 つの
raise RuntimeError(...)は example の自己検証です。以前の版は要約が空でも「(No reasoning summary deltas were returned.)」と表示して正常終了していましたが、現在は推論要約も通常出力も必ず返ることを検査し、返らなければ例外で落とす作りに変わりました。CI で example を通しで流したときに、静かな失敗を見逃さないための変更です
続いて非ストリーミング版です。応答全体を待ってから一括で受け取ります。
response = await model.get_response(
system_instructions="You are a helpful assistant that explains technical concepts clearly.",
input=prompt,
model_settings=ModelSettings(reasoning=Reasoning(effort="high", summary="auto")),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
# Extract reasoning content and regular content from the response
reasoning_parts: list[str] = []
regular_parts: list[str] = []
for item in response.output:
if isinstance(item, ResponseReasoningItem):
reasoning_parts.extend(summary.text for summary in item.summary)
elif isinstance(item, ResponseOutputMessage):
for content_item in item.content:
if isinstance(content_item, ResponseOutputText):
regular_parts.append(content_item.text)
elif isinstance(content_item, ResponseOutputRefusal):
regular_parts.append(content_item.refusal)
reasoning_content = "\n".join(reasoning_parts)
regular_content = "\n".join(regular_parts)
if not reasoning_content:
raise RuntimeError(f"Model {MODEL_NAME} returned no reasoning summary.")
if not regular_content:
raise RuntimeError(f"Model {MODEL_NAME} returned no regular output content.")
- 非ストリーミングでは、
response.outputというリストの中に複数種類の「出力アイテム」が入って返ってきます type == "reasoning"のアイテムが推論コンテンツで、要約テキストはitem.summary[0].textに格納されていますtype == "message"のアイテムが通常回答です。中身がResponseOutputText(通常のテキスト)かResponseOutputRefusal(モデルが回答を拒否した場合の拒否メッセージ)かをisinstanceで判定しています
最後に、2 つの関数を順に実行するエントリポイントです。非同期(async / await。処理の完了を待つ間に他の処理を進められる Python の仕組み)で書かれているため、asyncio.run() で起動します。
async def main():
await stream_with_reasoning_content()
await get_response_with_reasoning_content()
if __name__ == "__main__":
asyncio.run(main())
実行手順
- リポジトリのルートディレクトリ(
openai-agents-python)にいることを確認します OpenAI の API キーを環境変数に設定します
export OPENAI_API_KEY=sk-...(任意)使用するモデルを変えたい場合は
REASONING_MODEL_NAMEを設定しますexport REASONING_MODEL_NAME=gpt-5.6実行します
uv run python examples/reasoning_content/main.py
期待される動作・出力例
まずストリーミング例が始まり、黄色い文字で推論要約が、続いて緑色の文字で俳句(通常回答)が逐次表示されます。その後、非ストリーミング例で「Reasoning Content」と「Regular Content」がまとめて表示されます。
=== Streaming Example ===
Prompt: Write a haiku about recursion in programming
(黄色: 推論要約が流れる)
(緑色: 俳句が流れる)
=== Non-streaming Example ===
Prompt: Explain the concept of recursion in programming
### Reasoning Content:
(推論要約のテキスト)
### Regular Content:
(再帰の説明テキスト)
質問が簡単な場合、モデルが要約を生成せず (No reasoning summary deltas were returned.) と表示されることもあります。これは正常な挙動です。
つまずきポイント
summaryを指定し忘れると推論要約は一切返ってきません。Reasoning(effort=...)だけでは不十分です- 使用するモデルが推論要約に対応していない場合はエラーになるか、要約が空になります。エラーメッセージが出たら
REASONING_MODEL_NAMEで対応モデルに切り替えてください - 端末によっては ANSI カラーコードが解釈されず、
\033[33mのような文字列がそのまま表示されることがあります。その場合も処理自体は正常です
runner_example.py
実務で最もよく使う Runner API(Runner.run() / Runner.run_streamed())を使って、推論コンテンツを取り出す example です。main.py の低レベル API と違い、Agent を定義して普段どおり実行するだけで推論コンテンツにアクセスできます。
コード解説
まず Agent の定義です。
import asyncio
import os
from openai.types.shared.reasoning import Reasoning
from agents import Agent, ModelSettings, Runner, trace
from agents.items import ReasoningItem
MODEL_NAME = os.getenv("REASONING_MODEL_NAME") or "gpt-5.6"
async def main():
print(f"Using model: {MODEL_NAME}")
# Create an agent with a model that supports reasoning content
agent = Agent(
name="Reasoning Agent",
instructions="You are a helpful assistant that explains your reasoning step by step.",
model=MODEL_NAME,
model_settings=ModelSettings(reasoning=Reasoning(effort="high", summary="auto")),
)
Agentのmodel_settingsにReasoning(effort="high", summary="auto")を渡すだけで、この Agent の全実行に推論要約のリクエストが適用されます。低レベル API のような大量の引数は不要です
次に非ストリーミング実行から推論コンテンツを取り出す部分です。
# Example 1: Non-streaming response
with trace("Reasoning Content - Non-streaming"):
print("\n=== Example 1: Non-streaming response ===")
result = await Runner.run(
agent, "What is the square root of 841? Please explain your reasoning."
)
# Extract reasoning content from the result items
reasoning_parts: list[str] = []
for item in result.new_items:
if isinstance(item, ReasoningItem):
reasoning_parts.extend(summary.text for summary in item.raw_item.summary)
reasoning_content = "\n".join(reasoning_parts)
if not reasoning_content:
raise RuntimeError(f"Model {MODEL_NAME} returned no reasoning summary.")
print("\n### Reasoning Content:")
print(reasoning_content)
print("\n### Final Output:")
print(result.final_output)
trace("...")はトレーシング用のコンテキストマネージャで、このwithブロック内の実行を 1 つのトレース(実行記録)としてまとめ、OpenAI のダッシュボードで確認できるようにしますresult.new_itemsには、実行中に生成された出力アイテム(メッセージ、ツール呼び出し、推論など)が型付きで入っています。その中からReasoningItem型(SDK が推論アイテムを包むラッパークラス。agents.itemsで定義)を探しますitem.raw_item.summary[0].textで要約テキストを取得します。raw_itemは OpenAI API が返した生のオブジェクト(ResponseReasoningItem)で、summaryが空リストの可能性があるため長さチェックをしてからアクセスしています- 最終回答はいつもどおり
result.final_outputです。推論コンテンツはfinal_outputには含まれず、あくまで別のアイテムとして分離されている点が重要です
最後にストリーミング実行です。
# Example 2: Streaming response
with trace("Reasoning Content - Streaming"):
print("\n=== Example 2: Streaming response ===")
stream = Runner.run_streamed(
agent,
"A recursive function uses T(n) = 2 * T(n - 1) + 1 with T(0) = 1. "
"Compute T(20) and derive a closed form.",
)
output_text_already_started = False
saw_reasoning_summary_delta = False
saw_output_text_delta = False
async for event in stream.stream_events():
if event.type == "raw_response_event":
if event.data.type == "response.reasoning_summary_text.delta":
saw_reasoning_summary_delta = True
print(f"\033[33m{event.data.delta}\033[0m", end="", flush=True)
elif event.data.type == "response.output_text.delta":
saw_output_text_delta = True
if not output_text_already_started:
print("\n")
output_text_already_started = True
print(f"\033[32m{event.data.delta}\033[0m", end="", flush=True)
if not saw_reasoning_summary_delta:
raise RuntimeError(f"Model {MODEL_NAME} returned no streaming reasoning summary.")
if not saw_output_text_delta:
raise RuntimeError(f"Model {MODEL_NAME} returned no streaming output text.")
print("\n")
Runner.run_streamed()のイベントのうち、event.type == "raw_response_event"は「LLM からのイベントを加工せずそのまま通したもの」です(RawResponsesStreamEventクラス)。そのevent.data.typeを見ると、main.pyのときと同じイベント名(response.reasoning_summary_text.deltaとresponse.output_text.delta)で振り分けられます- つまり、低レベル API でも Runner API でも「ストリーミングで購読するイベント名は同じ」ということです。Runner の場合は一段
event.dataを挟むだけです
実行手順
OpenAI の API キーを環境変数に設定します
export OPENAI_API_KEY=sk-...- (任意)モデルを変えたい場合は
REASONING_MODEL_NAMEを設定します 実行します
uv run python examples/reasoning_content/runner_example.py
期待される動作・出力例
Using model: gpt-5.6
=== Example 1: Non-streaming response ===
### Reasoning Content:
(841 の平方根を求める際の推論要約。29 × 29 = 841 の確認など)
### Final Output:
The square root of 841 is 29. ...
=== Example 2: Streaming response ===
(黄色で推論要約、続いて緑色で 15 × 27 = 405 の説明が流れる)
トレーシングが有効なため、OpenAI ダッシュボードの Traces ページに「Reasoning Content - Non-streaming」「Reasoning Content - Streaming」という 2 つのトレースが記録されます。
つまずきポイント
result.final_outputだけを見ても推論コンテンツは見えません。必ずresult.new_itemsからReasoningItemを探してくださいraw_item.summaryは空リストのことがあります。example のようにlen(...) > 0のチェックを省くとIndexErrorになり得ます- 推論要約は毎回同じ内容・同じ分量で返るとは限りません。実行のたびに出力が変わるのは正常です
gpt_oss_stream.py
OpenAI のオープンウェイトモデル gpt-oss-20b を、モデル配信サービス OpenRouter 経由で呼び出し、生の推論テキスト(要約ではない思考そのもの)をストリーミング表示する example です。第9章「他社モデル利用」のパターンの実践編でもあります。
コード解説
前半はモデルの接続設定です。
import asyncio
import os
from openai import AsyncOpenAI
from openai.types.shared import Reasoning
from agents import (
Agent,
ModelSettings,
OpenAIChatCompletionsModel,
Runner,
set_tracing_disabled,
)
set_tracing_disabled(True)
# import logging
# logging.basicConfig(level=logging.DEBUG)
gpt_oss_model = OpenAIChatCompletionsModel(
model="openai/gpt-oss-20b",
openai_client=AsyncOpenAI(
base_url="https://openrouter.ai/api/v1",
api_key=os.getenv("OPENROUTER_API_KEY"),
),
)
set_tracing_disabled(True)でトレーシングを無効化しています。トレースのアップロードには OpenAI の API キーが必要なため、OpenAI 以外のプロバイダだけで動かすときの定石です(第9章参照)AsyncOpenAIクライアントのbase_urlを OpenRouter のエンドポイントに向け、認証には環境変数OPENROUTER_API_KEYを使います。OpenRouter は多数のモデルを OpenAI 互換 API で提供する中継サービスです- モデル名
"openai/gpt-oss-20b"は OpenRouter 上での識別子です。gpt-oss-20b は OpenAI が公開したオープンウェイトの推論モデルで、生の推論テキストをレスポンスに含めて返します - Responses API ではなく Chat Completions API 互換で提供されるため、
OpenAIChatCompletionsModelを使います - コメントアウトされた
loggingの 2 行を有効にすると、リクエスト / レスポンスの詳細なデバッグログが見られます
後半は Agent の定義とストリーミングです。
async def main():
agent = Agent(
name="Assistant",
instructions="You're a helpful assistant. You provide a concise answer to the user's question.",
model=gpt_oss_model,
model_settings=ModelSettings(
reasoning=Reasoning(effort="high", summary="detailed"),
),
)
result = Runner.run_streamed(agent, "Tell me about recursion in programming.")
print("=== Run starting ===")
print("\n")
async for event in result.stream_events():
if event.type == "raw_response_event":
if event.data.type == "response.reasoning_text.delta":
print(f"\033[33m{event.data.delta}\033[0m", end="", flush=True)
elif event.data.type == "response.output_text.delta":
print(f"\033[32m{event.data.delta}\033[0m", end="", flush=True)
print("\n")
print("=== Run complete ===")
if __name__ == "__main__":
asyncio.run(main())
Agentのmodelに、モデル名の文字列ではなく先ほど作ったOpenAIChatCompletionsModelのインスタンスを直接渡しています- 最大の注目点はイベント名です。
runner_example.pyではresponse.reasoning_summary_text.delta(要約)でしたが、ここではresponse.reasoning_text.delta(生の推論テキスト) を購読しています - なぜ違うのか。概念解説で触れたとおり、OpenRouter は推論テキストを Chat Completions レスポンスの
reasoningフィールドで返し、SDK のストリーム変換層がそれをresponse.reasoning_text.deltaイベントに変換するためです(DeepSeek のようにreasoning_contentフィールドで返すプロバイダの場合はresponse.reasoning_summary_text.deltaに変換されます)。どちらのイベントが飛んでくるかはプロバイダのレスポンス形式次第、と覚えてください
実行手順
- OpenRouter(https://openrouter.ai/)でアカウントを作成し、API キーを発行します(gpt-oss-20b の利用にはクレジットが必要な場合があります)
API キーを環境変数に設定します
export OPENROUTER_API_KEY=sk-or-...実行します(
OPENAI_API_KEYは不要です。トレーシングは無効化済みのため)uv run python examples/reasoning_content/gpt_oss_stream.py
期待される動作・出力例
=== Run starting ===
(黄色: gpt-oss の生の思考テキストが長めに流れる。
"The user asks about recursion. I should explain..." のような一人称の思考)
(緑色: 再帰についての簡潔な回答が流れる)
=== Run complete ===
要約ではなく生の思考なので、runner_example.py と比べて黄色部分が口語的で長くなる傾向があります。推論モデルの「頭の中」がそのまま見える、この章で最も面白い example です。
つまずきポイント
OPENROUTER_API_KEYが未設定だと認証エラー(401)になります。OpenAI の API キーとは別物です- イベント名を
response.reasoning_summary_text.deltaにしてしまうと何も表示されません。gpt-oss(OpenRouter 経由)ではresponse.reasoning_text.deltaを購読します - OpenRouter 側のモデル提供状況によっては、応答が遅い・一時的に利用不可ということがあります。デバッグにはコメントアウトされている logging を有効化すると原因を特定しやすくなります
- 生の推論テキストは品質・安全性のフィルタが要約ほど効いていない場合があります。エンドユーザーにそのまま見せる UI にする際は注意してください
まとめ
- 推論モデルは、最終回答の前に「思考(推論コンテンツ)」をトークンとして生成する。数学のテストの途中式のようなもので、複雑なタスクの品質を高める代わりに時間と料金が増える
- OpenAI のホスト型モデル(gpt-5.6 など)では生の思考は見えず、
ModelSettings(reasoning=Reasoning(effort=..., summary=...))で 要約 をリクエストして取得する。summaryの指定を忘れると要約は返らない - 非ストリーミングでは、
Runner.run()のresult.new_itemsからReasoningItemを探し、raw_item.summary[0].textで要約を取り出す。推論コンテンツはfinal_outputには含まれない - ストリーミングでは
raw_response_eventの中身を見る。要約はresponse.reasoning_summary_text.delta、生の推論テキストはresponse.reasoning_text.deltaという別イベントで届く - gpt-oss などのオープンウェイトモデルは OpenRouter 経由 +
OpenAIChatCompletionsModelで利用でき、生の推論テキストが取得できる。OpenAI 以外のプロバイダのみで動かす場合はset_tracing_disabled(True)を忘れない
理解度チェック
推論モデルが返す「推論コンテンツ」と「通常の出力(final_output)」の違いを説明してください。
Agent に推論要約を生成させるために必要な ModelSettings の設定を、コードで書いてください。
Runner.run()(非ストリーミング)の結果から推論要約のテキストを取り出すには、どのプロパティをどうたどればよいでしょうか。
runner_example.py では response.reasoning_summary_text.delta を購読しているのに、gpt_oss_stream.py では response.reasoning_text.delta を購読しています。この違いが生じる理由を説明してください。
gpt_oss_stream.py の冒頭で set_tracing_disabled(True) を呼んでいるのはなぜでしょうか。
解答と解説
問1 の解答と解説
推論コンテンツは、モデルが最終回答を作る前に生成する「思考過程」(またはその要約)で、数学の途中式に相当します。通常の出力はユーザーへの最終回答そのものです。SDK では両者は別々の出力アイテム(type == "reasoning" のアイテムと type == "message" のアイテム)として分離されており、推論コンテンツが final_output に混ざることはありません。
問2 の解答と解説
from openai.types.shared.reasoning import Reasoning
from agents import Agent, ModelSettings
agent = Agent(
name="Reasoning Agent",
instructions="...",
model="gpt-5.6",
model_settings=ModelSettings(reasoning=Reasoning(effort="high", summary="auto")),
)
ポイントは Reasoning の summary パラメータです。effort だけでは推論は行われても要約が返ってきません。
問3 の解答と解説
result.new_items をループし、isinstance(item, ReasoningItem) で推論アイテムを見つけ、item.raw_item.summary の各要素の .text を読みます。summary は複数要素になることも空リストになることもあるため、現在の example は reasoning_parts.extend(summary.text for summary in item.raw_item.summary) ですべて集めてから "\n".join(...) で連結し、結果が空なら RuntimeError を送出しています(以前は先頭の 1 要素だけを読んで break していました)。
問4 の解答と解説
プロバイダがどのフィールドで推論を返すかが異なるためです。OpenAI の Responses API は推論の「要約」を返すため要約イベント(response.reasoning_summary_text.delta)になります。一方、OpenRouter 経由の gpt-oss は Chat Completions レスポンスの reasoning フィールドで「生の推論テキスト」を返し、SDK がこれを response.reasoning_text.delta イベントに変換します。購読すべきイベントは、使うモデル・プロバイダの返し方に合わせて選ぶ必要があります。
問5 の解答と解説
SDK のトレーシングは実行記録を OpenAI のサーバーにアップロードするため、OpenAI の API キーが必要です。この example は OpenRouter の API キーだけで動かす想定なので、トレーシングを有効のままにすると OPENAI_API_KEY 未設定によるエラーや警告が発生します。それを避けるために無効化しています(第9章で学んだ、他社プロバイダ利用時の定石です)。
参考リンク
- モデルの設定と reasoning effort(公式ドキュメント): https://openai.github.io/openai-agents-python/models/ | 日本語版: https://openai.github.io/openai-agents-python/ja/models/
- コード例一覧(reasoning_content カテゴリの紹介): https://openai.github.io/openai-agents-python/examples/ | 日本語版: https://openai.github.io/openai-agents-python/ja/examples/
- 実行結果(RunResult と new_items): https://openai.github.io/openai-agents-python/results/ | 日本語版: https://openai.github.io/openai-agents-python/ja/results/
- ストリーミング(raw_response_event の仕組み): https://openai.github.io/openai-agents-python/streaming/ | 日本語版: https://openai.github.io/openai-agents-python/ja/streaming/
- LiteLLM 経由での任意モデル利用(第9章の関連資料): https://openai.github.io/openai-agents-python/models/litellm/ | 日本語版: https://openai.github.io/openai-agents-python/ja/models/litellm/
第11章 実践アプリ①: 航空会社カスタマーサービス(customer_service)
ここまでの章では、Agent(エージェント)、function tool(関数ツール)、handoff(ハンドオフ)、context(コンテキスト)といった部品を1つずつ学んできました。この章からは「実践アプリ編」として、それらの部品が1つのアプリケーションにどう統合されるかを見ていきます。
題材は examples/customer_service に含まれる航空会社のカスタマーサービスシステムです。「窓口で要件を聞き、担当部署に取り次ぎ、担当者が処理する」という、現実のコールセンターそのままの構造を約190行のPythonファイル1つで実装しています。
この章で学ぶこと
- handoff・context・function tool という3つの部品が、1つの対話型アプリの中でどう役割分担するか
- トリアージ(振り分け)エージェントと専門エージェントによる「窓口・担当者」パターンの設計方法
input()を使った対話ループの作り方と、result.to_input_list()による会話履歴の引き回しresult.last_agentを使って「次のターンをどのエージェントから始めるか」を維持する方法on_handoffコールバックでハンドオフの瞬間に context へデータを仕込むテクニックresult.new_itemsに含まれる項目(MessageOutputItemなど)を種類ごとに表示する方法
前提知識
この章は「ここまでの総復習」に当たるため、以下の内容を先に理解しておくことを強くおすすめします。
| 既習事項 | 対応する章 |
|---|---|
開発環境の構築(git clone と uv sync、OPENAI_API_KEY の設定) | 第1章 |
Agent と Runner の基本(Runner.run() の使い方、RunResult の読み方) | 第2章(basic) |
function tool(@tool / 別名 @function_tool デコレータによるツール定義) | tools を扱った章 |
handoff(エージェント間の会話の引き継ぎ、handoff() 関数) | handoffs を扱った章 |
context(RunContextWrapper によるアプリ内データの共有) | context を扱った章(basic 章内の解説を含む) |
なお、非同期処理(async/await。処理の完了を待つ間に他の処理を進められるPythonの仕組み)と、トレーシング(エージェントの実行記録を自動で収集し、後から可視化できる SDK の機能)もこの章のコードに登場します。忘れている場合は該当章を軽く見返してから読み進めてください。
概念解説: 「窓口→担当部署」モデルとしてのマルチエージェント
航空会社のコールセンターを想像する
航空会社に電話をかけたときの流れを思い浮かべてください。
- まず総合窓口のオペレーターが出て、要件を聞きます(「座席の変更ですか? お荷物のご質問ですか?」)
- 要件に応じて担当部署(座席変更係、FAQ係)に電話が転送されます
- 担当者は自分の専門業務だけを処理します。専門外の質問をされたら、総合窓口に転送し直します
- 転送の間も、顧客情報のメモ(お名前、予約番号、便名)は部署間で共有され、顧客が同じことを何度も言わされないようになっています
この章の example は、この構造をそのまま3つのエージェントで実装します。
- 総合窓口 = Triage Agent(トリアージエージェント。「トリアージ」とは要件を判断して適切な担当に振り分けること)
- FAQ係 = FAQ Agent(
faq_lookup_toolというツールで社内FAQを検索して回答) - 座席変更係 = Seat Booking Agent(
update_seatというツールで座席を更新) - 電話の転送 = handoff(エージェント間で会話の主導権を引き継ぐ仕組み)
- 顧客情報のメモ = context(
AirlineAgentContextという Pydantic モデル。エージェント間・ツール間で共有されるローカルデータ)
3つの部品の役割分担
ここまでの章で学んだ部品は、このアプリの中で次のように役割分担します。
| 部品 | このアプリでの役割 | 現実世界の比喩 |
|---|---|---|
| handoff | 会話の主導権をエージェント間で移す | 電話の転送 |
| function tool | 個々のエージェントが実務を行う | 担当者が業務システムを操作する |
| context | 会話をまたいでアプリ内のデータを保持・共有する | 部署間で共有される顧客カルテ |
| 対話ループ + 会話履歴 | ユーザーとの往復と文脈の維持 | 通話そのもの |
重要なのは、context は LLM(大規模言語モデル。ChatGPT のような文章生成AI)には送信されないという点です。context はあくまで「Python コード側で共有されるメモ」であり、LLM が見るのは会話履歴だけです。この区別は後述の update_seat ツールの解説で具体的に確認します。
全体構成図
このアプリの構造を図にすると次のようになります。実線の矢印が handoff(会話の主導権の移動)、点線がツールの利用です。
handoff が双方向に張られている点に注目してください。Triage Agent から専門エージェントへ行くだけでなく、専門エージェントが「これは自分の担当外だ」と判断したら Triage Agent に戻せるようになっています。これにより、会話がどのエージェントにいても、ユーザーはどんな話題でも切り出せます。
各 example の解説
examples/customer_service ディレクトリに含まれるファイルは main.py の1つだけです。この1ファイルを、セクションコメント(### CONTEXT、### TOOLS、### HOOKS、### AGENTS、### RUN)に沿って上から順に読み解いていきます。
main.py
航空会社のカスタマーサービスを模した対話型のマルチエージェントアプリです。ユーザーの入力をトリアージエージェントが受け、FAQ回答と座席変更という2つの専門エージェントに handoff で振り分けます。
(1) インポート部: 登場する道具の確認
from __future__ import annotations as _annotations
import asyncio
import random
import uuid
from pydantic import BaseModel
from agents import (
Agent,
HandoffOutputItem,
ItemHelpers,
MessageOutputItem,
RunContextWrapper,
Runner,
ToolCallItem,
ToolCallOutputItem,
TResponseInputItem,
handoff,
trace,
)
from agents.decorators import tool
from agents.extensions.handoff_prompt import RECOMMENDED_PROMPT_PREFIX
from examples.auto_mode import input_with_fallback, is_auto_mode
見慣れないインポートがいくつかあるので整理します。
HandoffOutputItem/MessageOutputItem/ToolCallItem/ToolCallOutputItem: 実行結果result.new_itemsに入る「実行中に起きた出来事」を表すクラス群です。それぞれ「handoff が完了した」「LLM がメッセージを出力した」「ツールが呼ばれた」「ツールが結果を返した」に対応し、後述の表示ループでisinstance()による種類分けに使います。ItemHelpers: 出来事アイテムから情報を取り出すためのユーティリティクラスです。この example ではtext_message_output()(メッセージアイテムからテキスト部分だけを抽出するメソッド)を使います。TResponseInputItem: モデルへの入力1件(ユーザー発言やツール結果など)を表す型です。会話履歴はこの型のリストとして管理します。RECOMMENDED_PROMPT_PREFIX: handoff を使うエージェントの instructions(エージェントへの指示文。システムプロンプトとも呼ばれます)の先頭に付けることが推奨されている定型文です。「あなたはマルチエージェントシステムの一員で、transfer_to_<エージェント名>という関数で会話を転送できる。転送のことはユーザーに悟らせないように」といった内容が書かれています。examples.auto_modeのinput_with_fallback/is_auto_mode: リポジトリの examples 共通ヘルパーです。環境変数EXAMPLES_INTERACTIVE_MODE=autoが設定されているときだけ、キーボード入力の代わりに既定のテスト入力を返します(CI などで自動実行するための仕組み)。普通に実行する場合、input_with_fallback()は単に Python 標準のinput()を呼ぶだけです。
from examples.auto_mode import ... というリポジトリ内の別モジュールへの import があるため、このファイルは単独スクリプトとしてではなく、リポジトリルートからモジュールとして実行する必要があります(実行手順で後述)。
(2) CONTEXT: エージェント間で共有する「顧客カルテ」
### CONTEXT
class AirlineAgentContext(BaseModel):
passenger_name: str | None = None
confirmation_number: str | None = None
seat_number: str | None = None
flight_number: str | None = None
Pydantic の BaseModel(型チェック付きのデータクラスを簡単に作れるライブラリの基底クラス)を使って、乗客名・予約番号・座席番号・便名の4項目を持つ context を定義しています。すべて None で初期化され、会話が進むにつれてツールやコールバックが値を書き込んでいきます。
context の章で学んだとおり、このオブジェクトは Runner.run(..., context=...) で渡され、すべてのツール関数・コールバックから RunContextWrapper 経由で同じインスタンスにアクセスできます。エージェントが交代(handoff)しても context は同じものが使われ続けるため、「座席変更係が記録した予約番号を、後で別の係も参照できる」状態になります。まさに部署間で共有される顧客カルテです。
(3) TOOLS: 2つの function tool
1つ目は FAQ 検索ツールです。
@tool(name_override="faq_lookup_tool", description_override="Lookup frequently asked questions.")
async def faq_lookup_tool(question: str) -> str:
question_lower = question.lower()
if any(
keyword in question_lower
for keyword in ["bag", "baggage", "luggage", "carry-on", "hand luggage", "hand carry"]
):
return (
"You are allowed to bring one bag on the plane. "
"It must be under 50 pounds and 22 inches x 14 inches x 9 inches."
)
elif any(keyword in question_lower for keyword in ["seat", "seats", "seating", "plane"]):
return (
"There are 120 seats on the plane. "
"There are 22 business class seats and 98 economy seats. "
"Exit rows are rows 4 and 16. "
"Rows 5-8 are Economy Plus, with extra legroom. "
)
elif any(
keyword in question_lower
for keyword in ["wifi", "internet", "wireless", "connectivity", "network", "online"]
):
return "We have free wifi on the plane, join Airline-Wifi"
return "I'm sorry, I don't know the answer to that question."
@toolのname_override/description_overrideで、LLM に見せるツール名と説明文を明示的に指定しています。省略すると関数名と docstring から自動生成されますが、明示することでプロンプトの安定性が上がります。- 中身は LLM を一切使わない、キーワードマッチによる擬似的な FAQ データベースです。実際のアプリではここが社内ナレッジ検索や RAG(外部知識を検索して回答に使う手法)の呼び出しに置き換わるイメージです。「荷物」「座席」「Wi-Fi」のどのキーワードにも一致しなければ「わかりません」を返します。
- FAQ Agent の instructions には「自分の知識に頼らず必ずこのツールを使うこと」と書かれています(後述)。これは、LLM が事実と異なる内容をもっともらしく生成してしまう現象(ハルシネーション)を防ぎ、回答の出所を管理下に置くための実践的なパターンです。
2つ目は座席更新ツールで、ここで context が本格的に登場します。
@tool
async def update_seat(
context: RunContextWrapper[AirlineAgentContext], confirmation_number: str, new_seat: str
) -> str:
"""
Update the seat for a given confirmation number.
Args:
confirmation_number: The confirmation number for the flight.
new_seat: The new seat to update to.
"""
# Update the context based on the customer's input
context.context.confirmation_number = confirmation_number
context.context.seat_number = new_seat
# Ensure that the flight number has been set by the incoming handoff
assert context.context.flight_number is not None, "Flight number is required"
return f"Updated seat to {new_seat} for confirmation number {confirmation_number}"
このツールには重要なポイントが3つあります。
- 第1引数の
RunContextWrapper[AirlineAgentContext]は LLM からは見えません。 SDK はツールの引数リストから context 引数を自動的に除外し、LLM にはconfirmation_numberとnew_seatの2つだけをパラメータとして提示します。LLM が会話から聞き出した値を引数に渡し、SDK が context を自動注入する、という分業です。 context.context.xxx = ...で context に書き込んでいます。RunContextWrapperはラッパー(包み紙)であり、中身のアプリ定義オブジェクトには.contextプロパティでアクセスします。ここで書き込んだ予約番号と座席番号は、以降のツールやコールバックからも読めます。assert context.context.flight_number is not Noneという検証があります。 便名はユーザーから聞くのではなく、次に説明する handoff コールバックが自動設定する設計です。この assert は「座席変更は必ず handoff 経由で始まっているはずだ」というアプリの前提をコードで表明しています。
この可視・不可視の境界を図にすると次のようになります。
(4) HOOKS: handoff の瞬間に発火するコールバック
### HOOKS
async def on_seat_booking_handoff(context: RunContextWrapper[AirlineAgentContext]) -> None:
flight_number = f"FLT-{random.randint(100, 999)}"
context.context.flight_number = flight_number
on_handoff コールバックは、LLM が handoff ツールを呼び出した瞬間(=会話が転送される瞬間)に実行される関数です。公式ドキュメントは「handoff が起きたと分かった時点でデータ取得を先行して始める」用途を挙げています。
この example では「Seat Booking Agent への転送が発生したら、FLT- + 3桁乱数の便名を生成して context に書き込む」という処理を行っています。現実のアプリなら「顧客IDから予約データベースを引いて便名を取得する」処理に相当するでしょう。デモを簡単にするために乱数で代用しているわけです。
先ほどの update_seat の assert と組み合わせると、データの流れはこうなります。
Triage Agent が transfer_to_seat_booking_agent を呼ぶ
→ on_seat_booking_handoff が発火し context.flight_number = "FLT-123" を設定
→ Seat Booking Agent が会話を引き継ぎ、予約番号と希望座席を聞く
→ update_seat ツールが実行され、assert が「便名設定済み」を確認して通過
図にすると次のようになります。
つまり「handoff で context を仕込み、後段のツールがそれを利用する」という連携パターンをこの2つの関数が実演しています。
(5) AGENTS: 3体のエージェントと双方向 handoff
まず専門エージェント2体の定義です。
faq_agent = Agent[AirlineAgentContext](
name="FAQ Agent",
handoff_description="A helpful agent that can answer questions about the airline.",
instructions=f"""{RECOMMENDED_PROMPT_PREFIX}
You are an FAQ agent. If you are speaking to a customer, you probably were transferred to from the triage agent.
Use the following routine to support the customer.
# Routine
1. Identify the last question asked by the customer.
2. Use the faq lookup tool to answer the question. Do not rely on your own knowledge.
3. If you cannot answer the question, transfer back to the triage agent.""",
tools=[faq_lookup_tool],
)
seat_booking_agent = Agent[AirlineAgentContext](
name="Seat Booking Agent",
handoff_description="A helpful agent that can update a seat on a flight.",
instructions=f"""{RECOMMENDED_PROMPT_PREFIX}
You are a seat booking agent. If you are speaking to a customer, you probably were transferred to from the triage agent.
Use the following routine to support the customer.
# Routine
1. Ask for their confirmation number.
2. Ask the customer what their desired seat number is.
3. Use the update seat tool to update the seat on the flight.
If the customer asks a question that is not related to the routine, transfer back to the triage agent. """,
tools=[update_seat],
)
Agent[AirlineAgentContext]というジェネリクス表記(角括弧で型を指定する Python の型ヒント記法)で、「このエージェントはAirlineAgentContext型の context と一緒に使う」と宣言しています。これにより、異なる context 型を想定したツールを誤って混ぜたとき、型チェッカーがエラーを検出できます。1回の実行に関わる全エージェント・全ツールは同じ context 型で揃える必要があります。handoff_descriptionは、他のエージェントがこのエージェントへ handoff するかどうかを判断する材料として使われる説明文です。handoff はLLMからはtransfer_to_...という名前のツールとして見えており、この説明文がそのツールの説明に反映されます。- instructions は
RECOMMENDED_PROMPT_PREFIX+ 番号付きの手順書(Routine)という構成です。「顧客対応エージェントには明確なルーチンを与える」のは実務でも有効なプロンプト設計で、手順から外れた要求が来たら「トリアージに戻せ」という脱出条件まで手順に含めている点が参考になります。
次にトリアージエージェントです。
triage_agent = Agent[AirlineAgentContext](
name="Triage Agent",
handoff_description="A triage agent that can delegate a customer's request to the appropriate agent.",
instructions=(
f"{RECOMMENDED_PROMPT_PREFIX} "
"You are a helpful triaging agent. You can use your tools to delegate questions to other appropriate agents."
),
handoffs=[
handoff(agent=faq_agent, tool_name_override="transfer_to_faq_agent"),
handoff(
agent=seat_booking_agent,
on_handoff=on_seat_booking_handoff,
tool_name_override="transfer_to_seat_booking_agent",
),
],
)
- Triage Agent 自身は業務ツールを1つも持ちません。仕事は振り分けだけです。「1つの何でも屋より、1つの仕事に秀でた専門エージェントを複数」という公式ドキュメント(multi_agent)の推奨をそのまま体現しています。
handoffsパラメータにはAgentをそのまま渡すこともできますが、ここではhandoff()関数でカスタマイズしています。tool_name_overrideは LLM に見せるツール名の明示指定です(デフォルトでもtransfer_to_<エージェント名>形式になりますが、明示することで名前が安定します)。Seat Booking Agent への handoff にだけon_handoff=on_seat_booking_handoffが付いており、前述の便名採番が仕込まれています。
最後に、逆方向の handoff を後から追加します。
faq_agent.handoffs.append(
handoff(agent=triage_agent, tool_name_override="transfer_to_triage_agent")
)
seat_booking_agent.handoffs.append(
handoff(agent=triage_agent, tool_name_override="transfer_to_triage_agent")
)
なぜ定義時ではなく後から append するのでしょうか。それは相互参照(FAQ Agent は Triage Agent を参照し、Triage Agent は FAQ Agent を参照する)があるためです。Python では変数は定義した後でないと参照できないので、「先に3体を定義 → 後から逆方向の handoff を追加」という2段階にしています。これで構成図のとおり、どのエージェントからも Triage Agent に戻れる双方向のネットワークが完成します。
(6) RUN: 対話ループの心臓部
いよいよメインループです。ここが本章の最重要ポイントなので、丁寧に見ていきます。
async def main():
current_agent: Agent[AirlineAgentContext] = triage_agent
input_items: list[TResponseInputItem] = []
context = AirlineAgentContext()
auto_mode = is_auto_mode()
# Normally, each input from the user would be an API request to your app, and you can wrap the request in a trace()
# Here, we'll just use a random UUID for the conversation ID
conversation_id = uuid.uuid4().hex[:16]
ループの外側で、会話全体を通して維持する4つの状態を初期化しています。
| 変数 | 役割 |
|---|---|
current_agent | 次のターンを担当するエージェント。初期値は Triage Agent(=最初は総合窓口が出る) |
input_items | 会話履歴。ユーザー発言・エージェント応答・ツール呼び出しの記録が蓄積されるリスト |
context | 顧客カルテ。AirlineAgentContext() で全項目 None の状態から開始 |
conversation_id | トレーシング用の会話ID。uuid モジュールでランダムな16文字を生成 |
ここで思い出してほしいのが、LLM には記憶がないという事実です。Runner.run() の1回の呼び出しが終わると、モデル側には何も残りません。「会話が続いている」ように見せるには、アプリ側が履歴(input_items)を保持し、毎回まるごと渡し直す必要があります。この example はその履歴管理を手動で行う実装例です(SDK には Session という自動履歴管理の仕組みもありますが、この example は仕組みの理解のためにあえて手動で行っています)。
続いてループ本体です。
while True:
user_input = input_with_fallback(
"Enter your message: ",
"What are your store hours?",
)
with trace("Customer service", group_id=conversation_id):
input_items.append({"content": user_input, "role": "user"})
result = await Runner.run(current_agent, input_items, context=context)
input_with_fallback(...)は通常実行ではinput("Enter your message: ")と同じで、ターミナルでユーザーのキーボード入力を1行待ち受けます。自動モード(EXAMPLES_INTERACTIVE_MODE=auto)のときだけ、固定文 "What are your store hours?" を代わりに返します。with trace("Customer service", group_id=conversation_id):で、この1ターンの処理全体を1つのトレース(実行記録のまとまり)として包んでいます。group_idに会話IDを渡しているため、複数ターンにわたるトレースが「同じ会話」としてグループ化され、OpenAI ダッシュボードのトレース画面で会話単位に追跡できます。コメントにあるとおり、実際のWebアプリならユーザーからの1リクエストごとにこのtrace()で包むイメージです。input_items.append({"content": user_input, "role": "user"})で、ユーザーの発言を履歴の末尾に追加します。role: "user"はチャット履歴における発言者の区別(ユーザー発言)を表します。Runner.run(current_agent, input_items, context=context)がエージェント実行です。第2章では入力に文字列を渡しましたが、履歴リストをそのまま渡すこともできます。開始エージェントは固定のtriage_agentではなくcurrent_agentである点に注意してください(理由はすぐ後で分かります)。
実行結果の表示部分です。
for new_item in result.new_items:
agent_name = new_item.agent.name
if isinstance(new_item, MessageOutputItem):
print(f"{agent_name}: {ItemHelpers.text_message_output(new_item)}")
elif isinstance(new_item, HandoffOutputItem):
print(
f"Handed off from {new_item.source_agent.name} to {new_item.target_agent.name}"
)
elif isinstance(new_item, ToolCallItem):
print(f"{agent_name}: Calling a tool")
elif isinstance(new_item, ToolCallOutputItem):
print(f"{agent_name}: Tool call output: {new_item.output}")
else:
print(f"{agent_name}: Skipping item: {new_item.__class__.__name__}")
result.new_items には、今回の実行中に新しく生成された「出来事」が時系列で入っています。各アイテムは .agent 属性で「どのエージェントの出来事か」を持っており、種類ごとに表示を変えています。
MessageOutputItem: LLM が生成したメッセージ。ItemHelpers.text_message_output()でテキスト部分だけを取り出して「エージェント名: 発言」形式で表示します。HandoffOutputItem: handoff の完了。source_agent(転送元)とtarget_agent(転送先)の属性を持つので、「誰から誰へ転送されたか」を表示できます。ToolCallItem/ToolCallOutputItem: ツールの呼び出しとその結果。ToolCallOutputItem.outputにツールの戻り値が入っています。- どれにも該当しないアイテム(handoff のツール呼び出し部分を表す
HandoffCallItemなど)は、クラス名だけ表示してスキップします。
4種類のクラスをカードで整理すると次のようになります。
そしてループの締めくくり、次のターンへの状態の引き継ぎです。
input_items = result.to_input_list()
current_agent = result.last_agent
if auto_mode:
break
この2行がこのアプリの対話継続の要です。
result.to_input_list()は、「今回渡した入力 + 今回新しく生成された全アイテム」を次回の入力として使える形式に変換して返します。これをinput_itemsに代入することで、エージェントの応答・ツール呼び出し・handoff の記録まで含めた完全な履歴が次のターンに引き継がれます。result.last_agentは、実行の最後に会話を担当していたエージェントです。たとえば今回のターン中に Triage Agent → Seat Booking Agent への handoff が起きたなら、last_agentは Seat Booking Agent になります。これをcurrent_agentに代入しておくことで、次のユーザー発言は Seat Booking Agent 宛てに直接届きます。毎回窓口からやり直しにならず、「担当者と話が続く」体験が実現されるわけです。公式ドキュメント(results)も、この2つを組み合わせるパターンをユーザーが次に何か入力する対話アプリの基本形として紹介しています。
最後の if auto_mode: break は自動実行モード用で、通常の対話実行では while True が回り続けます(終了方法は後述のつまずきポイント参照)。
if __name__ == "__main__":
asyncio.run(main())
エントリーポイントは他の章と同じく、asyncio.run() で非同期関数 main() を起動します。
1ターンの流れをシーケンス図で確認する
「座席を変えたい」と入力したターンで内部的に何が起きるかを、時系列で図にします。
実行手順
- リポジトリのルートディレクトリ(
openai-agents-python)にいることを確認します。第1章の手順どおりgit cloneとuv syncが済んでいれば追加の依存関係はありません。 OpenAI の API キーを環境変数に設定します(設定済みなら不要です)。
export OPENAI_API_KEY="sk-..."モジュールとして実行します。
examples.auto_modeを import しているため、python examples/customer_service/main.pyのようなファイルパス指定ではなく、必ずリポジトリルートから-m付きで起動してください。uv run python -m examples.customer_service.mainEnter your message:と表示されたら、メッセージを英語で入力して Enter を押します(例:I want to change my seatやCan I bring a carry-on bag?)。エージェントの instructions と FAQ ツールのキーワード判定が英語ベースのため、英語での入力が最も安定して動作します。- 会話を終えるときは
Ctrl+Cで停止します。 (任意)対話なしで1ターンだけ自動実行したい場合は、環境変数を付けて起動します。
EXAMPLES_INTERACTIVE_MODE=auto uv run python -m examples.customer_service.main
期待される動作・出力例
座席変更を依頼した場合の典型的なセッションは次のようになります(LLM の応答文はモデルの出力なので実行のたびに変わります。便名の数字は乱数です)。
Enter your message: I want to change my seat
Handed off from Triage Agent to Seat Booking Agent
Seat Booking Agent: I can help with that! Could you please provide your confirmation number?
Enter your message: My confirmation number is LL0EZ6
Seat Booking Agent: Thank you! What seat number would you like to change to?
Enter your message: Seat 12C please
Seat Booking Agent: Calling a tool
Seat Booking Agent: Tool call output: Updated seat to 12C for confirmation number LL0EZ6
Seat Booking Agent: Your seat has been updated to 12C. Is there anything else I can help you with?
Enter your message: How many seats are on the plane?
Handed off from Seat Booking Agent to Triage Agent
Handed off from Triage Agent to FAQ Agent
FAQ Agent: Calling a tool
FAQ Agent: Tool call output: There are 120 seats on the plane. There are 22 business class seats and 98 economy seats. Exit rows are rows 4 and 16. Rows 5-8 are Economy Plus, with extra legroom.
FAQ Agent: The plane has 120 seats total: 22 business class and 98 economy. Exit rows are rows 4 and 16, and rows 5-8 are Economy Plus with extra legroom.
注目してほしい挙動が2つあります。
- 2ターン目・3ターン目の入力が最初から Seat Booking Agent に届いていること。
current_agent = result.last_agentの効果です。 - 座席変更の途中で FAQ 的な質問をすると、Seat Booking Agent → Triage Agent → FAQ Agent と2段階の handoff が起きること。専門エージェントの instructions に「担当外はトリアージへ戻せ」とある効果です(モデルの判断によっては転送経路が変わることもあります)。
また、実行後に OpenAI ダッシュボードの Traces 画面(https://platform.openai.com/traces )を開くと、「Customer service」という名前のトレースがターンごとに記録され、同じ group_id でグループ化されていることが確認できます。どのエージェントがどのツールを呼んだかが可視化されるので、ぜひ一度見てみてください。
つまずきポイント
ModuleNotFoundError: No module named 'examples'が出る: ファイルパス指定(uv run python examples/customer_service/main.py)で実行すると発生します。この example はexamples.auto_modeを import しているため、必ずリポジトリルートからuv run python -m examples.customer_service.mainと実行してください。- 終了コマンドがない: このループには「quit で終了」のような仕組みがなく、
while Trueが回り続けます。Ctrl+C(KeyboardInterrupt)で停止してください。入力待ちでCtrl+Dを押すと EOFError の Traceback が出て終了しますが、これも異常ではありません。 AssertionError: Flight number is requiredが出る:update_seatは「handoff 時にon_seat_booking_handoffが便名を設定済み」であることを前提にしています。通常の会話フローでは起きませんが、コードを改造して Seat Booking Agent を直接Runner.run()の開始エージェントにした場合など、handoff を経由しないと assert に引っかかります。「context の値はどこで誰が設定するのか」という依存関係を意識する練習材料と捉えてください。- FAQ の回答が「I'm sorry, I don't know...」ばかりになる:
faq_lookup_toolは英語キーワードの単純一致です。日本語で質問すると、エージェントがツールに渡す検索文字列次第でヒットしないことがあります。荷物(bag)・座席(seat)・Wi-Fi(wifi)に関する質問を英語で試すのが確実です。 - 履歴を引き継ぎ忘れる改造ミス: ループ末尾の
input_items = result.to_input_list()を消す(またはappendだけにする)と、次のターンでエージェントが直前のやりとりを忘れます。会話の継続は「アプリ側が履歴を渡し直しているから」成立している、という点はこの章の核心なので、あえて消して挙動の違いを観察してみるのも良い実験です。
まとめ
examples/customer_serviceは、Triage(窓口)+ 専門エージェント2体という「窓口→担当部署」型のマルチエージェント構成を1ファイルで実装した実践 example です。- handoff は双方向に張るのが対話アプリの定石です。専門エージェントの instructions に「担当外はトリアージへ戻す」という脱出条件を明記し、
RECOMMENDED_PROMPT_PREFIXで handoff の振る舞いをモデルに教えます。 - context(
AirlineAgentContext)は LLM に送られないアプリ内の共有メモです。on_handoffコールバックで値を仕込み、function tool(update_seat)がRunContextWrapper経由で読み書きする、という連携ができます。 - 対話ループの基本形は「
input()で受け取る → 履歴にappend→Runner.run()→result.new_itemsを表示 →input_items = result.to_input_list()とcurrent_agent = result.last_agentで次のターンへ引き継ぐ」です。 result.new_itemsの各アイテムはMessageOutputItem/HandoffOutputItem/ToolCallItem/ToolCallOutputItemなどのクラスで種類分けでき、isinstance()で処理を振り分けられます。trace(..., group_id=会話ID)でターンごとのトレースを会話単位にグループ化でき、ダッシュボードでマルチエージェントの動きを追跡できます。
理解度チェック
このアプリで、ユーザーが座席変更の会話の途中で「機内Wi-Fiはありますか?」と質問した場合、会話の主導権はどのように移動しますか。エージェント名を順に挙げてください。
update_seat ツールの第1引数 context: RunContextWrapper[AirlineAgentContext] について、正しい説明はどれですか。
- (a) LLM はこの引数の値を生成してツールに渡す
- (b) この引数は LLM からは見えず、SDK が自動的に注入する
- (c) この引数の内容は毎回 LLM への入力に含めて送信される
メインループ末尾の current_agent = result.last_agent を current_agent = triage_agent に書き換えると、ユーザー体験はどう変わりますか。
context.context.flight_number に値を設定しているのはどの関数で、それはどのタイミングで実行されますか。
input_items = result.to_input_list() の役割を、「LLM には記憶がない」という性質と関連付けて説明してください。
解答と解説
問1 の解答と解説
Seat Booking Agent → Triage Agent → FAQ Agent の順に移動します。Seat Booking Agent の instructions に「ルーチン(座席変更手順)と関係ない質問はトリアージへ転送せよ」とあるため、まず transfer_to_triage_agent で Triage Agent に戻り、Triage Agent が質問内容から transfer_to_faq_agent で FAQ Agent に振り分けます。専門エージェント同士が直接 handoff し合う経路は定義されていない点がポイントです(ただし転送はモデルの判断なので、実行によって経路が揺れる可能性はあります)。
問2 の解答と解説
(b) です。SDK は function tool のシグネチャから RunContextWrapper 型の引数を検出すると、それを LLM に見せるパラメータ一覧から除外し、実行時に自動注入します。LLM が生成するのは confirmation_number と new_seat だけです。また context の内容は LLM への入力には含まれません(公式ドキュメント context.md の「context オブジェクトは LLM に送信されない」参照)ので、(a)(c) は誤りです。
問3 の解答と解説
handoff で専門エージェントに繋がっても、次のユーザー発言のたびに必ず Triage Agent(総合窓口)からやり直しになります。たとえば Seat Booking Agent が「予約番号を教えてください」と聞いた直後の返答も、まず Triage Agent が受けてから再度 handoff の判断をすることになり、会話が回りくどくなったり、振り分けを誤るリスクが増えたりします。last_agent の引き継ぎは「担当者との会話を継続する」ための仕組みです。
問4 の解答と解説
on_seat_booking_handoff 関数で、Triage Agent が transfer_to_seat_booking_agent(Seat Booking Agent への handoff ツール)を呼び出した瞬間に実行されます。handoff() 関数の on_handoff パラメータに登録されており、FLT- + 3桁乱数の便名を context に書き込みます。後続の update_seat ツールは assert 文でこの値が設定済みであることを前提検証しています。
問5 の解答と解説
LLM は1回の呼び出しごとに完結しており、前のターンの内容を自分では覚えていません。そのため会話を継続するには、アプリ側が「これまでの全やりとり」を毎回入力として渡し直す必要があります。result.to_input_list() は「今回の実行に渡した入力 + 今回新しく生成されたアイテム(エージェントの発言、ツール呼び出し、handoff の記録など)」を次回入力用のリストに変換して返すメソッドで、これを input_items に代入して次の Runner.run() に渡すことで、履歴の欠けない会話の継続が実現されます。
参考リンク
- Examples 一覧(customer_service の紹介): https://openai.github.io/openai-agents-python/examples/ / 日本語版: https://openai.github.io/openai-agents-python/ja/examples/
- Handoffs(handoff の作成・
on_handoff・推奨プロンプト): https://openai.github.io/openai-agents-python/handoffs/ / 日本語版: https://openai.github.io/openai-agents-python/ja/handoffs/ - Context management(
RunContextWrapperと context の性質): https://openai.github.io/openai-agents-python/context/ / 日本語版: https://openai.github.io/openai-agents-python/ja/context/ - Orchestrating multiple agents(マルチエージェントの設計指針): https://openai.github.io/openai-agents-python/multi_agent/ / 日本語版: https://openai.github.io/openai-agents-python/ja/multi_agent/
- Results(
new_items/last_agent/to_input_list()): https://openai.github.io/openai-agents-python/results/ / 日本語版: https://openai.github.io/openai-agents-python/ja/results/ - Tracing(
trace()とgroup_id): https://openai.github.io/openai-agents-python/tracing/ / 日本語版: https://openai.github.io/openai-agents-python/ja/tracing/ - ソースコード(GitHub): https://github.com/openai/openai-agents-python/tree/main/examples/customer_service
第12章 実践アプリ②: マルチエージェント・リサーチボット(research_bot)
この章では、examples/research_bot に収録されている「リサーチボット」を題材に、複数のエージェントをコードで連携させる(オーケストレーションする)実践的なアプリケーションの作り方を学びます。ユーザーが調べたいテーマを入力すると、検索計画の立案 → Web 検索(並列実行)→ レポート執筆という 3 段階のパイプラインが動き、数千語規模の詳細なレポートが自動生成されます。OpenAI が提供する「Deep Research」機能の簡易版を自分の手で動かせる、本書の中でも特に達成感のある章です。
この章で学ぶこと
- planner → search → writer という 3 種類のエージェントによる役割分担の設計方法
- LLM に流れを任せず、Python コードでエージェントの実行順序を制御する「コードによるオーケストレーション」パターン
asyncioを使って 複数のエージェントを並列実行し、処理時間を大幅に短縮するテクニックoutput_type(構造化出力)を使って、エージェント間でデータを型安全に受け渡す方法WebSearchTool(OpenAI ホスト型の Web 検索ツール)の使い方trace/custom_spanによるトレーシングと、richライブラリによる進捗表示の実装
前提知識
この章を読む前に、以下を理解しておくとスムーズです。
- 第1章: リポジトリのクローン、
uv syncによる環境構築、OPENAI_API_KEYの設定(この章の実行手順はすべて第1章のセットアップが済んでいる前提です) - Agent と Runner の基本(基礎編):
Agent(name=..., instructions=...)でエージェントを定義し、Runner.run()で実行する流れ - 構造化出力(基礎編):
output_typeに Pydantic モデルを指定すると、エージェントの最終出力が Python オブジェクトとして返ってくること - ストリーミング(基礎編):
Runner.run_streamed()とstream_events()でイベントを逐次受け取れること - 第11章: 実践アプリ①(カスタマーサービス)。handoff 中心の構成と本章のコード主導の構成を対比すると理解が深まります
概念解説
リサーチボットは「編集部」である
この章のアプリを日常の比喩で表すと、雑誌の編集部がぴったりです。
- 編集長(planner_agent): 「電気自動車が電力網に与える影響」という特集テーマを受け取ると、「何をどんな観点で調べるべきか」を考え、調査項目のリストを作ります。ただし編集長自身は調査しません。
- リサーチャーたち(search_agent): 調査項目を 1 つずつ受け取り、全員が同時に図書館やインターネットへ散らばって調べ物をし、それぞれ 300 語以内の要約メモを持ち帰ります。
- ライター(writer_agent): 集まった要約メモと元のテーマを机に広げ、構成を考えてから 1 本の長いレポートに書き上げます。
重要なのは、「次に誰が動くか」を決めているのが AI ではなく編集部の業務フロー(= Python コード) だという点です。第11章のカスタマーサービスでは、LLM 自身が handoff(エージェント間の会話の引き継ぎ)で「次は誰に任せるか」を判断していました。一方この章では、ResearchManager というクラスが「計画 → 検索 → 執筆」の順序をコードで固定しています。
コードによるオーケストレーション
オーケストレーション(orchestration)とは、複数のエージェントを「どの順序で、どう連携させて動かすか」という制御の仕組みのことです。公式ドキュメント(docs/multi_agent.md)では、大きく 2 つの方式が紹介されています。
| 方式 | 仕組み | 向いているケース |
|---|---|---|
| LLM によるオーケストレーション | handoff や「エージェントをツール化」する機能で、LLM 自身が次の行動を判断する | タスクが自由度の高い探索的なものである場合 |
| コードによるオーケストレーション | Runner.run() の呼び出しを Python コードでつなぎ、順序や並列度をコードで決める | 処理の流れが決まっていて、速度・コスト・結果の予測可能性を重視する場合 |
この 2 方式の違いを図で整理すると、次のようになります。
リサーチボットは後者の代表例です。公式ドキュメントは、コード方式の代表的なパターンとして次の 4 つを挙げています。
- 構造化出力(structured outputs)でエージェントの出力を検査可能なデータとして受け取り、次の処理をコードで選ぶ
- あるエージェントの出力を次のエージェントの入力に変換して連鎖(チェーン)させる
- 評価役のエージェントと組み合わせて
whileループで改善を繰り返す - 互いに依存しないタスクを
asyncio.gatherのような Python の並列実行機能で同時に走らせる
リサーチボットはこのうち「構造化出力」「連鎖」「並列実行」の 3 つを一度に体験できる教材になっています。
非同期処理と並列実行
非同期処理(asynchronous processing)とは、時間のかかる処理(ここでは LLM への API 呼び出し)の完了を待っている間に、他の処理を進められるプログラミング手法です。Python では asyncio モジュールと async / await 構文で実現します。
planner が作る検索計画には 5〜20 個の検索項目が含まれます。仮に 1 検索あたり 10 秒かかるとして、15 個を 1 つずつ順番に実行すると約 150 秒かかりますが、15 個を同時に開始すれば全体は最も遅い 1 件分(約 10 秒強)で済みます。検索同士はお互いの結果に依存しないため、並列化に最適な処理なのです。
全体の構成図
各 example の解説
examples/research_bot は、これまでの章のような「1 ファイル 1 example」の集まりではなく、複数ファイルで構成された 1 つのアプリケーションです。ディレクトリ構成は次のとおりです。
examples/research_bot/
├── README.md # 構成説明と実行コマンド
├── main.py # エントリポイント(起動用ファイル)
├── manager.py # オーケストレーションの本体
├── printer.py # ターミナルへの進捗表示
├── agents/
│ ├── planner_agent.py # ① 検索計画を立てるエージェント
│ ├── search_agent.py # ② Web 検索して要約するエージェント
│ └── writer_agent.py # ③ レポートを書くエージェント
└── sample_outputs/ # 実行結果のサンプル
├── product_recs.txt / .md # サーフボード選びのリサーチ例
└── vacation.txt / .md # カリブ海旅行のリサーチ例
以降、データが流れる順(agents → manager → main → printer → sample_outputs)に読み解いていきます。
実行手順(アプリ全体)
このアプリはパッケージ型の example なので、リポジトリのルートディレクトリからモジュール指定(-m)で起動します。以降の各ファイルの節では実行手順を繰り返さないため、実際に動かす際はこの手順を参照してください。
環境変数
OPENAI_API_KEYを設定します(第1章参照)。export OPENAI_API_KEY="sk-..."リポジトリのルートディレクトリで次のコマンドを実行します(カテゴリ README 記載のコマンドです。追加の extra 依存は不要で、進捗表示に使う
richライブラリはuv syncで一緒にインストールされています)。uv run python -m examples.research_bot.mainWhat would you like to research?と聞かれるので、調べたいテーマを入力して Enter を押します(英語・日本語どちらでも動作しますが、収録サンプルは英語です)。- 進捗表示(計画 → 検索の完了数 → 執筆)がリアルタイムに更新され、数分程度でレポート全文とフォローアップ質問がターミナルに出力されます。
注意(コストとモデル): このアプリは 1 回の実行で「計画 1 回 + Web 検索 5〜20 回 + 長文レポート生成 1 回」の API 呼び出しを行います。
WebSearchToolは OpenAI がサーバー側で実行するホスト型ツールで、モデル利用料とは別にツール利用料が発生します。また、コード中でgpt-5.6-solとgpt-5-miniが指定されているため、お使いの API キーでこれらのモデルが利用可能である必要があります。
agents/planner_agent.py — 検索計画を立てるエージェント
ユーザーのリサーチテーマを受け取り、「どんな検索語で、なぜ検索するのか」のリスト(検索計画)を構造化データとして返すエージェントです。31 行の短いファイルなので全文を引用します。
from openai.types.shared.reasoning import Reasoning
from pydantic import BaseModel
from agents import Agent, ModelSettings
PROMPT = (
"You are a helpful research assistant. Given a query, come up with a set of web searches "
"to perform to best answer the query. Output between 5 and 20 terms to query for."
)
class WebSearchItem(BaseModel):
reason: str
"Your reasoning for why this search is important to the query."
query: str
"The search term to use for the web search."
class WebSearchPlan(BaseModel):
searches: list[WebSearchItem]
"""A list of web searches to perform to best answer the query."""
planner_agent = Agent(
name="PlannerAgent",
instructions=PROMPT,
model="gpt-5.6-sol",
model_settings=ModelSettings(reasoning=Reasoning(effort="medium")),
output_type=WebSearchPlan,
)
コード解説
WebSearchItemは「検索 1 件」を表す Pydantic モデルです。query(検索語)だけでなくreason(なぜこの検索が必要かの理由)も持たせている点がポイントです。理由を言語化させることで、モデルはより的を絞った検索語を出しやすくなり、後段のデバッグでも「なぜこの検索をしたのか」を追跡できます。WebSearchPlanはそのWebSearchItemのリストを包むモデルです。output_type=WebSearchPlanと指定することで、エージェントの最終出力(final_output)は自由な文章ではなく、この型に沿った Python オブジェクトとして返ってきます。これが「構造化出力」で、後段のコードがplan.searchesのように安全にアクセスできる土台になります。instructions(システムプロンプトに相当する指示文)では「5〜20 個の検索語を出すこと」と数の範囲を明示しています。後述のサンプル出力ではいずれも 15 個の検索が計画されており、この指示が効いていることが分かります。model_settings=ModelSettings(reasoning=Reasoning(effort="medium"))は、推論モデル(回答の前に内部で思考ステップを踏むタイプのモデル)に対して「どれくらい念入りに考えるか」を指定する設定です。Reasoningは OpenAI 公式 Python ライブラリの共有型(openai.types.shared.reasoning)からインポートしています。計画立案の品質が全体を左右するため、中程度の思考量(effort="medium")を割り当てています。
実行手順: このファイルは単体では実行せず、アプリ全体の一部として動きます(前述「実行手順(アプリ全体)」参照)。
期待される動作: 「電気自動車と電力網」のようなテーマに対し、「EV 充電需要の統計」「スマートグリッドとの連携事例」など 5〜20 件の WebSearchItem を含む WebSearchPlan が生成されます。
agents/search_agent.py — Web 検索して要約するエージェント
検索語 1 つを受け取り、WebSearchTool で Web を検索して 300 語未満の要約を返すエージェントです。こちらも全文を引用します。
from agents import Agent, WebSearchTool
INSTRUCTIONS = (
"You are a research assistant. Given a search term, you search the web for that term and "
"produce a concise summary of the results. The summary must be 2-3 paragraphs and less than 300 "
"words. Capture the main points. Write succinctly, no need to have complete sentences or good "
"grammar. This will be consumed by someone synthesizing a report, so its vital you capture the "
"essence and ignore any fluff. Do not include any additional commentary other than the summary "
"itself."
)
search_agent = Agent(
name="Search agent",
model="gpt-5.6-sol",
instructions=INSTRUCTIONS,
tools=[WebSearchTool()],
)
コード解説
WebSearchToolは、OpenAI が Responses API 上で提供するホスト型ツール(hosted tool)です。ホスト型ツールとは、自分のマシンではなく OpenAI のサーバー側で実行されるツールのことで、関数を自作しなくてもtools=[WebSearchTool()]と渡すだけでエージェントが Web 検索できるようになります。SDK のソースコード(src/agents/tool.py)を見ると、user_location(検索結果を特定地域向けに調整)やsearch_context_size(検索に使うコンテキスト量。"low"/"medium"/"high"、既定値は"medium")などのオプションも指定できます。instructionsは「読み手は人間ではなくレポート統合役のエージェントである」ことを明確に伝えています。「完全な文でなくてよい、文法が崩れてもよい、余計な前置きは書くな」という指示は、エージェント間でやり取りする中間データは人間向けの体裁より情報密度を優先すべき、という実践的なプロンプト設計の好例です。これによりトークン(LLM が扱う文字列の最小単位。API 課金の単位でもあります)の消費も抑えられます。- このエージェントには
output_typeがありません。つまり最終出力は通常のテキスト(要約文)です。構造化が必要なのは「計画」と「最終レポート」であって、中間の要約メモはただの文字列で十分、という割り切りです。
実行手順: 単体実行はしません(「実行手順(アプリ全体)」参照)。
期待される動作: 「Search term: ...\nReason for searching: ...」という形式の入力に対し、2〜3 段落・300 語未満の英語の要約テキストが返ります。
つまずきポイント: WebSearchTool は OpenAI モデル + Responses API 専用です。他社モデルや Chat Completions API 経由では利用できません。また、Web 検索はモデル利用料と別枠で課金されます。
agents/writer_agent.py — レポートを書くエージェント
元のテーマと検索要約の束を受け取り、Markdown 形式の長文レポートを構造化データとして返すエージェントです。全文を引用します。
# Agent used to synthesize a final report from the individual summaries.
from openai.types.shared.reasoning import Reasoning
from pydantic import BaseModel
from agents import Agent, ModelSettings
PROMPT = (
"You are a senior researcher tasked with writing a cohesive report for a research query. "
"You will be provided with the original query, and some initial research done by a research "
"assistant.\n"
"You should first come up with an outline for the report that describes the structure and "
"flow of the report. Then, generate the report and return that as your final output.\n"
"The final output should be in markdown format, and it should be lengthy and detailed. Aim "
"for 5-10 pages of content, at least 1000 words."
)
class ReportData(BaseModel):
short_summary: str
"""A short 2-3 sentence summary of the findings."""
markdown_report: str
"""The final report"""
follow_up_questions: list[str]
"""Suggested topics to research further"""
writer_agent = Agent(
name="WriterAgent",
instructions=PROMPT,
model="gpt-5-mini",
model_settings=ModelSettings(reasoning=Reasoning(effort="medium")),
output_type=ReportData,
)
コード解説
- 出力型
ReportDataは 3 つのフィールドを持ちます。short_summary(2〜3 文の要約)、markdown_report(レポート本文)、follow_up_questions(さらに調べるべきトピックの提案)です。1 回の生成で「本文」「要約」「次のアクション」を同時に、しかもそれぞれ独立したフィールドとして受け取れるのが構造化出力の強みです。もし出力がただのテキストだったら、要約部分だけを切り出すのに正規表現などの後処理が必要になってしまいます。 instructionsでは「まずアウトライン(構成案)を作り、それからレポートを生成せよ」と手順を指示しています。長文生成では、いきなり書き始めるより構成を先に考えさせたほうが一貫性の高い文章になります。- モデルは planner や search の
gpt-5.6-solと異なりgpt-5-miniを指定しています。このように工程ごとに異なるモデルを割り当てられるのもマルチエージェント構成の利点で、品質とコスト・速度のバランスを工程単位で調整できます。
実行手順: 単体実行はしません(「実行手順(アプリ全体)」参照)。
期待される動作: 1000 語以上(5〜10 ページ相当)の Markdown レポートを含む ReportData オブジェクトが返ります。
manager.py — オーケストレーションの本体(この章の核心)
ResearchManager クラスが、3 つのエージェントを「計画 → 並列検索 → 執筆」の順につなぐオーケストレーターです。この章で最も重要なファイルなので、メソッド単位で丁寧に読み解きます。
run(): パイプライン全体の流れ
class ResearchManager:
def __init__(self):
self.console = Console()
self.printer = Printer(self.console)
async def run(self, query: str) -> None:
trace_id = gen_trace_id()
with trace("Research trace", trace_id=trace_id):
self.printer.update_item(
"trace_id",
f"View trace: https://platform.openai.com/logs/trace?trace_id={trace_id}",
is_done=True,
hide_checkmark=True,
)
self.printer.update_item(
"starting",
"Starting research...",
is_done=True,
hide_checkmark=True,
)
search_plan = await self._plan_searches(query)
search_results = await self._perform_searches(search_plan)
report = await self._write_report(query, search_results)
final_report = f"Report summary\n\n{report.short_summary}"
self.printer.update_item("final_report", final_report, is_done=True)
self.printer.end()
print("\n\n=====REPORT=====\n\n")
print(f"Report: {report.markdown_report}")
print("\n\n=====FOLLOW UP QUESTIONS=====\n\n")
follow_up_questions = "\n".join(report.follow_up_questions)
print(f"Follow up questions: {follow_up_questions}")
コード解説
- パイプラインの本体は中程の 3 行です。
_plan_searchesの戻り値(WebSearchPlan)が_perform_searchesの入力になり、その戻り値(要約のリスト)が_write_reportの入力になる——まさに「あるエージェントの出力を次のエージェントの入力に変換して連鎖させる」パターンをそのまま実装しています。順序は LLM ではなくこのコードが決めているため、実行のたびに流れが変わることはありません。 gen_trace_id()とwith trace("Research trace", trace_id=...)はトレーシング(エージェントの実行履歴を記録・可視化する仕組み)の設定です。通常Runner.run()を呼ぶたびに個別のトレースが作られますが、with trace(...)ブロックで囲むと、ブロック内のすべてのエージェント実行(計画 1 回 + 検索 15 回 + 執筆 1 回など)が 1 つのトレースにまとまり、OpenAI ダッシュボードの Traces 画面でワークフロー全体を 1 画面で追えるようになります。トレース ID を先に生成しているのは、実行開始直後に閲覧用 URL(https://platform.openai.com/logs/trace?trace_id=...)をユーザーに表示するためです。report.markdown_reportやreport.follow_up_questionsに属性アクセスできているのは、writer_agent の出力がReportData型に構造化されているおかげです。
_plan_searches(): planner_agent の実行
async def _plan_searches(self, query: str) -> WebSearchPlan:
self.printer.update_item("planning", "Planning searches...")
result = await Runner.run(
planner_agent,
f"Query: {query}",
)
self.printer.update_item(
"planning",
f"Will perform {len(result.final_output.searches)} searches",
is_done=True,
)
return result.final_output_as(WebSearchPlan)
コード解説
Runner.run(planner_agent, f"Query: {query}")で planner を 1 回実行します。awaitが付いているのは、API 呼び出しの完了を非同期に待つためです。result.final_output_as(WebSearchPlan)は、final_outputを指定した型として返す補助メソッドです。SDK のソース(src/agents/result.py)によれば、既定では型チェッカー向けのキャスト(型の読み替え)であり、実行時に型変換をやり直すわけではありません(raise_if_incorrect_type=Trueを渡した場合のみ、型が合わないとTypeErrorを送出します)。output_type=WebSearchPlanを指定したエージェントの出力なので、実体はすでにWebSearchPlanです。
_perform_searches(): 並列検索(このファイルの見せ場)
async def _perform_searches(self, search_plan: WebSearchPlan) -> list[str]:
with custom_span("Search the web"):
self.printer.update_item("searching", "Searching...")
num_completed = 0
num_succeeded = 0
num_failed = 0
tasks = [asyncio.create_task(self._search(item)) for item in search_plan.searches]
results = []
for task in asyncio.as_completed(tasks):
result = await task
if result is not None:
results.append(result)
num_succeeded += 1
else:
num_failed += 1
num_completed += 1
status = f"Searching... {num_completed}/{len(tasks)} finished"
if num_failed:
status += f" ({num_succeeded} succeeded, {num_failed} failed)"
self.printer.update_item(
"searching",
status,
)
summary = f"Searches finished: {num_succeeded}/{len(tasks)} succeeded"
if num_failed:
summary += f", {num_failed} failed"
self.printer.update_item("searching", summary, is_done=True)
return results
コード解説
- 並列化の核心は
tasks = [asyncio.create_task(self._search(item)) for item in search_plan.searches]の 1 行です。asyncio.create_task()はコルーチン(async defで定義された関数の呼び出し)を「タスク」として登録し、その瞬間からバックグラウンドで実行を開始します。リスト内包表記で計画中の全検索項目分のタスクを一気に作るため、15 件の検索であれば 15 個の API 呼び出しがほぼ同時に走り始めます。 asyncio.as_completed(tasks)は、登録したタスクを完了した順に取り出すイテレータです。ループ 1 周ごとに「どれか 1 件の検索が終わるまで待つ → 結果を回収 → 進捗表示を1/15 finished、2/15 finished... と更新」を繰り返します。公式ドキュメント(docs/multi_agent.md)では並列実行の代表例としてasyncio.gatherが挙げられていますが、gatherが「全部終わってから結果をまとめて受け取る」のに対し、as_completedは「終わったものから順に処理できる」ため、このように進捗をリアルタイム表示したい場面に向いています。並列に実行されるという点はどちらも同じです。with custom_span("Search the web"):は、トレースの中に「Search the web」という名前のカスタムスパン(トレース内の区間記録)を作ります。ダッシュボード上で「検索フェーズ全体にかかった時間」や「その配下で走った 15 個のエージェント実行」を階層的に確認できるようになります。- 成功件数・失敗件数を数え、失敗があれば進捗表示に
(12 succeeded, 3 failed)のように反映します。失敗の扱いは次の_search()を見ると分かります。
この並列実行の様子をタイムラインで示すと、次のようになります。
_search(): 検索 1 件の実行とエラー処理
async def _search(self, item: WebSearchItem) -> str | None:
input = f"Search term: {item.query}\nReason for searching: {item.reason}"
try:
result = await Runner.run(
search_agent,
input,
)
return str(result.final_output)
except Exception:
return None
コード解説
- planner が構造化出力で返した
WebSearchItemのqueryとreasonを、search_agent 向けのプロンプト文字列に組み立て直しています。構造化データ → テキストへの「変換」もオーケストレーションコードの仕事です。 try / except Exceptionで例外をすべて握りつぶし、失敗時はNoneを返します。これは意図的な設計で、15 件中 1 件の検索が失敗してもリサーチ全体は止めず、残り 14 件の要約でレポートを書くという「一部の失敗を許容する」方針です。呼び出し側の_perform_searchesはNoneを結果リストから除外し、失敗件数としてカウントするだけです。
ここまでのデータの型の変化を整理すると、次のようになります。
_write_report(): ストリーミング実行と進捗演出
async def _write_report(self, query: str, search_results: list[str]) -> ReportData:
self.printer.update_item("writing", "Thinking about report...")
input = f"Original query: {query}\nSummarized search results: {search_results}"
result = Runner.run_streamed(
writer_agent,
input,
)
update_messages = [
"Thinking about report...",
"Planning report structure...",
"Writing outline...",
"Creating sections...",
"Cleaning up formatting...",
"Finalizing report...",
"Finishing report...",
]
last_update = time.time()
next_message = 0
async for _ in result.stream_events():
if time.time() - last_update > 5 and next_message < len(update_messages):
self.printer.update_item("writing", update_messages[next_message])
next_message += 1
last_update = time.time()
self.printer.mark_item_done("writing")
return result.final_output_as(ReportData)
コード解説
- 執筆だけは
Runner.run()ではなくRunner.run_streamed()を使っています。これはストリーミング(生成の途中経過をイベントとして逐次受け取る仕組み)用の実行メソッドで、awaitせずにRunResultStreamingオブジェクトを即座に返します。実際の生成はstream_events()(非同期イテレータ)を回している間に進みます。 - 面白いのは、イベントの中身をまったく使っていないことです(
async for _ in ...と変数名が_)。ここでのストリーミングの目的は本文を逐次表示することではなく、「生成がまだ進行中である」ことを検知して、5 秒おきに"Planning report structure..."→"Writing outline..."と進捗メッセージを演出的に切り替えることだけです。長文レポートの生成は数十秒〜数分かかるため、無言で待たせない工夫として参考になります。 - ループを抜けた時点で生成は完了しており、
result.final_output_as(ReportData)で構造化された最終出力を取り出します。
実行手順・期待される動作: 「実行手順(アプリ全体)」を参照してください。
つまずきポイント
asyncio.create_task()は呼び出した時点でタスクが動き始めます。「as_completedに渡すまで実行されない」と誤解しないようにしましょう。- 検索失敗は静かに
Noneになります。全件失敗しても空のリストで writer に進んでしまうため、実運用では「成功が 0 件なら中断する」といったガード処理を足すとよいでしょう(README の「Suggested improvements」にも、評価ステップの追加が改善案として挙げられています)。
main.py — エントリポイント
ユーザーからリサーチテーマを受け取り、ResearchManager を起動するだけの小さなファイルです。全文を引用します。
import asyncio
from examples.auto_mode import input_with_fallback
from .manager import ResearchManager
async def main() -> None:
query = input_with_fallback(
"What would you like to research? ",
"Impact of electric vehicles on the grid.",
)
await ResearchManager().run(query)
if __name__ == "__main__":
asyncio.run(main())
コード解説
input_with_fallbackは examples 共通のユーティリティ(examples/auto_mode.py)で、通常はinput()と同じくキーボード入力を待ちます。環境変数EXAMPLES_INTERACTIVE_MODE=autoが設定されている場合のみ、入力を待たずに第 2 引数の既定値(ここでは「電気自動車が電力網に与える影響」)を自動採用します。CI などで無人実行するための仕組みです。asyncio.run(main())が非同期処理の入口です。ResearchManager.run()以下はすべてasync関数のため、必ずイベントループ(非同期タスクを実行する仕組み)の中で動かす必要があります。from .manager import ...という相対インポートとfrom examples.auto_mode import ...というパッケージインポートがあるため、このファイルは必ずリポジトリルートから-m付きで実行します。uv run python examples/research_bot/main.pyのようにファイルパス指定で実行するとインポートエラーになります。
実行手順
OPENAI_API_KEYを設定します。uv run python -m examples.research_bot.mainを実行します。- (任意)無人実行したい場合は
EXAMPLES_INTERACTIVE_MODE=auto uv run python -m examples.research_bot.mainとすると、既定テーマで自動実行されます。
期待される動作: テーマ入力後、トレース URL と進捗が表示され、最後にレポート全文が出力されます(次節のサンプル出力参照)。
printer.py — rich による進捗表示
エージェント SDK とは直接関係のない補助ファイルですが、CLI アプリの体験を大きく左右する部分です。ターミナル表示ライブラリ rich を使い、複数の進捗項目を 1 画面で「その場更新」します。全文を引用します。
from typing import Any
from rich.console import Console, Group
from rich.live import Live
from rich.spinner import Spinner
class Printer:
def __init__(self, console: Console):
self.live = Live(console=console)
self.items: dict[str, tuple[str, bool]] = {}
self.hide_done_ids: set[str] = set()
self.live.start()
def end(self) -> None:
self.live.stop()
def hide_done_checkmark(self, item_id: str) -> None:
self.hide_done_ids.add(item_id)
def update_item(
self, item_id: str, content: str, is_done: bool = False, hide_checkmark: bool = False
) -> None:
self.items[item_id] = (content, is_done)
if hide_checkmark:
self.hide_done_ids.add(item_id)
self.flush()
def mark_item_done(self, item_id: str) -> None:
self.items[item_id] = (self.items[item_id][0], True)
self.flush()
def flush(self) -> None:
renderables: list[Any] = []
for item_id, (content, is_done) in self.items.items():
if is_done:
prefix = "✅ " if item_id not in self.hide_done_ids else ""
renderables.append(prefix + content)
else:
renderables.append(Spinner("dots", text=content))
self.live.update(Group(*renderables))
コード解説
rich.live.Liveは「ターミナルの同じ領域を書き換え続ける」ための仕組みです。self.itemsにitem_id(例:"planning","searching","writing")をキーとして状態を持ち、更新のたびにflush()で全項目を再描画します。- 未完了の項目は
Spinner("dots", ...)でくるくる回るアニメーション付きに、完了した項目は ✅ 付きのテキストになります。hide_checkmark=Trueを指定した項目(トレース URL など、タスクではない情報表示)にはチェックマークを付けません。 - manager.py 側は「
item_idを決めてupdate_itemを呼ぶ」だけでよく、表示ロジックが 1 か所に分離されています。並列検索の進捗(Searching... 7/15 finished)が同じ行で数字だけ増えていくのは、この仕組みのおかげです。
この状態遷移を図示すると次のようになります。
実行手順: 単体実行はしません。rich は SDK リポジトリの開発用依存グループに含まれるため、uv sync 済みなら追加インストールは不要です。
sample_outputs/ — 実行結果のサンプル
実際に API を呼ばなくても完成イメージをつかめるように、2 つのリサーチ例の「ターミナル出力(.txt)」と「最終レポート(.md)」が収録されています。
product_recs.txt/product_recs.md: 「初心者向けサーフボードのおすすめ(11 フィートのボードからの乗り換え、予算帯別)」というテーマの例vacation.txt/vacation.md: 「4 月のカリブ海で、サーフィン・ハイキング・ウォータースポーツに最適な旅行先」というテーマの例
vacation.txt の冒頭部分を引用します(ターミナルの様子がそのまま記録されています)。
$ uv run python -m examples.research_bot.main
What would you like to research? Caribbean vacation spots in April, optimizing for surfing, hiking and water sports
View trace: https://platform.openai.com/logs/trace?trace_id=trace_....
Starting research...
✅ Will perform 15 searches
✅ Searching... 15/15 completed
✅ Finishing report...
✅ Report summary
どちらの例でも planner は 15 件の検索を計画しており(指示した 5〜20 件の範囲内)、最終レポートは目次付き・章立ての Markdown 文書として数千語規模で生成されています。レポート末尾にはフォローアップ質問(例: 「新しいボードの予算はいくらか、中古も検討するか?」)が並び、ReportData.follow_up_questions がそのまま表示されていることが確認できます。
なお、このサンプルは過去のバージョンで採取されたもので、表示文言が現行コードと少し異なります(サンプルの Searching... 15/15 completed に対し、現行の manager.py は Searches finished: 15/15 succeeded と表示します)。動作の本質は同じです。
つまずきポイント: サンプルのトレース URL は trace_.... と伏せ字になっています。自分で実行した際は実際のトレース ID 付き URL が表示され、OpenAI ダッシュボード(Traces 画面)で「計画 → 並列検索 15 件 → 執筆」の全体像を可視化できます。
README.md — 改善のアイデア
カテゴリ README には、構成説明と実行コマンドに加えて「自分でリサーチボットを作るなら」という 4 つの改善案が載っています。学習の次のステップとしてそのまま使えるので紹介します。
- 検索の拡張(Retrieval): ベクトルストア(文書を意味で検索できるデータベース)からの情報取得を追加する。
FileSearchToolが使えます。 - 画像・ファイルのアップロード: PDF などをリサーチの前提資料として添付できるようにする。
- 計画と思考の強化: 計画工程を改善し、結果を自己評価して「追加で検索する」「書き直す」を選べる評価ステップを足す。
- コード実行: データ分析のためにコードを実行できるようにする。
まとめ
- research_bot は planner(計画)→ search(並列検索)→ writer(執筆) の 3 エージェント構成で、Deep Research の簡易版を実装した実践アプリです。
- エージェントの実行順序は LLM ではなく
ResearchManager(Python コード)が制御しています。これがコードによるオーケストレーションで、handoff 中心の LLM 主導型(第11章)と対をなすパターンです。 - 並列検索は
asyncio.create_task()で全タスクを同時に開始し、asyncio.as_completed()で完了した順に結果を回収します。公式ドキュメントが挙げるasyncio.gatherと同様の並列実行を、進捗表示に適した形で実現しています。 output_type(構造化出力)により、WebSearchPlanやReportDataといった Pydantic モデルでエージェント間のデータ受け渡しが型安全になります。中間の検索要約はあえて非構造化テキストのままにする、という使い分けも学びました。with trace(...)で複数のエージェント実行を 1 つのトレースにまとめ、custom_spanで工程単位の区間を記録できます。- 検索 1 件の失敗はアプリ全体を止めず
Noneとして集計する、執筆中はストリーミングイベントを「進行中の検知」にだけ使って進捗を演出する、など実運用を意識した細かい工夫が詰まっています。
理解度チェック
research_bot では、「次にどのエージェントを動かすか」を誰が決めていますか。第11章の customer_service(handoff 方式)との違いを説明してください。
_perform_searches() の中で、複数の検索が「並列に」実行されることを保証しているのはどのコードですか。また asyncio.as_completed() は asyncio.gather() と比べてどんな利点がありますか。
planner_agent と writer_agent には output_type が指定されていますが、search_agent には指定されていません。それぞれの理由を説明してください。
_search() メソッドが例外を握りつぶして None を返すのはなぜですか。この設計の利点と、実運用で補うべき点を挙げてください。
_write_report() はなぜ Runner.run() ではなく Runner.run_streamed() を使っているのでしょうか。ストリーミングイベントの中身を使っていない点に注目して答えてください。
解答と解説
問1 の解答と解説
答1. Python コード(ResearchManager.run())が決めています。_plan_searches → _perform_searches → _write_report の順序はコードに固定されており、実行のたびに変わりません。一方 customer_service では、LLM が会話内容に応じて handoff 先(次に会話を担当するエージェント)を自分で判断していました。前者は速度・コスト・結果の予測可能性に優れ、後者は自由度の高いタスクに向く、というトレードオフがあります。
問2 の解答と解説
答2. tasks = [asyncio.create_task(self._search(item)) for item in search_plan.searches] です。asyncio.create_task() は呼び出した時点でタスクの実行を開始するため、この 1 行で全検索が同時に走り始めます。asyncio.as_completed() は完了したタスクから順に結果を取り出せるため、「7/15 finished」のような進捗をリアルタイム更新できます。asyncio.gather() は全タスクの完了を待ってから結果をまとめて返すため、途中経過の表示には不向きです(並列に実行される点は同じです)。
問3 の解答と解説
答3. planner_agent の出力(検索計画)は、後続のコードが plan.searches としてループ処理する必要があるため、WebSearchPlan という構造化データでなければなりません。writer_agent も「本文」「要約」「フォローアップ質問」を別々のフィールドとして取り出す必要があるため ReportData を指定しています。一方 search_agent の出力(要約メモ)は、そのまま文字列として writer への入力に埋め込まれるだけなので、構造化する必要がなく、通常のテキスト出力で十分です。
問4 の解答と解説
答4. 15 件前後ある検索のうち 1 件が失敗しただけでリサーチ全体を中断するのはもったいないため、失敗した検索は None として除外し、成功した要約だけでレポートを書き続ける設計になっています。利点は堅牢性(部分的な失敗への耐性)です。一方、極端な場合は全件失敗しても空のリストのまま執筆に進んでしまうため、実運用では「成功件数が閾値未満なら中断・再試行する」などのガードを追加するのが望ましいでしょう。
問5 の解答と解説
答5. レポート生成は数十秒〜数分かかる長い処理のため、完了まで無言で待たせないようにするためです。stream_events() をループで回している間は「生成がまだ進行中」であることが分かるので、5 秒ごとに "Writing outline..." などの進捗メッセージを切り替えて表示しています。イベントの中身(生成途中のテキスト)自体は使っておらず(async for _ in ...)、ストリーミングを「進行中であることの検知」にだけ利用している点がこの実装の特徴です。
参考リンク
- マルチエージェントのオーケストレーション: https://openai.github.io/openai-agents-python/multi_agent/(日本語版: https://openai.github.io/openai-agents-python/ja/multi_agent/)
- ツール(WebSearchTool などのホスト型ツール): https://openai.github.io/openai-agents-python/tools/(日本語版: https://openai.github.io/openai-agents-python/ja/tools/)
- 実行結果(final_output / final_output_as): https://openai.github.io/openai-agents-python/results/(日本語版: https://openai.github.io/openai-agents-python/ja/results/)
- ストリーミング: https://openai.github.io/openai-agents-python/streaming/(日本語版: https://openai.github.io/openai-agents-python/ja/streaming/)
- トレーシング(trace / custom_span): https://openai.github.io/openai-agents-python/tracing/(日本語版: https://openai.github.io/openai-agents-python/ja/tracing/)
- コード例一覧: https://openai.github.io/openai-agents-python/examples/(日本語版: https://openai.github.io/openai-agents-python/ja/examples/)
- research_bot のソースコード: https://github.com/openai/openai-agents-python/tree/main/examples/research_bot
第13章 実践アプリ③: 金融リサーチエージェント(financial_research_agent)
この章で学ぶこと
- 第12章の research_bot を発展させた「金融リサーチエージェント」の全体構成(計画 → 検索 → 専門分析 → 執筆 → 検証 → 修正)を理解する
Agent.as_tool()を使って専門エージェントを「ツール」として別のエージェントに渡すパターン(agents as tools)を学ぶas_tool()のcustom_output_extractorで、サブエージェントの出力から必要な部分だけを取り出す方法を学ぶ- verifier(検証者)エージェントによるレポートの品質チェックと、不合格時に自動で書き直す修正ループの設計を学ぶ
- 検索時に集めたソース URL を「証拠」として構造化し、執筆側と検証側の両方に同じ証拠セットを渡す根拠ベースの検証の作り方を学ぶ
Agent.clone()で既存のエージェント定義を複製し、実行時にツールを付け替えるテクニックを学ぶ- handoff(ハンドオフ)と agents as tools の使い分けの判断基準を整理する
前提知識
この章に進む前に、以下を理解しておいてください。
- 第1章: 環境構築(
git clone→uv sync)とOPENAI_API_KEYの設定方法 - 第2章:
AgentとRunnerの基本的な使い方(Runner.run()/Runner.run_streamed()、output_typeによる構造化出力) - 第12章: research_bot の実装(planner → search → writer の 3 段パイプライン、
WebSearchTool、asyncioによる並列検索、rich ライブラリによる進捗表示)
この章のコードは第12章の research_bot と骨格がほぼ同じです。第12章を読んでいない場合は、必ず先に読んでください。この章では「第12章との差分」に焦点を当てて解説します。
概念解説
金融リサーチエージェントとは — 「調査会社のチーム」の比喩
この章の example は、「Apple の直近四半期を分析して」といった金融に関する質問を受け取ると、Web 検索と専門分析を組み合わせて Markdown 形式の長文レポートを自動生成するアプリケーションです。
第12章の research_bot を「編集部」に例えたのを覚えているでしょうか。今回はそれを一段グレードアップした「金融調査会社」をイメージしてください。
- プランナー(planner_agent): 編集デスク。依頼内容を読んで「何を調べるべきか」の検索計画を立てる
- 検索担当(search_agent): リサーチアシスタント。計画に沿って Web を検索し、要約メモを作る
- 専門アナリスト(financials_agent / risk_agent): 財務専門家とリスク専門家。シニアアナリストから「この観点で短評をくれ」と頼まれたときだけ登場する
- シニアアナリスト(writer_agent): 検索メモと専門家の短評を統合し、レポート本文を執筆する
- 監査役(verifier_agent): 完成したレポートを読み、矛盾や根拠不足がないかを最終チェックする
第12章の research_bot は 1・2・4 に相当するメンバーしかいませんでした。今回追加されたのは 3 の専門アナリストと 5 の監査役です。この 2 つが、この章で学ぶ新しい設計パターンの主役です。
差分① 専門エージェントを「ツール」として使う(agents as tools)
これまでの章で、エージェントに電卓や Web 検索などのツール(tool。モデルが呼び出せる外部機能)を持たせる方法を学びました。実は Agents SDK では、エージェントそのものをツールに変換することができます。それが Agent.as_tool() メソッドです。
tool = specialist_agent.as_tool(
tool_name="fundamentals_analysis",
tool_description="Use to get a short write‑up of key financial metrics",
)
こうして作ったツールを別のエージェント(この例ではライター)に渡すと、ライターの LLM(大規模言語モデル。文章を生成する AI モデルのこと)は執筆中に「財務の短評が欲しいな」と判断したタイミングで、通常の関数ツールと同じ感覚で fundamentals_analysis を呼び出せます。呼び出されると、裏側でサブエージェントが 1 回実行され、その結果がツールの戻り値としてライターに返ります。
比喩で言えば、シニアアナリストが執筆の手を止めずに、内線電話で専門家に「2 段落で短評ちょうだい」と頼み、返ってきたメモを原稿に貼り込むイメージです。会話の主導権は常にライターが握ったままです。
handoff との違い — 「電話を替わる」か「内線で聞く」か
第4章以降で学んだ handoff(ハンドオフ。会話の担当をエージェント間で引き継ぐ仕組み)と、agents as tools は似て非なるものです。SDK のソースコード(src/agents/agent.py の as_tool() の docstring)には、両者の違いが明確に書かれています。
- handoff では、新しいエージェントが会話履歴全体を受け取る。agents as tools では、サブエージェントは呼び出し元が生成した入力(ツール引数)だけを受け取る
- handoff では、新しいエージェントが会話を引き継ぐ。agents as tools では、サブエージェントはツールとして呼ばれるだけで、会話は元のエージェントが続ける
公式ドキュメント(docs/multi_agent.md)では、使い分けの目安を次のように整理しています。
| パターン | 動作 | 向いている場面 |
|---|---|---|
| Agents as tools | マネージャー役のエージェントが会話の主導権を保ちつつ、Agent.as_tool() 経由で専門エージェントを呼ぶ | 1 つのエージェントに最終回答を担わせたい。複数の専門家の出力を統合したい |
| Handoffs | 振り分け役が会話を専門エージェントに引き渡し、以降はその専門エージェントが会話の主体になる | 専門エージェントに直接ユーザーへ応答させたい。プロンプトを役割ごとに完全に分けたい |
この章の example は前者の典型です。「最終レポートは必ずライターが 1 本にまとめる」という要件があるため、専門アナリストに会話を渡してしまう handoff ではなく、出力の統合権をライターに残す agents as tools が選ばれています。この違いを図にすると次のようになります。
差分② verifier による検証ステップ
もう 1 つの差分は、レポート完成後に検証専用のエージェントを走らせる点です。LLM は流暢な文章を書けますが、内容に矛盾や過剰な断定が紛れ込むことがあります。そこで「書く人」と「チェックする人」を分け、完成したレポートを別のエージェントに監査させます。
これは、コードを書いた本人とは別の人がコードレビューをするのと同じ発想です。ただしこの example の検証は、レポートを読ませて「それらしいか」を判断させるだけではありません。検索時に集めたソース URL つきの証拠一式と、調査の締め日(research cutoff)を検証者に渡し、その証拠だけを根拠に判定させます。 検証者は自分の記憶で採否を決めることを禁じられています。
判定結果は verified: bool と issues: list[VerificationIssue] を持つ構造化出力(後述)として返り、不合格なら自動で 1 回書き直して再検証します。それでも通らなければ RuntimeError でパイプラインを止めます。「検証に落ちたのに、それらしいレポートが出てきてしまう」という最悪の失敗を防ぐ設計です。
パイプライン全体像
処理の流れを図にすると次のようになります。矢印はすべて Python コードによるオーケストレーション(manager.py が順番に Runner.run() を呼ぶ)で、点線はライターの LLM が自律的に判断して呼ぶツール呼び出しです。
各 example の解説
このディレクトリ(examples/financial_research_agent/)は、複数ファイルで構成された 1 つのアプリケーションです。エントリーポイントは main.py で、実際のオーケストレーションは manager.py、各エージェントの定義は agents/ サブディレクトリにあります。
examples/financial_research_agent/
├── README.md … 構成説明と実行コマンド
├── main.py … エントリーポイント
├── manager.py … パイプライン全体のオーケストレーション(この章の核心)
├── printer.py … rich による進捗表示ユーティリティ
└── agents/
├── planner_agent.py … 検索計画を立てるエージェント
├── search_agent.py … Web 検索エージェント
├── financials_agent.py … 財務専門アナリスト(ツール化される)
├── risk_agent.py … リスク専門アナリスト(ツール化される)
├── writer_agent.py … レポート執筆エージェント
└── verifier_agent.py … レポート検証エージェント
main.py
ユーザーから金融リサーチの質問を受け取り、FinancialResearchManager に処理を委ねるエントリーポイントです。
import asyncio
from examples.auto_mode import input_with_fallback
from .manager import FinancialResearchManager
# Entrypoint for the financial bot example.
# Run this as `python -m examples.financial_research_agent.main` and enter a
# financial research query, for example:
# "Write up an analysis of Apple Inc.'s most recent quarter."
async def main() -> None:
query = input_with_fallback(
"Enter a financial research query: ",
"Write a short analysis of Apple's long-term revenue drivers and key risks. "
"Avoid making claims about unreleased quarterly results.",
)
mgr = FinancialResearchManager()
await mgr.run(query)
if __name__ == "__main__":
asyncio.run(main())
コード解説
input_with_fallback()は examples/auto_mode.py にある共通ヘルパーです。通常は Python 標準のinput()でユーザーに質問を入力させますが、環境変数EXAMPLES_INTERACTIVE_MODE=autoが設定されていると、対話をスキップして第2引数のデフォルト質問(Apple の長期的な収益ドライバーとリスクの分析)を自動的に使います。CI などの無人実行を想定した仕組みです。asyncio.run(main())で非同期処理(複数の処理を待ち時間の間に切り替えながら並行して進める Python の仕組み)を起動します。後述するように、検索フェーズで複数の Web 検索を同時に走らせるため、アプリ全体がasyncで書かれています。
実行手順
- リポジトリのルートディレクトリ(openai-agents-python)にいることを確認します。
OPENAI_API_KEYを設定します(第1章参照)。export OPENAI_API_KEY="sk-..."次のコマンドで実行します(README 記載のコマンドを
uv run経由で実行します)。uv run python -m examples.financial_research_agent.mainプロンプトが表示されたら、質問を入力します。README では次の例が挙げられています。
Write up an analysis of Apple Inc.'s most recent quarter.日本語で「トヨタ自動車の直近四半期を分析して」のように入力しても動作します。
(任意)対話なしで動かしたい場合は、環境変数を付けて実行します。
EXAMPLES_INTERACTIVE_MODE=auto uv run python -m examples.financial_research_agent.main
期待される動作・出力例
実行中はターミナルに進捗がライブ表示され、完了すると標準出力にレポート全文が印字されます(内容は実行のたびに変わります。以下は形式のイメージです)。
View trace: https://platform.openai.com/logs/trace?trace_id=trace_...
✅ Starting financial research...
✅ Will perform 8 searches
✅ Searches finished: 8/8 succeeded
✅ Finalizing report...
✅ Report summary
Apple's revenue remains anchored by iPhone sales and a fast-growing
services segment, while regulatory pressure and China exposure are key risks...
=====REPORT=====
Report:
# Apple Inc. — Long-Term Revenue Drivers and Key Risks
## Executive Summary
...(数千文字の Markdown レポート)...
=====FOLLOW UP QUESTIONS=====
How is Apple's services margin trending relative to hardware?
...
=====VERIFICATION=====
verified=True issues='The report is internally consistent. Note that some
growth figures are approximations from search summaries...'
つまずきポイント
WebSearchToolは OpenAI がホストするツールのため、Responses API に対応した OpenAI の API キーが必須です。他社モデルプロバイダ(第9章)では動きません。- 検索を複数回、さらに gpt-5.6-sol での長文生成を行うため、1 回の実行にある程度の API 料金と数分程度の時間がかかります。まずはデフォルトの質問 1 回で試すことをおすすめします。
- 実行ログの先頭に表示されるトレース URL(
https://platform.openai.com/logs/trace?trace_id=...)を開くと、パイプライン内の全エージェント実行を可視化できます。ライターがサブアナリストをツールとして呼んだ様子もここで確認できます。
manager.py
パイプライン全体(計画 → 検索 → 執筆 → 検証)をコードでオーケストレーションする、この章の核心ファイルです。ブロックごとに見ていきます。
インポートとカスタム出力抽出関数
from agents import Runner, RunResult, RunResultStreaming, custom_span, gen_trace_id, trace
from examples.web_search_utils import extract_url_citations, extract_web_search_source_urls
from .agents.financials_agent import financials_agent
from .agents.planner_agent import FinancialSearchItem, FinancialSearchPlan, planner_agent
from .agents.risk_agent import risk_agent
from .agents.search_agent import FinancialSearchSummary, search_agent
from .agents.verifier_agent import VerificationResult, verifier_agent
from .agents.writer_agent import REVISION_PROMPT, FinancialReportData, writer_agent
from .printer import Printer
class FinancialSource(BaseModel):
title: str
url: str
class FinancialSearchEvidence(BaseModel):
query: str
reason: str
summary: str
sources: list[FinancialSource]
retrieved_at: str
def _extract_financial_sources(items: Sequence[object]) -> list[FinancialSource]:
sources: list[FinancialSource] = []
seen: set[str] = set()
for citation in extract_url_citations(items):
if citation.url in seen:
continue
seen.add(citation.url)
sources.append(FinancialSource(title=citation.title, url=citation.url))
for url in extract_web_search_source_urls(items):
if url in seen:
continue
seen.add(url)
sources.append(FinancialSource(title=url, url=url))
return sources
class FinancialSource(BaseModel):
title: str
url: str
class FinancialSearchEvidence(BaseModel):
query: str
reason: str
summary: str
sources: list[FinancialSource]
retrieved_at: str
def _extract_financial_sources(items: Sequence[object]) -> list[FinancialSource]:
sources: list[FinancialSource] = []
seen: set[str] = set()
for citation in extract_url_citations(items):
if citation.url in seen:
continue
seen.add(citation.url)
sources.append(FinancialSource(title=citation.title, url=citation.url))
for url in extract_web_search_source_urls(items):
if url in seen:
continue
seen.add(url)
sources.append(FinancialSource(title=url, url=url))
return sources
async def _summary_extractor(run_result: RunResult | RunResultStreaming) -> str:
"""Custom output extractor for sub‑agents that return an AnalysisSummary."""
# The financial/risk analyst agents emit an AnalysisSummary with a `summary` field.
# We want the tool call to return just that summary text so the writer can drop it inline.
return str(run_result.final_output.summary)
_summary_extractor は、後で as_tool() に渡すカスタム出力抽出関数です。サブエージェント(財務・リスクアナリスト)は AnalysisSummary という Pydantic モデル(Python の型ヒントを使ってデータ構造を定義・検証するライブラリのクラス)を返しますが、ツールの戻り値としてライターに渡したいのはその中の summary フィールドの文字列だけです。この関数はサブエージェントの実行結果(RunResult)を受け取り、final_output.summary だけを取り出して返します。
SDK のソースコード上、custom_output_extractor は「RunResult を受け取り文字列を返す非同期関数」である必要があります(Callable[[RunResult | RunResultStreaming], Awaitable[str]])。指定しない場合は、サブエージェントの最後のメッセージがそのままツールの戻り値になります。
run() — 全体の流れ
async def run(self, query: str) -> None:
trace_id = gen_trace_id()
try:
with trace("Financial research trace", trace_id=trace_id):
self.printer.update_item(
"trace_id",
f"View trace: https://platform.openai.com/logs/trace?trace_id={trace_id}",
is_done=True,
hide_checkmark=True,
)
self.printer.update_item("start", "Starting financial research...", is_done=True)
search_plan = await self._plan_searches(query)
search_results = await self._perform_searches(search_plan)
report, verification = await self._produce_verified_report(query, search_results)
final_report = f"Report summary\n\n{report.short_summary}"
self.printer.update_item("final_report", final_report, is_done=True)
finally:
self.printer.end()
# Print to stdout
print("\n\n=====REPORT=====\n\n")
print(f"Report:\n{report.markdown_report}")
print("\n\n=====FOLLOW UP QUESTIONS=====\n\n")
print("\n".join(report.follow_up_questions))
print("\n\n=====VERIFICATION=====\n\n")
print(verification)
gen_trace_id()とtrace()で、パイプライン全体を 1 つのトレース(アプリの実行履歴を記録・可視化する仕組み。第12章参照)にまとめています。planner・search・writer・verifier という複数のエージェント実行が、OpenAI のダッシュボード上で 1 つのワークフローとして見えるようになります。- 処理本体は 3 行だけです。
_plan_searches→_perform_searches→_produce_verified_reportと、コードが順番を決める決定論的なオーケストレーションになっています。第12章との違いは最後の_produce_verified_reportで、この中に「執筆 → 検証 →(不合格なら)修正 → 再検証」のループが入っています。 try/finallyでself.printer.end()を囲んでいる点にも注目してください。検証に通らなかった場合など途中で例外が飛んでも、rich の画面制御を必ず終了させるためです(これを怠るとターミナルが表示崩れしたまま残ります)。__init__でself.research_cutoff = datetime.now(timezone.utc).date().isoformat()を保持しています。これは「この調査で参照できる情報の締め日」で、後述の検証で「まだ発表されていない事実に基づいていないか」を判断する基準になります。
_plan_searches() と _perform_searches() — 第12章と同じ骨格
async def _plan_searches(self, query: str) -> FinancialSearchPlan:
self.printer.update_item("planning", "Planning searches...")
result = await Runner.run(planner_agent, f"Query: {query}")
self.printer.update_item(
"planning",
f"Will perform {len(result.final_output.searches)} searches",
is_done=True,
)
return result.final_output_as(FinancialSearchPlan)
planner_agent を実行し、構造化出力 FinancialSearchPlan(検索語のリスト)を受け取ります。final_output_as() は実行結果を指定した型として取り出すメソッドです。
async def _perform_searches(
self, search_plan: FinancialSearchPlan
) -> Sequence[FinancialSearchEvidence]:
with custom_span("Search the web"):
self.printer.update_item("searching", "Searching...")
tasks = [asyncio.create_task(self._search(item)) for item in search_plan.searches]
results: list[FinancialSearchEvidence] = []
num_completed = 0
num_succeeded = 0
num_failed = 0
for task in asyncio.as_completed(tasks):
result = await task
if result is not None:
results.append(result)
num_succeeded += 1
else:
num_failed += 1
num_completed += 1
status = f"Searching... {num_completed}/{len(tasks)} finished"
if num_failed:
status += f" ({num_succeeded} succeeded, {num_failed} failed)"
self.printer.update_item(
"searching",
status,
)
summary = f"Searches finished: {num_succeeded}/{len(tasks)} succeeded"
if num_failed:
summary += f", {num_failed} failed"
self.printer.update_item("searching", summary, is_done=True)
return results
async def _search(self, item: FinancialSearchItem) -> FinancialSearchEvidence | None:
input_data = f"Search term: {item.query}\nReason: {item.reason}"
try:
result = await Runner.run(search_agent, input_data)
search_summary = result.final_output_as(FinancialSearchSummary)
sources = _extract_financial_sources(result.new_items)
if not sources:
return None
return FinancialSearchEvidence(
query=item.query,
reason=item.reason,
summary=search_summary.summary,
sources=sources,
retrieved_at=self.research_cutoff,
)
except Exception:
return None
第12章と同じく、asyncio.create_task() で全検索を同時に開始し、asyncio.as_completed() で終わった順に回収します。custom_span() は、トレースの中に「Search the web」という自前の区間(スパン)を作り、複数の検索実行をグループ化するための関数です。
第12章との細かな差分として、成功数と失敗数を分けてカウントしています。個々の検索は _search() 内の try/except で失敗しても None を返すだけなのでパイプライン全体は止まりませんが、その状況が「8/10 succeeded, 2 failed」のように進捗表示へ反映されます。
_write_report() — この章の最重要ブロック
async def _write_report(
self,
query: str,
search_results: Sequence[FinancialSearchEvidence],
) -> FinancialReportData:
# Expose the specialist analysts as tools so the writer can invoke them inline
# and still produce the final FinancialReportData output.
fundamentals_tool = financials_agent.as_tool(
tool_name="fundamentals_analysis",
tool_description="Use to get a short write‑up of key financial metrics",
custom_output_extractor=_summary_extractor,
)
risk_tool = risk_agent.as_tool(
tool_name="risk_analysis",
tool_description="Use to get a short write‑up of potential red flags",
custom_output_extractor=_summary_extractor,
)
writer_with_tools = writer_agent.clone(tools=[fundamentals_tool, risk_tool])
self.printer.update_item("writing", "Thinking about report...")
input_data = self._report_input(query, search_results)
result = Runner.run_streamed(writer_with_tools, input_data)
update_messages = [
"Planning report structure...",
"Writing sections...",
"Finalizing report...",
]
last_update = time.time()
next_message = 0
async for _ in result.stream_events():
if time.time() - last_update > 5 and next_message < len(update_messages):
self.printer.update_item("writing", update_messages[next_message])
next_message += 1
last_update = time.time()
self.printer.mark_item_done("writing")
return result.final_output_as(FinancialReportData)
ここに agents as tools パターンのすべてが詰まっています。順に見ていきましょう。
as_tool()によるツール化:financials_agentとrisk_agentをそれぞれfundamentals_analysis/risk_analysisという名前のツールに変換します。tool_descriptionは LLM がツールを使うかどうか判断する材料になるので、「何を得られるツールか」を簡潔に書きます。custom_output_extractor=_summary_extractorにより、ツールの戻り値はAnalysisSummary全体ではなくsummaryの文字列だけになります。clone()による実行時のツール差し込み:writer_agent.clone(tools=[...])は、writer_agent の定義(名前・instructions・model・output_type)を引き継ぎつつ、toolsだけ差し替えた新しい Agent オブジェクトを作ります。エージェント定義ファイル(agents/writer_agent.py)を汚さずに、実行時の文脈に応じてツール構成を変えられるのがポイントです。SDK ソースコードによるとclone()はdataclasses.replaceによる浅いコピー(オブジェクトの中身までは複製しないコピー)なので、リスト属性を変えたいときはこの例のように新しいリストを渡します。- ストリーミング実行と経過表示:
Runner.run_streamed()はエージェントをストリーミング(結果を少しずつ逐次受け取る方式)で実行します。ここではstream_events()のイベント内容自体は使わず(async for _ in ...)、イベントを受信し続けながら 5 秒おきに進捗メッセージを切り替えるという割り切った使い方をしています。長文生成は数十秒かかるため、「Planning report structure... → Writing sections... → Finalizing report...」と表示を進めてユーザーの体感待ち時間を和らげる工夫です。 - 最終出力の取り出し: ストリーミングが完了したら
result.final_output_as(FinancialReportData)で構造化されたレポートを取り出します。ライターはツールを何回呼んでも(あるいは 1 回も呼ばなくても)、最終的には必ずFinancialReportData型で出力を返します。
custom_output_extractor が実際に何を切り詰めているかを図にすると次のようになります。
_produce_verified_report() / _revise_report() / _verify_report() — 検証と修正のループ
まず、執筆と検証をまとめる司令塔です。
async def _produce_verified_report(
self,
query: str,
search_results: Sequence[FinancialSearchEvidence],
) -> tuple[FinancialReportData, VerificationResult]:
report = await self._write_report(query, search_results)
verification = await self._verify_report(query, report, search_results)
if verification.verified:
return report, verification
report = await self._revise_report(query, report, search_results, verification)
verification = await self._verify_report(query, report, search_results)
if not verification.verified:
raise RuntimeError(
"Financial report failed evidence verification after one revision: "
f"{verification.model_dump_json()}"
)
return report, verification
- 検証に落ちたら 1 回だけ書き直すという単純明快なポリシーです。1 回の修正でも通らなければ
RuntimeErrorを投げてパイプラインを止めます。「検証が通らなかったのに、それらしいレポートが出てきてしまう」という最悪の失敗を防ぐ設計です - 「不合格なら書き直させるループを組める」というのは以前の版では読者への宿題でしたが、現在は example 本体に取り込まれています
この合否分岐をコードの流れそのままに図示すると次のようになります。
書き直しと検証の本体は次の 2 つです。
async def _revise_report(
self,
query: str,
report: FinancialReportData,
search_results: Sequence[FinancialSearchEvidence],
verification: VerificationResult,
) -> FinancialReportData:
self.printer.update_item("revising", "Revising report from verification feedback...")
revision_agent = writer_agent.clone(instructions=REVISION_PROMPT)
input_data = (
f"{self._report_input(query, search_results)}\n"
f"Existing report:\n{report.model_dump_json()}\n"
f"Verification feedback:\n{verification.model_dump_json()}"
)
result = await Runner.run(revision_agent, input_data)
self.printer.mark_item_done("revising")
return result.final_output_as(FinancialReportData)
async def _verify_report(
self,
query: str,
report: FinancialReportData,
search_results: Sequence[FinancialSearchEvidence],
) -> VerificationResult:
self.printer.update_item("verifying", "Verifying report...")
result = await Runner.run(
verifier_agent,
self._verification_input(query, report, search_results),
)
self.printer.mark_item_done("verifying")
return result.final_output_as(VerificationResult)
_revise_report()はwriter_agent.clone(instructions=REVISION_PROMPT)で instructions だけ差し替えたライターを作ります。エージェントを新しく定義するのではなく、既存の定義から「役割だけ変えた分身」を作るのがclone()の使いどころです。入力には「元の証拠一式 + 既存レポート + 検証フィードバック」の 3 点を JSON で連結して渡します_verify_report()は、レポートの Markdown 本文だけでなく_verification_input()が組み立てた JSON ペイロードを渡します。中身は次のとおりです。
def _verification_input(
self,
query: str,
report: FinancialReportData,
search_results: Sequence[FinancialSearchEvidence],
) -> str:
return json.dumps(
{
"original_query": query,
"research_cutoff": self.research_cutoff,
"report": report.model_dump(mode="json"),
"evidence": [item.model_dump(mode="json") for item in search_results],
"allowed_source_urls": sorted(
{source.url for item in search_results for source in item.sources}
),
},
ensure_ascii=False,
)
- ここが以前の版からの最大の変更点です。検証者に「証拠」を渡すようになりました。
evidenceには各検索の要約とソース URL が、allowed_source_urlsには許可された URL の一覧が入ります。検証者は自分の記憶ではなく、この証拠だけを根拠に判定します research_cutoffを添えることで、「この日付以前に公開された情報は入手可能とみなす」という時間軸の基準を検証者と共有します。「まだ発表されていない決算の数字を書いている」といった誤りを検出できるようになります- 執筆側にも同じ形の
_report_input()(original_query/research_cutoff/evidenceの JSON)が渡されます。書く側と検証する側が同一の証拠セットを見るので、「ライターが知らない情報で検証者が減点する」といった噛み合わない指摘が起きません
「生成物を別エージェントで監査する」という考え方自体は実務でそのまま応用できます。この example は、そこからさらに一歩進んで「監査に必要な証拠を機械的に集めて渡し、判定が構造化データで返り、不合格なら自動で書き直す」という検証可能なパイプラインの形にまで持っていった例として読めます。
執筆側と検証側が同じ証拠セットを共有している様子を図にすると次のようになります。
printer.py
第12章の research_bot にも登場した、rich ライブラリ(ターミナルに色付き・動的な表示を行う Python ライブラリ)による進捗表示ユーティリティです。内容はほぼ同一なので要点のみ示します。
def update_item(
self, item_id: str, content: str, is_done: bool = False, hide_checkmark: bool = False
) -> None:
self.items[item_id] = (content, is_done)
if hide_checkmark:
self.hide_done_ids.add(item_id)
self.flush()
def flush(self) -> None:
renderables: list[Any] = []
for item_id, (content, is_done) in self.items.items():
if is_done:
prefix = "✅ " if item_id not in self.hide_done_ids else ""
renderables.append(prefix + content)
else:
renderables.append(Spinner("dots", text=content))
self.live.update(Group(*renderables))
item_id("planning"、"searching" など)ごとに 1 行を管理し、未完了ならスピナー(回転アニメーション)、完了ならチェックマーク付きテキストを表示します。rich.live.Live が画面を毎回書き換えるため、複数タスクの進捗が同じ位置で更新され続けます。SDK の機能ではなく純粋な表示補助ですが、複数エージェントが動くアプリでは「今どの段階か」の可視化が体験を大きく左右します。
agents/planner_agent.py
ユーザーの質問を金融分析向けの検索計画(検索語 5〜15 個)に変換するエージェントです。40行以下なので全文を引用します。
from pydantic import BaseModel
from agents import Agent
# Generate a plan of searches to ground the financial analysis.
# For a given financial question or company, we want to search for
# recent news, official filings, analyst commentary, and other
# relevant background.
PROMPT = (
"You are a financial research planner. Given a request for financial analysis, "
"produce a set of web searches to gather the context needed. Aim for recent "
"headlines, earnings calls or 10‑K snippets, analyst commentary, and industry background. "
"Output between 5 and 15 search terms to query for."
)
class FinancialSearchItem(BaseModel):
reason: str
"""Your reasoning for why this search is relevant."""
query: str
"""The search term to feed into a web (or file) search."""
class FinancialSearchPlan(BaseModel):
searches: list[FinancialSearchItem]
"""A list of searches to perform."""
planner_agent = Agent(
name="FinancialPlannerAgent",
instructions=PROMPT,
model="o3-mini",
output_type=FinancialSearchPlan,
)
コード解説
- instructions が金融ドメインに特化しています。「最近のヘッドライン、決算説明会(earnings calls)や 10-K(米国企業の年次報告書)の抜粋、アナリストのコメント、業界の背景情報を狙え」と、何を検索すべきかの観点を具体的に指示しています。第12章の汎用プランナーとの違いはこのドメイン知識の注入です。
model="o3-mini"と、推論モデル(第10章参照。回答前に内部で思考する工程を持つモデル)を指定しています。「どんな検索語の組み合わせが分析の土台として十分か」を考える計画立案は、推論モデルが得意とするタスクです。output_type=FinancialSearchPlanにより、出力は必ず「reason(この検索が必要な理由)とquery(検索語)のペアのリスト」という構造化出力(LLM の出力を自由文ではなく決まったデータ構造で受け取る仕組み)になります。
実行手順・期待される動作: このファイル単体では実行しません。main.py の実行時、パイプラインの最初のステップとして呼ばれ、進捗表示に「Will perform N searches」と検索数が表示されます。
agents/search_agent.py
検索語を 1 つ受け取り、WebSearchTool で Web を検索して 300 語以内の要約を返すエージェントです。全文を引用します。
from pydantic import BaseModel
from agents import Agent, ModelSettings, WebSearchTool
# Given a search term, use web search to pull back a brief summary.
# Summaries should be concise but capture the main financial points.
INSTRUCTIONS = (
"You are a research assistant specializing in financial topics. "
"Given a search term, use web search to retrieve up‑to‑date context and "
"produce a short summary of at most 300 words. Focus on key numbers, events, "
"or quotes that will be useful to a financial analyst."
)
class FinancialSearchSummary(BaseModel):
summary: str
"""A concise summary of the search findings."""
search_agent = Agent(
name="FinancialSearchAgent",
model="gpt-5.6-sol",
instructions=INSTRUCTIONS,
tools=[WebSearchTool()],
model_settings=ModelSettings(response_include=["web_search_call.action.sources"]),
output_type=FinancialSearchSummary,
)
コード解説
WebSearchTool()は OpenAI がホストする組み込み Web 検索ツールです(第12章で解説済み)。README には「PDF や 10-K をインデックス済みならFileSearchToolを追加してもよい」と書かれており、社内文書検索との組み合わせが想定されています。- instructions は「金融アナリストに役立つ数字・イベント・発言の引用にフォーカスせよ」と指示しています。汎用要約ではなく、後段のライターが使いやすい素材を作らせる工夫です。
output_type=FinancialSearchSummaryにより、出力は自由文ではなくsummaryフィールドを持つ構造化データになります。manager.py 側はresult.final_output_as(FinancialSearchSummary)で受け取ります。model_settings=ModelSettings(response_include=["web_search_call.action.sources"])が加わりました。これを指定すると、応答に検索で参照したソース URL の一覧が含まれるようになります。manager.py の_extract_financial_sources()がこれと本文のインライン引用を突き合わせて、後段の検証に渡す「証拠」を組み立てます(第3章web_search_filters.pyと同じ仕組みで、抽出処理は共通モジュールexamples/web_search_utils.pyに切り出されています)。- ソース URL が 1 件も取れなかった検索結果は、
_search()がNoneを返して捨てられます。「根拠の URL が示せない要約はレポートの材料にしない」という方針がコードで担保されています。
期待される動作: main.py 実行時に検索語の数だけ並列実行され、進捗表示に「Searching... 3/8 finished」のようなカウントが流れます。
agents/financials_agent.py
企業のファンダメンタルズ(収益・利益・マージン・成長性などの基礎的な財務指標)を分析する専門エージェントです。このファイル自体は普通の Agent 定義で、manager.py で as_tool() によりツール化されます。全文を引用します。
from pydantic import BaseModel
from agents import Agent
# A sub‑agent focused on analyzing a company's fundamentals.
FINANCIALS_PROMPT = (
"You are a financial analyst focused on company fundamentals such as revenue, "
"profit, margins and growth trajectory. Given a collection of web (and optional file) "
"search results about a company, write a concise analysis of its recent financial "
"performance. Pull out key metrics or quotes. Keep it under 2 paragraphs."
)
class AnalysisSummary(BaseModel):
summary: str
"""Short text summary for this aspect of the analysis."""
financials_agent = Agent(
name="FundamentalsAnalystAgent",
instructions=FINANCIALS_PROMPT,
output_type=AnalysisSummary,
)
コード解説
- instructions で「2 段落以内の簡潔な分析」と出力の分量を制限しています。ツールとして呼ばれる前提なので、戻り値が長すぎるとライターのコンテキスト(モデルが一度に読める入力全体)を圧迫するためです。
output_type=AnalysisSummaryでsummaryフィールドを持つ構造化出力を強制し、manager.py の_summary_extractorがこのsummaryだけを抜き出します。modelを指定していない点に注目してください。この場合 SDK のデフォルトモデルが使われます(本書確認時点の v0.22.2 ではgpt-5.6-luna。環境変数OPENAI_DEFAULT_MODELで変更可能)。短い専門分析には軽量モデルで十分、という役割分担です。
期待される動作: ライターが執筆中に fundamentals_analysis ツールを呼ぶと実行されます。呼ぶかどうかはライターの LLM の判断次第で、毎回必ず呼ばれるとは限りません。トレース画面で呼び出しの有無を確認できます。
agents/risk_agent.py
企業のリスク要因(競争上の脅威、規制、サプライチェーン、成長鈍化など)を洗い出す専門エージェントです。構造は financials_agent と対になっています。全文を引用します。
from pydantic import BaseModel
from agents import Agent
# A sub‑agent specializing in identifying risk factors or concerns.
RISK_PROMPT = (
"You are a risk analyst looking for potential red flags in a company's outlook. "
"Given background research, produce a short analysis of risks such as competitive threats, "
"regulatory issues, supply chain problems, or slowing growth. Keep it under 2 paragraphs."
)
class AnalysisSummary(BaseModel):
summary: str
"""Short text summary for this aspect of the analysis."""
risk_agent = Agent(
name="RiskAnalystAgent",
instructions=RISK_PROMPT,
output_type=AnalysisSummary,
)
コード解説
- financials_agent との違いは instructions(観点がファンダメンタルズかリスクか)だけです。「観点の数だけ小さな専門エージェントを増やす」のがこのパターンの拡張方法で、たとえば「ESG アナリスト」「競合比較アナリスト」を同じ形で追加し、
as_tool()でライターに渡せばレポートの観点が増えます。 AnalysisSummaryクラスが financials_agent.py にも同名で定義されています。それぞれのファイルで独立に定義された同一構造のモデルであり、_summary_extractorはどちらの出力に対してもsummary属性へのアクセスだけで動作します。
agents/writer_agent.py
検索要約と専門家の短評を統合して、最終レポートを執筆するシニアライターです。全文を引用します。
from pydantic import BaseModel
from agents import Agent
# Writer agent brings together the raw search results and optionally calls out
# to sub‑analyst tools for specialized commentary, then returns a cohesive markdown report.
WRITER_PROMPT = (
"You are a senior financial analyst. You will be provided with the original query and "
"a set of raw search summaries. Your task is to synthesize these into a long‑form markdown "
"report (at least several paragraphs) including a short executive summary and follow‑up "
"questions. If needed, you can call the available analysis tools (e.g. fundamentals_analysis, "
"risk_analysis) to get short specialist write‑ups to incorporate. Every material numeric or "
"time-sensitive claim must include an inline Markdown citation using a URL supplied in the "
"research evidence. Never invent or alter a source URL."
)
REVISION_PROMPT = (
f"{WRITER_PROMPT} You are revising an existing report after evidence verification. Address "
"every verification issue, remove claims that cannot be supported, preserve valid analysis, "
"and return a complete replacement report rather than a patch or commentary. Copy replacement "
"citation URLs verbatim from each verification issue's source_urls; do not change path casing, "
"language segments, or query parameters."
)
class FinancialReportData(BaseModel):
short_summary: str
"""A short 2‑3 sentence executive summary."""
markdown_report: str
"""The full markdown report."""
follow_up_questions: list[str]
"""Suggested follow‑up questions for further research."""
# Note: We will attach handoffs to specialist analyst agents at runtime in the manager.
# This shows how an agent can use handoffs to delegate to specialized subagents.
writer_agent = Agent(
name="FinancialWriterAgent",
instructions=WRITER_PROMPT,
model="gpt-5.6-sol",
output_type=FinancialReportData,
)
コード解説
- instructions の中で
fundamentals_analysisとrisk_analysisというツール名を明示的に予告しています。ツール自体は実行時にclone()で差し込まれますが、プロンプト側でも「必要ならこのツールを呼べる」と伝えておくことで、LLM がツールを適切に使う確率が上がります。ツール定義とプロンプトはセットで設計する、という実践的なコツです。 - instructions の末尾に 「重要な数値・時期に関する主張には、証拠として渡された URL を使ったインライン Markdown 引用を必ず付けよ。出典 URL を発明したり改変したりするな」 という指示が加わりました。後段の検証者が URL を厳密照合するため、書き手の側でもルールを明示しておく必要があります。
REVISION_PROMPTはWRITER_PROMPTを土台に「検証後の書き直し用」の指示を足したものです。「パッチや講評ではなく、完全な差し替え版レポートを返せ」「差し替える引用 URL は各指摘のsource_urlsから逐語コピーし、パスの大文字小文字・言語セグメント・クエリパラメータを変えるな」と、修正のやり方まで指定しています。曖昧にすると LLM が「ここをこう直しました」という報告文を返してきてしまうためです。FinancialReportDataは第12章のReportDataに相当する構造化出力で、エグゼクティブサマリー(short_summary)・レポート本文(markdown_report)・追加調査のための質問リスト(follow_up_questions)の 3 点セットです。ツール呼び出しを挟んでも最終出力はこの型に固定されます。- 末尾のコメントには「handoffs を実行時に付ける」とありますが、実際に manager.py で使われているのは handoff ではなく
as_tool()(agents as tools)です。コメントが実装の変遷に追従していない例で、読むときは実装コードを正としてください。本章の概念解説で述べたとおり、両者は「会話の主導権が移るかどうか」が異なり、この example の実装は主導権をライターに残す agents as tools です。
agents/verifier_agent.py
完成したレポートを監査する検証エージェントです。全文を引用します。
from typing import Literal
from pydantic import BaseModel
from agents import Agent
# Agent to sanity‑check a synthesized report for consistency and recall.
# This can be used to flag potential gaps or obvious mistakes.
VERIFIER_PROMPT = (
"You are a meticulous evidence auditor. You will receive an original request, an explicit "
"research cutoff date, a financial report, and structured web research evidence with source "
"URLs. Judge the report only against that supplied evidence; do not reject or approve claims "
"based on your own memory. Check that material numeric and time-sensitive claims are supported "
"by the evidence, that citations use supplied URLs, that the report is internally consistent, "
"and that uncertainty is appropriately caveated. The payload includes allowed_source_urls; "
"compare citation URL strings exactly against that list. If a citation exactly matches an "
"allowed URL, accept the URL even when it contains a tracking parameter or another allowed "
"variant exists. Treat information published on or before the research cutoff as potentially "
"available. Mark unsupported claims separately from claims that the evidence directly "
"contradicts."
)
class VerificationIssue(BaseModel):
claim: str
"""The report claim that needs attention."""
category: Literal["unsupported", "contradicted", "stale_or_unreleased", "other"]
"""The evidence problem associated with the claim."""
explanation: str
"""Why the evidence does not support the claim."""
source_urls: list[str]
"""Relevant supplied source URLs, if any."""
class VerificationResult(BaseModel):
verified: bool
"""Whether the report is coherent and supported by the supplied evidence."""
issues: list[VerificationIssue]
"""Evidence-based issues that must be corrected before publication."""
verifier_agent = Agent(
name="VerificationAgent",
instructions=VERIFIER_PROMPT,
model="gpt-5.6-sol",
output_type=VerificationResult,
)
コード解説
- instructions の要は 「渡された証拠だけで判定せよ。自分の記憶で採否を決めるな」(
do not reject or approve claims based on your own memory)という一文です。LLM の記憶は古かったり誤っていたりするため、判定の根拠を外から与えた証拠に限定します。以前の版は「完全な出典監査は行わない(合成された要約しか渡していないから)」と限界を明示する内容でしたが、実際に出典 URL を渡すようになったため、監査の射程そのものが広がりました。 - チェック対象は 4 点です。(1) 重要な数値・時期に関する主張が証拠で裏付けられているか、(2) 引用が渡された URL を使っているか、(3) レポート内部で矛盾がないか、(4) 不確実性に適切な但し書きがあるか。
- URL の照合ルールが細かく指定されている点にも注目してください。
allowed_source_urlsと文字列として厳密に比較し、完全一致するならトラッキングパラメータが付いていても受理する、と決めています。曖昧さを残すと、検証者が「URL が微妙に違う」という不毛な指摘を量産するためです。 research_cutoff以前に公開された情報は「入手可能だったとみなす」と指示することで、時間軸のズレによる誤判定を防いでいます。output_type=VerificationResultにより、判定(verified: bool)と指摘事項が構造化されます。指摘は自由文のissues: strからissues: list[VerificationIssue]へ変わりました。 各指摘は「どの主張が(claim)」「どういう問題で(category:unsupported/contradicted/stale_or_unreleased/other)」「なぜ裏付けられないか(explanation)」「関連する出典 URL(source_urls)」を持ちます。とくに 「証拠が足りない(unsupported)」と「証拠が明確に否定している(contradicted)」を分けているのが実務的です。前者は書き足せば直りますが、後者は主張そのものを撤回する必要があり、対処が違います。- 構造化されたことで、
_revise_report()が指摘 1 件ずつを機械的にライターへ渡せるようになりました。自由文の頃は「指摘全体を読ませて察してもらう」しかありませんでした。
期待される動作: main.py 実行の最終ステップで呼ばれ、標準出力の =====VERIFICATION===== セクションに verified=True issues=[] のような形で表示されます。検証に落ちた場合は 1 回だけ書き直しが走り、それでも通らなければ RuntimeError でパイプラインが停止します。
まとめ
- financial_research_agent は、第12章 research_bot の「計画 → 検索 → 執筆」パイプラインに専門アナリスト(ツール化されたサブエージェント)と検証・修正ステップ(verifier + 書き直し)を追加した発展形です。
Agent.as_tool()を使うと、エージェントを別のエージェントのツールに変換できます。handoff と違い、会話の主導権は呼び出し元に残り、サブエージェントはツール引数として生成された入力だけを受け取ります。as_tool()のcustom_output_extractorに非同期関数を渡すと、サブエージェントの実行結果(RunResult)から必要な部分(この例ではAnalysisSummary.summary)だけをツールの戻り値にできます。Agent.clone(tools=[...])により、エージェント定義を変更せずに実行時にツール構成を差し替えられます。clone()は浅いコピーである点に注意してください。- 検証エージェントは「書く人とチェックする人を分ける」パターンです。構造化出力(
verified: bool/issues: list[VerificationIssue])で返すことで、後続処理の自動分岐が可能になります。指摘を「証拠が足りない(unsupported)」と「証拠が否定している(contradicted)」に分類しているのが実務的で、対処法が異なる問題を混ぜずに扱えます。 - 検証は証拠ベースです。
search_agentにresponse_include=["web_search_call.action.sources"]を付けてソース URL を回収し、allowed_source_urlsと調査締め日(research_cutoff)を添えて検証者に渡します。検証者は自分の記憶ではなくその証拠だけで判定します。ソース URL が取れなかった検索結果はそもそも証拠から除外されます。 - 不合格なら
writer_agent.clone(instructions=REVISION_PROMPT)で 1 回だけ書き直し、それでも通らなければRuntimeErrorで停止します。検証に落ちたレポートが黙って世に出ないことをコードで担保する設計です。 - 役割ごとにモデルを使い分けています(計画=o3-mini、検索・執筆・検証=gpt-5.6-sol、短評サブエージェント=SDK デフォルトモデル)。タスクの難易度に応じたモデル選択はコストと品質のバランスを取る基本テクニックです。
理解度チェック
manager.py の _write_report() では、financials_agent と risk_agent を handoff ではなく as_tool() でライターに渡しています。handoff にした場合と比べて、動作上どのような違いがありますか。2 点挙げてください。
as_tool() の custom_output_extractor に渡している _summary_extractor は何をする関数ですか。これを指定しなかった場合、ツールの戻り値はどうなりますか。
writer_agent.clone(tools=[fundamentals_tool, risk_tool]) は何をしていますか。writer_agent.py の定義に最初から tools=[...] を書かず、実行時に clone() で差し込む利点を説明してください。
verifier_agent の instructions には「完全な出典監査(full citation audit)は行わない」と明記されています。なぜこの一文が入っているのか、検証エージェントを設計する際の教訓とあわせて説明してください。
manager.py の _perform_searches() で、個々の検索が失敗してもパイプライン全体が停止しないのはなぜですか。該当するコード上の仕組みを挙げてください。
解答と解説
問1 の解答と解説
(1) handoff ではサブエージェントが会話履歴全体を受け取るのに対し、as_tool() ではサブエージェントは呼び出し元の LLM が生成したツール引数(入力テキスト)だけを受け取ります。(2) handoff では会話の主導権がサブエージェントに移り、以降の応答をサブエージェントが担いますが、as_tool() ではサブエージェントの実行結果がツールの戻り値として返るだけで、会話は元のエージェント(ライター)が続けます。この example では「最終レポートは必ずライターが 1 本にまとめる」必要があるため、主導権が移らない agents as tools が採用されています。
問2 の解答と解説
_summary_extractor はサブエージェントの実行結果(RunResult または RunResultStreaming)を受け取り、構造化出力 AnalysisSummary の summary フィールドの文字列だけを取り出して返す非同期関数です。指定しなかった場合は、サブエージェントの最後のメッセージがそのままツールの戻り値になります(as_tool() の docstring に記載)。ライターの原稿に貼り込みやすいプレーンな文字列を返すために使われています。
問3 の解答と解説
clone() は writer_agent の定義(名前・instructions・モデル・output_type)を引き継ぎつつ、tools だけを差し替えた新しい Agent オブジェクトを作ります。利点は、エージェント定義ファイルを純粋な「役割の定義」として保ち、どのツールを持たせるかという実行時の構成をオーケストレーション側(manager.py)に集約できることです。同じ writer_agent を、状況に応じて異なるツールセットで再利用することもできます。なお clone() は dataclasses.replace による浅いコピーのため、リスト属性を変えるときは新しいリストを渡す必要があります。
問4 の解答と解説
この example では検証エージェントに渡るのはレポート本文だけで、元の検索ソース文書は渡されません。そのため「引用が原典と一致しているか」を検証することは原理的に不可能です。できないことを instructions に求めると、LLM が根拠のない検証結果を作り上げる(ハルシネーションする)恐れがあるため、「内部矛盾・但し書きの適切さ・明らかにあり得ない事実」というチェック可能な範囲に役割を限定しています。教訓は「検証エージェントには、与えた入力で実際に検証可能なことだけを instructions で指示する」です。
問5 の解答と解説
個々の検索を実行する _search() メソッドの内部が try/except Exception で囲まれており、失敗時は例外を握りつぶして None を返すためです。呼び出し側の _perform_searches() は None を結果リストに加えず失敗数としてカウントし、進捗表示に「(N succeeded, M failed)」と反映するだけで処理を続行します。一部の検索が失敗しても、成功した検索結果だけでレポート執筆に進める設計です。
参考リンク
- Orchestrating multiple agents(英語) / 複数エージェントのオーケストレーション(日本語) — agents as tools と handoffs の使い分けの公式ガイド
- Tools — Agents as tools(英語) / ツール(日本語) —
as_tool()の詳細(構造化入力、承認ゲート、カスタム出力抽出) - Handoffs(英語) / ハンドオフ(日本語) — 比較対象としての handoff の仕様
- Results(英語) / 実行結果(日本語) —
RunResult/final_output_as()の解説 - Tracing(英語) / トレーシング(日本語) —
trace()/custom_span()/gen_trace_id()の解説 - Examples(英語) / コード例(日本語) — financial_research_agent を含む公式 examples の一覧
第14章 音声エージェント(voice)
本章のSTT → テキスト処理 → TTSは、中間テキストを検査・加工したい場合に向く方式です。音声理解・判断・応答を1つの会話モデルで扱うRealtime APIは第15章で学びます。現行の GPT-Live は、全二重の音声フロントエンドと独立したバックエンドを組み合わせる別の選択肢です。既存のテキストワークフローを音声会話の裏で継続したい場合に比較してください。公式:音声方式の比較 · GPT-Live
この章で学ぶこと
v0.22.1:文字起こし設定と音声処理
ストリーム文字起こしへ language・prompt を渡す処理が修正され、languages・keywords も設定できるようになりました。gpt-transcribe / gpt-live-transcribe では languages が単数の language より優先します。設定は対応する文字起こしモデルの仕様に合わせてください。カスタムTTS voice IDへの対応も追加されています。VoicePipelineの仕様
この章で学ぶこと
- 音声パイプライン(
VoicePipeline)の全体像 — 音声入力 → STT(音声認識)→ エージェント → TTS(音声合成)→ 音声出力という 3 ステップの流れ - 2 種類の音声入力方式の違い —
AudioInput(static: 録音済み音声を一括処理)とStreamedAudioInput(streamed: マイク音声を流し込み、発話終了を自動検出) SingleAgentVoiceWorkflowによる最小構成と、VoiceWorkflowBaseを継承したカスタムワークフローの作り方- パイプラインの実行結果(
StreamedAudioResult)からイベントをストリーミングで受け取り、音声を再生する方法 - 追加依存(
uv sync --extra voice)と、マイク・スピーカーを扱うsounddeviceライブラリの注意点
前提知識
この章を読む前に、以下を理解していることを前提とします。
- 第1章: リポジトリのクローンと
uv syncによるセットアップ、OPENAI_API_KEYの設定 - 第2章:
AgentとRunnerの基本。特にストリーミング実行(Runner.run_streamed())と、to_input_list()で会話履歴を引き継ぐ方法 - 第3章:
@tool(別名@function_tool)デコレータによるツール定義 - 第5章: handoff(ハンドオフ。エージェントが会話ごと別のエージェントに引き継ぐ仕組み)と
prompt_with_handoff_instructions
また、この章の example はマイクとスピーカーを備えたマシン(一般的なノート PC であれば十分です)でのローカル実行を前提とします。SSH 先のサーバーや音声デバイスのないコンテナ内では動作しません。
概念解説
音声エージェントとは — 「電話越しの AI アシスタント」の比喩
これまでの章のエージェントは、テキストで質問してテキストで答えが返ってくるものでした。この章で扱う音声エージェントは、マイクに向かって話しかけると、AI が音声で答えてくれるアプリケーションです。スマートスピーカーや、電話の自動応答窓口を思い浮かべてください。
ここで重要なのは、LLM(大規模言語モデル)自体は基本的にテキストを処理する装置だということです。人間の「声」をそのまま理解するわけではありません。そこで、人間とテキスト処理エージェントの間に「通訳」を 2 人立てます。
- STT(Speech-to-Text、音声認識) — 音声をテキストに変換する通訳。あなたの発話を文字起こしします
- TTS(Text-to-Speech、音声合成) — テキストを音声に変換する通訳。エージェントの返答を読み上げます
この 2 人の通訳とエージェント本体を一直線につないだものが音声パイプラインです。パイプライン(pipeline)とは、複数の処理を順番につなげて、前の処理の出力を次の処理の入力にする構成のことです。
音声パイプラインの全体像
SDK ではこのパイプラインを VoicePipeline クラスが担います。処理は次の 3 ステップです。
教材固定版v0.22.2の既定の STT モデルが gpt-4o-transcribe、既定の TTS モデルが gpt-4o-mini-tts(声は ash)であることは、SDK のソースコード(src/agents/voice/models/openai_model_provider.py と openai_tts.py)で定義されています。v0.23.1ではSTTの既定値は gpt-transcribe です。現行SDKの定義を確認してください。VoicePipeline のコンストラクタ引数 stt_model / tts_model / config で差し替え・調整が可能です。
②の「ワークフロー」は VoiceWorkflowBase を継承したクラスで、「文字起こしされたテキストを受け取り、話すべきテキストを順次 yield する」だけの契約です。中身は自由なので、単一エージェントの実行でも、複数エージェントの分岐でも、LLM を使わない固定応答でも構いません。単純に 1 つのエージェントを動かすだけなら、SDK 付属の SingleAgentVoiceWorkflow がそのまま使えます。
static と streamed — 2 つの入力方式
VoicePipeline.run() に渡せる音声入力は 2 種類あり、この章の 2 つの example がそれぞれに対応します。
static(AudioInput) | streamed(StreamedAudioInput) | |
|---|---|---|
| 入力の渡し方 | 完成した録音データ(numpy 配列)を一括で渡す | マイクからの音声チャンクを逐次 add_audio() で流し込む |
| 発話終了の判断 | アプリ側が明示する(録音停止ボタンなど) | パイプラインが発話区間検出で自動判定 |
| 会話ターン数 | 1 回の run() につき 1 ターン | 1 回の run() で複数ターンの会話が続く |
| 向いている用途 | 録音済み音声の処理、プッシュ・トゥ・トーク型アプリ | ハンズフリーの対話アプリ |
| 対応する example | examples/voice/static | examples/voice/streamed |
図で流れを比較すると次のとおりです。
streamed 方式の「発話終了の自動判定」には、既定で semantic_vad というターン検出(turn detection)設定が使われます(src/agents/voice/models/openai_stt.py の DEFAULT_TURN_DETECTION)。VAD(Voice Activity Detection、発話区間検出)とは「今ユーザーが話しているか、話し終えたか」を音声から判定する技術で、semantic_vad は無音だけでなく発話内容の意味的な区切りも考慮して「話し終えた」と判断します。ユーザーが話し終えると、パイプラインが自動的にワークフローを起動して応答を生成します。この 1 往復をターン(turn)と呼びます。
実行結果はイベントのストリーム
pipeline.run() の戻り値は StreamedAudioResult で、stream() メソッドからイベントが非同期に流れてきます。ストリーミングとは、結果全体の完成を待たず、できた部分から順次受け取る方式のことです。イベントは 3 種類あります(src/agents/voice/events.py)。
VoiceStreamEventAudio(event.type == "voice_stream_event_audio")— 音声データのチャンク。event.dataを順次スピーカーに書き込めば再生できますVoiceStreamEventLifecycle("voice_stream_event_lifecycle")— ターンの開始(turn_started)・終了(turn_ended)・セッション終了(session_ended)の通知VoiceStreamEventError("voice_stream_event_error")— エラー通知
なお、TTS の生成中にユーザーが話し始めた場合の割り込み(interruption)処理は SDK には組み込まれていません。検出された各ターンが独立にワークフローを起動するだけです。割り込み相当の制御をしたい場合は、turn_started / turn_ended イベントを使って「エージェントが話している間はマイクをミュートする」といった制御をアプリ側で実装します。
追加依存と実行環境の準備
音声機能は SDK 本体のオプション機能です。リポジトリの pyproject.toml では次のように定義されています。
[project.optional-dependencies]
voice = ["numpy>=2.2.0, <3; python_version>='3.10'", "websockets>=15.0, <17"]
numpy— 音声データを数値配列として扱うための数値計算ライブラリwebsockets— streamed 方式で OpenAI のリアルタイム文字起こし API と WebSocket(サーバーと常時接続を張って双方向にデータを送り合う通信方式)で通信するために必要
クローンしたリポジトリでは、次のコマンドでインストールします。
uv sync --extra voice
さらに example は、マイク・スピーカーを扱う sounddevice と、streamed 版の端末 UI に使う textual に依存します。この 2 つはリポジトリの開発用依存([dependency-groups] の dev)に含まれており、通常の uv sync で一緒にインストールされます(uv は既定で dev グループを含めます)。つまり、リポジトリ内で example を動かす限り uv sync --extra voice の 1 コマンドで準備完了です。
自分のプロジェクトで音声機能を使う場合は python -m pip install 'openai-agents[voice]==0.22.2' に加えて、音声の録音・再生を行うなら pip install sounddevice も必要です。
sounddevice ライブラリの注意点:
sounddeviceは内部で PortAudio という C ライブラリを使います。macOS / Windows では wheel(ビルド済みパッケージ)にバイナリが同梱されるため追加作業は不要ですが、Linux ではシステムに PortAudio が必要です(例:sudo apt-get install libportaudio2)- macOS では、初回実行時にターミナルアプリへのマイクアクセス許可を求めるダイアログが出ます。拒否すると無音が録音されるだけでエラーにならないため、「動いているのに文字起こしが空」という症状になったら「システム設定 → プライバシーとセキュリティ → マイク」を確認してください
- 音声デバイスが 1 つもない環境(サーバー、CI、Docker コンテナ等)では
sd.InputStream/sd.OutputStreamの生成時にエラーになります - この章の example はすべてサンプリングレート 24,000 Hz・モノラル(1 チャンネル)で統一されています。サンプリングレートとは音声を 1 秒あたり何個の数値で記録するかの値で、OpenAI の音声モデルの入出力仕様(24kHz PCM)に合わせています。PCM とは、圧縮せず波形の数値をそのまま並べた音声データ形式です
各 example の解説
examples/voice には 2 つのサブディレクトリがあります。
| サブディレクトリ | ファイル | 内容 |
|---|---|---|
static/ | main.py, util.py | 録音 → 一括処理 → 再生の最小構成 |
streamed/ | main.py, my_workflow.py | マイク常時入力 + ターン自動検出の対話アプリ |
static/ — 録音済み音声を一括処理するデモ
ターミナルでスペースキーを押して録音を開始・停止し、その録音データを VoicePipeline に渡して、エージェントの音声応答をスピーカーで再生する example です。「押している間だけ話す」トランシーバー型(プッシュ・トゥ・トーク)アプリの原型と考えてください。
コード解説: main.py — エージェント定義
エージェント部分は、これまでの章で学んだ「ツール + handoff」の構成そのものです。
@tool
def get_weather(city: str) -> str:
"""Get the weather for a given city."""
print(f"[debug] get_weather called with city: {city}")
choices = ["sunny", "cloudy", "rainy", "snowy"]
return f"The weather in {city} is {random.choice(choices)}."
spanish_agent = Agent(
name="Spanish",
handoff_description="A spanish speaking agent.",
instructions=prompt_with_handoff_instructions(
"You're speaking to a human, so be polite and concise. Speak in Spanish.",
),
model="gpt-5-mini",
)
agent = Agent(
name="Assistant",
instructions=prompt_with_handoff_instructions(
"You're speaking to a human, so be polite and concise. If the user speaks in Spanish, handoff to the spanish agent.",
),
model="gpt-5-mini",
handoffs=[spanish_agent],
tools=[get_weather],
)
注目してほしいのは、音声用に特別なエージェントを作る必要がない点です。天気ツールを持ち、スペイン語ならスペイン語担当に handoff する普通のエージェントを、そのまま音声パイプラインに載せられます。instructions の「You're speaking to a human, so be polite and concise(人間と話しているので丁寧かつ簡潔に)」は音声アプリならではの工夫です。読み上げられる文章なので、長文や箇条書きを避けるようモデルに促しています。
コード解説: main.py — パイプラインの実行
class WorkflowCallbacks(SingleAgentWorkflowCallbacks):
def on_run(self, workflow: SingleAgentVoiceWorkflow, transcription: str) -> None:
print(f"[debug] on_run called with transcription: {transcription}")
async def main():
pipeline = VoicePipeline(
workflow=SingleAgentVoiceWorkflow(agent, callbacks=WorkflowCallbacks())
)
audio_input = AudioInput(buffer=record_audio())
result = await pipeline.run(audio_input)
with AudioPlayer() as player:
async for event in result.stream():
if event.type == "voice_stream_event_audio":
player.add_audio(event.data)
print("Received audio")
elif event.type == "voice_stream_event_lifecycle":
print(f"Received lifecycle event: {event.event}")
# Add 1 second of silence to the end of the stream to avoid cutting off the last audio.
player.add_audio(np.zeros(24000 * 1, dtype=np.int16))
ブロックごとに見ていきます。
SingleAgentVoiceWorkflow(agent, callbacks=...)— 「文字起こし結果を会話履歴に追加 →Runner.run_streamed()でエージェント実行 → テキストを逐次 yield → 履歴と現在のエージェントを更新」という定型処理を丸ごと引き受ける既製ワークフローです。SingleAgentWorkflowCallbacksのon_run()をオーバーライドすると、文字起こし完了のタイミングでフックできます。ここではデバッグ表示に使っており、実行すると自分の発話がどう文字起こしされたかをターミナルで確認できますAudioInput(buffer=record_audio())—record_audio()(後述の util.py)が返す numpy 配列を、static 方式の入力AudioInputに包みます。AudioInputは int16 または float32 の配列を受け付け、float32 は SDK 内部で int16 に変換されます(src/agents/voice/input.py)result.stream()のループ — 音声イベントが来たらプレイヤーに書き込み、ライフサイクルイベントはログに出します。TTS はエージェントの応答テキストを受け取り次第、音声チャンクを生成し始めるので、応答全文の完成を待たずに再生が始まります- 最後の
np.zeros(24000 * 1, dtype=np.int16)— 24,000 サンプル = ちょうど 1 秒の無音を末尾に足しています。sounddeviceの出力ストリームはバッファリングの都合で、書き込み直後にストリームを閉じると末尾が切れて聞こえることがあるため、その回避策です。コメントにも「最後の音声が切れるのを避けるため」とあります
この一連の流れをシーケンス図で示すと次のとおりです。
コード解説: util.py — 録音と再生のヘルパー
util.py は SDK とは無関係の、sounddevice を使った録音・再生ユーティリティです。録音部分の骨格は次のとおりです。
def _record_audio(screen: curses.window) -> npt.NDArray[np.float32]:
...
def _audio_callback(indata, frames, time_info, status):
...
if recording:
audio_buffer.append(indata.copy())
# Open the audio stream with the callback.
with sd.InputStream(samplerate=24000, channels=1, dtype=np.float32, callback=_audio_callback):
while True:
key = screen.getch()
if key == ord(" "):
recording = not recording
...
sd.InputStream(samplerate=24000, channels=1, dtype=np.float32, callback=...)— マイクを 24kHz・モノラル・float32 で開き、音声が入るたびに_audio_callbackが呼ばれます。録音フラグが立っている間だけバッファに追記し、最後にnp.concatenateで 1 本の配列に結合しますcurses.wrapper(_record_audio)— キー入力の検出に curses(ターミナル画面を直接制御する標準ライブラリ)を使っています。ソースコメントによると、これは「macOS でアクセシビリティ権限を要求しないで済み、ターミナルをブロックしない」ための選択です。pynputのようなグローバルキーフックのライブラリは macOS で特別な権限が必要になるため、それを避けています
再生側の AudioPlayer は、sd.OutputStream(samplerate=24000, channels=1, dtype=np.int16) を with 文で開閉する薄いラッパーで、add_audio() が stream.write() を呼ぶだけです。
実行手順
追加依存を含めてセットアップします(リポジトリのルートで実行)。
uv sync --extra voiceOPENAI_API_KEYを設定します。export OPENAI_API_KEY=sk-...マイクとスピーカーが使える状態で実行します(README 記載のとおりモジュール形式で起動します)。
uv run python -m examples.voice.static.main- 画面に「Press <spacebar> to start recording.」と表示されたら、スペースキーを押して録音開始 → 話す → もう一度スペースキーで録音停止します。macOS では初回にマイク許可のダイアログが出るので許可してください。
README が提案している発話例:
- 「Tell me a joke」— ジョークを話してくれます
- 「What's the weather in Tokyo?」—
get_weatherツールが呼ばれてから応答します - 「Hola, como estas?」— スペイン語エージェントに handoff されます
期待される動作・出力例
録音停止後、文字起こし → エージェント実行 → 音声再生が順に走り、ターミナルには次のようなログが流れ、スピーカーから応答が聞こえます。
[debug] on_run called with transcription: What's the weather in Tokyo?
Received lifecycle event: turn_started
[debug] get_weather called with city: Tokyo
Received audio
Received audio
...
Received lifecycle event: turn_ended
Received lifecycle event: session_ended
つまずきポイント
- README の記述とコードのずれ:
static/README.mdには「secret word(合言葉)を言うと特別な応答をするカスタムワークフロー」「Tell me about dogs で固定メッセージが返る」という記述がありますが、現在のstatic/main.pyは既製のSingleAgentVoiceWorkflowを使っており、合言葉ロジックはありません。合言葉の実装は後述の streamed 版(my_workflow.py、合言葉は "dog")にあります。README の一部が古い状態です - 録音したのに無反応・空の文字起こし: macOS のマイク許可を拒否した、または入力デバイスが意図しないものになっているケースが典型です。
python -c "import sounddevice; print(sounddevice.query_devices())"でデバイス一覧を確認できます ImportError: numpy + websockets are required to use voice:--extra voiceを付けずに sync した場合に出ます。uv sync --extra voiceを実行してください- 応答の最後が切れる: 末尾の無音追加を削ると発生します。ストリーム再生の落とし穴として覚えておいてください
streamed/ — 会話を続けられる対話型デモ
マイクの音声を常時パイプラインに流し込み、話し終えたことを semantic_vad が自動検出してエージェントが応答する、対話型の example です。textual(Python 製のターミナル UI フレームワーク。ターミナル内にウィジェットやログ画面を描画できます)による TUI(Text User Interface、文字ベースの画面)アプリとして実装されています。static 版との本質的な違いは、AudioInput の代わりに StreamedAudioInput を使う点と、ワークフローを自作している点の 2 つです。
コード解説: my_workflow.py — カスタムワークフロー
エージェント定義(get_weather ツール + スペイン語 handoff。モデルは gpt-5.6-sol)は static 版とほぼ同じなので省略し、核心のカスタムワークフローを見ます。
class MyWorkflow(VoiceWorkflowBase):
def __init__(self, secret_word: str, on_start: Callable[[str], None]):
"""
Args:
secret_word: The secret word to guess.
on_start: A callback that is called when the workflow starts. The transcription
is passed in as an argument.
"""
self._input_history: list[TResponseInputItem] = []
self._current_agent = agent
self._secret_word = secret_word.lower()
self._on_start = on_start
async def run(self, transcription: str) -> AsyncIterator[str]:
self._on_start(transcription)
# Add the transcription to the input history
self._input_history.append(
{
"role": "user",
"content": transcription,
}
)
# If the user guessed the secret word, do alternate logic
if self._secret_word in transcription.lower():
yield "You guessed the secret word!"
self._input_history.append(
{
"role": "assistant",
"content": "You guessed the secret word!",
}
)
return
# Otherwise, run the agent
result = Runner.run_streamed(self._current_agent, self._input_history)
async for chunk in VoiceWorkflowHelper.stream_text_from(result):
yield chunk
# Update the input history and current agent
self._input_history = result.to_input_list()
self._current_agent = result.last_agent
VoiceWorkflowBase を継承したクラスに求められるのは、「文字起こしテキストを受け取り、話すテキストを yield する非同期ジェネレータ run()」の実装だけです。この example はその自由度を 3 つの形で示しています。
- 合言葉の分岐: 発話に合言葉(この example では "dog")が含まれていたら、LLM を一切呼ばずに固定文「You guessed the secret word!」を yield して終了します。yield された文字列はそのまま TTS で読み上げられるので、「ワークフロー = LLM 呼び出し」である必要すらない、というデモです
VoiceWorkflowHelper.stream_text_from(result):Runner.run_streamed()の結果からテキスト差分イベント(response.output_text.delta)だけを取り出して yield するヘルパーです。これにより、エージェントの応答が生成されるそばから TTS に流れます- 状態の持ち越し:
_input_historyと_current_agentをインスタンス変数に保持し、ターンのたびにto_input_list()とlast_agentで更新します。これにより 複数ターンにわたる会話の文脈が維持され、handoff 先のエージェントも次のターンに引き継がれます(一度スペイン語エージェントに handoff されたら、以降のターンもスペイン語エージェントが応答します)。既製のSingleAgentVoiceWorkflowの内部実装(src/agents/voice/workflow.py)もほぼ同じ構造をしており、この example は「それを自作するとこうなる」という手本になっています
合言葉分岐のロジックを図にすると次のとおりです。
コード解説: main.py — StreamedAudioInput とマイク送信
main.py の大部分は textual の UI 定義(CSS 風のスタイルやウィジェット配置)なので、パイプラインに関わる部分に絞って解説します。まず初期化です。
self.pipeline = VoicePipeline(
workflow=MyWorkflow(secret_word="dog", on_start=self._on_transcription)
)
self._audio_input = StreamedAudioInput()
self.audio_player = sd.OutputStream(
samplerate=SAMPLE_RATE,
channels=CHANNELS,
dtype=FORMAT,
)
StreamedAudioInput は内部に非同期キュー(asyncio.Queue。非同期処理間でデータを受け渡す待ち行列)を 1 つ持つだけの軽いクラスで、add_audio() で音声チャンクを積んでいきます。static 版と違い、先に空の入力オブジェクトを作ってパイプラインを走らせておき、後から音声を流し込むのがポイントです。
アプリ起動時(on_mount)に 2 つの非同期タスクが並行起動します。1 つ目がパイプラインの実行とイベント消費です。
async def start_voice_pipeline(self) -> None:
try:
self.audio_player.start()
self.result = await self.pipeline.run(self._audio_input)
async for event in self.result.stream():
bottom_pane = self.query_one("#bottom-pane", RichLog)
if event.type == "voice_stream_event_audio":
self.audio_player.write(event.data)
bottom_pane.write(
f"Received audio: {len(event.data) if event.data is not None else '0'} bytes"
)
elif event.type == "voice_stream_event_lifecycle":
bottom_pane.write(f"Lifecycle event: {event.event}")
except Exception as e:
bottom_pane = self.query_one("#bottom-pane", RichLog)
bottom_pane.write(f"Error: {e}")
finally:
self.audio_player.close()
pipeline.run() に StreamedAudioInput を渡すと、SDK は内部で OpenAI のリアルタイム文字起こし API と WebSocket セッションを張り、semantic_vad がターンの区切りを検出するたびに MyWorkflow.run() を呼んで応答音声をイベントとして流します(src/agents/voice/pipeline.py の _run_multi_turn())。アプリ側は届いた音声チャンクをスピーカーに書き続けるだけです。
2つのタスクの並行関係を図で整理すると次のとおりです。
2 つ目のタスクがマイク音声の送信です。
async def send_mic_audio(self) -> None:
device_info = sd.query_devices()
print(device_info)
read_size = int(SAMPLE_RATE * 0.02)
stream = sd.InputStream(
channels=CHANNELS,
samplerate=SAMPLE_RATE,
dtype="int16",
)
stream.start()
status_indicator = self.query_one(AudioStatusIndicator)
try:
while True:
if stream.read_available < read_size:
await asyncio.sleep(0)
continue
await self.should_send_audio.wait()
status_indicator.is_recording = True
data, _ = stream.read(read_size)
await self._audio_input.add_audio(data)
await asyncio.sleep(0)
- マイクを int16・24kHz で開き、約 20 ミリ秒分(
24000 × 0.02 = 480サンプル)たまるたびに読み出してadd_audio()でパイプラインに送ります。このように小刻みに送るからこそ、パイプライン側がリアルタイムに発話終了を検出できます await self.should_send_audio.wait()—asyncio.Event(オン・オフを切り替えられる非同期のフラグ)を使ったゲートです。K キーでこのフラグをトグルする(on_keyハンドラ)ため、K を押して録音オンにしている間だけ音声が送られます。エージェントの応答中にマイクをオフにできるので、スピーカーの音を拾ってしまう「自己応答ループ」を手動で防げる作りですsd.query_devices()の結果を起動時に print しており、認識されている音声デバイスの一覧を確認できます
キー操作は on_key に集約されています: K = 録音オン/オフの切り替え、Q = 終了です。
実行手順
static 版と同じく、追加依存と API キーを準備します。
uv sync --extra voice export OPENAI_API_KEY=sk-...マイクとスピーカーが使えるローカルターミナルで起動します。
uv run python -m examples.voice.streamed.main- ダークカラーの TUI 画面が開いたら、K キーを押して録音を開始し(表示が「🔴 Recording...」に変わります)、マイクに向かって話します。
- 話すのをやめて少し待つと、semantic_vad がターン終了を検出し、エージェントの応答音声が再生されます。画面下部のログに文字起こし(
Transcription: ...)と受信イベントが表示されます。 そのまま続けて話せば会話が継続します。試す例:
- 「Tell me a joke」/「What's the weather in Tokyo?」/「Hola, como estas?」(static 版と同じ動き)
- "dog" を含む文(例: 「Tell me about dogs」)— LLM を経由せず「You guessed the secret word!」と即答します
- Q キーで終了します。
期待される動作・出力例
画面下部の RichLog にはおおよそ次のように表示されます。
Transcription: What's the weather in Tokyo?
Lifecycle event: turn_started
Received audio: 96000 bytes
Received audio: 96000 bytes
...
Lifecycle event: turn_ended
エージェントが応答し終わった後も、K で録音オンのままなら続けて次の発話ができます。
つまずきポイント
- K を押し忘れる: 起動直後は録音オフ(「⚪ Press K to start recording」表示)です。話しても何も起きない場合、まず録音状態の表示を確認してください
- エージェントの声に反応してしまう: スピーカー再生中もマイクがオンだと、エージェント自身の声を拾って新しいターンが始まることがあります。応答再生中は K でマイクをオフにするのが確実です。概念解説で述べたとおり、割り込み処理は SDK 組み込みではないため、
turn_started/turn_endedを使った自動ミュートは自作する必要があります textualがない: リポジトリ外の自作プロジェクトに移植する場合、textualとsounddeviceはopenai-agents[voice]に含まれないので個別にインストールが必要です- WebSocket 接続エラー: streamed 方式は文字起こしに WebSocket を使うため、プロキシ環境などで接続がブロックされると
STTWebsocketConnectionError系のエラーになります。まず static 版で疎通確認するのが切り分けの近道です - モデル名の違い: static 版のエージェントは
gpt-5-mini、streamed 版はgpt-5.6-solと、example 間でモデル指定が異なります。これは essential な違いではなく、応答の速さや質を変えたければAgentのmodelを差し替えて構いません
まとめ
- 音声エージェントは
VoicePipelineで作れます。処理は「① STT で文字起こし → ② ワークフロー(エージェント)実行 → ③ TTS で音声化」の 3 ステップで、教材固定版の既定モデルは STT がgpt-4o-transcribe、TTS がgpt-4o-mini-ttsです。v0.23.1のSTT既定値はgpt-transcribeです - 入力は 2 方式あります。
AudioInput(static)は完成した録音を一括処理する 1 ターン方式、StreamedAudioInput(streamed)は音声チャンクを流し込み、semantic_vad によるターン検出で複数ターンの会話を自動進行させる方式です - ワークフローは「文字起こしを受け取りテキストを yield する」契約(
VoiceWorkflowBase)で、単一エージェントなら既製のSingleAgentVoiceWorkflow、カスタムロジック(合言葉分岐、履歴管理、handoff 引き継ぎ等)が必要なら継承して自作します - 実行結果
StreamedAudioResult.stream()からは音声(voice_stream_event_audio)・ライフサイクル(turn_started/turn_ended/session_ended)・エラーの 3 種のイベントが流れ、音声チャンクを順次スピーカーへ書き込むことで低遅延の再生ができます - 実行には
uv sync --extra voice(numpy + websockets)とOPENAI_API_KEY、そしてマイク・スピーカーのあるローカル環境が必要です。sounddeviceは Linux では PortAudio の導入、macOS ではマイク許可に注意します - 割り込み処理は SDK 組み込みではないため、必要ならライフサイクルイベントを使ってアプリ側で実装します
理解度チェック
VoicePipeline の 3 ステップを順に挙げ、それぞれの入出力(何を受け取り何を出すか)を説明してください。
AudioInput と StreamedAudioInput はどう使い分けますか。「発話終了の判断」という観点を含めて説明してください。
streamed 版の MyWorkflow で、発話に "dog" が含まれていたときに起きることを、LLM 呼び出しの有無に触れながら説明してください。
static 版の main.py が、イベントループの最後に np.zeros(24000 * 1, dtype=np.int16) をプレイヤーに書き込んでいるのはなぜですか。
リポジトリを uv sync(オプションなし)だけでセットアップして examples.voice.static.main を実行したところ、numpy と websockets に関する ImportError が出ました。原因と対処法を答えてください。
解答と解説
問1 の解答と解説
① STT(音声認識): 音声データを受け取り、文字起こしテキストを出します。② ワークフロー: 文字起こしテキストを受け取り、エージェント等を実行して応答テキストをストリーミングで yield します。③ TTS(音声合成): 応答テキストを受け取り、再生可能な音声チャンク(24kHz PCM)をストリーミングで出します。パイプライン全体としては「音声を受け取り音声を返す」変換器になります。
問2 の解答と解説
AudioInput は完成した録音バッファを一括で渡す方式で、「ユーザーが話し終えたか」をアプリ側が判断できる場合(録音済み音声の処理、録音停止操作のあるプッシュ・トゥ・トーク型)に使います。StreamedAudioInput は音声チャンクを逐次 add_audio() で流し込む方式で、発話終了の判断をパイプライン側のターン検出(既定は semantic_vad)に任せたい対話型アプリに使います。前者は 1 回の run() で 1 ターン、後者は 1 回の run() で複数ターンの会話が続きます。
問3 の解答と解説
MyWorkflow.run() の冒頭で文字起こしに合言葉("dog")が含まれるかを判定し、含まれていれば LLM(エージェント)を一切呼ばずに固定文字列「You guessed the secret word!」を yield して return します。yield されたテキストはそのまま TTS で音声化されるため、即座に固定の応答が読み上げられます。あわせて固定応答を会話履歴(_input_history)にも追加し、以降のターンの文脈に含めます。
問4 の解答と解説
24,000 サンプル(= 24kHz で 1 秒分)の無音を末尾に書き込むことで、最後の音声チャンクを再生しきる前にストリームが閉じられて応答の末尾が切れて聞こえることを防ぐためです。sounddevice の出力ストリームはバッファを介して再生するため、書き込み完了と再生完了にずれがあることへの対策です。
問5 の解答と解説
音声機能が依存する numpy と websockets は SDK 本体の必須依存ではなく、voice というオプション依存(extra)として定義されているためです。uv sync --extra voice を実行して音声用依存を追加インストールすれば解決します(自作プロジェクトなら python -m pip install 'openai-agents[voice]==0.22.2')。なお、example が使う sounddevice と textual はリポジトリの dev 依存グループに含まれ、通常の uv sync でインストールされます。
参考リンク
Voice agents quickstart(音声エージェント入門): https://openai.github.io/openai-agents-python/voice/quickstart/
- 日本語版: https://openai.github.io/openai-agents-python/ja/voice/quickstart/
Pipelines and workflows(パイプラインとワークフローの詳細): https://openai.github.io/openai-agents-python/voice/pipeline/
- 日本語版: https://openai.github.io/openai-agents-python/ja/voice/pipeline/
Voice tracing(音声パイプラインのトレーシング設定): https://openai.github.io/openai-agents-python/voice/tracing/
- 日本語版: https://openai.github.io/openai-agents-python/ja/voice/tracing/
Handoffs(handoff の詳細、第5章の参考): https://openai.github.io/openai-agents-python/handoffs/
- 日本語版: https://openai.github.io/openai-agents-python/ja/handoffs/
Streaming(
Runner.run_streamedとイベントの詳細): https://openai.github.io/openai-agents-python/streaming/- 日本語版: https://openai.github.io/openai-agents-python/ja/streaming/
Examples 一覧: https://openai.github.io/openai-agents-python/examples/
- 日本語版: https://openai.github.io/openai-agents-python/ja/examples/
第15章 リアルタイムエージェント(realtime)
現行ガイドではサーバーで短期のclient secretを発行し、ブラウザのSDKをWebRTCで接続します。長期APIキーをブラウザへ渡しません。GAのHTTP APIでは /v1/realtime/client_secrets と /v1/realtime/calls を使用し、旧betaの OpenAI-Beta: realtime=v1 ヘッダーは外します。本章のPython/FastAPI examplesと、TypeScriptのブラウザ直接接続例では構成が異なります。SDKのイベント名と生のHTTP/WebSocketイベント名も区別してください。公式:RealtimeとGA移行
この章で学ぶこと
- Realtime API を使った speech-to-speech(音声から音声への直接変換)型エージェントの仕組みと、第14章の音声パイプライン型(STT → エージェント → TTS)との違い
RealtimeAgent/RealtimeRunner/RealtimeSessionという 3 つの中核コンポーネントとセッションのライフサイクル- 発話の割り込み(barge-in)を正しく処理するための
RealtimePlaybackTrackerと turn detection(発話区切り検出)の設定 - Web UI(FastAPI + WebSocket)、CLI(マイク/スピーカー直結)、電話(Twilio Media Streams / Twilio SIP)という 4 つの実装パターン
- ツール承認(human-in-the-loop)、handoff、画像入力をリアルタイム会話の中で使う方法
前提知識
v0.22.1:Realtimeの状態保持
ガードレールのしきい値をまたぐ処理と、取得した会話アイテムのstatusを保持する処理が修正されました。音声の終了・キャンセルと、画面上での完了表示を同じ状態に合わせることを確認してください。公式リリース
この章を読む前に、以下を理解していることを前提とします。
- 第1章: リポジトリのクローンと
uv syncによるセットアップ、OPENAI_API_KEYの設定 - 第2章:
AgentとRunnerの基本的な使い方 - 第3章:
@tool(別名@function_tool)デコレータによるツール(LLM が呼び出せる関数)の定義 - 第5章: handoff(エージェントが会話ごと別のエージェントに引き継ぐ仕組み)と
RECOMMENDED_PROMPT_PREFIX - 第14章: 音声パイプライン(
VoicePipeline)による音声エージェント。この章の対比対象です
概念解説
音声パイプライン型(第14章)との違い — 「通訳リレー」と「同時通訳」
第14章で学んだ音声パイプラインは、3 つの処理を順番に直列実行する方式でした。
- STT(speech-to-text、音声認識)でユーザーの発話をテキストに変換する
- そのテキストを通常のエージェント(LLM)に渡して応答テキストを生成する
- TTS(text-to-speech、音声合成)で応答テキストを音声に変換して再生する
これは「逐次通訳のリレー」に似ています。話し終わるのを待ってから翻訳者がメモを訳し、さらに別の人がそれを読み上げる。確実ですが、各段階の処理時間が積み重なるため応答までの待ち時間(レイテンシ)が長くなり、途中で話に割り込むこともできません。
この章のリアルタイムエージェントは、OpenAI の Realtime API を使い、音声を音声のまま単一のモデルで処理する speech-to-speech 方式です。こちらは「同時通訳」に相当します。具体的には次の点が異なります。
| 観点 | 音声パイプライン(第14章) | リアルタイムエージェント(本章) |
|---|---|---|
| 処理方式 | STT → LLM → TTS の 3 段直列 | 音声対応モデル 1 つで speech-to-speech |
| 接続形態 | ターンごとにリクエスト | WebSocket による常時接続・双方向ストリーミング |
| レイテンシ | 各段の処理時間が加算される | 低遅延(音声を細切れに送受信しながら処理) |
| 割り込み | 基本的に不可 | 対応(ユーザーが話し始めるとモデルの発話を中断できる) |
| 発話の区切り | アプリ側で判断 | サーバー側の turn detection(VAD)が自動判定可能 |
この違いを図で整理すると次のとおりです。
ここで WebSocket とは、HTTP と異なりクライアントとサーバーが接続を張りっぱなしにして、双方向に随時データを送り合える通信プロトコルです。またストリーミングとは、データ全体の完成を待たずに、生成された端から少しずつ送受信する方式を指します。リアルタイムエージェントはこの 2 つを土台に、音声を約 40〜50 ミリ秒単位の細切れ(チャンク)で送り合うことで「人と話しているような」応答速度を実現します。
なお公式ドキュメントが明記している重要な境界として、Python SDK はブラウザ用の WebRTC トランスポートを提供していません。この SDK が扱うのは「サーバー側で管理する WebSocket セッション」と「SIP(電話網との接続プロトコル)へのアタッチ」であり、ブラウザから直接 Realtime API に WebRTC 接続する構成は SDK の範囲外です。
3 つの中核コンポーネント
リアルタイムエージェントは、通常のエージェントと似た名前の専用クラスを使います。
RealtimeAgent: リアルタイム会話用のエージェント定義。instructions(指示文)、tools、handoffs、output guardrails を持ちます。通常のAgentより意図的に機能が絞られており、モデルの指定はエージェント単位ではなくセッション単位で行います。構造化出力(structured outputs)は使えませんRealtimeRunner: 開始エージェントとトランスポート(通信層)を結び付けるセッション工場。デフォルトでは OpenAI のサーバー側 WebSocket 実装(OpenAIRealtimeWebSocketModel)を使いますRealtimeSession: 生きた会話セッション。入力の送信、イベントの受信、会話履歴の追跡、ツール実行をすべて担います
典型的なライフサイクル(公式ガイドより)は次のとおりです。
RealtimeAgentを 1 つ以上作る- 開始エージェントを渡して
RealtimeRunnerを作る await runner.run()でRealtimeSessionを得るasync with session:またはawait session.enter()でセッションに入る(ここで接続が開く)send_message()(テキスト・構造化メッセージ)やsend_audio()(生の音声バイト列)で入力を送るasync for event in session:でイベントを処理し続ける
図にすると次の流れになります。
テキスト専用の Runner.run() と違い、runner.run() は「最終結果」をすぐ返しません。返ってくるのはライブなセッションオブジェクトで、会話が続く限りイベントが流れ続けます。
セッションイベント — 会話の実況中継
RealtimeSession は非同期イテレータ(async for で順番に値を取り出せるオブジェクト。非同期とは、処理の完了を待つ間に他の処理を進められる仕組みです)としてイベントを配信します。主なイベントは次のとおりです。
audio/audio_end/audio_interrupted: モデルの音声チャンク到着・発話終了・割り込み発生agent_start/agent_end: エージェントのターン開始・終了tool_start/tool_end/tool_approval_required: ツール実行の開始・終了・承認要求handoff: エージェント間の引き継ぎhistory_added/history_updated: 会話履歴への追加・更新(UI 表示に最も有用)guardrail_tripped: 出力ガードレール(安全チェック)の発動error: エラーraw_model_event: SDK が加工する前の生のモデルイベント
turn detection と割り込み — 「会話の間合い」を機械が読む
対面の会話では「相手が話し終わった」ことを間合いで判断します。Realtime API ではこれを turn detection(発話区切り検出)が担います。設定は turn_detection で指定し、代表的な方式が 2 つあります。
server_vad: VAD(Voice Activity Detection、音声区間検出)。無音が一定時間(例:silence_duration_ms: 500)続いたら発話終了とみなす、音量ベースの素朴な方式semantic_vad: 発話内容の意味も考慮して「言い終わったか」を判定する、より賢い方式
interrupt_response: True を設定すると、モデルが話している最中にユーザーが話し始めたとき、モデルの応答を自動的に中断(割り込み、barge-in)できます。
割り込みで問題になるのが「モデルはどこまで聞いてもらえたか」です。モデルは音声を先行生成してクライアントに送りつけるため、割り込み時点で「送信済みだがまだ再生されていない音声」が残っています。何も対策しないと、会話履歴上は「全部話した」ことになり、実際にユーザーが聞いた内容とズレてしまいます。これを解決するのが RealtimePlaybackTracker です。アプリ側が「実際にスピーカーで再生したバイト数」を on_play_bytes(item_id, item_content_index, bytes) で報告することで、割り込み時の履歴切り詰め(truncation)が「実際に聞こえた位置」で行われます。特に電話のように再生が遠隔・遅延する環境では必須のパターンで、後述の Twilio example が実例です。
この一連の流れを図にすると次のとおりです。
この章の examples の全体像
examples/realtime には 4 つのサブディレクトリがあり、同じ SDK を異なる入出力経路で使う方法を示しています。
| ディレクトリ | 入出力 | 一言でいうと |
|---|---|---|
app/ | ブラウザ(マイク/スピーカー) | FastAPI + WebSocket の Web デモアプリ。画像入力・ツール承認・handoff 付き |
cli/ | ローカルのマイク/スピーカー | UI なしのターミナル用デモ。音声再生の低レベル処理を丁寧に実装 |
twilio/ | 電話(Twilio Media Streams) | 電話の音声を WebSocket 経由で中継する電話エージェント |
twilio_sip/ | 電話(SIP 経由) | Realtime Calls API + SIP トランクで着信を直接受ける方式 |
4 つの音声経路を並べると、違いがはっきりします。
公式の examples 一覧(docs/examples.md)でもこのカテゴリは「構造化テキスト・画像メッセージを扱う Web アプリケーションパターン、コマンドラインでの音声ループと再生処理、WebSocket 越しの Twilio Media Streams 連携、Realtime Calls API のアタッチフローを使う Twilio SIP 連携」と紹介されています。
各 example の解説
app/ — Web ベースのリアルタイム音声アシスタント
FastAPI 製バックエンドと HTML/JavaScript 製フロントエンドからなる、ブラウザで動くリアルタイム音声デモです。マイク入力・音声再生・会話履歴表示・生イベント表示・画像送信・ツール承認までを一通り備えた、このカテゴリで最も多機能な example です。
構成ファイルは次のとおりです。
agent.py: エージェント定義(トリアージ + FAQ + 座席予約の 3 エージェント構成)server.py: FastAPI サーバー。WebSocket でブラウザと接続し、RealtimeSessionとの橋渡しをするstatic/: フロントエンド(index.html、app.js、音声処理用の AudioWorklet 2 ファイル)
コード解説(agent.py)— リアルタイム版マルチエージェント
agent.py は第11章のカスタマーサービス例とよく似た「航空会社サポート」の構成を、リアルタイム用クラスで組み立てています。まずツール定義です。
@tool(name_override="faq_lookup_tool", description_override="Lookup frequently asked questions.")
async def faq_lookup_tool(question: str) -> str:
# Simulate a slow API call
await asyncio.sleep(3)
q = question.lower()
if "wifi" in q or "wi-fi" in q:
return "We have free wifi on the plane, join Airline-Wifi"
...
await asyncio.sleep(3) はわざと 3 秒かかる遅い API を模しています。リアルタイム会話では、ツール実行中も会話を止めないためにツール呼び出しが非同期に処理される(RealtimeRunConfig の async_tool_calls、デフォルトで有効)ことを体感できます。
次が本章の目玉のひとつ、承認が必要なツールです。
@tool(needs_approval=True)
async def update_seat(confirmation_number: str, new_seat: str) -> str:
"""
Update the seat for a given confirmation number.
Args:
confirmation_number: The confirmation number for the flight.
new_seat: The new seat to update to.
"""
return f"Updated seat to {new_seat} for confirmation number {confirmation_number}"
needs_approval=True を付けると、モデルがこのツールを呼ぼうとした瞬間にセッションが tool_approval_required イベントを発行して実行を保留します。アプリ側が session.approve_tool_call(call_id) または session.reject_tool_call(call_id) を呼ぶまでツールは実行されません。人間が最終判断を挟む human-in-the-loop の仕組みです。
図にすると次の流れです。
エージェントは RealtimeAgent と realtime_handoff() で相互接続されます。
triage_agent = RealtimeAgent(
name="Triage Agent",
handoff_description="A triage agent that can delegate a customer's request to the appropriate agent.",
instructions=(
f"{RECOMMENDED_PROMPT_PREFIX} "
"You are a helpful triaging agent. You can use your tools to delegate questions to other appropriate agents."
),
tools=[get_weather],
handoffs=[
realtime_handoff(faq_agent, tool_name_override="transfer_to_faq_agent"),
realtime_handoff(seat_booking_agent, tool_name_override="transfer_to_seat_booking_agent"),
],
)
faq_agent.handoffs.append(
realtime_handoff(triage_agent, tool_name_override="transfer_to_triage_agent")
)
seat_booking_agent.handoffs.append(
realtime_handoff(triage_agent, tool_name_override="transfer_to_triage_agent")
)
第5章の handoff() に対応するリアルタイム版が realtime_handoff() です。名前や説明の上書きなど基本は同じですが、公式ガイドによればリアルタイム handoff は input_filter をサポートしません(会話が音声で連続しているため、履歴の加工を挟む余地がないのです)。最後の get_starting_agent() がトリアージエージェントを返し、サーバーはこれを開始エージェントとして使います。
コード解説(server.py)— WebSocket と RealtimeSession の橋渡し
サーバーの心臓部は RealtimeWebSocketManager です。ブラウザからの WebSocket 接続 1 本につき RealtimeSession を 1 つ作ります。
async def connect(self, websocket: WebSocket, session_id: str):
await websocket.accept()
self.websockets[session_id] = websocket
agent = get_starting_agent()
runner = RealtimeRunner(agent)
# If you want to customize the runner behavior, you can pass options:
# runner_config = RealtimeRunConfig(async_tool_calls=False)
# runner = RealtimeRunner(agent, config=runner_config)
model_config: RealtimeModelConfig = {
"initial_model_settings": {
"model_name": "gpt-realtime-2.1",
"turn_detection": {
"type": "server_vad",
"prefix_padding_ms": 300,
"silence_duration_ms": 500,
"interrupt_response": True,
"create_response": True,
},
},
}
session_context = await runner.run(model_config=model_config)
session = await session_context.__aenter__()
model_nameにgpt-realtime-2.1を指定しています。v0.18.0 からは SDK のデフォルトモデル自体がgpt-realtime-2.1になったため明示指定は必須ではありませんが、公式 examples はバージョン差異を避けるため明示していますturn_detectionはserver_vad方式で、「発話開始の 300 ミリ秒前から録音に含める(prefix_padding_ms)」「500 ミリ秒の無音で発話終了(silence_duration_ms)」「割り込み有効」「発話終了時に自動で応答生成(create_response)」という設定です
v0.20.0 の追加: 入力書き起こし(transcription)の GA 設定
ユーザー発話の書き起こしは
audio.input.transcriptionで設定します。SDK は v0.20.0 でgpt-live-transcribe/gpt-transcribe/gpt-realtime-whisperの GA 設定をそのまま転送するようになりました。
gpt-live-transcribe— 低遅延の逐次書き起こし向け。prompt(録音状況の自由文)、keywords(音声中に出現しうる固有名詞などの literal な語)、languages(想定入力言語のリスト)を渡せます。単数形のlanguageではなく複数形のlanguagesを使い、両方を同時に送ってはいけませんgpt-transcribe— WebSocket 経由で、音声ターンが確定してから書き起こしを始めたい場合や、検出言語の出力がほしい場合に使います。過去の書き起こし済みターンを自動で文脈に使います。完了イベントのlanguages出力フィールドで検出言語が返ります(gpt-live-transcribeの入力側languagesとは別物です)gpt-realtime-whisper— このモデルだけdelayを指定できます(minimal/low/medium/high/xhigh)。低い値ほど部分テキストが早く出て、高い値ほど書き起こしモデルが多くの音声文脈を得て精度が上がる傾向があります。固定の遅延時間を仮定せず、実際の音声でベンチマークしてくださいrunner = RealtimeRunner( starting_agent=agent, config={ "model_settings": { "audio": { "input": { "transcription": { "model": "gpt-live-transcribe", "prompt": "A support call about the OpenAI Agents SDK.", "keywords": ["RunState", "MCPServerManager"], "languages": ["en", "ja"], }, "turn_detection": None, } } } }, )
audio.input.turn_detectionをNoneにすると自動ターン検出が無効になります。その場合、音声ターンの確定と応答生成はアプリケーション側が明示的に制御する必要があります
受信したイベントはブラウザ向けに JSON へ変換(シリアライズ)されます。音声はそのままでは JSON に載らないため、base64(バイナリデータを英数字文字列に変換する符号化方式)でエンコードします。
elif event.type == "tool_approval_required":
base_event["tool"] = event.tool.name
base_event["call_id"] = event.call_id
base_event["arguments"] = event.arguments
base_event["agent"] = event.agent.name
elif event.type == "audio":
base_event["audio"] = base64.b64encode(event.audio.data).decode("utf-8")
ブラウザ → サーバー方向では、WebSocket エンドポイントがメッセージ種別ごとに処理を分岐します。音声は 16 ビット整数の配列をバイト列に戻して send_audio() へ渡します。
if message["type"] == "audio":
# Convert int16 array to bytes
int16_data = message["data"]
audio_bytes = struct.pack(f"{len(int16_data)}h", *int16_data)
await manager.send_audio(session_id, audio_bytes)
画像は構造化ユーザーメッセージとして送ります。RealtimeUserInputMessage に input_image(データ URL 形式の画像)と input_text(任意のプロンプト)を並べて session.send_message() に渡すのが、リアルタイム会話に画像を入れる公式の方法です。
user_msg: RealtimeUserInputMessage = {
"type": "message",
"role": "user",
"content": (
[
{"type": "input_image", "image_url": data_url, "detail": "high"},
{"type": "input_text", "text": prompt_text},
]
if prompt_text
else [{"type": "input_image", "image_url": data_url, "detail": "high"}]
),
}
await manager.send_user_message(session_id, user_msg)
大きな画像は WebSocket の 1 フレームに収まらないことがあるため、フロントエンドは image_start → image_chunk(約 60KB ずつ)→ image_end の 3 段階で分割送信し、サーバー側で結合します。サーバー起動時にも ws_max_size=16 * 1024 * 1024(16MB)とフレーム上限を広げています。
ツール承認の決定はブラウザから tool_approval_decision メッセージで届き、サーバーが RealtimeSession の API に中継します。
if approve:
await manager.approve_tool_call(session_id, call_id, always=always)
else:
await manager.reject_tool_call(session_id, call_id, always=always)
always=True を渡すと「このツールは今後も常に許可/拒否」という決定を記憶します。
セッションの後始末も見どころです。イベント処理タスクを self.event_tasks[session_id] に保持しておき、切断時にキャンセル → 完了待ち → セッションコンテキストの終了 → 各種辞書からの削除を try / finally で順に行います。以前は asyncio.create_task() の戻り値を捨てていたため、切断後もイベント処理タスクが残り得ました。「作ったタスクは必ず持っておき、終わらせる責任を持つ」というのは、長寿命の WebSocket サーバーを書くときの基本作法です。
デバッグ: 環境変数 LOG_LEVEL=DEBUG を付けて起動すると、response.done の生の usage・モダリティ内訳つきの型付き usage・セッション累計 usage がログに出ます。
cd examples/realtime/app && LOG_LEVEL=DEBUG uv run python server.py
サーバー・モデル・セッション・履歴・ツール・handoff・エラー・usage の各イベントが簡潔な要約で出力されます。音声フレームと大量に流れるデルタ系イベントは除外され、書き起こしの内容もログには出ません。 uvicorn と WebSocket プロトコルのログは INFO のままなので、LOG_LEVEL=DEBUG にしても通信の生ペイロードが吐き出されることはありません。リアルタイム音声のトークン消費を調べたいときに使ってください。
フロントエンド(static/app.js、約 700 行)は素の JavaScript で書かれており、AudioWorklet(ブラウザの音声処理をメインスレッドと別に実行する仕組み)でマイク音声を 24kHz モノラルの PCM16 形式に変換して送信し、受信した音声チャンクを約 20 ミリ秒のフェード付きで再生します。tool_approval_required を受け取ると window.confirm ダイアログで承認を求めます。教科書としてはサーバー側が主題のため詳細は割愛しますが、「ブラウザ側の音声処理も自前で書く必要がある」ことは覚えておいてください。
実行手順
追加依存をインストールします(README の指示どおり)。
uv add fastapi uvicorn websocketsAPI キーを設定します。
export OPENAI_API_KEY="sk-..."サーバーを起動します。
cd examples/realtime/app && uv run python server.py- ブラウザで http://localhost:8000 を開きます。
- Connect をクリックするとセッションが確立し、マイク採取が自動で始まります(ブラウザがマイク使用許可を求めてきたら許可します)。
- そのまま話しかけます。Mic On/Off でミュート切り替え、画像を送る場合はプロンプトを入力して Send Image からファイルを選択、終わったら Disconnect をクリックします。
期待される動作・出力例
- 左ペインに会話(ユーザー発話の書き起こしとアシスタント応答、画像サムネイル)が表示され、右ペインに生イベントが流れます
- 「座席を変更したい」と話すと Seat Booking Agent へ handoff され、確認番号と希望座席を聞かれた後、
update_seatツールの実行直前にブラウザの確認ダイアログ(許可/拒否)が表示されます - 「機内 Wi-Fi はありますか」と聞くと FAQ Agent へ handoff され、
faq_lookup_tool(3 秒の擬似遅延付き)の結果を音声で答えます
つまずきポイント
- 以前の
server.pyはstaticディレクトリを相対パスで参照していたため、起動前にexamples/realtime/appへ移動する必要がありました。現在はSTATIC_DIR = Path(__file__).with_name("static")とファイル自身の位置から解決するので、どのディレクトリから起動しても静的ファイルは見つかります(README は従来どおりcdする手順を示しています) - ブラウザのマイク許可を拒否すると音声入力が始まりません。localhost はセキュアコンテキスト扱いなので HTTP でも動作します
- 自分のスピーカー音声がマイクに回り込む(エコー)とモデルが自分の声に反応することがあります。ヘッドホンの使用を推奨します
cli/demo.py — UI なしのターミナル用音声ループ
Web UI を使わず、ローカルのマイクとスピーカーを sounddevice ライブラリ(PortAudio という音声入出力ライブラリの Python ラッパー)で直接扱うデモです。リアルタイム音声アプリの「音声再生まわりの泥臭い処理」——ジッターバッファ、フェードアウト、エコー抑制付き割り込み検出——を 1 ファイルで学べる、技術的に最も濃い example です。
コード解説
エージェント定義は最小限です。
@tool
def get_weather(city: str) -> str:
"""Get the weather in a city."""
return f"The weather in {city} is sunny."
agent = RealtimeAgent(
name="Assistant",
instructions="You always greet the user with 'Top of the morning to you'.",
tools=[get_weather],
)
音声設定の定数に、このデモの工夫が凝縮されています。
CHUNK_LENGTH_S = 0.04 # 40ms aligns with realtime defaults
SAMPLE_RATE = 24000
FORMAT = np.int16
CHANNELS = 1
ENERGY_THRESHOLD = 0.015 # RMS threshold for barge‑in while assistant is speaking
PREBUFFER_CHUNKS = 3 # initial jitter buffer (~120ms with 40ms chunks)
FADE_OUT_MS = 12 # short fade to avoid clicks when interrupting
PLAYBACK_ECHO_MARGIN = 0.002 # extra energy above playback echo required to count as speech
- 音声は 24kHz・モノラル・16 ビット整数(PCM16)で、40 ミリ秒ずつ送受信します
- ジッターバッファ: ネットワーク到着間隔のゆらぎで音が途切れないよう、再生開始前にチャンクを 3 個(約 120 ミリ秒)貯めます
- フェードアウト: 割り込みで音声をぶつ切りにすると「プチッ」というクリックノイズが出るため、12 ミリ秒かけて音量を線形に絞ってから止めます
セッションの開始部分では、RealtimeModelConfig に playback tracker と semantic_vad を設定しています。
runner = RealtimeRunner(agent)
# Attach playback tracker and enable server‑side interruptions + auto response.
model_config: RealtimeModelConfig = {
"playback_tracker": self.playback_tracker,
"initial_model_settings": {
"model_name": "gpt-realtime-2.1",
"turn_detection": {
"type": "semantic_vad",
"interrupt_response": True,
"create_response": True,
},
},
}
async with await runner.run(model_config=model_config) as session:
self.session = session
print("Connected. Starting audio recording...")
# Start audio recording
await self.start_audio_recording()
print("Audio recording started. You can start speaking - expect lots of logs!")
# Process session events
async for event in session:
await self._on_event(event)
app/ の server_vad と違い、こちらは意味を考慮する semantic_vad を使っています。
マイク採取のタスクも self.audio_capture_task として保持され、セッション終了時の finally で stop_audio_recording() が呼ばれます。このメソッドは recording = False にしたうえでタスクを cancel() し、asyncio.CancelledError を握りつぶしながら完了を待ちます。マイクの入力ストリームを確実に解放してから終了するためで、これを怠るとプロセスが終わってもデバイスが掴まれたままになることがあります(app/ の server.py と同じ「作ったタスクは終わらせる」作法です)。再生コールバック(OS の音声システムから定期的に呼ばれる関数)の中では、実際にスピーカーへ書き込んだバイト列をそのつど tracker に報告します。
# Inform playback tracker about played bytes
try:
self.playback_tracker.on_play_bytes(
item_id=item_id,
item_content_index=content_index,
bytes=chunk_data.tobytes(),
)
except Exception:
pass
マイク採取ループには「賢い割り込み(smart barge-in)」が実装されています。アシスタントが話している間は、マイク音声を無条件に送るのではなく、RMS(二乗平均平方根。音の大きさの指標)を計算して「スピーカーからのエコーより十分大きい音 = 本物のユーザー発話」のときだけ送信します。
# Smart barge‑in: if assistant audio is playing, send only if mic has speech.
assistant_playing = (
self.current_audio_chunk is not None or not self.output_queue.empty()
)
if assistant_playing:
# Compute RMS energy to detect speech while assistant is talking
samples = data.reshape(-1)
mic_rms = self._compute_rms(samples)
# Require the mic to be louder than the echo of the assistant playback.
playback_gate = max(
ENERGY_THRESHOLD,
self.playback_rms * 0.6 + PLAYBACK_ECHO_MARGIN,
)
if mic_rms >= playback_gate:
# Locally flush queued assistant audio for snappier interruption.
self.interrupt_event.set()
await self.session.send_audio(audio_bytes)
else:
await self.session.send_audio(audio_bytes)
イベントハンドラは、audio イベントで届いた音声を再生キューに積み、audio_interrupted でフェードアウトとキュー破棄を開始します。
elif event.type == "audio":
# Enqueue audio for callback-based playback with metadata
np_audio = np.frombuffer(event.audio.data, dtype=np.int16)
# Non-blocking put; queue is unbounded, so drops won’t occur.
self.output_queue.put_nowait((np_audio, event.item_id, event.content_index))
elif event.type == "audio_interrupted":
print("Audio interrupted")
# Begin graceful fade + flush in the audio callback and rebuild jitter buffer.
self.prebuffering = True
self.interrupt_event.set()
実行手順
追加依存をインストールします。
numpyとwebsocketsは voice extra に、sounddeviceは開発用依存グループ(uv syncでデフォルト導入)に含まれます。uv sync --extra voiceもし
sounddeviceが見つからないというエラーが出る場合はuv add sounddeviceを実行してください。API キーを設定します。
export OPENAI_API_KEY="sk-..."デモを起動します(単独スクリプトなのでファイル直接指定で実行できます)。
uv run python examples/realtime/cli/demo.pyConnected. Starting audio recording...と表示されたら、マイクに向かって話しかけます。終了はCtrl+Cです。
期待される動作・出力例
- instructions の指定どおり、アシスタントは "Top of the morning to you"(アイルランド風の朝の挨拶)で会話を始めます
- 「What's the weather in Tokyo?」のように聞くと
tool_start/tool_endのログとともにget_weatherが実行され、結果を音声で答えます - ターミナルには
Agent started: AssistantやRaw model event: ...(200 文字で切り詰め)などのログが大量に流れます。ソースコードのコメントにも "expect lots of logs!" とあるとおり、正常な動作です - アシスタントの発話中に大きめの声で話し始めると
Audio interruptedと表示され、音声が短いフェードで止まって応答が切り替わります
つまずきポイント
- macOS では初回実行時にターミナルアプリへのマイクアクセス許可を求められます。許可しないと無音が送られ続けます
- スピーカー再生時は、エコー検出のしきい値(
ENERGY_THRESHOLDなど)が環境と合わずに割り込みが誤発動・不発になることがあります。ヘッドホンを使うと安定します - Linux では
sounddeviceの下層にある PortAudio ライブラリが別途必要な場合があります(例:apt install libportaudio2)
twilio/ — 電話(Twilio Media Streams)との接続
Twilio(電話回線と Web をつなぐクラウド通信サービス)の Media Streams 機能を使い、実際の電話の通話音声を WebSocket 経由で Realtime API と橋渡しする example です。電話をかけると AI アシスタントが応答し、天気と現在時刻のツールを使いながらリアルタイムに会話できます。
必要なもの(外部アカウント等):
- Python 3.10 以上
- Realtime API にアクセスできる OpenAI API キー
- Twilio アカウントと電話番号(有料。トライアルでも可)
- ngrok などのトンネリングサービス(ローカルサーバーを公開 HTTPS URL で外部に晒すツール。Twilio からの着信 webhook を受けるために必要)
構成は server.py(FastAPI サーバー)と twilio_handler.py(橋渡しロジック本体)の 2 ファイルです。
コード解説(server.py)— 着信を WebSocket ストリームへ誘導
Twilio は着信があると、設定された webhook URL(/incoming-call)に HTTP リクエストを送ります。サーバーは TwiML(Twilio の動作を指示する XML)を返し、「挨拶を再生してから、通話音声を WebSocket にストリームせよ」と指示します。
@app.post("/incoming-call")
@app.get("/incoming-call")
async def incoming_call(request: Request):
"""Handle incoming Twilio phone calls"""
host = request.headers.get("Host")
twiml_response = f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say>Hello! You're now connected to an AI assistant. You can start talking!</Say>
<Connect>
<Stream url="wss://{host}/media-stream" />
</Connect>
</Response>"""
return PlainTextResponse(content=twiml_response, media_type="text/xml")
これを受けて Twilio が /media-stream に WebSocket 接続してくると、TwilioHandler が起動します。
コード解説(twilio_handler.py)— 音声フォーマット変換と再生追跡
エージェント定義は電話向けに「簡潔で親しみやすく」と指示されています。
agent = RealtimeAgent(
name="Twilio Assistant",
instructions=(
"You are a helpful assistant that starts every conversation with a creative greeting. "
"Keep responses concise and friendly since this is a phone conversation."
),
tools=[get_weather, get_current_time],
)
セッション設定で最重要なのが音声フォーマットです。電話網は歴史的に G.711 μ-law(8kHz・8 ビットの電話品質音声コーデック)を使うため、Realtime API 側にもそれを指定します。
self.session = await runner.run(
model_config={
"api_key": api_key,
"initial_model_settings": {
"model_name": "gpt-realtime-2.1",
"input_audio_format": "g711_ulaw",
"output_audio_format": "g711_ulaw",
"turn_detection": {
"type": "semantic_vad",
"interrupt_response": True,
"create_response": True,
},
},
"playback_tracker": self.playback_tracker,
}
)
await self.session.enter()
ここでは async with の代わりに await self.session.enter() でセッションに入っています(後始末を自分で管理する場合の書き方です)。この設定により、Twilio から届く μ-law 音声を変換せずそのまま Realtime API に流せます。
再生追跡には Twilio の mark イベントを使います。Twilio に音声を送る際、直後に名前付きの mark を送っておくと、その音声が実際に受話器で再生され終わったタイミングで同じ名前の mark が返ってきます。これを playback tracker への報告に使うのが、遠隔再生環境での正しい割り込み処理です。
async def _handle_mark_event(self, message: dict[str, Any]) -> None:
"""Handle mark events from Twilio to update playback tracker."""
try:
mark_data = message.get("mark", {})
mark_id = mark_data.get("name", "")
if mark_id in self._mark_data:
item_id, item_content_index, byte_count = self._mark_data[mark_id]
audio_bytes = b"\x00" * byte_count # Placeholder bytes for tracker
self.playback_tracker.on_play_bytes(item_id, item_content_index, audio_bytes)
割り込みが起きたときは、Twilio 側に溜まっている未再生音声を clear イベントで破棄させます。
elif event.type == "audio_interrupted":
print("Sending audio interrupted to Twilio")
await self.twilio_websocket.send_text(
json.dumps({"event": "clear", "streamSid": self._stream_sid})
)
また、接続直後の音声をいきなり送らず最初の数チャンク(環境変数 TWILIO_STARTUP_BUFFER_CHUNKS、デフォルト 3)を貯めてからまとめて送る「ウォームアップ」処理や、50 ミリ秒周期でバッファを監視して滞留を防ぐ _buffer_flush_loop など、電話特有の安定化の工夫も実装されています。
実行手順
依存をインストールします(
requirements.txtにはopenai-agents/fastapi/uvicorn[standard]/websockets/python-dotenvが列挙されています)。uv pip install -r examples/realtime/twilio/requirements.txtAPI キーを設定してサーバーを起動します(README の手順ではディレクトリ内から起動します)。
export OPENAI_API_KEY="sk-..." cd examples/realtime/twilio && uv run server.pyポートは環境変数
PORTで変更できます(デフォルト 8000)。別ターミナルで ngrok を起動し、公開 URL を控えます。
ngrok http 8000- Twilio Console で自分の電話番号を選び、着信時の webhook URL に
https://<ngrokのURL>/incoming-callを、HTTP メソッドに POST を設定します。 - その Twilio 番号に電話をかけます。
期待される動作・出力例
- 「Hello! You're now connected to an AI assistant. You can start talking!」という TwiML の挨拶が流れます
- 続いてエージェントが独自の挨拶をし、以降リアルタイムに会話できます。「今何時?」「ニューヨークの天気は?」と聞くとツールが実行されます
- サーバーのコンソールには
Media stream started with SID: ...やPlayback tracker updated: ...などのログが出ます
つまずきポイント
- 電話の音声は 8kHz の μ-law なので、Web デモ(24kHz)に比べて音質は明確に劣ります。これは仕様です
- Twilio・自サーバー・OpenAI の 3 者間のネットワーク遅延が応答速度に直結します
- ngrok の無料プランでは再起動のたびに URL が変わるため、Twilio 側の webhook 設定を更新し忘れないよう注意してください
次の twilio_sip/ は同じ「電話 × AI」でも音声の経路が根本的に異なります。先に図で比較しておきます。
twilio_sip/ — Realtime Calls API + SIP トランクで着信を受ける
同じ「電話 × AI」でも、こちらは音声を自サーバー経由で中継しません。Twilio の Elastic SIP Trunking(SIP = 電話の発着信を制御するインターネットプロトコル)で着信を OpenAI の SIP エンドポイントに直接転送し、自サーバーは OpenAI からの webhook(realtime.call.incoming)を受けて通話を「受諾」し、call_id でセッションにアタッチして観測・制御する構成です。音声データが自サーバーを通らないぶん構成がシンプルで、app/ と同様のトリアージ + 専門エージェント(FAQ / 記録更新)の handoff 構成が電話で動きます。
必要なもの(外部アカウント等):
- Python 3.10 以上
- Realtime API にアクセスできる OpenAI API キー
- OpenAI プロジェクトの webhook 設定と署名シークレット(
OPENAI_WEBHOOK_SECRET) - Twilio アカウント + 電話番号 + Elastic SIP Trunking の有効化
- ngrok などの公開 HTTPS エンドポイント
コード解説(agents.py)
app/agent.py と同じパターンで、トリアージ・FAQ・記録更新の 3 エージェントを realtime_handoff で相互接続しています。特徴的なのは、トリアージエージェントの instructions に固定の第一声を埋め込んでいる点です。
WELCOME_MESSAGE = "Hello, this is ABC customer service. How can I help you today?"
triage_agent = RealtimeAgent(
name="Triage Agent",
handoff_description="Greets callers and routes them to the most appropriate specialist.",
instructions=(
f"{RECOMMENDED_PROMPT_PREFIX} "
"Always begin the call by saying exactly: '"
f"{WELCOME_MESSAGE}' "
"before collecting details. Once the greeting is complete, gather context and hand off to "
"the FAQ or Records agents when appropriate."
),
handoffs=[
realtime_handoff(faq_agent, tool_name_override="transfer_to_faq_agent"),
realtime_handoff(records_agent, tool_name_override="transfer_to_records_agent"),
],
)
コード解説(server.py)— webhook 受諾とセッションアタッチ
処理の起点は OpenAI からの webhook です。AsyncOpenAI クライアントの webhooks.unwrap() が署名検証(webhook が本当に OpenAI から来たことをシークレットで確認する処理)を行います。
@app.post("/openai/webhook")
async def openai_webhook(request: Request) -> Response:
body = await request.body()
try:
event = client.webhooks.unwrap(body, request.headers)
except InvalidWebhookSignatureError as exc:
raise HTTPException(status_code=400, detail="Invalid webhook signature") from exc
if event.type == "realtime.call.incoming":
call_id = event.data.call_id
await accept_call(call_id)
_track_call_task(call_id)
return Response(status_code=200)
accept_call() は Realtime Calls API の REST エンドポイントを直接呼んで通話を受諾します(コード中のコメントによれば、執筆時点の AsyncOpenAI には高レベルヘルパーがないため client.post() を直接使っています)。
await client.post(
f"/realtime/calls/{call_id}/accept",
body={
"type": "realtime",
"model": "gpt-realtime-2.1",
"instructions": instructions_payload,
},
cast_to=dict,
)
受諾後、バックグラウンドタスク observe_call() が OpenAIRealtimeSIPModel を使ってセッションにアタッチします。デフォルトの WebSocket モデルの代わりに SIP 用モデルを RealtimeRunner に渡し、model_config の call_id で既存の通話に接続するのがポイントです。
runner = RealtimeRunner(assistant_agent, model=OpenAIRealtimeSIPModel())
try:
initial_model_settings: RealtimeSessionModelSettings = {
"turn_detection": {
"type": "semantic_vad",
"interrupt_response": True,
}
}
async with await runner.run(
model_config={
"call_id": call_id,
"initial_model_settings": initial_model_settings,
}
) as session:
接続直後には、発信者が黙っていても第一声が流れるよう、生のクライアントイベント response.create を session.model.send_event() で直接送っています。これは公式ガイドの「手動レスポンス制御(manual response control)」パターンの実例です。
await session.model.send_event(
RealtimeModelSendRawMessage(
message={
"type": "response.create",
"other_data": {
"response": {
"instructions": (
"Say exactly '"
f"{WELCOME_MESSAGE}"
"' now before continuing the conversation."
)
}
},
}
)
)
以降は history_added イベントから発信者の発話テキストとアシスタントの音声書き起こし(transcript)を取り出してログに出力します。音声そのものは OpenAI と電話網の間で直接やり取りされるため、このサーバーは「観測と制御」に徹しています。なお、同じ webhook が重複配送されても観測タスクが二重起動しないよう active_call_tasks 辞書で管理する、発信者が電話を切った後の再試行 webhook は 404 を握りつぶして正常応答する、といった実運用向けの防御も入っています。
実行手順
依存をインストールします(
requirements.txtはfastapi>=0.120.0/openai>=3.0.0,<4/uvicorn[standard]>=0.38.0)。uv pip install -r examples/realtime/twilio_sip/requirements.txt環境変数を設定します。
export OPENAI_API_KEY="sk-..." export OPENAI_WEBHOOK_SECRET="whsec_..."- OpenAI 側の設定: platform settings で対象プロジェクトを選び、
https://<公開ホスト>/openai/webhookを宛先にrealtime.call.incomingイベントの webhook を作成し、署名シークレットを控えます。 - Twilio 側の設定: Elastic SIP トランクを作成し、Origination タブに
sip:proj_<プロジェクトID>@sip.api.openai.com;transport=tlsという Origination SIP URI を追加、そのトランクに電話番号を最低 1 つ紐付けます。 サーバーを起動します(パッケージ型なのでモジュールパスで指定します)。
uv run uvicorn examples.realtime.twilio_sip.server:app --host 0.0.0.0 --port 8000ngrok で公開します。
ngrok http 8000
期待される動作・出力例
- トランクに紐付けた Twilio 番号へ電話をかけると、Twilio が通話を
sip.api.openai.comに転送し、OpenAI がrealtime.call.incomingwebhook を発火、本サーバーが受諾します - トリアージエージェントが「Hello, this is ABC customer service. How can I help you today?」と第一声を発します
- 質問内容に応じて FAQ Agent(
faq_lookup_tool)または Records Agent(update_customer_record)へ handoff されます - サーバーのコンソールに
Caller: ...(発信者の発話)とAssistant (audio transcript): ...(アシスタント発話の書き起こし)が流れ、電話を切るとCall <id> endedと表示されます
つまずきポイント
- webhook の署名シークレット(
OPENAI_WEBHOOK_SECRET)を間違えると、すべての着信がInvalid webhook signature(400)で弾かれます twilio/(Media Streams)との違いを混同しやすい点に注意してください。twilio/は音声が自サーバーを通る中継方式、twilio_sip/は音声は OpenAI と電話網で直結し自サーバーは webhook とcall_idアタッチで制御する方式です- Twilio の SIP トランク設定(Origination URI の
transport=tlsまで含めた正確な記述)が誤っていると、着信しても webhook が届きません
まとめ
- リアルタイムエージェントは Realtime API 上の speech-to-speech 方式で、第14章のパイプライン型(STT → LLM → TTS)と違い、WebSocket 常時接続による低遅延の双方向会話と発話割り込みに対応します
- 中核は
RealtimeAgent(定義)・RealtimeRunner(セッション工場)・RealtimeSession(ライブセッション)の 3 つで、async for event in session:でイベントを処理し続けるのが基本形です。Python SDK はブラウザ WebRTC を提供せず、サーバー側 WebSocket と SIP アタッチが守備範囲です turn_detection(server_vad/semantic_vad)が発話の区切りと割り込みを自動処理し、RealtimePlaybackTrackerが「実際に再生された位置」を報告することで割り込み時の履歴を正しく保ちますapp/は FastAPI + WebSocket の Web デモで、画像入力(input_imageの構造化メッセージ)とツール承認(needs_approval=True→tool_approval_required→approve_tool_call()/reject_tool_call())を実演しますcli/demo.pyはマイク/スピーカー直結のターミナルデモで、ジッターバッファ・フェードアウト・エコー抑制付き barge-in という音声再生の実務的な工夫を学べます- 電話連携には 2 方式あります。
twilio/は Media Streams で音声を自サーバー中継(G.711 μ-law・8kHz、mark イベントで再生追跡)、twilio_sip/は SIP トランク +realtime.call.incomingwebhook +OpenAIRealtimeSIPModelのcall_idアタッチで、音声を中継せずに制御します
理解度チェック
第14章の音声パイプライン型と本章のリアルタイムエージェントの、処理方式の違いを説明してください。
turn_detection の server_vad と semantic_vad はそれぞれどのような方式ですか。
RealtimePlaybackTracker は何のために存在しますか。これがないと、ユーザーが割り込んだときにどのような問題が起きますか。
app/ の example で、モデルが update_seat ツールを呼ぼうとしたとき、実行前に何が起きますか。アプリ側はどの API で応答しますか。
twilio/ と twilio_sip/ の 2 つの電話連携方式の最大の違いは何ですか。
解答と解説
問1 の解答と解説
音声パイプライン型は STT(音声認識)→ エージェント(テキスト処理)→ TTS(音声合成)という 3 段階を直列に実行するため、各段の処理時間が積み重なりレイテンシが大きく、割り込みも基本的にできません。リアルタイムエージェントは音声対応モデル 1 つが音声を音声のまま処理する speech-to-speech 方式で、WebSocket の常時接続上で音声チャンクを双方向にストリーミングするため低遅延であり、発話中の割り込み(barge-in)にも対応します。
問2 の解答と解説
server_vad は音声区間検出(VAD)ベースの方式で、無音が指定時間(例: silence_duration_ms: 500)続いたら発話終了と判定します。semantic_vad は音量だけでなく発話内容の意味も考慮して「話し終わったか」を判定する方式です。いずれも interrupt_response: True を併用すると、ユーザーの発話開始でモデルの応答を自動中断できます。
問3 の解答と解説
モデルは音声を先行生成してクライアントへ送るため、割り込み時点で「送信済みだが未再生」の音声が残ります。playback tracker がないと、会話履歴上はその未再生部分まで「話し終えた」扱いになり、ユーザーが実際に聞いた内容と履歴がズレます。RealtimePlaybackTracker.on_play_bytes() で実際の再生量を報告することで、割り込み時の履歴切り詰めが「実際に聞こえた位置」で行われます。電話(Twilio)のように再生が遠隔で遅延する環境では特に重要で、twilio/ example は Twilio の mark イベントを使ってこれを実装しています。
問4 の解答と解説
update_seat は @tool(needs_approval=True) で定義されているため、モデルが呼び出そうとするとツールは実行されず、セッションが tool_approval_required イベント(ツール名・引数・call_id を含む)を発行して保留状態になります。アプリ側は session.approve_tool_call(call_id) で許可、session.reject_tool_call(call_id) で拒否します(always=True を渡すと以後の同ツール呼び出しにも決定を適用)。app/ ではこのイベントがブラウザに中継され、window.confirm ダイアログでユーザーが判断します。
問5 の解答と解説
音声データの経路が違います。twilio/(Media Streams 方式)は、通話音声が Twilio → 自サーバーの WebSocket → Realtime API と自サーバーを経由して中継されます(そのため G.711 μ-law の音声処理コードをサーバーに書きます)。twilio_sip/(SIP 方式)は、Twilio の SIP トランクが着信を OpenAI の SIP エンドポイントへ直接転送するため音声は自サーバーを通らず、自サーバーは realtime.call.incoming webhook で通話を受諾し、OpenAIRealtimeSIPModel と call_id でセッションにアタッチして観測・制御のみを行います。
参考リンク
- Realtime クイックスタート: https://openai.github.io/openai-agents-python/realtime/quickstart/ (日本語版: https://openai.github.io/openai-agents-python/ja/realtime/quickstart/ )
- Realtime エージェントガイド(ライフサイクル・承認・handoff・ガードレール・低レベル制御): https://openai.github.io/openai-agents-python/realtime/guide/ (日本語版: https://openai.github.io/openai-agents-python/ja/realtime/guide/ )
- Realtime トランスポート(WebSocket と SIP の選び方): https://openai.github.io/openai-agents-python/realtime/transport/ (日本語版: https://openai.github.io/openai-agents-python/ja/realtime/transport/ )
- コード例一覧: https://openai.github.io/openai-agents-python/examples/ (日本語版: https://openai.github.io/openai-agents-python/ja/examples/ )
- examples/realtime ソースコード: https://github.com/openai/openai-agents-python/tree/main/examples/realtime
第16章 サンドボックスエージェント(sandbox)
現行のSandbox Agentsガイドは、認証・モデル呼び出し・承認・監査を扱うハーネスと、ファイルやコマンドを扱うcomputeを分離して設計します。SDKではあなたのアプリがハーネスを動かし、Agents APIではOpenAIが動かします。サンドボックスの選択はこの管理主体を変更しません。モデルAPIキーなど制御側の資格情報を、モデルが実行するコマンドへ無条件に継承させないでください。公式:Sandbox Agents
注意(ベータ機能): サンドボックスエージェントは確認時点(openai-agents v0.22.2)でベータ版です。API の詳細・デフォルト値・サポートされる機能は一般提供までに変更される可能性があります。
この章で学ぶこと
- SandboxAgent とは何か — 「ファイルシステムとシェルを持つ隔離された作業場(サンドボックス)」で実際にコマンドを実行しながら働くエージェントの仕組み
- ワークスペースの初期状態を宣言する Manifest、エージェントに道具を与える Capability、実行環境を選ぶ SandboxRunConfig という 3 つの中核概念
- ローカル(Unix-local)、Docker、クラウド各社(E2B / Modal / Daytona 等)というサンドボックスバックエンドの選び方
- スナップショットによる作業の中断・再開と、Memory capability による「実行をまたいだ学習」
- handoff・agents-as-tools・MCP・function tool など、これまでの章で学んだ部品とサンドボックスの組み合わせ方
examples/sandbox配下の全 example(トップレベル 12 本 + 6 つのサブディレクトリ)の内容と実行方法
前提知識
v0.22.1 / v0.22.2:UnixLocalの環境とファイル操作
UnixLocalSandboxClient(inherit_host_environment=False) で親プロセスの環境変数を許可リストに絞れます。host_environment_allowlist を渡すと既定リストを置き換えます。これは環境変数の継承を制御する設定で、ローカルのshellコマンドをOSレベルで隔離するものではありません。v0.22.2にはファイルAPIのsymlink race修正も含まれます。サンドボックスクライアント
この章は自分で Runner を動かす SandboxAgent の説明です。OpenAIがハーネスも管理する構成はAgents APIの実行環境を参照してください。
- 第1章: リポジトリのクローン、
uv syncによる環境構築、OPENAI_API_KEYの設定、example の実行方法 - 第2章:
Agentの作成とRunner.run()/Runner.run_streamed()による実行、result.final_outputの読み方 - 第3章: ツールの基本(
function_tool、ShellToolなどの組み込みツール) - 第4章: agents-as-tools パターン(エージェントを別のエージェントのツールとして公開する
as_tool()) - 第5章: handoff(エージェント間で会話の主導権を引き継ぐ仕組み)
- 第6章: MCP(Model Context Protocol。外部ツールサーバーとの標準接続規格)
- 第8章: セッションメモリ(
SQLiteSessionによる会話履歴の自動管理) - Python の
async/await(非同期処理。処理の完了を待つ間に他の処理を進められる書き方)。本章の全 example で使われます - Docker の基礎知識があると理想的です。Docker はアプリケーションを「コンテナ」(ホスト OS から隔離された軽量な実行環境)で動かすツールで、本章の多くの example が Docker を必要とします
概念解説
エージェントに「作業机」を与える
これまでの章のエージェントは、たとえるなら「電話越しに口頭で質問に答えるオペレーター」でした。ツール(第3章)で外部の情報を取ってくることはできても、エージェント自身が自由に使えるファイル置き場や作業スペースは持っていませんでした。
しかし現実の知的作業の多くは、机の上に資料を広げ、書き込み、道具を使い、成果物を作るという形をとります。バグ修正なら「リポジトリを開いて、コードを読んで、直して、テストを走らせる」。書類仕事なら「元書類を読んで、様式に転記して、完成品をフォルダに保存する」。こうした作業にはファイルシステム(ファイルとフォルダを管理する仕組み)とシェル(ls や cat のようなコマンドを実行する対話環境)が必要です。
SandboxAgent(サンドボックスエージェント)は、この「作業机つきのエージェント」を実現する仕組みです。サンドボックス(sandbox。直訳は「砂場」)とは、作業用のファイルシステムと実行環境を用意する仕組みです。隔離の強さはバックエンドと設定によって異なります。特にUnixLocalはホスト上のプロセスとして動き、ファイルやネットワークへアクセスできるため、PC本体を保護する隔離境界として扱えません。Dockerやホスト型環境を選ぶ場合も、マウント・権限・ネットワークを用途に合わせて設定します。
公式ドキュメントは SandboxAgent を次のように位置づけています。
SandboxAgentis still anAgent.(SandboxAgent はあくまで Agent である)
つまり instructions、tools、handoffs、mcp_servers、output_type、ガードレール、フックといった通常の Agent の機能はすべてそのまま使え、実行も通常どおり Runner 経由です。変わるのは「実行の境界」だけで、エージェントにワークスペース(workspace。サンドボックス内の作業ディレクトリ)が付与されます。
3 つの中核部品 — Manifest・Capability・SandboxRunConfig
サンドボックスエージェントの設計は、次の 3 つの質問に答えることに対応します。
| 部品 | 答える質問 | 日常の比喩 |
|---|---|---|
Manifest | 作業開始時、机の上に何を置いておくか? | 作業机に並べる書類一式のリスト |
Capability | エージェントにどんな道具を持たせるか? | 工具箱の中身(シェル、ファイル編集、スキル等) |
SandboxRunConfig | 今回の実行はどの部屋(実行環境)で行うか? | 「自宅の机」「レンタルオフィス」「クラウド上の仮想デスク」の選択 |
Manifest(マニフェスト)は、新しいサンドボックスセッションを作るときのワークスペースの初期状態の宣言です。エントリとして次のような種類を混在できます。
| Manifest エントリ | 用途 |
|---|---|
File / Dir | コード中で直接定義する小さな合成ファイル・ディレクトリ(出力先フォルダにも使う) |
LocalFile / LocalDir | ホスト(あなたの PC)上の実ファイル・ディレクトリをサンドボックスへコピー |
GitRepo | Git リポジトリをワークスペースへ取得 |
S3Mount / GCSMount / R2Mount / AzureBlobMount 等 | 外部ストレージをワークスペース内にマウント(外部の保存領域をフォルダとして見えるように接続すること) |
エントリのパスはワークスペース相対で、絶対パスや .. による脱出は禁止されています。ワークスペースの外の特定パスにアクセスさせたい場合だけ、extra_path_grants で明示的に許可を与えます。
Capability(ケイパビリティ、機能)は、サンドボックス専用のツールや指示文をエージェントに取り付ける部品です。組み込みで次の 5 つが用意されています。
| Capability | 提供するもの |
|---|---|
Shell | exec_command ツール(シェルコマンド実行)。PTY 対応バックエンドでは write_stdin も追加 |
Filesystem | apply_patch(差分形式でのファイル作成・編集・削除)と view_image(画像の視覚的な確認) |
Skills | スキル(SKILL.md に手順を書いた再利用可能な作業マニュアル)の発見と読み込み |
Memory | 実行をまたいだ記憶の読み書き(後述) |
Compaction | 長い会話のコンテキスト(モデルに渡す入力全体)を自動で圧縮する仕組み |
重要な仕様として、SandboxAgent の capabilities を省略すると Capabilities.default() が使われ、その中身は Filesystem(), Shell(), Compaction() の 3 点セットです。capabilities=[...] を明示的に渡すとデフォルトは丸ごと置き換えられるため、デフォルトも残したい場合は Capabilities.default() + [Skills(...)] のように連結します。
SandboxRunConfig は RunConfig(sandbox=...) として Runner に渡す実行時設定で、「今回の実行がサンドボックスセッションをどう入手するか」を決めます。主な指定方法は 3 通りです。
client=...— サンドボックスクライアントを渡し、Runner に新しいセッションを作らせる(一番手軽)session=...— 自分で作成・起動済みのセッションを注入する(ライフサイクルを自分で管理したいとき)session_state=.../snapshot=...— 保存済みの状態やスナップショットから再開・初期化する
バックエンド(サンドボックスクライアント)の選択
「どの部屋で作業するか」の選択肢がサンドボックスクライアントです。
| クライアント | 追加インストール | 選ぶ場面 |
|---|---|---|
UnixLocalSandboxClient | 不要 | macOS / Linux 上で最速のローカル開発。一時ディレクトリをワークスペースとして使う |
DockerSandboxClient | uv sync --extra docker + Docker 本体 | コンテナ隔離や特定イメージでの再現性が欲しいとき |
| クラウド各社のクライアント | 各社の extra + API キー | 本番相当の隔離やホスト型実行が欲しいとき(E2B / Modal / Daytona / Cloudflare / Vercel / Runloop / Blaxel) |
ポイントは、SandboxAgent の定義はそのままに、SandboxRunConfig のクライアントだけ差し替えられることです。ローカルで開発して、そのまま Docker やクラウドに移行できます。
スナップショットとメモリ — 作業の続きができるエージェント
サンドボックスセッションは使い捨てにもできますが、実務では「昨日の続きから再開したい」ことがよくあります。そのための仕組みが 2 つあります。
- スナップショット(snapshot): ワークスペースの状態を丸ごと保存した「写真」。
LocalSnapshotSpec(ローカル保存)やRemoteSnapshotSpec(S3 などのリモート保存)を指定してセッションを作ると、停止時に保存され、client.resume(...)で同じ状態から新しいセッションを再開できます - Memory capability: 会話履歴そのものではなく「過去の実行から得た教訓」をファイル(
memories/MEMORY.md等)として蒸留・保存する仕組み。第8章のセッションメモリ(会話履歴の保存)とは別物で、次回の実行が探索をやり直さずに済むようにします
この章の memory.py 系 example は、まさに「1 回目の実行でバグを直し、2 回目の実行がその記憶を読んで回帰テストを書く」という流れを実演します。
いつ SandboxAgent を使うべきか
公式ガイドの指針をまとめると次のとおりです。
- ファイルや生きたファイルシステムが不要なら、通常の
Agentのままでよい - シェルアクセスが「たまに使う 1 ツール」程度なら、ツールガイドのホスト型シェルで十分
- ワークスペースの隔離・バックエンドの選択・セッションの再開が設計の一部になるなら SandboxAgent の出番
典型的な適用領域は、コーディングとデバッグ、ドキュメント処理(税務書類の下書き作成など)、ファイルに基づくレビューや分析、レビュアーごとに独立ワークスペースを持たせるマルチエージェント構成、複数回にまたがるワークスペース作業です。
実行コマンド一覧(カテゴリ README より)
examples/sandbox/README.md には「小さな API example」の実行コマンド表があります。以降の個別解説の前に全体像として掲げておきます。ほとんどの example はモデルを呼び出すため、OPENAI_API_KEY をリポジトリルートの .env、example 個別の .env、またはシェル環境変数のいずれかに設定しておいてください。
| Example | 実行コマンド | 見どころ |
|---|---|---|
basic.py | uv run python examples/sandbox/basic.py | Manifest からセッションを作り、SandboxAgent を実行して結果をストリーミング |
handoffs.py | uv run python examples/sandbox/handoffs.py | サンドボックスエージェントを含む handoff |
sandbox_agent_capabilities.py | uv run python examples/sandbox/sandbox_agent_capabilities.py | ワークスペース capability の総合デモ |
sandbox_agent_with_tools.py | uv run python examples/sandbox/sandbox_agent_with_tools.py | サンドボックス capability とホスト側ツールの併用 |
sandbox_agents_as_tools.py | uv run python examples/sandbox/sandbox_agents_as_tools.py | サンドボックスエージェントを別エージェントのツールとして公開 |
sandbox_agent_with_remote_snapshot.py | uv run python examples/sandbox/sandbox_agent_with_remote_snapshot.py | リモートスナップショットからの開始 |
memory.py | uv run python examples/sandbox/memory.py | スナップショット再開をまたいで 1 エージェントを 2 回実行し、記憶を読み書き |
memory_s3.py | source ~/.s3.env && uv run python examples/sandbox/memory_s3.py | 2 つの新規 Docker サンドボックス間で S3 保存のメモリを共有 |
memory_multi_agent_multiturn.py | uv run python examples/sandbox/memory_multi_agent_multiturn.py | 1 つのワークスペースを共有する 2 エージェントのメモリ分離 |
shared_session_workdirs.py | uv run python examples/sandbox/shared_session_workdirs.py | 1 つのサンドボックスを信頼できるエージェント間で共有しつつ、Shell・view_image・apply_patch が実行ごとの cwd を基準に相対パスを解決する(閉じ込めではない。信頼できないエージェントや計算資源の分離にはセッションを分ける) |
unix_local_pty.py | uv run python examples/sandbox/unix_local_pty.py | Unix ローカルサンドボックスでの対話的擬似端末(PTY) |
unix_local_runner.py | uv run python examples/sandbox/unix_local_runner.py | Unix ローカルバックエンドを直接使う最小 example |
クラウドバックエンドの example は extensions/ 配下にあり、OPENAI_API_KEY に加えて各プロバイダーの認証情報が必要です。チュートリアルは tutorials/、ワークフロー実例は healthcare_support/、Docker 固有のヘルパーは docker/、共有コードは misc/ にあります。
各 example の解説
unix_local_runner.py — まずはここから
Docker なしで動く、最小構成の Unix ローカルサンドボックス example です。契約更新パケット(4 つの Markdown ファイル)をワークスペースに用意し、エージェントにシェルで読ませてから要約させます。ファイル冒頭のコメントにも「Start here if you want the simplest Unix-local sandbox example.(最小の Unix ローカル例が欲しいならここから始めてください)」とあります。
コード解説: Manifest は共有ヘルパー text_manifest()(後述の misc/ 参照)で文字列辞書から組み立てます。さらに extra_path_grants で「ワークスペース外のパスへのアクセス許可」を実演しています。
).model_copy(
update={
"extra_path_grants": (
SandboxPathGrant(
path=str(external_dir),
read_only=True,
description="read-only external renewal packet notes",
),
SandboxPathGrant(
path=str(scratch_dir),
description="temporary renewal packet scratch files",
),
)
},
deep=True,
)
SandboxPathGrant は、ワークスペース外の絶対パス(ここでは一時ディレクトリ内の external/ と scratch/)への読み取り専用/読み書きアクセスをエージェントに与えます。read_only=True のパスへの書き込みは WorkspaceArchiveWriteError で拒否されることを、--verify-extra-path-grants フラグの検証コードが確かめています。
エージェント本体と実行部分は次のとおりです。
agent = SandboxAgent(
name="Renewal Packet Analyst",
model=model,
instructions=(
"You review renewal packets for an account team. Inspect the packet before "
"answering. Keep the response concise, business-focused, and cite the file names "
"that support each conclusion. ..."
),
default_manifest=manifest,
capabilities=[WorkspaceShellCapability()],
)
# With Unix-local sandboxes, the runner creates and cleans up the temporary workspace for us.
run_config = RunConfig(
sandbox=SandboxRunConfig(client=UnixLocalSandboxClient()),
workflow_name="Unix local sandbox review",
tracing_disabled=True,
)
SandboxRunConfig(client=UnixLocalSandboxClient()) とクライアントだけを渡しているのがポイントです。セッションの作成と後片付けは Runner がすべて代行します。
実行手順:
OPENAI_API_KEYを設定します(リポジトリルートの.envまたはシェル)uv run python examples/sandbox/unix_local_runner.pyを実行します- ストリーミング表示(モデルの出力を少しずつ受け取ってリアルタイム表示する方式)で見たい場合は
--streamを付けます - モデル呼び出しなしでパス許可の仕組みだけ確認したい場合は
--verify-extra-path-grantsを付けます(API キー不要)
期待される動作: エージェントが account_brief.md 等のファイル名を引用しながら、顧客状況・ブロッカー・次のアクション 2 つを簡潔にまとめた回答を出力します。--verify-extra-path-grants では extra_path_grants verification passed と表示されます。
つまずきポイント: UnixLocalSandboxClient は macOS / Linux 用です。Windows の場合は WSL か Docker バックエンドを使ってください。また Unix ローカルはプロセス隔離が弱い(あなたの PC 上の一時ディレクトリで直接実行される)ため、信頼できないコードを扱う用途では Docker 以上の隔離を選びます。
basic.py — Docker / Modal バックエンドとセッションの明示管理
カテゴリの看板 example です。合成ファイル 3 つの小さなワークスペースを作り、Docker(デフォルト)または Modal のサンドボックスで SandboxAgent を実行し、結果をストリーミング表示します。unix_local_runner.py との最大の違いは、セッションを自分で作成して Runner に注入する流れを見せることです。
コード解説: Manifest はバイト列を直接指定する File エントリで組み立てます。
return Manifest(
entries={
"README.md": File(
content=(
b"# Demo Project\n\n"
+ (
f"This sandbox contains a tiny demo project for the {backend_label} "
"sandbox runner.\n"
).encode()
+ b"The goal is to show how Runner can prepare a sandbox workspace.\n"
)
),
"src/app.py": File(
content=b'def greet(name: str) -> str:\n return f"Hello, {name}!"\n'
),
...
}
)
Docker バックエンドのセッション作成部分では、DockerSandboxClient に Docker SDK のクライアントを渡し、使用イメージを指定します。DEFAULT_PYTHON_SANDBOX_IMAGE の実体は python:3.14-slim です(src/agents/sandbox/config.py で定義)。
client = DockerSandboxClient(docker_from_env())
sandbox = await client.create(
manifest=manifest,
options=DockerSandboxClientOptions(image=DEFAULT_PYTHON_SANDBOX_IMAGE),
)
return client, sandbox
実行部分では、作成済みセッションを SandboxRunConfig(session=sandbox) として注入し、async with sandbox でライフサイクルを管理します。
# `async with sandbox` keeps the example on the public session lifecycle API.
# `Runner` reuses the already-running session without starting it a second time.
async with sandbox:
# `Runner.run_streamed()` drives the model and yields text and tool events in real time.
result = Runner.run_streamed(
agent,
question,
run_config=RunConfig(
sandbox=SandboxRunConfig(session=sandbox),
workflow_name=f"{backend.title()} sandbox example",
),
)
ストリームからは、アシスタントの文字単位の出力(ResponseTextDeltaEvent)と、ツール呼び出し・ツール出力のイベント(run_item_stream_event)の両方が流れてきて、[tool call] shell のようなバナーを挟みながら表示されます。最後に finally ブロックで await client.delete(sandbox) を呼び、コンテナを確実に削除します。
実行手順:
- Docker Desktop(または Docker デーモン)を起動しておきます
- Docker SDK を導入します:
uv sync --extra docker(リポジトリ開発ならmake syncでも可) OPENAI_API_KEYを設定しますuv run python examples/sandbox/basic.pyを実行します(初回はpython:3.14-slimイメージの取得が走ります)- Modal で動かす場合は
uv sync --extra modalとトークン設定の上でuv run python examples/sandbox/basic.py --backend modalを実行します
期待される動作: sandbox.ls(".") の結果(ワークスペースのファイル一覧)が最初に表示され、続いて [tool call] shell バナーとともにエージェントがファイルを調べ、最後にプロジェクトの 2 文要約が出力されます。
つまずきポイント: このファイルは examples/sandbox/docker/ というローカルディレクトリが Python の docker パッケージと名前衝突する問題を、_import_docker_from_env() で sys.path を一時調整して回避しています。直接実行で import エラーが出る場合は、エラーメッセージにあるとおりモジュール形式 uv run python -m examples.sandbox.basic --backend docker で実行してください。
sandbox_agent_capabilities.py — 組み込み capability の総点検
Filesystem / Shell / Skills / Compaction という組み込み capability を 1 回の実行で全部使わせ、その痕跡をコードで検証する「スモークテスト」型の example です。エージェントは手順書どおりに、ワークスペース調査 → README.md 読解 → view_image で画像確認 → スキル読み込み → ファイル作成 → apply_patch で更新 → ファイル削除 → シェルで内容表示、と進みます。
コード解説: capability の組み立てが本題です。デフォルト 3 点セットに Skills を足し、さらに Filesystem の apply_patch の承認要求(human-in-the-loop)をこのデモでは無効化しています。
capabilities = Capabilities.default() + [
Skills(
lazy_from=LocalDirLazySkillSource(
# This is a host path read by the SDK process.
# Requested skills are copied into `skills_path` in the sandbox.
source=LocalDir(src=skills_root),
)
),
]
def apply_patch_needs_approval(
ctx: RunContextWrapper[Any], operation: ApplyPatchOperation, call_id: str
):
return False
def _configure_filesystem(toolset: FilesystemToolSet):
toolset.apply_patch.needs_approval = apply_patch_needs_approval
for capability in capabilities:
if isinstance(capability, Filesystem):
capability.configure_tools = _configure_filesystem
Skills(lazy_from=LocalDirLazySkillSource(...)) は遅延読み込み方式のスキル供給です。ホスト側のスキルディレクトリ(この example では一時ディレクトリに SKILL.md を動的生成)をインデックス化し、モデルが load_skill ツールを呼んだときに初めてサンドボックス内へコピーします。スキルが多いときにワークスペースを汚さない推奨方式です。
実行後の検証コードでは、期待するツール群が実際に呼ばれたかを確認しています。
expected_tools = {
"load_skill",
"apply_patch",
"exec_command",
"view_image",
}
missing_tools = expected_tools - set(tool_calls)
さらに view_image の出力が data:image/ で始まるデータ URL の ToolOutputImage であること、Compaction capability がモデル設定に context_management パラメータを付与していること(RecordingModel というラッパーで最初のモデル呼び出しを記録して確認)、検証ファイルの中身が期待の 4 行と一致することまでチェックします。
実行手順:
OPENAI_API_KEYを設定しますuv run python examples/sandbox/sandbox_agent_capabilities.pyを実行します(Docker 不要。Unix ローカルで動きます)
期待される動作: [tool:call] exec_command などのイベントログが順に流れ、最後に === Final summary === として final_output: capability smoke complete、使用ツール一覧、検証ファイルの内容が表示されます。検証に失敗すると RuntimeError で異常終了します。
つまずきポイント: このファイルは docs/assets/images/graph.png を LocalFile でワークスペースに持ち込むため、リポジトリのチェックアウト全体が手元にある前提です。また、Manifest の extra_path_grants にスキルディレクトリを渡しているのは、一時ディレクトリが SDK プロセスの作業ディレクトリ外にあるためです(LocalDir.src は既定では作業ディレクトリ配下しか許可されません)。
sandbox_agent_with_tools.py — サンドボックス + function tool + MCP の三刀流
1 つの SandboxAgent に、(1) サンドボックスのシェル capability、(2) 通常の Python 関数ツール、(3) ローカル MCP サーバー、という出自の異なる 3 種類のツールを同時に持たせる example です。題材は企業契約の更新レビューで、割引の承認ルートを関数ツールで、ポリシー確認を MCP で行います。
注記: この example はリポジトリ同梱の MCP v2 リファレンスサーバーを起動するため、ロックされた開発環境(
uv sync済みのこのリポジトリ)で実行することが前提です。Agents SDK のクライアント側は MCP v1 / v2 の両方に対応しています(第6章の「バージョン注記」参照)。
コード解説: 関数ツールは第3章で学んだ @tool そのままです。
@tool
def get_discount_approval_path(discount_percent: int) -> str:
"""Return the approver required for a proposed discount percentage."""
if discount_percent <= 10:
return "The account executive can approve discounts up to 10 percent."
if discount_percent <= 15:
return "The regional sales director must approve discounts from 11 to 15 percent."
return "Finance and the regional sales director must both approve discounts above 15 percent."
MCP サーバーは同じ examples 内の misc/reference_policy_mcp_server.py(FastMCP 製の 25 行のサーバー)を標準入出力(stdio)経由で起動します。そして SandboxAgent に 3 種類を同時装備させます。
agent = SandboxAgent(
name="Renewal Review Assistant",
model=model,
instructions=(
"You review renewal requests. Inspect the packet, use "
"`get_discount_approval_path` for discount routing, and use the MCP reference "
"policy server when you need confirmation. ..."
),
default_manifest=manifest,
tools=[get_discount_approval_path],
mcp_servers=[server],
capabilities=[WorkspaceShellCapability()],
)
「SandboxAgent はあくまで Agent」なので、tools= と mcp_servers= がそのまま使えることが分かります。
実行手順:
OPENAI_API_KEYを設定しますuv run python examples/sandbox/sandbox_agent_with_tools.pyを実行します(Docker 不要)
期待される動作: [tools used] shell, get_discount_approval_path, get_policy_reference のような使用ツール一覧に続き、「14% の割引には地域セールスディレクターの承認が必要」「セキュリティレビューは未完了」といった、ワークスペースの文書・関数ツール・MCP の三者を突き合わせた回答が出力されます。
sandbox_agents_as_tools.py — サンドボックスエージェントをツール化する
第4章で学んだ agents-as-tools パターンのサンドボックス版です。独立したワークスペースを持つ 2 人のレビュアー(価格レビュアーとロールアウトレビュアー)を、ファイルを見ない外側のオーケストレーター(全体の指揮役エージェント)がツールとして呼び出します。
コード解説: 各レビュアーには別々の Manifest(価格系文書とロールアウト系文書)を与え、output_type に Pydantic モデルを指定して構造化出力(自由文ではなく型の決まった JSON を返させる仕組み)にしています。ツール化の中心部は次のとおりです。
tools=[
pricing_agent.as_tool(
tool_name="review_pricing_packet",
tool_description="Inspect the pricing packet and summarize commercial risk.",
custom_output_extractor=_structured_tool_output_extractor,
run_config=pricing_run_config,
max_turns=6,
),
rollout_agent.as_tool(
tool_name="review_rollout_risk",
tool_description="Inspect the rollout packet and summarize implementation risk.",
custom_output_extractor=_structured_tool_output_extractor,
run_config=rollout_run_config,
max_turns=6,
),
get_discount_approval_rule,
],
注目点は as_tool(..., run_config=...) です。ツールごとに独立した RunConfig(sandbox=SandboxRunConfig(client=UnixLocalSandboxClient())) を渡すことで、各レビュアーの実行時にそれぞれ専用のサンドボックスが作られ、互いのファイルは一切見えません。custom_output_extractor は、内側のエージェントの構造化出力(Pydantic モデル)を JSON 文字列に変換してオーケストレーターへ返すための変換関数です。
実行手順:
OPENAI_API_KEYを設定しますuv run python examples/sandbox/sandbox_agents_as_tools.pyを実行します(Docker 不要)
期待される動作: [tools used] review_pricing_packet, review_rollout_risk, get_discount_approval_rule に続き、価格リスク・ロールアウトリスク・次のアクションをまとめた推薦文が出力されます。割引率 15% は review_pricing_packet の JSON から取り出され、承認ルールの関数ツールに渡されます。
つまずきポイント: オーケストレーター自身は通常の Agent なので、Runner.run(orchestrator, ...) にはサンドボックス設定を渡していません。サンドボックスが必要なのはツール側だけ、という切り分けを覚えておきましょう。
handoffs.py — サンドボックスエージェントへの引き継ぎ
第5章の handoff(会話の主導権の引き継ぎ)にサンドボックスを組み合わせます。流れは「受付(通常 Agent)→ サンドボックスレビュアー(SandboxAgent)→ 文書化担当(通常 Agent)」の 3 段引き継ぎです。
コード解説: 中間のレビュアーだけがワークスペース(オンボーディング資料 3 ファイル)を検査でき、検証済みの事実を最終ライターに handoff します。
# This sandbox agent can inspect the workspace, then hand its findings to the writer above.
sandbox_reviewer = SandboxAgent(
name="Onboarding Packet Reviewer",
model=model,
instructions=(
"You inspect onboarding documents in the sandbox, verify the facts, then hand off "
"to the account executive assistant to draft the final note. Do not answer the user "
"directly after reviewing the packet."
),
default_manifest=manifest,
handoffs=[account_manager],
capabilities=[WorkspaceShellCapability()],
)
# The starting agent is a normal agent. It only decides when to hand off into the sandbox.
intake_agent = Agent(
name="Deal Desk Intake",
model=model,
instructions=(
"You triage internal requests. If a request depends on attached documents, hand off "
"to the onboarding packet reviewer immediately."
),
handoffs=[sandbox_reviewer],
)
instructions の「Do not answer the user directly after reviewing the packet.(資料を確認した後、ユーザーに直接回答しない)」が handoff を確実に発火させる工夫です。実行は受付エージェントから開始し、RunConfig(sandbox=SandboxRunConfig(client=UnixLocalSandboxClient())) を渡しておけば、handoff 先の SandboxAgent が必要になった時点で Runner がサンドボックスを用意します。
実行手順:
OPENAI_API_KEYを設定しますuv run python examples/sandbox/handoffs.pyを実行します(Docker 不要)
期待される動作: 最終的に文書化担当エージェントが、見出し・主要リスク(セキュリティ質問票が未完、管理者研修が未了、データ移行の時期問題など)・推奨ネクストステップからなる社内向けメモを出力します。
memory.py — スナップショット再開と記憶の生成・活用
この章の中核 example の 1 つです。1 つのエージェントを「スナップショット再開」をまたいで 2 回実行します。1 回目で請求書計算のバグ(subtotal + tax_rate という誤った式)を修正すると、セッション終了時に Memory capability が記憶ファイルを生成します。2 回目はスナップショットから再開した新しいセッションで、その記憶を読んでから回帰テストを追加します。
コード解説: エージェント定義では Memory と、その動作要件である Shell / Filesystem を並べます。
capabilities=[
# `Memory()` enables both read and generate behavior with live updates on by default.
Memory(),
Filesystem(),
Shell(),
],
公式ドキュメントによると、Memory() の読み取りには Shell() が、実行中の記憶更新(live update、既定で有効)には Filesystem() が必要です。コメントには詳細設定の例(MemoryLayoutConfig でディレクトリ変更、MemoryReadConfig(live_update=False) で実行中更新の無効化、MemoryGenerateConfig で統合時の生メモリ上限や追加プロンプト指定)も記されています。
スナップショット再開の流れが本題です。
# Use a local snapshot so the second run resumes the same workspace in a new sandbox
# session. That makes the second prompt rely on memory instead of in-process agent state.
sandbox = await client.create(
manifest=manifest,
snapshot=LocalSnapshotSpec(base_path=Path(snapshot_dir)),
)
1 回目の実行を async with sandbox: ブロックで終えると(このタイミングで記憶生成が走ります)、resumed_sandbox = await client.resume(sandbox.state) で同じワークスペース内容を持つ新しいセッションを作り、2 回目を実行します。最後に _print_memory_tree() が生成された記憶ファイル群を表示します。
実行手順:
OPENAI_API_KEYを設定しますuv run python examples/sandbox/memory.pyを実行します(Docker 不要。Unix ローカル + ローカルスナップショット)
期待される動作: [first run] でバグ修正の要約、[second run] で回帰テスト追加の要約が出た後、Generated memory artifacts: として次のようなファイルツリーが表示されます。
sessions/(実行の会話記録。rollout ID ごとの.jsonl)memories/MEMORY.md(統合された記憶の索引)memories/memory_summary.md(次回実行の冒頭に注入される要約)memories/raw_memories.md、memories/raw_memories/、memories/rollout_summaries/(中間生成物)
つまずきポイント: 記憶の生成はサンドボックスセッションが閉じたときに走ります。async with を抜けずに 2 回目を実行すると、まだ記憶ファイルが無い状態になります。また、記憶生成にも追加のモデル呼び出しが発生するため、実行時間と API 利用料は通常より多めです。
memory_multi_agent_multiturn.py — 2 エージェントの記憶を 1 ワークスペースで分離
GTM アナリスト(営業戦略分析役。2 ターンの会話)とエンジニア(バグ修正役。1 ターン)が同じサンドボックスワークスペースを共有しつつ、記憶は別々に持つexample です。
コード解説: 分離の鍵は MemoryLayoutConfig です。エージェント名ではなくレイアウト(保存先ディレクトリ)が記憶の分離単位になります。
capabilities=[
# Same layout + same SDK session across turns means one memory conversation.
Memory(
layout=MemoryLayoutConfig(
memories_dir="memories/gtm",
sessions_dir="sessions/gtm",
)
),
Filesystem(),
Shell(),
...
],
エンジニア側は memories/engineering / sessions/engineering という別レイアウトを使います。もう 1 つの鍵は第8章の SQLiteSession との併用です。
gtm_conversation_session = SQLiteSession(GTM_SESSION_ID)
...
gtm_first = await Runner.run(
gtm_agent,
GTM_TURN_1,
session=gtm_conversation_session,
run_config=gtm_config,
)
同じ SDK セッション(session=...)を 2 ターンで使い回すことで、2 つの Runner.run() が1 つの記憶会話としてまとめて抽出されます。公式ドキュメントによれば、記憶会話の識別子は conversation_id → session.session_id → RunConfig.group_id → 生成 ID の順で解決されます。
実行手順:
OPENAI_API_KEYを設定しますuv run python examples/sandbox/memory_multi_agent_multiturn.pyを実行します(Docker 不要)
期待される動作: GTM 2 ターン(セグメント分析 → gtm_hypothesis.md の保存)とエンジニアリング 1 ターン(バグ修正とテスト実行)の出力後、[gtm memory] と [engineering memory] のツリーが別ディレクトリに生成されていることが表示されます。GTM の分析がエンジニアリングのバグ修正記憶に混ざらない、という分離を確認できます。
shared_session_workdirs.py — 1 つのサンドボックスを共有しつつ作業ディレクトリを分ける
v0.21.1 で追加された「実行スコープのサンドボックス作業ディレクトリ」を扱う example です(本書では v0.22.2 のコードを参照)。 1 つのサンドボックスセッションを 2 つのエージェントで共有しながら、実行(run)ごとに相対パスの基準となるディレクトリを変える方法を示します。
前節の memory_multi_agent_multiturn.py が「記憶の置き場所を分ける」話だったのに対し、こちらは「相対パスの解決基準を分ける」話です。2 つのタスクディレクトリには brief.md / reference.png / notes.md とわざと同じファイル名のファイルが置かれており、それでも各エージェントが自分の担当ディレクトリのファイルだけを触れることを確認します。
重要(この example 自身が冒頭で警告しています): 実行スコープの作業ディレクトリは相対パスを一貫させるための仕組みであって、閉じ込め(confinement)ではありません。 信頼できないエージェントや、計算資源の分離が必要なワークロードには、サンドボックスセッション自体を分けてください。
コード解説
肝は SandboxRunConfig の cwd です。セッションは同じものを渡し、cwd だけを変えます。
client = UnixLocalSandboxClient()
agent_a = _build_agent(name="Task A worker", model=model)
agent_b = _build_agent(name="Task B worker", model=model)
shared_sandbox = await client.create(manifest=_build_manifest())
try:
# The session is shared, but each run gets a different base for relative paths.
run_a_config = RunConfig(
sandbox=SandboxRunConfig(
session=shared_sandbox,
cwd="tasks/task-a",
),
workflow_name="Shared sandbox task A",
)
run_b_config = RunConfig(
sandbox=SandboxRunConfig(
session=shared_sandbox,
cwd="tasks/task-b",
),
workflow_name="Shared sandbox task B",
)
session=shared_sandboxは両方で同じオブジェクトです。サンドボックスは 1 つしか作られません(起動コストもディスクも 1 つ分)cwd="tasks/task-a"/"tasks/task-b"が実行ごとの基準ディレクトリです。Shell(exec_command)・view_image・apply_patchのいずれも、このcwdを起点に相対パスを解決します- 従来は「セッション全体の cwd を変更する」しかなく、共有すると互いに干渉しました。実行スコープになったことで、セッション横断の状態を書き換えずに並行実行できます。最後の
printのwithout session-global cwd mutation(セッション全体の cwd を書き換えずに)という文言がまさにその点を指しています
エージェントへの指示は、相対パスだけを使うよう厳しく縛っています。
def _task_prompt(task_name: str) -> str:
return f"""
Complete {task_name} in your current run working directory. Use only these relative paths:
1. Call `exec_command` with `{SHELL_COMMAND}`. Do not pass `workdir`.
2. Call `view_image` with `page.png` and identify its color.
3. Use `apply_patch` on `notes.md` to change only `status=pending-{task_name}` to
`status=complete-{task_name}`.
4. Reply with the task name and image color from the files you inspected.
Do not prefix paths with `tasks/`, use absolute paths, or use the shell to edit `notes.md`.
""".strip()
workdirを渡すな・tasks/を前置するな・絶対パスを使うな、と念入りに禁じています。実行スコープの cwd が本当に効いているかを検証するための指示であり、実アプリでここまで縛る必要はありません- 同様に
ModelSettings(tool_choice="required", parallel_tool_calls=False)も、ツール呼び出しの順序をデモとして決定的にするためだけの設定です(コメントにその旨が明記されています)
2 つの実行は asyncio.gather で並行に走らせます。
async def _run_concurrently(
run_tasks: list[asyncio.Task[RunResult]],
) -> tuple[RunResult, RunResult]:
"""Keep both runs terminal before the caller closes their shared session."""
try:
result_a, result_b = await asyncio.gather(*run_tasks)
except BaseException:
for run_task in run_tasks:
if not run_task.done():
run_task.cancel()
await asyncio.gather(*run_tasks, return_exceptions=True)
raise
return result_a, result_b
- 片方が失敗したときにもう片方を必ず終端させてから例外を投げ直します。共有セッションを閉じる前に実行中のタスクが残っていると、閉じられたセッションに対する操作が飛んで別のエラーになるためです。共有リソースを使う並行処理では定石の書き方です
実行手順
OPENAI_API_KEYを設定します。次を実行します(バックエンドは Unix ローカルなので Docker 不要です)。
uv run python examples/sandbox/shared_session_workdirs.py
期待される動作・出力例
各タスクについて [task-a] cwd=tasks/task-a color=red のような行が出て、最後に Verified: distinct run-scoped cwd values worked without session-global cwd mutation. と表示されます。_verify_task() が実際のツール呼び出しを検査する自己検証つきなので、想定どおりに動かなければ失敗します。
つまずきポイント
- 繰り返しになりますが、これはセキュリティ境界ではありません。同じセッションにいる以上、エージェントは絶対パスを使えば相手のディレクトリに到達できます。信頼境界が必要ならセッションを分けてください
- 実行スコープの cwd は v0.21.1 で入った比較的新しい機能です。それ以前のバージョンでは同じことをするのに、セッションを分けるか、ツール呼び出しごとに
workdirを明示する必要がありました
memory_s3.py — S3 マウントで記憶を「サンドボックスの外」に永続化
memory.py の発展形です。スナップショット再開ではなく、毎回まっさらな Docker サンドボックスを 2 回作り、記憶ディレクトリだけを S3(Amazon の代表的なオブジェクトストレージサービス)マウントに置くことで実行をまたぎます。「サンドボックスは使い捨て、記憶はクラウドに永続」という実務的な構成です。
コード解説: Manifest にコードファイル群と並べて S3Mount エントリを置きます。
mount_dir: S3Mount(
bucket=config.bucket,
access_key_id=config.access_key_id,
secret_access_key=config.secret_access_key,
session_token=config.session_token,
prefix=config.prefix,
region=config.region,
endpoint_url=config.endpoint_url,
mount_strategy=DockerVolumeMountStrategy(driver="rclone"),
read_only=False,
),
mount_strategy は「どうやって接続するか」の指定で、ここではコンテナ内で rclone(多種のクラウドストレージを扱えるツール)を動かす InContainerMountStrategy(pattern=RcloneMountPattern()) を使います。そのため通常の Python イメージではなく、docker/Dockerfile.mount からビルドされるマウントツール入りイメージが必要で、ensure_mount_image() が未ビルドなら自動でビルドします。
Memory 側は、記憶の保存先をマウント先ディレクトリに変更し、記憶生成への追加指示も与えます。
Memory(
layout=_persistent_layout(mount_dir=mount_dir),
generate=MemoryGenerateConfig(extra_prompt=MEMORY_EXTRA_PROMPT),
),
実行フローは「1 台目の Docker サンドボックスでバグ修正 → 削除 → 2 台目の新品サンドボックスで記憶要約の存在を確認してから回帰テスト追加 → 削除 → 3 台目で永続化された記憶ツリーを表示」で、各段階に _assert_fixed() などの機械検証が入っています。
実行手順:
- Docker を起動し、
uv sync --extra dockerを済ませます - S3 バケットと AWS 認証情報を環境変数に設定します:
S3_BUCKET(またはS3_MOUNT_BUCKET)、AWS_ACCESS_KEY_ID、AWS_SECRET_ACCESS_KEY、必要に応じてAWS_REGION/S3_ENDPOINT_URL。README はsource ~/.s3.envのようにファイルにまとめる運用を想定しています OPENAI_API_KEYを設定しますsource ~/.s3.env && uv run python examples/sandbox/memory_s3.pyを実行します(初回はマウント用イメージのビルドで数分かかります)
期待される動作: [first sandbox]、[second sandbox] の出力に続き、S3-backed memory artifacts: として S3 マウント越しの記憶ツリーと、使用された S3 プレフィックス(既定ではユニークな sandbox-memory-example/<ランダム値>)が表示されます。
つまずきポイント: FUSE(ユーザー空間でファイルシステムを実装する仕組み)を使うため、コンテナに特権的な設定が必要です。Docker 環境によっては rclone マウントが失敗することがあります。また S3 の読み書き・リクエスト料金が発生します。
sandbox_agent_with_remote_snapshot.py — スナップショットを S3 に保存する
memory.py のローカルスナップショットに対し、こちらはスナップショット自体をリモート(S3)に保存する example です。「ワークスペースの tar アーカイブを S3 にアップロードし、再開時にダウンロードして復元する」という往復(round-trip)を検証します。
コード解説: 中心は RemoteSnapshotSpec と依存性注入(必要な部品を外から渡す設計手法)です。まず boto3(AWS の Python SDK)を包んだ最小の S3 クライアントアダプタを定義します。
class S3SnapshotClient:
"""Minimal S3 client adapter for `RemoteSnapshot`."""
def upload(self, snapshot_id: str, data: io.IOBase) -> None:
self._s3.upload_fileobj(data, self._bucket, self._object_key(snapshot_id))
def download(self, snapshot_id: str) -> io.IOBase:
buffer = io.BytesIO()
self._s3.download_fileobj(self._bucket, self._object_key(snapshot_id), buffer)
buffer.seek(0)
return buffer
def exists(self, snapshot_id: str) -> bool: ...
(クラス本体は一部省略)このアダプタを Dependencies に登録し、キー文字列でスナップショット仕様から参照させます。
dependencies = Dependencies().bind_value(
SNAPSHOT_CLIENT_DEPENDENCY_KEY,
S3SnapshotClient(bucket=_require_s3_bucket(), prefix=SNAPSHOT_OBJECT_PREFIX),
)
client = UnixLocalSandboxClient(dependencies=dependencies)
sandbox = await client.create(
manifest=manifest,
snapshot=RemoteSnapshotSpec(client_dependency_key=SNAPSHOT_CLIENT_DEPENDENCY_KEY),
options=None,
)
検証パートでは、セッションに検証ファイルを書いて stop()(このとき S3 へスナップショットがアップロードされます)、client.resume(sandbox.state) で復元し、ファイルが戻ってくることを確認します。最後に通常のエージェント実行も 1 回行います。
実行手順:
- boto3 を導入します:
uv sync --extra s3 S3_MOUNT_BUCKET環境変数に書き込み可能な S3 バケット名を設定し、AWS 認証情報(AWS_ACCESS_KEY_ID等、boto3 の標準的な解決方法)を用意しますOPENAI_API_KEYを設定しますuv run python examples/sandbox/sandbox_agent_with_remote_snapshot.pyを実行します(Docker 不要)
期待される動作: snapshot round-trip ok (s3) に続いて、ワークスペースの 1 文要約が出力されます。S3 バケットの openai-agents-python/sandbox-snapshots/ 配下に <snapshot_id>.tar が作られます。
unix_local_pty.py — 対話的プロセスを維持する PTY デモ
PTY(pseudo-terminal、擬似端末)とは、対話型プログラム(Python の REPL など)に「人間がターミナルから操作している」と思わせるための仮想的な端末です。この example は、エージェントが同じ対話的 Python プロセスを保ったまま連続した計算を行えることを示します。
コード解説: エージェント定義は組み込みの Shell() capability を使います(これまでの example の自作 WorkspaceShellCapability とは異なり、PTY 対応の exec_command / write_stdin ツールが提供されます)。
def _build_agent(model: str) -> SandboxAgent:
return SandboxAgent(
name="Unix-local PTY Demo",
model=model,
instructions=(
"Complete the task by inspecting and interacting with the sandbox through the shell "
"capability. Keep the final answer concise. "
"Preserve process state when the task depends on it. If you start an interactive "
"program, continue using that same process instead of launching a second one."
),
default_manifest=_build_manifest(),
capabilities=[Shell()],
model_settings=ModelSettings(tool_choice="required"),
)
デフォルトの課題文は「対話的 Python セッションを開始し、同じセッション内で 5 + 5 を計算し、その結果にさらに 5 を足し、1 つの Python プロセスに留まったことを報告せよ」です。ストリーミング表示では、ツール呼び出しとツール出力を call_id で対応付けて [tool call] exec_command のように表示します。
実行手順:
OPENAI_API_KEYを設定しますuv run python examples/sandbox/unix_local_pty.pyを実行します(Docker 不要)
期待される動作: exec_command(対話 Python の起動)と、同じプロセスへの追加入力(write_stdin)のツールイベントが流れ、最後に「10 と 15 が得られ、1 つの Python プロセス内で完結した」旨の簡潔な報告が出力されます。
つまずきポイント: write_stdin ツールが現れるかどうかはバックエンドの PTY 対応に依存します。状態を保つ必要があるタスクでは、この example のように instructions で「同じプロセスを使い続けること」を明示するのが定石です。
tax_prep.py — 書類仕事の実務デモ(W-2 から Form 1040 を作る)
サンドボックスエージェントの「ドキュメント処理」ユースケースを示す、対話型の実務デモです。米国の源泉徴収票(W-2)の PDF と空欄の確定申告書(Form 1040)の PDF をワークスペースに与え、GitHub 上のスキルを使って記入済み PDF を output/ に生成させ、最後にホスト側へコピーします。
コード解説: Manifest では Dir に description(モデルに見せるディレクトリの説明)を付けるパターンが登場します。
return Manifest(
entries={
"taxpayer_data": Dir(
children={"sample_w2.pdf": LocalFile(src=W2_PATH)},
description="Taxpayer income documents such as W-2s and 1099s.",
),
"reference_forms": Dir(
children={"f1040.pdf": LocalFile(src=FORM_1040_PATH)},
description="Blank tax forms the agent can use as templates.",
),
"output": Dir(description="Write finalized tax documents here."),
}
)
スキルは Git リポジトリから取得します。Skills(from_=GitRepo(...)) は遅延ではなく前もってのステージング方式です。
capabilities=Capabilities.default()
+ [
Skills(
from_=GitRepo(repo=skills_repo, ref=skills_ref),
),
],
既定のスキルリポジトリは sdcoffey/tax-prep-skills(federal-tax-prep スキルを含む)です。実行ループは対話型で、最初の質問(既定は「2025 年分の 1040 を作成して」)の後、input("> ") でフォローアップを受け付け、Ctrl+D / Ctrl+C で終了すると _copy_output_dir() がサンドボックスの output/ を再帰的に読み出してローカルの tax-prep-results/ にコピーします。
実行手順:
- Docker を起動し、
uv sync --extra docker(またはmake sync)を済ませます - この example の既定イメージは
tax-prep:latestです。リポジトリには対応する Dockerfile が含まれていないため、PDF 処理ツールを備えたイメージを自分で用意してtax-prep:latestとタグ付けするか、--imageフラグで手持ちのイメージを指定します OPENAI_API_KEYを設定しますuv run python examples/sandbox/tax_prep.pyを実行します。モデル・イメージ・質問・出力先・スキルのリポジトリ/ref は--model/--image/--question/--output-dir/--skills-repo/--skills-refで変更できます- 対話プロンプト
>で追加指示を出し、終えたら Ctrl+D で抜けます
期待される動作: [tool call] の連続(スキル読み込み、PDF の確認、記入処理)の後、収入・控除・税額・還付/納付額の要約が streaming 表示され、終了時に Copied N file(s) to .../tax-prep-results と記入済み PDF のパスが表示されます。
つまずきポイント: これはデモであり、instructions に「独身・SSN は 123-45-6789 と仮定」など明確に合成のテスト値を使う指示が入っています。実際の税務書類を扱う用途を意図したものではありません。またスキルを GitHub から取得するため、サンドボックスからのネットワークアクセスが必要です。
misc/ — 複数 example が共有する支援コード
misc/ は example ではなく共有部品置き場です。4 つのファイルがあります。
example_support.py:text_manifest()(文字列辞書 →Fileエントリの Manifest 変換)とtool_call_name()(ツール呼び出しイベントから表示名を取り出す)という 2 つの小さなヘルパーworkspace_shell.py: 本章の多くの example が使う自作 capability の見本です。Capabilityを継承し、bind()でセッションを受け取り、tools()でShellToolを返し、instructions()で使い方の指示文を注入します。カスタム capability を作るときの最小テンプレートとして一読の価値があります
class WorkspaceShellCapability(Capability):
"""Expose one shell tool for inspecting the active sandbox workspace."""
def __init__(self) -> None:
super().__init__(type="workspace_shell")
self._session: BaseSandboxSession | None = None
def bind(self, session: BaseSandboxSession) -> None:
self._session = session
def tools(self) -> list[Tool]:
return [ShellTool(executor=self._execute_shell)]
workspace_apply_patch.py:ApplyPatchToolをサンドボックスセッションに接続するカスタム capability。create_file/update_file/delete_fileをセッションのread/write/rmAPI で実装しており、組み込みFilesystemcapability の内部動作を理解する教材になりますreference_policy_mcp_server.py: FastMCP 製の 25 行のスタンドアロン MCP サーバー。get_policy_reference(topic)ツール 1 つを提供し、sandbox_agent_with_tools.pyから stdio で起動されます
docker/ — Docker 入門ランナーとクラウドストレージマウント検証
Docker バックエンド固有のヘルパー集です。
docker_runner.py(代表例): 冒頭コメントに「Start here if you are new to Docker-backed sandbox examples.」とあるとおり、Docker 版の最小入門です。basic.pyと同じ 3 ファイルのワークスペースをDockerSandboxClient+DEFAULT_PYTHON_SANDBOX_IMAGE(python:3.14-slim)で実行し、ツール呼び出しの引数と出力(先頭 2000 文字まで)を丁寧に表示します。実行は Docker 起動 +uv sync --extra docker+OPENAI_API_KEYの上でuv run python -m examples.sandbox.docker.docker_runnerです(dockerパッケージ名との衝突を避けるためモジュール形式が安全です)Dockerfile.mount: rclone / mount-s3(Mountpoint)/ blobfuse2 / mount.s3files という各種マウント CLI を全部入りにした Ubuntu 22.04 ベースの検証用イメージ定義。memory_s3.pyもこのイメージを使いますmounts/: マウント方式ごとのスモークテスト群です。mount_smoke.py(共通フレームワーク。イメージの自動ビルドensure_mount_image()を含む)、s3_mount_read_write.py、gcs_mount_read_write.py、azure_mount_read_write.pyがあり、それぞれ対応する認証情報(例: S3 系はS3_MOUNT_BUCKETと AWS キー)を環境変数で要求します。現在のスモークケースはDockerVolumeMountStrategy(Docker のボリュームドライバでコンテナ起動前に接続)に絞られています。InContainerMountStrategy(コンテナ内で rclone 等を実行)は SDK 側には引き続き存在しますが、examples のスモークケースからは外れました(s3_files_mount_read_write.pyも削除されています)- なお
docker/Dockerfile.mountは、以前curl https://rclone.org/install.sh | bashでインストールしていた rclone を、バージョン固定(RCLONE_VERSION)+ アーキテクチャ別の SHA-256 検証つきのアーカイブ取得に変更しました。「インストールスクリプトをパイプでシェルに流す」書き方をやめ、取得物のハッシュを検証してから配置する形です。サプライチェーンの観点で参考になる書き換えです
docs/ — 公式ドキュメント連動のコーディングタスク
docs/coding_task.py は、公式ドキュメント(sandbox_agents.md のクイックスタート)が参照する自己検証型のコーディング example です。ワークスペースに小さなシェルスクリプトのリポジトリ(docs/repo/。クレジットノートの表示バグ入り credit_note.sh と、そのテスト)を LocalDir でステージし、docs/skills/credit-note-fixer/SKILL.md のスキルを遅延読み込みで与えます。
default_manifest=Manifest(
entries={
"repo": LocalDir(src=EXAMPLE_DIR / "repo"),
}
),
capabilities=Capabilities.default()
+ [
Skills(
lazy_from=LocalDirLazySkillSource(
source=LocalDir(src=EXAMPLE_DIR / "skills"),
)
),
],
実行後の検証コードは、load_skill と apply_patch が呼ばれたこと、指定の検証コマンド sh tests/test_credit_note.sh が repo/ で実行され 2 passed が出たことを確認し、さらにホスト側からも同じテストを再実行して二重に検証します。instructions とタスク文(repo/task.md)には「apply_patch のパスはシェルの作業ディレクトリではなくワークスペースルート相対(repo/credit_note.sh)」という、実際にハマりやすい注意点が明記されています。
実行は OPENAI_API_KEY 設定の上で uv run python examples/sandbox/docs/coding_task.py です(Docker 不要)。
tutorials/ — ガイド付きチュートリアルの足場と 5 本のチュートリアル
tutorials/ には共有ヘルパー(misc.py: Rich ライブラリによる整形表示、Docker/Unix ローカル切り替え、対話ループ等)、共有 Docker イメージ定義(Dockerfile: python:3.14-slim + uv + git + poppler-utils + ripgrep + pypdf)、共有フィクスチャ生成器(data/dataroom/setup.py: 合成 10-K 書類パケットの生成)が置かれ、その上に 5 本のチュートリアルが乗っています。
いずれも基本は Unix ローカルで動き、--docker を付けると共有チュートリアルイメージで動きます。Docker で動かす場合は先にイメージを 1 回ビルドします:
docker build -t sandbox-tutorials:latest -f examples/sandbox/tutorials/Dockerfile .
| チュートリアル | 内容 | 実行コマンド(リポジトリルートから) |
|---|---|---|
sandbox_resume | AGENTS.md の指示を読み込ませて FastAPI アプリを構築 → セッション状態を直列化 → 再開してテストを追加、という 2 段構成 | uv run python examples/sandbox/tutorials/sandbox_resume/main.py |
dataroom_qa | 合成 10-K パケットに対する出典付き Q&A。回答には [n](data/file.txt:line:14) 形式の引用が付く | uv run python examples/sandbox/tutorials/data/dataroom/setup.py → uv run python examples/sandbox/tutorials/dataroom_qa/main.py |
dataroom_metric_extract | 財務指標を構造化抽出して CSV/JSONL に出力し、ホスト側の決定的な検証スクリプトで採点 | setup.py 実行後、uv run python examples/sandbox/tutorials/dataroom_metric_extract/main.py --output-format csv → uv run python examples/sandbox/tutorials/dataroom_metric_extract/evals.py --artifact-path examples/sandbox/tutorials/dataroom_metric_extract/output/financial_metrics.csv |
repo_code_review | GitRepo(...) で pypa/sampleproject を固定 ref でマウントし、テスト実行 → 行レベルの指摘 → output/review.md / findings.jsonl / fix.patch を生成、evals.py で検証 | uv run python examples/sandbox/tutorials/repo_code_review/main.py → uv run python examples/sandbox/tutorials/repo_code_review/evals.py |
vision_website_clone | 参考サイトのスクリーンショットを view_image で視認し、静的 HTML/CSS で再現。playwright スキルを遅延読み込みして自分のドラフトのスクリーンショットを撮り、視覚デバッグループを回す | uv run python examples/sandbox/tutorials/vision_website_clone/main.py |
いずれも OPENAI_API_KEY が必要です。dataroom 系は最初に setup.py でフィクスチャ生成を忘れないでください。対話継続が不要なら --no-interactive を付けられます(dataroom 系)。
extensions/ — クラウドサンドボックスと Temporal 連携
extensions/ は agents.extensions.sandbox 配下のクラウドバックエンドを手動検証するための example 群です。すべて OPENAI_API_KEY に加えてプロバイダー固有の認証情報が必要で、CI では検証されない「1 コマンドで手元確認する」用途のスクリプトです。どれも「小さな Manifest を作り、シェル capability 1 つで検査させ、短い要約を出す」共通の形をしています。
| ファイル / ディレクトリ | バックエンド | 追加 extra | 必要な環境変数 | 実行コマンド |
|---|---|---|---|---|
e2b_runner.py | E2B | uv sync --extra e2b | E2B_API_KEY | uv run python examples/sandbox/extensions/e2b_runner.py --stream |
modal_runner.py | Modal | uv sync --extra modal | MODAL_TOKEN_ID / MODAL_TOKEN_SECRET(または CLI でトークン設定) | uv run python examples/sandbox/extensions/modal_runner.py --app-name openai-agents-python-sandbox-example --stream |
cloudflare_runner.py | Cloudflare | uv sync --extra cloudflare | CLOUDFLARE_SANDBOX_WORKER_URL(必要なら CLOUDFLARE_SANDBOX_API_KEY) | uv run python examples/sandbox/extensions/cloudflare_runner.py --stream |
vercel_runner.py | Vercel | uv sync --extra vercel | VERCEL_OIDC_TOKEN または VERCEL_TOKEN + VERCEL_PROJECT_ID + VERCEL_TEAM_ID | uv run python examples/sandbox/extensions/vercel_runner.py --stream |
blaxel_runner.py | Blaxel | uv sync --extra blaxel | BL_API_KEY / BL_WORKSPACE | uv run python examples/sandbox/extensions/blaxel_runner.py --stream |
daytona/(daytona_runner.py ほか) | Daytona | uv sync --extra daytona | DAYTONA_API_KEY | uv run python examples/sandbox/extensions/daytona/daytona_runner.py --stream |
runloop/(runner.py, capabilities.py) | Runloop | uv sync --extra runloop | RUNLOOP_API_KEY | uv run python examples/sandbox/extensions/runloop/runner.py --stream |
いくつか特徴的なオプションを挙げると、E2B は --sandbox-type e2b_code_interpreter で Jupyter 風インターフェースに切り替え、Modal は --workspace-persistence tar / snapshot_filesystem / snapshot_directory で永続化方式を選択、Cloudflare / Blaxel は --demo pty で PTY デモが動きます。Modal / Cloudflare / Blaxel / Daytona / E2B / Runloop はそれぞれ専用のクラウドバケットマウント戦略(ModalCloudBucketMountStrategy 等)を持ちます。
代表例として 2 つ紹介します。
daytona/usaspending_text2sql/: NASA の連邦支出データ(USAspending.gov)を SQLite に取り込み、自然言語 → SQL 変換で答えるマルチターン会話エージェントです。読み取り専用・行数制限・タイムアウト付きのrun_sqlツールを提供する自作SqlCapability、Compactionによる長会話の自動圧縮、exitで一時停止して再実行時に同じサンドボックスへ再接続する pause/resume、Memoryによる会話間の学習と、この章の要素技術の集大成になっています。実行はuv sync --extra daytona+OPENAI_API_KEY+DAYTONA_API_KEYの上でuv run python -m examples.sandbox.extensions.daytona.usaspending_text2sql.agentですtemporal/: Temporal(ワークフローの実行状態を永続化し、障害から自動復旧できるオーケストレーション基盤)上で動く、耐久性のある会話型コーディングエージェントです。1 つのワーカーに全バックエンド(Daytona / Docker / E2B / Unix ローカル)を登録し、TUI(ターミナル UI)から/switchでバックエンドを切り替えたり/forkでセッションを分岐したりできます。まず最小確認としてexport OPENAI_API_KEY=...の上でuv run --extra temporal python -m examples.sandbox.extensions.temporal.local_hello_workflow(Temporal CLI 不要のローカルスモークテスト)を実行し、フル構成はjustと Temporal CLI を導入してcd examples/sandbox/extensions/temporal→.env作成 → 3 端末でjust temporal/just worker/just tuiを起動します
healthcare_support/ — 総合ワークフロー実例
通常のエージェントとサンドボックスエージェントを組み合わせた、合成データによる医療系サポートワークフローのデモです。患者の請求・保険適用の質問に対し、オーケストレーター(通常 Agent)がローカル記録を照会し、サンドボックスポリシーエージェントが隔離ワークスペースでポリシー文書(policies/*.md)を検索・分析して output/ に Markdown 成果物を書き、曖昧なケースは人間の承認(human-in-the-loop)を経てレビューキューに回します。
README が挙げるデモ内容は、標準エージェントのオーケストレーション、Shell / Filesystem / 遅延 Skills の capability、承認ゲート付きツール、SQLiteSession によるシナリオ横断の永続メモリ、専門エージェントごとの構造化出力、トレーシング(実行の記録を OpenAI のトレースビューアで確認できる機能)です。難易度順の 6 シナリオ(eligibility_verification_basic から、人間承認が発火する messy_ambiguous_knee_case まで)が同梱されています。
実行手順(リポジトリルートから。Docker 不要、OPENAI_API_KEY が必要):
uv run python examples/sandbox/healthcare_support/main.py
uv run python examples/sandbox/healthcare_support/main.py --list-scenarios
uv run python examples/sandbox/healthcare_support/main.py --scenario blue_cross_pt_benefits
uv run python examples/sandbox/healthcare_support/main.py --reset-memory
無人実行では EXAMPLES_INTERACTIVE_MODE=auto を付けると承認プロンプトが自動応答になります。生成物は .cache/healthcare_support/output/<scenario_id>/ にコピーされます。コードを読む順序は README の指示どおり main.py → workflow.py → support_agents.py → tools.py → スキル SKILL.md が分かりやすいです。全データは合成であり、本番の医療システムではありません。
まとめ
- SandboxAgent は「ワークスペース(ファイルシステム + シェル)を持つ Agent」であり、通常の Agent の機能(tools / handoffs / mcp_servers / output_type 等)はすべてそのまま使えます。実行も通常の
Runner経由です - 設計の基本形は Manifest(初期ファイルの宣言)+ Capability(サンドボックス用の道具)+ SandboxRunConfig(実行環境の選択)の 3 点です
capabilitiesを省略するとFilesystem/Shell/Compactionのデフォルト 3 点セットが付き、明示指定するとデフォルトは置き換えられます- バックエンドは
UnixLocalSandboxClient(手軽・macOS/Linux)→DockerSandboxClient(コンテナ隔離、uv sync --extra docker+ Docker 本体が必要)→ クラウド各社(extra + API キー)と、エージェント定義を変えずに差し替えられます - 作業の継続にはスナップショット(
LocalSnapshotSpec/RemoteSnapshotSpec+client.resume())を、実行をまたいだ学習には Memory capability(memories/MEMORY.md等への蒸留、MemoryLayoutConfigでエージェント別に分離)を使います apply_patchのパスはワークスペースルート相対、Manifest のエントリパスもワークスペース相対で、外部パスはextra_path_grantsで明示許可します- example は Unix ローカルで動く入門群(
unix_local_runner.py等)、Docker が必要な群(basic.py既定、docker/、memory_s3.py、tax_prep.py、tutorials の--docker)、クラウド認証が必要なextensions/に大別されます
理解度チェック
SandboxAgent に capabilities=[Skills(...)] とだけ指定したとき、エージェントはシェルコマンドを実行できるでしょうか。
Manifest の Dir と LocalDir の違いを説明してください。
SandboxRunConfig の client=... と session=... は、それぞれどんなときに使いますか。basic.py と unix_local_runner.py の違いを踏まえて答えてください。
memory.py では、なぜ 2 回目の実行を「同じセッションの続き」ではなく「スナップショットから再開した新しいセッション」で行うのでしょうか。
memory_multi_agent_multiturn.py で、GTM エージェントとエンジニアリングエージェントの記憶が混ざらないのは何によるものですか。エージェント名でしょうか。
解答と解説
問1 の解答と解説
答1. できません。capabilities=[...] を明示的に渡すとデフォルトの Capabilities.default()(Filesystem(), Shell(), Compaction())は丸ごと置き換えられます。シェルも使わせたい場合は Capabilities.default() + [Skills(...)] のように連結するか、[Skills(...), Shell(), Filesystem()] と自分で列挙します。
問2 の解答と解説
答2. Dir はサンドボックス内に合成のディレクトリを作るエントリで、コード中で定義した子要素を置くか、output のような空の出力先を用意します。ホストのファイルシステムからは何も読みません。一方 LocalDir はホスト上の既存ディレクトリをサンドボックスへコピーするエントリです(例: docs/coding_task.py の "repo": LocalDir(src=EXAMPLE_DIR / "repo"))。
問3 の解答と解説
答3. client=... は「サンドボックスの作成と後片付けを Runner に任せる」最も手軽な方法で、unix_local_runner.py が使っています。session=... は「自分で client.create() して起動したセッションを注入する」方法で、basic.py が使っています。実行前に sandbox.ls() でファイルを確認したい、複数の Runner.run() で同じワークスペースを使い回したい、削除タイミングを自分で制御したい、といった場合は session=... を選びます。
問4 の解答と解説
答4. Memory capability の記憶生成はサンドボックスセッションが閉じたときに走るためです。また、同一セッションの続きだとエージェントがプロセス内の状態や会話コンテキストで答えられてしまい、「記憶ファイルを読んで過去の作業を思い出す」ことのデモになりません。スナップショット再開により、ワークスペースの内容(修正済みコードと記憶ファイル)だけを引き継いだ新しいセッションを作り、2 回目のプロンプトが記憶に依存する状況を意図的に作っています。
問5 の解答と解説
答5. エージェント名ではなく MemoryLayoutConfig(記憶の保存先レイアウト)によります。GTM 側は memories/gtm / sessions/gtm、エンジニアリング側は memories/engineering / sessions/engineering と別のディレクトリを指定しているため、同じサンドボックスワークスペースを共有していても、rollout ファイル・生メモリ・MEMORY.md・memory_summary.md がすべて別々に保たれます。逆に、同じレイアウトと同じ記憶会話 ID を持つエージェント同士は記憶を共有します。
参考リンク
- サンドボックスエージェント クイックスタート(英語): https://openai.github.io/openai-agents-python/sandbox_agents/
- サンドボックスエージェント クイックスタート(日本語): https://openai.github.io/openai-agents-python/ja/sandbox_agents/
- 概念ガイド(Manifest / Capability / 権限 / スナップショット)(英語): https://openai.github.io/openai-agents-python/sandbox/guide/
- 概念ガイド(日本語): https://openai.github.io/openai-agents-python/ja/sandbox/guide/
- サンドボックスクライアント(バックエンドとマウント)(英語): https://openai.github.io/openai-agents-python/sandbox/clients/
- サンドボックスクライアント(日本語): https://openai.github.io/openai-agents-python/ja/sandbox/clients/
- エージェントメモリ(英語): https://openai.github.io/openai-agents-python/sandbox/memory/
- エージェントメモリ(日本語): https://openai.github.io/openai-agents-python/ja/sandbox/memory/
- ツールガイド(ホスト型シェルとの使い分け)(英語): https://openai.github.io/openai-agents-python/tools/
- example ソースコード: https://github.com/openai/openai-agents-python/tree/main/examples/sandbox
用語集
本書(第0〜16章)に登場する重要用語を、カテゴリ別にまとめた初心者向けの用語集です。各用語には英語表記と、主に登場する章を添えています。読み進める途中で分からない言葉に出会ったら、このページと該当章を往復してください。SDK 固有のクラス名・関数名は、公式リポジトリのソースコード(src/agents/ 配下)とドキュメントに基づいて記述しています。
基本概念
- AI エージェント(AI agent) — LLM が自分でツールを使い、状況を判断しながら複数ステップのタスクを最後まで遂行するプログラムです。1 回の質問に 1 回答えるだけのチャットボットとの本質的な違いは、この「自律的に行動する」点にあります。(主な章: 第0章)
- LLM(Large Language Model / 大規模言語モデル) — 膨大なテキストから言葉のつながりを学習し、入力文の続きとして自然な文章を生成する AI モデルです。OpenAI の GPT シリーズが代表例で、エージェントの「頭脳」に当たります。(主な章: 第0章ほか全章)
- トークン(token) — LLM が文章を処理するときの最小単位で、文字数との比率はモデル・言語・文章によって異なります。API の利用料はトークン数に応じた従量課金で計算されるため、コスト管理の基礎になる概念です。(主な章: 第0・1章)
- プロンプト / instructions(prompt / instructions) — プロンプトとは LLM に与える入力文の総称です。そのうち「あなたは親切なアシスタントです」のように役割や振る舞いを定める文をシステムプロンプトと呼び、SDK では
Agentのinstructions引数で指定します。固定文字列のほか、実行時の状況に応じて内容を変える関数も渡せます。(主な章: 第0・2章) - API キー(API key) — OpenAI などの API を呼び出す際に利用者を識別する秘密の文字列です。SDK は環境変数(OS がプログラムに渡す設定値)
OPENAI_API_KEYから自動で読み取ります。漏洩すると自分のアカウントで課金され続けるため、コードに直接書かない・Git にコミットしないことが鉄則です。(主な章: 第1章) - エージェントループ(agent loop) — 「LLM を呼ぶ → ツール呼び出しがあれば実行して結果を返す → handoff があれば担当を切り替える → 最終出力が出たら終了」という SDK 組み込みの繰り返し処理です。開発者がこのループを自分で書く必要はなく、Runner が代行します。(主な章: 第0・2章)
- 非同期処理(async/await) — API 応答などの待ち時間の間に、他の処理を進められる Python の仕組みです。examples の大半は
async def main()とasyncio.run(main())の形で書かれています。(主な章: 第2章ほか全章) - ストリーミング(streaming) — 応答の全文完成を待たず、生成された端から少しずつ受け取る方式です。チャット画面で文字がパラパラと表示されるのはこの仕組みで、SDK では
Runner.run_streamed()で利用します。(主な章: 第2章ほか) - 構造化出力(structured output) — 回答を自由文ではなく、あらかじめ定めた JSON 構造(Pydantic モデルなど)で受け取る仕組みです。「LLM の判断を Python コードの分岐に接続する」ための要で、
Agentのoutput_type引数で指定します。(主な章: 第2・4・12章) - コンテキスト(context) —
Runner.run(..., context=...)で実行に持ち込む、ツール・フック・動的 instructions 関数など自分の Python コード側で共有するデータの入れ物です。LLM には送信されない点が最重要の性質で、実行中はRunContextWrapperというラッパーに包まれて各所へ渡されます。(主な章: 第2・11章) - Pydantic(Pydantic) — データの型と検証を定義できる Python ライブラリです。SDK では構造化出力の
output_typeや、ツール・handoff の引数スキーマの定義に広く使われます。(主な章: 第2・4・12章) - uv(uv) — Rust 製の高速な Python パッケージマネージャです。公式リポジトリは uv を開発ツールとして採用しており、
uv syncで依存関係を揃え、uv runで仮想環境内のコマンドを実行します。音声などのオプション機能は extras(追加依存グループ)として分離されており、uv sync --extra voiceのように必要な章でだけ追加します。(主な章: 第1章) - 推論モデル(reasoning model) — 回答を出力する前に「頭の中で考える」工程を明示的に持つ LLM です。OpenAI の o 系・GPT-5 系や gpt-oss、DeepSeek-R1 などが該当し、複雑なタスクの品質が上がる代わりに時間と料金が増えます。(主な章: 第10章)
- 推論コンテンツ(reasoning content) — 推論モデルが最終回答の前に生成する「途中式」に当たる思考テキスト、またはその要約です。OpenAI のホスト型モデルでは生の思考は見えず、
ModelSettings(reasoning=Reasoning(summary=...))で要約をリクエストして取得します。(主な章: 第10章)
SDK のクラス・API
- Agent(エージェントクラス) — instructions・ツール・guardrail・handoff などを設定した LLM を表す、SDK の中心クラスです。
nameとinstructionsだけで最小構成が完成し、定義した時点では API 呼び出しは発生しません。(主な章: 第0・2章ほか全章) - Runner(実行エンジン) — Agent を実際に動かし、エージェントループを回すクラスです。非同期の
Runner.run()、同期のRunner.run_sync()、ストリーミングのRunner.run_streamed()の 3 つの実行方法があります。(主な章: 第0・2章ほか全章) - RunResult(実行結果) —
Runner.run()の戻り値です。最終出力のfinal_output、実行中に生成されたアイテムのnew_items、履歴を次回の入力に変換するto_input_list()、直近の応答 IDlast_response_idなどを持ちます。(主な章: 第2章ほか) - RunResultStreaming(ストリーミング実行結果) —
Runner.run_streamed()が返すストリーミング用の結果オブジェクトです。stream_events()を async for で消費することで実行が進み、イテレータの完了後にfinal_outputが確定します。(主な章: 第2章) - ストリームイベント(stream events) — ストリーミング実行中に流れてくる通知です。トークン差分などの生イベント
raw_response_event、メッセージ完成やツール呼び出しなどアイテム単位のrun_item_stream_event、担当エージェント切り替えのagent_updated_stream_eventの 3 粒度があります。(主な章: 第2章) - ModelSettings(モデル設定) — 最大トークン数・reasoning・verbosity・リトライなど、モデル呼び出し時のパラメータをまとめて指定する設定クラスです。Agent 単位にも RunConfig 単位にも設定でき、ネストした設定はマージされます。(主な章: 第2・4・10章)
- RunConfig(実行設定) — 1 回の実行(run)全体に適用する設定オブジェクトです。モデルプロバイダーの差し替え、
model_settingsの既定値、handoff の入力フィルタ、サンドボックス設定などをRunner.run(..., run_config=...)で渡します。(主な章: 第2・9・16章) - output_type / AgentOutputSchema(出力型) — Agent の最終出力の型を指定する仕組みです。既定では「必ずスキーマ通りの JSON が返る」strict モードが使われ、strict 非対応のスキーマは
AgentOutputSchema(型, strict_json_schema=False)やAgentOutputSchemaBaseの自作実装で扱います。(主な章: 第2・4章) - ライフサイクルフック(AgentHooks / RunHooks) — 実行の節目(開始・終了・LLM 呼び出し・ツール実行・handoff)に自動で呼ばれる自作の関数群です。
AgentHooksは特定の Agent に、RunHooksは実行全体に取り付け、ログ記録・監視・デバッグに使います。(主な章: 第2章) - Usage(使用量) — 実行で消費した API リクエスト数(
requests)と入出力トークン数(input_tokens/output_tokens/total_tokens)の集計オブジェクトです。result.context_wrapper.usageで実行全体の合計を取得でき、料金に直結します。(主な章: 第2章) - previous_response_id — OpenAI Responses API がサーバー側に保存した前回応答の ID を次の実行に渡し、会話履歴を再送せずに文脈を引き継ぐ仕組みです。Responses API 専用で、応答の保存期間(執筆時点で 30 日)という制約があります。(主な章: 第2章)
- Responses API — OpenAI の新しいエージェント向け API で、SDK はこれを既定で使用します。hosted tools・ホスト型 MCP・プロンプトテンプレート・previous_response_id などは、この API 専用の機能です。(主な章: 第2・3・7・9章)
- Chat Completions API — 従来型の OpenAI API 形式です。他社の OpenAI 互換サービスの多くはこちらのみに対応しているため、他社モデル利用時は
OpenAIChatCompletionsModelやset_default_openai_api("chat_completions")で切り替えるのが定石です。(主な章: 第9章) - OpenAIChatCompletionsModel — Chat Completions 形式でモデルを呼び出す Model 実装クラスです。
AsyncOpenAI(base_url=...)と組み合わせて、Ollama や OpenRouter など OpenAI 互換 API へ接続する基本部品になります。(主な章: 第2・9・10章) - リトライ(ModelRetrySettings / retry_policies) — レート制限(一定時間あたりの呼び出し上限。429 エラーの原因)やサーバーエラーなど一時的な失敗に対する自動再試行の設定です。待ち時間を倍々に増やす指数バックオフとジッター(ランダムなゆらぎ)を備え、
ModelSettings(retry=...)で構成します。(主な章: 第1・2章) - RunState と interruption(実行状態と中断) —
needs_approval=Trueのツールなどが呼ばれる直前に実行が停止すると、承認待ち項目がresult.interruptionsに入ります。result.to_state()で取り出したRunStateにapprove()/reject()を記録し、Runner.run(agent, state)へ渡すと続きから再開できます。RunStateは JSON にシリアライズでき、承認待ちを別プロセスに持ち越せます。(主な章: 第3・4・6・7・8章)
マルチエージェント
- マルチエージェント(multi-agent) — 1 つの万能エージェントにすべてを任せず、専門特化した複数のエージェントを連携させて品質を安定させる設計手法です。会社の「分業と手順の設計」に相当します。(主な章: 第4・11・12・13章)
- オーケストレーション(orchestration) — 複数のエージェントを「どの順序で、どう連携させて動かすか」の制御です。LLM 自身に次の行動を判断させる方式と、Python コードで流れを固定する方式の 2 つに大別されます。(主な章: 第4・12章)
- handoff(ハンドオフ) — あるエージェントが会話の主導権ごと別の専門エージェントへ引き継ぐ仕組みです。LLM からは
transfer_to_<agent_name>というツールとして見え、引き継ぎ先は会話履歴全体を受け取って以後の応答を担当します。(主な章: 第0・4・5・11章) - handoff() 関数(handoff function / Handoff) — handoff の挙動をカスタマイズするための関数で、
Handoffオブジェクトを生成します。ツール名・説明の上書き、発動時コールバックon_handoff、引数スキーマinput_type、履歴加工のinput_filter、動的な有効/無効切り替えis_enabledなどを指定できます。(主な章: 第5章) - トリアージエージェント(triage agent) — ユーザーの要件を見て、適切な専門エージェントへ handoff する受付・振り分け役のエージェントです。カスタマーサービスなどマルチエージェント構成の入口として定番です。(主な章: 第4・5・11章)
- input filter(入力フィルタ / HandoffInputData) — handoff で引き継ぐ会話履歴を加工する関数です。
HandoffInputDataを受け取って加工済みのものを返し、ツール記録の除去(handoff_filters.remove_all_tools)や履歴の切り詰め、個人情報の削除などに使います。(主な章: 第5章) - agents as tools(エージェントのツール化 / Agent.as_tool()) — 別のエージェントを 1 つのツールとして呼び出すパターンです。handoff と違って会話の主導権は呼び出し元が保持したまま、サブエージェントは渡された入力に対する結果だけを返します。司令塔が最終回答を統合したい場面に向きます。(主な章: 第2・4・13章)
- 決定的フロー(deterministic flow) — エージェントの実行順序を Python コードで固定するパターンです。前段の出力を次段の入力へ連鎖させ、構造化出力による品質ゲート(合否判定の分岐)で途中中断もできます。(主な章: 第4・12章)
- 並列実行(parallelization) — 互いに依存しない複数のエージェント実行を
asyncio.gatherで同時に走らせる手法です。実行時間の短縮のほか、複数案を生成して良いものを選ぶ用途にも使われます。(主な章: 第4・12・13章) - LLM-as-a-judge(LLM を審査員にする) — 生成役エージェントの出力を、評価役エージェント(審査員)が採点し、合格するまで生成と改善を繰り返すパターンです。品質基準をコードのループで担保できます。(主な章: 第4・13章)
- guardrail(ガードレール) — エージェントへの入力やエージェントからの出力を検証する安全装置です。
@input_guardrailは連鎖の最初のエージェントで、@output_guardrailは最終出力を生成したエージェントで実行され、違反を検知(tripwire 作動)するとInputGuardrailTripwireTriggeredなどの例外で実行が止まります。(主な章: 第0・4章) - ツールガードレール(tool guardrails) — ツール単位で、渡される引数(入力)と戻り値(出力)を検査する仕組みです。
ToolGuardrailFunctionOutputの返し方で「そのまま通す」「reject_contentで差し止めて実行は継続」「raise_exceptionで全体を停止」の 3 段階の強さを選べます。(主な章: 第2章) - Human-in-the-loop(HITL / 人間参加型) — 送金や削除など危険な操作の前に、人間の承認を挟む設計パターンです。SDK ではツールの
needs_approvalや MCP のrequire_approvalにより実行が中断し、承認・拒否ののち再開します。(主な章: 第2・3・4・6・7・8章)
ツール・MCP
- ツール(tool) — LLM が文章の生成以外の行動を起こすための手段です。Web 検索・コード実行・ファイル操作などを、エージェントの「手足」として追加します。どのツールをどんな引数で使うかは LLM 自身が判断します。(主な章: 第0・2・3章)
- function tool(関数ツール / @tool・@function_tool) — 普通の Python 関数を
@toolデコレータでエージェントのツールに変換する仕組みです。型ヒントから引数スキーマが、docstring から説明文が自動生成されて LLM に伝わります。toolは v0.19.0 でagents.decoratorsに追加された短縮エイリアスで、実体は従来名のfunction_toolと同一です(tool = function_tool)。公式 examples は v0.19.0 以降@toolに統一されました。(主な章: 第0・2章ほか) - hosted tools(OpenAI ホスト型ツール) — OpenAI のサーバー側で実行されるツール群です。
tools=[...]に並べるだけで動き、手元では何も実行されませんが、Responses API を使う OpenAI モデル専用です。(主な章: 第3章) - WebSearchTool(Web 検索ツール) — Web 検索を行う hosted tool です。検索処理は OpenAI 側で実行され、リサーチ系エージェントの中核部品になります。(主な章: 第3・12・13章)
- FileSearchTool / ベクトルストア(vector store) — 事前にアップロードした文書を、キーワード一致ではなく意味の近さで検索する hosted tool です。文書を数値ベクトルに変換して保存する「ベクトルストア」の ID(
vs_...)をvector_store_idsで必ず指定します。(主な章: 第3章) - ComputerTool(computer use) — モデルがスクリーンショットを見て次の操作(クリック座標や入力文字)を決め、あなたが実装した
AsyncComputer(Playwright 等)がそれを実際に実行するブラウザ・PC 操作ツールです。(主な章: 第3章) - ShellTool(シェルツール) — シェルコマンドを実行するツールです。実行部分(executor)を自作するローカルモードと、OpenAI 上のコンテナで実行する hosted モードがあり、承認フローやスキル(SKILL.md に手順を書いた作業マニュアル)のマウントにも対応します。(主な章: 第3・16章)
- tool_choice(ツール使用の強制) —
ModelSettings(tool_choice="required")などで「必ずツールを使う」ことを強制する設定です。tool_use_behaviorと組み合わせると、ツールの戻り値をそのまま最終出力にして LLM の再呼び出しを省けます。(主な章: 第2・4章) - MCP(Model Context Protocol) — アプリケーションが LLM にツールやコンテキストを提供する方法を標準化したオープンプロトコルです。「AI アプリケーションにおける USB-C ポート」と例えられ、一度公開された MCP サーバーのツールは対応するどのアプリケーションからも再利用できます。(主な章: 第6・7章)
- MCP サーバー / MCP クライアント(MCP server / client) — ツールを提供する側が MCP サーバー、利用する側がクライアントです。SDK では接続オブジェクトを
Agent(mcp_servers=[...])に渡すだけで、MCP のツールが function tool と同じ感覚で使えます。サーバーはツールのほかプロンプトやリソースも提供できます。(主な章: 第6章) - トランスポート(transport: stdio / SSE / Streamable HTTP) — MCP クライアントとサーバー間の通信方式です。ローカルサブプロセスと標準入出力でやり取りする stdio(
MCPServerStdio)、非推奨となった SSE(MCPServerSse)、HTTP 系の現行推奨方式 Streamable HTTP(MCPServerStreamableHttp)の 3 種類があります。(主な章: 第6章) - ホスト型 MCP(hosted MCP / HostedMCPTool) — MCP サーバーへの接続とツール実行を OpenAI のサーバー側に任せる方式です。
HostedMCPToolをtoolsリストに追加して使い(mcp_serversは使いません)、自分のコードへの往復が発生しないため低レイテンシです。(主な章: 第7章) - connectors(コネクタ) — Google カレンダーや Gmail など主要 SaaS 向けに、OpenAI が用意した認証付きの既製 MCP 接続口です。
server_urlの代わりにconnector_idと、本人に代わって API を呼ぶ許可証であるアクセストークンを渡して使います。(主な章: 第7章) - require_approval(承認ポリシー) — MCP ツールの実行前に承認を要求する設定です。
"always"/"never"のほかツール名ごとの指定もでき、コールバック(on_approval_request)方式と interruption 方式の 2 通りで承認を処理します。(主な章: 第6・7章)
メモリ
- ステートレス(stateless) — LLM の API 呼び出しが 1 回ごとに完全に独立しており、モデル自身は過去のやり取りを覚えていない性質です。チャットアプリが文脈を保てるのは、アプリ側が会話履歴を毎回の呼び出しに添付し直しているためです。(主な章: 第8章)
- Session(セッションメモリ) — 会話履歴の保存と復元を自動化する SDK の仕組みです。
get_items/add_items/pop_item/clear_sessionの 4 メソッドを持つプロトコル(契約)として定義されており、Runner.run(..., session=...)に同じインスタンスを渡し続けるだけで複数ターンの会話が成立します。(主な章: 第0・8章) - SQLiteSession — SQLite(ファイルベースの軽量データベース)に履歴を保存する標準のセッション実装です。追加依存なしで動き、ローカル開発の第一候補です。ほかに SQLAlchemy / Redis / MongoDB / Dapr など、用途に応じたバックエンド実装が用意されています。(主な章: 第8章)
- OpenAIConversationsSession — 履歴の保存を OpenAI の Conversations API(サーバー側)に任せるセッション実装です。自前のデータベースを一切運用せずに会話を継続できます。(主な章: 第8章)
- ラッパー型セッション(EncryptedSession / OpenAIResponsesCompactionSession) — 別のセッションを包んで機能を追加するセッション実装です。
EncryptedSessionは履歴の暗号化と TTL(有効期限)による自動失効を、OpenAIResponsesCompactionSessionは長くなった履歴を要約済みアイテムに置き換える圧縮(compaction)を提供し、既存コードをほぼ変えずに導入できます。(主な章: 第8章)
音声・リアルタイム
- STT / TTS(Speech-to-Text / Text-to-Speech) — STT(音声認識)は音声をテキストに、TTS(音声合成)はテキストを音声に変換する処理です。テキスト処理装置である LLM と人間の「声」の間に立つ 2 人の通訳に当たり、教材固定版の既定モデルはそれぞれ gpt-4o-transcribe と gpt-4o-mini-tts、v0.23.1のSTT既定値は gpt-transcribe です。(主な章: 第14章)
- VoicePipeline(音声パイプライン) — STT → エージェント実行(ワークフロー)→ TTS を一直線につないだ、音声エージェントの実行クラスです。録音済みデータを一括で渡す static(
AudioInput)と、マイク音声を逐次流し込む streamed(StreamedAudioInput)の 2 つの入力方式があります。(主な章: 第14章) - ターン検出(turn detection / VAD) — 「ユーザーが話し終えたか」を音声から判定する技術です。VAD(Voice Activity Detection、発話区間検出)のうち、無音時間で判定する
server_vadと、発話内容の意味的な区切りも考慮するsemantic_vadがあります。ユーザーの発話 1 回とその応答の 1 往復を「ターン」と呼びます。(主な章: 第14・15章) - リアルタイムエージェント(realtime agent) — OpenAI の Realtime API を使い、音声を音声のまま単一モデルで処理する speech-to-speech 方式のエージェントです。STT → LLM → TTS の直列処理(第14章)と違って WebSocket の常時接続で細切れの音声を送り合うため、低遅延で割り込みにも対応します。(主な章: 第15章)
- RealtimeAgent / RealtimeRunner / RealtimeSession — リアルタイム会話の 3 つの中核クラスです。
RealtimeAgentがエージェント定義、RealtimeRunnerがセッションの生成、RealtimeSessionが生きた会話(入力送信・イベント受信・履歴追跡・ツール実行)を担い、async forでイベントを処理し続けます。(主な章: 第15章) - 割り込み(interruption / barge-in) — モデルが話している最中にユーザーが話し始めたとき、モデルの応答を中断する機能です。「実際にどこまで再生されたか」を報告して履歴の切り詰め位置を正しくするために
RealtimePlaybackTrackerを併用します。(主な章: 第15章) - WebSocket — クライアントとサーバーが接続を張りっぱなしにして、双方向に随時データを送り合える通信プロトコルです。リクエストごとに接続し直す HTTP より低遅延で、ストリーミング実行やリアルタイム音声対話の土台になります。(主な章: 第2・14・15章)
運用・その他
Agents APIはOpenAIが管理するハーネスを使うAPI、environmentはコードとファイルを扱う実行環境、artifactはOpenAI管理の環境から公開された成果物のコピーです。第18章にAPIとSDKのsession・turnの違いをまとめています。
- tracing(トレーシング) — エージェントの実行記録(どのエージェントが、どのツールを、どんな引数で呼んだか)を自動収集し、OpenAI ダッシュボードの Traces ページで可視化する仕組みです。
with trace("名前"):で複数の実行を 1 つの作業単位にまとめられます。トレースは OpenAI の API キーでアップロードされるため、他社モデルのみを使う構成ではset_tracing_disabled(True)で無効化します。(主な章: 第0・1・9章ほか全章) - モデルプロバイダー(model provider) — SDK のモデル呼び出し部分を差し替える仕組みです。Agent 単位(
modelに Model オブジェクトを渡す)、run 単位(RunConfig(model_provider=...))、グローバル(set_default_openai_client())の 3 段階のスコープがあり、狭い設定ほど優先されます。(主な章: 第9章) - MultiProvider —
Runnerの既定のモデルプロバイダーです。モデル名のプレフィックス(litellm/、any-llm/など)を見て呼び出し先のプロバイダーを自動で振り分けます。(主な章: 第9章) - LiteLLM / any-llm — 100 以上の LLM プロバイダーの API 差異を吸収する「変換アダプター」となるライブラリです。SDK からは
model="litellm/プロバイダー/モデル名"のような文字列指定、またはLitellmModel/AnyLLMModelの直接利用で使い、それぞれ extras の追加インストールが必要です。(主な章: 第2・9章) - OpenAI 互換 API(OpenAI-compatible API) — OpenAI の Chat Completions API と同じリクエスト・レスポンス形式を採用した API のことです。Ollama(ローカル実行)や OpenRouter(マルチプロバイダー中継)などが該当し、接続先のベース URL と API キーを差し替えるだけで SDK から呼び出せます。(主な章: 第2・9・10章)
- サンドボックス(sandbox) — コードやファイルを扱う作業環境です。隔離の強さはバックエンドと設定によります。UnixLocalはホスト上で動くため、ホストのファイルやネットワークから隔離されるわけではありません。(主な章: 第3・16章)
- SandboxAgent(サンドボックスエージェント) — ワークスペース(サンドボックス内の作業ディレクトリ)とシェルを持つ Agent の拡張です。通常の Agent の機能(tools / handoffs / mcp_servers / output_type 等)はすべてそのまま使え、実行も通常どおり Runner 経由です。バックエンドはローカル・Docker・クラウド各社から差し替えられます。(主な章: 第16章)
- Manifest(マニフェスト) — サンドボックスのワークスペースの初期状態を宣言する部品です。コード中で定義する合成ファイル(
File/Dir)、ホストの実ファイルのコピー(LocalFile/LocalDir)、Git リポジトリの取得、外部ストレージのマウントを混在できます。(主な章: 第16章) - Capability(ケイパビリティ) — サンドボックス専用のツールや指示文をエージェントに取り付ける部品です。
Shell・Filesystem・Skills・Memory・Compactionが組み込みで用意され、省略時はFilesystem/Shell/Compactionの 3 点セットが付きます(明示指定すると既定は置き換えられます)。(主な章: 第16章) - スナップショット(snapshot) — サンドボックスのワークスペース状態を丸ごと保存した「写真」です。
client.resume(...)で同じ状態から新しいセッションを再開でき、「昨日の続きから作業する」エージェントを実現します。(主な章: 第16章) - Memory capability(記憶ケイパビリティ) — 会話履歴そのものではなく「過去の実行から得た教訓」をファイル(
memories/MEMORY.md等)として蒸留・保存する仕組みです。第8章のセッションメモリ(会話履歴の保存)とは別物で、次回の実行で探索をやり直さずに済むようにします。(主な章: 第16章)
第18章 Agents API — 実行をOpenAIに任せる
現行のPython例では sessions.create(..., stream=True).with_result_collection() でイベントを収集し、反復後に stream.get_final_result() から output_text と session_id を得ます。以下の教材例は3.13.0に固定したイベント判定方式を維持しています。最新SDKで補助メソッドを使う場合も、切断・失敗と正常完了を区別し、完了したという理由だけでツールや成果物の成功を断定しません。公式の現行コード
これまでの章では、あなたのPythonプログラムが Runner を動かしました。この章では、OpenAI側で動くCodexの実行基盤に仕事を渡し、セッションの続きや成果物を受け取る方法を学びます。
この章でできるようになること
- Agents API・Agents SDK・Responses APIを、実行ループの所在から選ぶ。
- サンドボックス付きセッションを作り、成功・失敗・切断を区別する。
- 同じセッションに追加の依頼を送り、作成されたファイルを取り出す。
- 既存のSDKアプリで、今回の更新が影響する場所を見つける。
18.1 何が発表されたのか
OpenAIは2026年9月10日付の発表でAgents APIを公開ベータとして紹介しました。Codexが使うエージェントの実行基盤を、アプリからAPIで利用できます。モデルを呼び、必要なツールを使い、結果を見て次の行動を決める一連の制御をハーネス(harness)と呼びます。Agents APIでは、このハーネスの運用をOpenAIが担当します。
発表の中心は、長い作業のコンテキスト圧縮、必要なツール定義の検索、コードで複数のツール呼び出しを組み合わせる機能、サブエージェントによる分担、ファイルを扱う環境の統合です。既存のSDKで学んだツール設計や検証の考え方は、そのまま役立ちます。公式発表・概要
18.2 API・SDK・Responses APIの使い分け
まず「モデルを呼ぶこと」と「仕事を最後まで進めること」を分けて考えます。Responses APIはモデルとのやり取りの基盤です。Agents SDKは自分のプロセス内でエージェントを組み立てるライブラリです。Agents APIはOpenAIが実行を管理するサービスです。
| 選択肢 | 主な呼び出し | 実行と状態の管理 | 向いている要件 |
|---|---|---|---|
| Agents API | client.beta.agents.sessions | OpenAIがハーネスとセッションを管理。アプリは仕事・ツール・実行環境を設定する | 長い作業を任せたい。ホスト型サンドボックスや管理されたセッションを使いたい |
| Agents SDK | Agent / Runner | 自分のアプリがSDKのループを動かす。セッションの保存先や再開処理を構成する | handoff・guardrail・フックを細かく制御したい。他社モデルを含む既存の構成を維持したい |
| Responses API | client.responses.create | モデル応答をAPIが生成。アプリは必要に応じて外側の処理やfunction結果の返却を組み立てる | 単発の生成や、呼び出し順を自分で決める処理を作りたい |
たとえば「CSVを調べ、コードを書いてグラフを出す」ならAgents APIのホスト型環境が候補です。「部署ごとに引き継ぎ条件を決め、既存のガードレールとDBを使う」ならAgents SDKを継続する判断ができます。これは要件からの設計判断であり、SDKが不要になったという意味ではありません。SDKの位置付け
同じ「SDK」という言葉に注意
openai-agents==0.22.2 は from agents import Agent, Runner のライブラリです。Agents APIのHTTPクライアントは別のパッケージ openai==3.13.0 で、from openai import OpenAI と書きます。TypeScriptでも @openai/agents と openai を区別します。
18.3 ハーネスと実行環境は別の場所
environment.type | できること | 準備・管理するもの |
|---|---|---|
openai_hosted | OpenAI管理のサンドボックスでコード・ファイルを扱う | 入力ファイル、必要なパッケージ、ネットワーク設定。環境の作成はOpenAIが担当 |
self_hosted | 自社インフラや外部プロバイダーの環境を接続する | 環境の起動、executor接続、再接続、停止、ファイル回収をアプリ側で管理 |
none | 環境なしで応答・remote MCP・function toolsを利用する | 初回の入力が必要。組み込みBash・apply-patch・環境内ファイル・executor MCPは使えない |
サーバー側の関数を呼ぶだけなら、サンドボックスが必須とは限りません。一方、self_hosted を選ぶだけで接続が完了するわけではありません。ハーネスの要求を実行環境へつなぐexecutorと、その稼働を支える処理が必要です。アーキテクチャ・OpenAI管理の環境
18.4 最初の実行 — ファイルを作って動かす
この章の例は通常のOpenAI Python SDK 3.13.0を基準にしています。第1章の openai-agents 用環境とは別の仮想環境で試すと、2種類のSDKを区別しやすくなります。クライアントの公式リリース
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
python -m pip install "openai==3.13.0"
Platformで発行したアプリ用APIキーを OPENAI_API_KEY に設定します。必要な権限は api.agents.read・api.agents.write・推論用の api.responses.write です。キーはアプリ側で使い、サンドボックスへ渡さないでください。HTTPを直接呼ぶときの OpenAI-Beta: agents=v1 ヘッダーは、ここで使うSDKが自動で追加します。公式クイックスタート
agents_api_quickstart.py として保存します。session_id とメインターンのIDを表示し、メインターンの完了を確認して終了する教材用の例です。
"""Agents APIの初回実行。API料金が発生し、終了後もセッションは保持されます。"""
from openai import OpenAI
def main():
with OpenAI() as client:
with client.beta.agents.sessions.create(
agent={
"model": "gpt-6-astra",
"instructions": "コードを書いて実行し、実際の結果を報告してください。",
},
environment={"type": "openai_hosted"},
input=(
"/workspace/outputs/tree.pyに、作業フォルダのツリーを表示する"
"Pythonスクリプトを作成してください。実行し、結果を確認してください。"
),
stream=True,
) as events:
for event in events:
print(event.to_json(indent=None), flush=True)
if event.type == "agent.session.created":
print(f"session_id={event.session.id}", flush=True)
if event.type in {
"error", "agent.session.failed", "agent.session.environment.failed"
}:
raise RuntimeError(f"セッションのエラー: {event.type}")
if event.type in {
"agent.session.turn.completed",
"agent.session.turn.failed",
"agent.session.turn.cancelled",
} and event.turn.subagent_id is None:
if event.type != "agent.session.turn.completed":
raise RuntimeError(f"メインターンが成功しませんでした: {event.type}")
print(f"turn_id={event.turn.id} / 出力内容も確認してください")
return
raise RuntimeError("完了前に接続が閉じました。再送前に保存済み状態を確認してください")
if __name__ == "__main__":
main()
python agents_api_quickstart.py で実行します。期待する結果は、サンドボックス内に /workspace/outputs/tree.py が作成され、実行結果が返り、最後にメインターンの完了が報告されることです。フォルダ内容や文章は実行ごとに変わります。キー・モデルの利用権限と課金設定が必要で、本文のコード掲載は実アカウントでの成功を保証するものではありません。
gpt-6-astra は公式クイックスタートに合わせた明示指定です。これをAgents SDKの既定モデル変更と混同しないでください。サンドボックスを省略しても同じ処理ができる、という例でもありません。
18.5 セッション・ターン・イベントを読む
session は会話と設定を保持する入れ物、turn はその中の1回の作業です。アイドル状態で入力を送ると新しいターンが始まり、実行中に送った入力はそのターンへの追加指示(steering)になります。セッションの継続
| 観測するイベント | アプリでの扱い |
|---|---|
agent.session.turn.completed | 対象のメインターンが完了。ツールの失敗や成果物の内容も確認する |
agent.session.turn.failed / cancelled | 失敗/取り消しとして記録し、結果を成功扱いにしない |
agent.session.requires_action | 保留中の関数結果や環境接続を確認。必要な処理を行う |
agent.session.idle | 待機状態。直前のターンの成功を表すとは限らない |
ライブのeventsと、保存されたメッセージ・ツール呼び出しであるitemsも区別します。ストリームには過去イベントの再送がありません。切断したら、まず新しいストリームを開いて受信をバッファし、その間にsessionとitemsを取得して状態を復元します。item IDで重複を整理してからライブ受信に戻ります。観測用の接続を閉じただけでターンを取り消したと判断してはいけません。イベントと再接続
アイドルのセッションに続きの仕事を送り、同時に観測する例です。session_id は直前の実行で保存した値を使います。
from openai import OpenAI
def continue_session(session_id: str):
with OpenAI() as client:
with client.beta.agents.sessions.stream(
session_id,
input="tree.pyに深さの上限を追加し、実行結果を確認してください。",
) as events:
for event in events:
print(event.to_json(indent=None), flush=True)
# 成功・失敗は初回の例と同様にメインターンで判定する
このSDKの補助メソッドは入力送信前に受信を開始します。実行中のセッションを監視する処理や独立したワーカーでは、sessions.events.stream と sessions.events.create を組み合わせます。再接続のたびに同じ依頼を送ると、仕事が重複するおそれがあります。保存済みの結果を先に調べてください。
18.6 ツールとサブエージェントを接続する
remote MCPはハーネスからサーバーへ接続できます。一方、function toolsはあなたのアプリが関数を実行し、結果を返します。環境を openai_hosted にしただけでは、アプリのPython関数がサンドボックスへ自動転送されるわけではありません。
agent.toolsに関数名・説明・JSON Schemaを登録する。event.session.required_actionsまたは取得したsessionから、現在結果を待っているfunctionを特定する。- 関数を実行し、保留中のactionの
turn_id・call_idを付けて結果を返す。
import json
# actionはrequired_actionsで確認した保留中の関数呼び出し。
# outputはアプリ側で実行して得た結果。これは返却部分だけの例。
def return_function_result(client, session_id, action, output):
client.beta.agents.sessions.events.create(
session_id,
events=[{
"type": "agent.session.input.tool_result",
"turn_id": action.turn_id,
"call_id": action.call_id,
"success": True,
"output": json.dumps(output, ensure_ascii=False),
}],
)
履歴に function_call があるだけでは未処理とは判定できません。外部への書き込みを行う関数はsession・turn・callのIDで実行結果を記録し、切断後は記録済みの結果を再送します。成否が不明なら、実際の書き込み先を確認してから再実行を判断します。UIが閉じても処理を続ける設計では、関数を処理するワーカーを稼働させておく必要があります。Functions
次は調査用の設定の一部です。ツール定義の遅延読み込みには tool_search、コードで呼び出しを組み合わせる処理には programmatic_tool_calling を使います。独立した調査を分担させる場合は multi_agent を有効にします。
agent = {
"model": "gpt-6-astra",
"instructions": "公式資料を調べ、主張に出典を付けてください。",
"tools": [
{"type": "tool_search"},
{"type": "programmatic_tool_calling"},
{"type": "web_search"},
{
"type": "mcp",
"server_label": "openai_docs",
"transport": {
"type": "http",
"server_url": "https://developers.openai.com/mcp",
},
},
],
"multi_agent": {"enabled": True, "max_concurrent_subagents": 3},
}
サブエージェントは別のコンテキストで作業します。並列数は料金と所要時間の両方に関係するため、独立した仕事だけを分けます。メインとサブのイベントを subagent_id で区別し、子の完了をアプリ全体の完了と取り違えないことが大切です。SDKの handoff() と、この管理されたサブエージェント機能は異なる仕組みです。Multi-agent・設定
18.7 成果物を受け取り、後片付けする
OpenAI管理の環境では、入力を environment.files で配置できます。結果を /workspace/outputs 以下へ保存すると、ターン完了時に変更できないコピーであるartifactとして公開されます。この「公開」はセッションのArtifacts APIから取得できる状態を指し、Webサイトへの一般公開という意味ではありません。
def save_artifact(client, session_id, turn_id, path, destination):
for artifact in client.beta.agents.sessions.artifacts.list(session_id):
if artifact.turn_id == turn_id and artifact.path == path:
with client.beta.agents.sessions.artifacts.with_streaming_response.content(
artifact.id, session_id=session_id
) as response:
response.stream_to_file(destination)
return
raise FileNotFoundError("指定したターンとパスの成果物がありません")
# 初回の例で得たIDを使う。clientはOpenAI()のインスタンス。
# save_artifact(client, session_id, turn_id,
# "/workspace/outputs/tree.py", "tree.py")
# ダウンロードと内容確認が終わり、続きの作業も不要になってから:
# client.beta.agents.sessions.delete(session_id)
環境の期限が切れても公開済みartifactは残りますが、セッションを削除する前に必要なものをダウンロードします。次のターンで同名ファイルを更新しても別のartifactなので、パスだけで最新結果を選ばないようにします。self_hosted のファイルはArtifacts APIの対象ではなく、環境側のAPIで回収します。ファイルと成果物
実行中の処理を止めたい場合は agent.session.input.cancel を送り、停止を確認します。セッション削除が 409 を返す場合は、実行や環境の準備が落ち着いた状態を確認してから再試行します。自前の環境の終了処理もアプリが担当します。セッション管理
18.8 料金・データ・移行の判断
Agents API自体の追加利用料はありませんが、モデルのトークン、利用するツール、OpenAI管理のサンドボックスにはそれぞれ料金が発生します。長い実行や複数のサブエージェントは、1回のモデル呼び出しと同じ費用にはなりません。単価は公式料金表で確認します。
2026-10-04に再確認した公式概要でも、Agents APIのデータ所在地対応は米国のみで、Zero Data Retention(ZDR)には対応していません。self_hosted を選んでも、このAPIがZDR対応になるわけではありません。導入時には「コードをどこで実行するか」と「会話をどこで保持するか」を別に確認します。概要のデータ保持に関する記載
既存のSDKアプリを評価するときは、まず1つの処理を選び、ツール・権限・成功条件・保存するファイルを整理します。関数本体を再利用できても、Runner.run() を sessions.create() に置き換えるだけで承認、セッション、イベント処理まで移行できるわけではありません。初回実行だけでなく、再接続と継続、失敗時の復元も比較します。
18.9 Agents SDKの更新 — v0.22.0からv0.22.2へ
本書の演習固定版は v0.22.2(2026-09-09)です。これは2026-09-12の改訂基準であり、現在の最新リリースを表す表記ではありません。本書が以前参照していたv0.22.0からは、v0.22.1とv0.22.2の変更を確認しました。Agent・Runner を中心とした学習順序はそのまま使えます。以下は、この教科書で特に影響する変更です。
| 変更領域 | 今回の更新と実装時の確認点 | 関連章 |
|---|---|---|
| ツール・ガードレール | Web検索の画像結果、MCPサーバー単位のtool guardrails、出力拒否時の置換文の設定が追加。検査の対象と適用時点を確認 | 3章・4章・6章 |
| 承認・履歴・再開 | 承認後の再開、保存失敗時の復元、圧縮中の並行書き込みを修正。v0.22.2ではpop後の圧縮用response chainをリセット | 5章・8章 |
| サンドボックス | UnixLocalの環境変数の継承設定を追加し、ファイルAPIのsymlink raceを修正。コマンド列の作業ディレクトリも修正 | 16章 |
| 音声・Realtime | ストリーム文字起こしの設定公開と転送、カスタムTTS voice ID、キャンセルや状態保持の修正 | 14章・15章 |
| 画像生成 | v0.22.2で現在の画像生成オプションに対応。tool_configと出力形式の扱いを確認 | 3章の画像生成 |
出典: v0.22.1・v0.22.2。依存条件はPython 3.10以上、openai>=3.0.0,<4のままです。ただしAgents APIの beta.agents を使うこの章では、通常のOpenAI SDKを3.13.0へ揃えます。
TypeScript版も使っている場合
@openai/agents は v0.18.0(2026-09-10)を確認しました。DockerのファイルAPIがコンテナ内実行へ変わり、イメージに /bin/sh と realpath などのGNUユーティリティが必要です。UnixLocalの fileIOProtection はファイル/編集操作を保護する設定で、ホストのshellコマンド全体を隔離するものではありません。画像生成には action の指定が追加されました。PythonとTypeScriptのバージョン番号は独立しています。TypeScript版リリース
18.10 理解度チェック
Q1. 自社のDockerを self_hosted で接続した場合、Agents APIのハーネスはどこで動きますか。
解答と解説
OpenAI側です。自前になるのは実行環境と、その起動・接続・停止の管理です。自分のプロセスで Runner を動かすAgents SDKとは異なります。
Q2. ストリームが切れました。同じ入力を直ちに再送すればよいでしょうか。
解答と解説
まず受信を再開し、sessionと保存済みitems・ターン結果を照合します。裏側で処理が完了している場合があり、入力や関数の再実行は重複を生みます。
Q3. agent.session.idle が来たら、成果物を成功として扱ってよいでしょうか。
解答と解説
いいえ。対象のメインターンが turn.completed になったことと、出力・ファイル内容を確認します。idleは待機状態であり、ツールの成功を保証しません。
Q4. pip install openai-agents と pip install openai は同じ用途でしょうか。
解答と解説
前者は Agent・Runner のAgents SDK、後者は OpenAI クライアントです。Agents APIは後者の beta.agents から呼びます。
小さな演習: 初回のtree.py作成、同じsessionへの機能追加、2番目のターンのartifact取得を試し、session ID・turn ID・出力ファイルを対応付けて記録してください。保存後、不要なsessionを削除します。各工程で「実際に何を確認したら成功と言えるか」を1行ずつ書くと、運用時の検査にも使えます。