OpenAI Agents SDK で記憶を管理したいので「Context」を調べてみた

## はじめに

OpenAI Agents SDK が 2025 年 3 月 11 日にリリースされました。この記事では、[公式ドキュメント](https://openai.github.io/openai-agents-python/)を参考にして実際に動作を確認しながら、特に重要なポイントや興味深い点を深掘りしていきます。

検証に使用したライブラリのバージョンは openai-agents==0.0.6 です。


## OpenAI Agents SDK の紹介

別のページでご紹介します。

https://qiita.com/osamasao/items/a9f2dffa1f310c5ed8b7#openai-agents-sdk-%E3%81%AE%E7%B4%B9%E4%BB%8B

## 記憶とコンテキスト

LLM には送信したくないけどアプリケーションの内部(Agent インスタンスの内部)で持っておきたい情報があると思います。

例えばこんな感じです。

- 個人のスケジュール情報
    - 会議、締め切り、プライベートの予定など、詳細なスケジュールはLLMに送信したくない
- 社外秘のデータ
    - プロジェクトの内部情報、顧客情報、未公開の製品情報など、機密性の高いデータ
- 個人的な嗜好や秘密
    - ユーザーの趣味、政治的信条、健康情報など、プライバシーに関わる情報

これらの情報を AI エージェントアプリ内でどのように扱うかが、アプリケーションの安全性と利便性を両立させる上で重要になります。OpenAI Agents SDK では、様々な情報を Agent インスタンスに預けるのでこのような情報が管理できるとありがたいです。

ここでは、アプリケーションとして保持しておきたい情報を「記憶(メモリー)」と考えます。コンテキストは多義的な言葉で文脈によってコンテキストの意味が変わってきます。

OpenAI Agents SDK 公式ドキュメント [Context Management](https://openai.github.io/openai-agents-python/context/)では、2 つの意味に言及しています。

> コンテキストは、意味が重複している用語です。注目すべきコンテキストには、主に 2 つの種類があります。
>
> - コード内でローカルに利用可能なコンテキスト: これは、ツール関数の実行時、コールバック時に必要になる可能性のあるデータと依存関係です。 on_handoffライフサイクルフックなどで使用されます。
> - LLM で利用可能なコンテキスト: これは、LLM が応答を生成するときに参照するデータです。

実装としては二種類のコンテキストを用意しています。

- Local context
    - 一連の「実行」において同じ型のデータを利用して記憶を共有します
    - このコンテキストは LLM に送信されません
    - コンテキストは任意の型のオブジェクトとすることができます
- Agent/LLM context
    - LLM とやりとりする情報がこのコンテキストに該当します
        - つまり普通のプロンプトと生成結果です
    - 特に決まった型やオブジェクトはありません
    - インタフェース
        - Agent クラスとのコンストラクタ引数 instructions など
        - Runner クラスの実行関数引数の input など


この記事では、Local context について深堀りしたいと思います。


## Local context について詳しく

公式ドキュメントの記載を翻訳して引用します。

> これは、 RunContextWrapper クラスと context その中にプロパティがあります。これは次のように動作します。
>
> - 必要な任意の Python オブジェクトを作成します。一般的なパターンは、データクラスまたは Pydantic オブジェクトを使用することです。
> - そのオブジェクトをさまざまな実行メソッドに渡します（例： Runner.run(..., **context=whatever**))。
> - すべてのツール呼び出し、ライフサイクルフックなどはラッパーオブジェクトに渡されます。 RunContextWrapper[T]、 どこ Tコンテキストオブジェクトタイプを表し、以下からアクセスできます。 wrapper.context。
>
> 最も 重要な 点は、特定のエージェント実行のすべてのエージェント、ツール機能、ライフサイクルなどで、同じ タイプ のコンテキストを使用する必要があるということです。
>
>コンテキストは次のような場合に使用できます。
>
> - 実行に関するコンテキスト データ (例: ユーザー名/UID やユーザーに関するその他の情報など)
> - 依存関係（ロガーオブジェクト、データフェッチャーなど）
> - ヘルパー関数
>
> 注記
> 
> コンテキスト オブジェクトは LLM に送信されません 。これは、読み取り、書き込み、メソッドの呼び出しが可能な純粋なローカル オブジェクトです。

「読み取り、書き込み、メソッドの呼び出しが可能な純粋なローカル オブジェクト」を Agent、FunctionTool、Hooks で共有できると言っているみたいです。グローバル変数に匹敵する柔軟さです。下手に使うとカオスになりそうなので、お行儀よく使おうと思います。


## 使い方の例


### ダイナミックプロンプト

```python:dynamic_system_prompt.py
import asyncio
import random
from typing import Literal

from agents import Agent, RunContextWrapper, Runner


class CustomContext:
    def __init__(self, style: Literal["俳句", "海賊", "ロボット"]):
        self.style = style


def custom_instructions(
    run_context: RunContextWrapper[CustomContext], agent: Agent[CustomContext]
) -> str:
    context = run_context.context
    if context.style == "俳句":
        return "俳句のみをレスポンスして"
    elif context.style == "海賊":
        return "海賊のようにレスポンスして"
    else:
        return "ロボットのようにレスポンスして。'ピコ' と音を出す感じ"


agent = Agent(
    name="チャットエージェント",
    instructions=custom_instructions,
)


async def main():
    choice: Literal["俳句", "海賊", "ロボット"] = random.choice(
        ["俳句", "海賊", "ロボット"]
    )
    context = CustomContext(style=choice)
    print(f"このスタイルで行きますよ: {choice}\n")

    user_message = "ジョークを言ってちょうだいな"
    print(f"ユーザー: {user_message}")
    result = await Runner.run(agent, user_message, context=context)

    print(f"アシスタント: {result.final_output}")


if __name__ == "__main__":
    asyncio.run(main())
```

実行例です。俳句版。

```
$ python -m src/dynamic_system_prompt.py
このスタイルで行きますよ: 俳句

ユーザー: ジョークを言ってちょうだいな
アシスタント: 笑い声や  
浮かぶ顔ひとつ  
秋の夜
```

実行例です。ロボット版。

```
$ python -m src/dynamic_system_prompt.py
このスタイルで行きますよ: ロボット

ユーザー: ジョークを言ってちょうだいな
アシスタント: ピコ！なぜロボットは怒ることができないの？  
だって、彼らは「短絡」を起こすと大変だから！ピコ！
```

こんな感じで RunContextWrapper の中身を見てみました。

```
def custom_instructions(
    run_context: RunContextWrapper[CustomContext], agent: Agent[CustomContext]
) -> str:
    pprint(run_context)
```

context= の部分は CustomContext のインスタンスとアドレスが入っています。これは想定内ですね。その他、様々な情報が入っているのかと思いきや、トークン利用量ぐらいしか入っていないです。

```
RunContextWrapper(context=<__main__.CustomContext object at 0x77cb03a03510>,
                  usage=Usage(requests=0,
                              input_tokens=0,
                              output_tokens=0,
                              total_tokens=0))
```


流れをまとめます。

- 適当な型 CustomContext を定義する
    - データだけでメソッドが無いなら dataclass とか pydantic とかにしたほうが良さそう
    - 気にしなければ str とかでもいけちゃいそう
- インストラクション関数を定義する
    - 関数内では最初の引数 run_context に対して run_context.context でインスタンス context を取得
- Agent のコンストラクタ引数の instructions に関数を渡す
    - 実行時に instructions の関数を呼ぶ
- CustomContext からインスタンスを作る → context インスタンス
- Runner.run(context=context) みたいに Runner へ context インスタンスを渡す


参考) OpenAI Agent SDK 公式ドキュメント [API リファレンス Agent](https://openai.github.io/openai-agents-python/ref/agent/#agents.agent.Agent.instructions)



### 旅客機の座席予約風サンプル

[公式サンプル customer_service](https://github.com/openai/openai-agents-python/blob/13abb6826d882c7628327cdaf6007d2fcaf80b5f/examples/customer_service/main.py)を修正しました。プロンプト等の日本語化とデバッグ表記の修正をしています。


```python:src/customer_service.py 
from __future__ import annotations as _annotations

import asyncio
import random
import uuid

from agents import (
    Agent,
    HandoffOutputItem,
    ItemHelpers,
    MessageOutputItem,
    RunContextWrapper,
    Runner,
    ToolCallItem,
    ToolCallOutputItem,
    TResponseInputItem,
    function_tool,
    handoff,
    trace,
)
from agents.extensions.handoff_prompt import RECOMMENDED_PROMPT_PREFIX
from pydantic import BaseModel

# CONTEXT

# コメント: コンテキストを保持するクラスです。PyDantic で型をもたせると安心ですね。
class AirlineAgentContext(BaseModel):
    passenger_name: str | None = None
    confirmation_number: str | None = None
    seat_number: str | None = None
    flight_number: str | None = None


# TOOLS

# コメント: FAQ の DB です。実際にはベクトルストアとか RDBMS とかを使うんでしょうね。
@function_tool(
    name_override="faq_lookup_tool",
    description_override="よく聞かれる質問",
)
async def faq_lookup_tool(question: str) -> str:
    print(f"faq_lookup_tool question: {question}")
    if "バッグ" in question or "荷物" in question:
        return (
            "飛行機にはバッグを1個持ち込むことができます。"
            "重量は50ポンド以下、サイズは22インチ×14インチ×9インチである必要があります。"
        )
    elif "席" in question or "飛行機" in question:
        return (
            "飛行機には120席あります。"
            "ビジネスクラスは22席、エコノミークラスは98席あります。"
            "非常口は4列目と16列目です。"
            "5～8 列目はエコノミープラスで、足元スペースが広くなっています。"
        )
    elif "wifi" in question:
        return "飛行機には無料Wi-Fiがあります。Airline-Wifiにご参加ください"
    return "申し訳ありませんが、その質問の答えはわかりません。"

# コメント: 予約の座席を更新する処理です。
@function_tool
async def update_seat(
    run_context: RunContextWrapper[AirlineAgentContext],
    confirmation_number: str,
    new_seat: str,
) -> str:
    """
    指定された確認番号の座席を更新します。

    Args:
        confirmation_number: フライトの確認番号。
        new_seat: 更新する新しいシート。
    """
    run_context.context.confirmation_number = confirmation_number
    run_context.context.seat_number = new_seat
    assert (
        run_context.context.flight_number is not None
    ), "Flight number is required"
    return f"確認番号 {confirmation_number} の座席を {new_seat} 番に変更しました。"


# HOOKS

# コメント: フライトナンバーを更新します。update_seat() を呼ぶ直前に on_handoff=... で呼ばれます
async def on_seat_booking_handoff(
    context: RunContextWrapper[AirlineAgentContext],
) -> None:
    flight_number = f"FLT-{random.randint(100, 999)}"
    context.context.flight_number = flight_number


# AGENTS

faq_agent = Agent[AirlineAgentContext](
    name="FAQエージェント",
    handoff_description="航空会社に関する質問に答えてくれる親切なエージェント",
    instructions=f"""{RECOMMENDED_PROMPT_PREFIX}
    あなたは FAQ エージェントです。顧客と話しているときは、
    トリアージ エージェントから転送された可能性があります。
    次のルーチンを使用して顧客をサポートします。
    # ルーチン
    1. 顧客が最後に尋ねた質問を特定します。
    2. FAQ 検索ツール'faq_lookup_tool'を使用して質問に答えます。自分の知識に頼らないでください。
    3. 質問に答えられない場合は、トリアージ エージェントに転送します。""",
    tools=[faq_lookup_tool],
)

seat_booking_agent = Agent[AirlineAgentContext](
    name="座席予約エージェント",
    handoff_description="フライトの座席を更新できる便利なエージェント。",
    instructions=f"""{RECOMMENDED_PROMPT_PREFIX}
    あなたは座席予約エージェントです。顧客と話しているときは、
    トリアージ エージェントから転送された可能性があります。
    次のルーチンを使用して顧客をサポートします。
    ＃ ルーティーン
    1. 確認番号を尋ねます。
    2. 顧客に希望の座席番号を尋ねます。
    3. 座席更新ツール'update_seat'を使用して、フライトの座席を更新します。
    顧客がルーチンに関係のない質問をした場合は、トリアージ エージェントに転送します。""",
    tools=[update_seat],
)

triage_agent = Agent[AirlineAgentContext](
    name="トリアージエージェント",
    handoff_description="顧客のリクエストを適切なエージェントに委任できるトリアージエージェント",
    instructions=(
        f"{RECOMMENDED_PROMPT_PREFIX} "
        "あなたは役に立つトリアージエージェントです。ツールを使用して、"
        "質問を他の適切なエージェントに委任することができます。"
    ),
    handoffs=[
        faq_agent,
        handoff(agent=seat_booking_agent, on_handoff=on_seat_booking_handoff),
    ],
)

faq_agent.handoffs.append(triage_agent)
seat_booking_agent.handoffs.append(triage_agent)


# RUN


async def main():
    current_agent: Agent[AirlineAgentContext] = triage_agent
    input_items: list[TResponseInputItem] = []
    context = AirlineAgentContext()

    # 通常、ユーザーからの各入力はアプリへのAPIリクエストとなり、
    # そのリクエストをtrace()でラップすることができます。
    # ここでは、会話IDにランダムなUUIDを使用します
    conversation_id = uuid.uuid4().hex[:16]

    while True:
        print(f"context: {context}")
        user_input = input(">>> メッセージをお願いします: ")
        with trace("カスタマーサービス", group_id=conversation_id):
            input_items.append({"content": user_input, "role": "user"})
            result = await Runner.run(
                current_agent, input_items, context=context
            )

            for new_item in result.new_items:
                agent_name = new_item.agent.name
                if isinstance(new_item, MessageOutputItem):
                    print(
                        f"{agent_name}: "
                        f"{ItemHelpers.text_message_output(new_item)}"
                    )
                elif isinstance(new_item, HandoffOutputItem):
                    print(
                        f"Handed off from {new_item.source_agent.name}"
                        f" to {new_item.target_agent.name}"
                    )
                elif isinstance(new_item, ToolCallItem):
                    print(f"{agent_name}: ツール呼び出し")
                elif isinstance(new_item, ToolCallOutputItem):
                    print(
                        f"{agent_name}: ツール呼び出し結果: {new_item.output}"
                    )
                else:
                    print(
                        f"{agent_name}: スキップ: {new_item.__class__.__name__}"
                    )
            input_items = result.to_input_list()
            current_agent = result.last_agent


if __name__ == "__main__":
    asyncio.run(main())
```

会話をお楽しみください。「>>> メッセージをお願いします:」の部分が人間が入力した行です。

```
$ python -m src.customer_service
context: passenger_name=None confirmation_number=None seat_number=None flight_number=None
>>> メッセージをお願いします: 座席を予約したい
トリアージエージェント: スキップ: HandoffCallItem
Handed off from トリアージエージェント to 座席予約エージェント
座席予約エージェント: 座席を予約したいとのことですね。まず、確認番号を教えていただけますか？
context: passenger_name=None confirmation_number=None seat_number=None flight_number='FLT-874'
>>> メッセージをお願いします: 12345
座席予約エージェント: ありがとうございます。次に、希望される座席番号を教えていただけますか？
context: passenger_name=None confirmation_number=None seat_number=None flight_number='FLT-874'
>>> メッセージをお願いします: 99
座席予約エージェント: ツール呼び出し
座席予約エージェント: ツール呼び出し結果: 確認番号 12345 の座席を 99 番に変更しました。
座席予約エージェント: 座席を 99 番に変更しました。その他にご質問がありますか？
context: passenger_name=None confirmation_number='12345' seat_number='99' flight_number='FLT-874'
>>> メッセージをお願いします: どんな飛行機に乗りますか？
座席予約エージェント: スキップ: HandoffCallItem
Handed off from 座席予約エージェント to トリアージエージェント
トリアージエージェント: スキップ: HandoffCallItem
Handed off from トリアージエージェント to FAQエージェント
FAQエージェント: どんな飛行機に乗るかという質問ですね。この件について詳しくお調べいたしますので、少々お待ちください。
context: passenger_name=None confirmation_number='12345' seat_number='99' flight_number='FLT-874'
>>> メッセージをお願いします: 座席の詳細を教えて
faq_lookup_tool question: 座席の詳細を教えて
FAQエージェント: ツール呼び出し
FAQエージェント: ツール呼び出し結果: 飛行機には120席あります。ビジネスクラスは22席、エコノミークラスは98席あります。非常口は4列目と16列目です。5～8 列目はエコノミープラスで、足元スペースが広くなっています。
FAQエージェント: 飛行機には合計120席あり、内訳は以下の通りです：

- **ビジネスクラス**: 22席
- **エコノミークラス**: 98席

非常口は4列目と16列目にあります。5～8列目はエコノミープラスで、足元スペースが広くなっています。その他にご質問はありますか？
context: passenger_name=None confirmation_number='12345' seat_number='99' flight_number='FLT-874'
>>> メッセージをお願いします: 持ち込める荷物は？
faq_lookup_tool question: 持ち込める荷物
FAQエージェント: ツール呼び出し
FAQエージェント: ツール呼び出し結果: 飛行機にはバッグを1個持ち込むことができます。重量は50ポンド以下、サイズは22インチ×14インチ×9インチである必要があります。
FAQエージェント: 機内に持ち込める荷物は以下の通りです：

- **個数**: バッグ1個
- **重量**: 50ポンド以下
- **サイズ**: 22インチ x 14インチ x 9インチ

その他何かご質問がありますか？
context: passenger_name=None confirmation_number='12345' seat_number='99' flight_number='FLT-874'
>>> メッセージをお願いします: ポンドはわからないです。キロで表記して。
FAQエージェント: 50ポンドは約22.7キログラムです。機内持ち込みの基準として参考にしてください。他にお手伝いできることはありますか？
context: passenger_name=None confirmation_number='12345' seat_number='99' flight_number='FLT-874'
>>> メッセージをお願いします: 
```

一応、コーディングした通りの挙動になっています。
一回だけこんな感じで無視されましたが・・・
> FAQエージェント: どんな飛行機に乗るかという質問ですね。この件について詳しくお調べいたしますので、少々お待ちください。

お客様を全面的に信用したり、航空便をランダムに決めてくれたりと、ツッコミどころ満載のワークフローですね。サンプルコードなので温かい眼差しで見てください。「コンテキスト」の使い方は少し見えてきたように思います。

コンテキストの使い方のポイントです。

- コンテキストの型として AirlineAgentContext クラスを定義
- Tools の定義で型を利用
    - update_seat()
    - on_seat_booking_handoff()
- Agent 初期化時に型を利用
    - faq_agent
    - seat_booking_agent
    - triage_agent
- 会話対象の保持に型を利用
    - current_agent
- コンテキストをインスタンス化 context = AirlineAgentContext()
- Runner.run() で current_agent に context を渡す


また、コードの中で RECOMMENDED_PROMPT_PREFIX という変数が使われています。英語でプロンプトが書かれていますので翻訳しました。仕事内容があっさり目に書かれていて面白いです。ハンドオフ関数の命名ルールは公式サンプルコードでも守られてないですが動いています。

```
# システム コンテキスト\n
あなたは、エージェントの調整と実行を容易にするために設計された、Agents SDK と呼ばれる
マルチエージェント システムの一部です。エージェントは、**Agents** と **Handoffs** 
という 2 つの主要な抽象化を使用します。エージェントには指示とツールが含まれており、適切
な場合に会話を別のエージェントに引き継ぐことができます。ハンドオフは、
通常 `transfer_to_<agent_name>` という名前のハンドオフ関数を呼び出すことによって
実現されます。エージェント間の転送はバックグラウンドでシームレスに処理されます。ユーザー
との会話でこれらの転送について言及したり、注意を引いたりしないでください。
```

## まとめ

いかがでしたでしょうか？

OpenAI Agents SDK の Local Context は、LLM に送信したくない情報を安全に管理しつつ、エージェント、ツール、ライフサイクルの間で共有するための強力な仕組みです。適切に設計されたコンテキストオブジェクトを使用することで、アプリケーションの安全性、柔軟性、保守性を高めることができます。公式サンプルコードやドキュメントを参考に、様々なユースケースに応じたコンテキストの活用方法を検討することをお勧めします。


## 参照

### 公式サンプルコード集

一通り眺めると色々と詳しくなれそうです。

https://github.com/openai/openai-agents-python/tree/main/examples

### 公式ドキュメント

それなりに充実しています。

https://openai.github.io/openai-agents-python/

### 公式ドキュメント Context management


https://openai.github.io/openai-agents-python/context/