🤖

OpenAI Agent SDKとTyperで作るCLI形式のAIチャットエージェント

に公開
7

はじめに

こんにちは!
株式会社エクスプラザのhyodoです!

ターミナルからAIチャットを使いたいと思ったことはありませんか?

ブラウザを開かなくても、いつものターミナルからサクッとAIに質問できる環境があれば、開発の効率がグッと上がります。今回は、OpenAI Agent SDKTyperを組み合わせて、CLI形式のAIチャットエージェントを構築する方法を紹介します。

単なるチャットだけでなく、ファイル添付、画像解析、Web検索といった機能の実装もご紹介いたします!

📚 OpenAI Agent SDK関連のおすすめ記事

我らがkaznishiがhandoff時のコンテキストの渡され方について詳しく解説しています。マルチエージェント構成に興味がある方はぜひ!

完成イメージ

$ python main.py chat

あなた: この子は何の動物? @./photo.jpg

アシスタント:
この写真には柴犬が写っています!茶色と白の毛並みが特徴的で、
カメラ目線でちょこんと座っている姿がとても愛らしいですね。

あなた: 柴犬の飼い方で気をつけることは?

アシスタント:
柴犬を飼う際のポイントをいくつか紹介しますね。

1. **運動量**: 活発な犬種なので、毎日30分〜1時間の散歩が必要です
2. **換毛期**: 春と秋に大量に毛が抜けるので、こまめなブラッシングを
3. **しつけ**: 頑固な一面があるので、子犬のうちから根気強く
4. **暑さ対策**: 厚い被毛があるため、夏場は熱中症に注意してください

ファイルを @ で参照するだけで自動的に添付され、画像なら解析、テキストなら内容を読み込んでAIに渡してくれます。会話履歴も保持されるので、文脈を踏まえたやり取りができます。

使用するライブラリ

ライブラリ 役割
openai-agents OpenAI Agent SDK(エージェント構築)
typer CLIアプリケーションフレームワーク
rich ターミナルでのリッチな出力
prompt_toolkit インタラクティブな入力(補完機能)
python-dotenv 環境変数の読み込み
pip install openai-agents typer rich prompt_toolkit python-dotenv

Typerでベースとなるアプリケーションを作る

まずはTyperでCLIアプリの骨格を作ります。

main.py
import typer
from rich.console import Console

app = typer.Typer(
    name="ai-chat",
    help="CLI形式のAIチャットツール",
    add_completion=False,
)

console = Console()


@app.command()
def chat(
    model: str = typer.Option("gpt-4o", "--model", "-m", help="使用するモデル"),
):
    """インタラクティブなチャットセッションを開始"""
    console.print("[bold cyan]AIチャットを開始します[/bold cyan]")
    # ここにチャットロジックを実装


@app.command()
def version():
    """バージョン情報を表示"""
    console.print("AI Chat CLI v1.0.0")


if __name__ == "__main__":
    app()

Typerを使うと、引数やオプションの定義、ヘルプメッセージの生成が自動化されます。--help を付けて実行すれば、きれいなヘルプが表示されます。

OpenAI Agent SDKの基本

OpenAI Agent SDKでエージェントを作成・実行する基本パターンは以下の通りです。

from agents import Agent, Runner

# エージェントを作成
agent = Agent(
    name="Chat Agent",
    instructions="あなたは親切なアシスタントです。日本語で回答してください。",
    model="gpt-4o",
)

# 同期実行
result = Runner.run_sync(
    agent,
    [{"role": "user", "content": "Pythonの特徴を教えて"}],
)

print(result.final_output)

Agentクラスでエージェントを定義し、Runner.run_sync()でメッセージを渡して実行します。会話履歴はメッセージのリストとして管理し、過去のやり取りを含めて渡すことで文脈を維持できます。

Richでリッチな出力を実現

Richを使うと、Markdown形式のレスポンスをターミナル上できれいに表示できます。

from rich.console import Console
from rich.markdown import Markdown
from rich.panel import Panel

console = Console()

# パネルで囲んで表示
console.print(
    Panel(
        "[bold]AIチャット[/bold]\n\n"
        "終了: exit | 履歴クリア: /clear",
        border_style="cyan",
    )
)

# AIの応答をMarkdownとしてレンダリング
response_text = "## 見出し\n- 箇条書き1\n- 箇条書き2"
console.print(Markdown(response_text))

処理中のインジケーター

時間のかかる処理にはconsole.status()でスピナーを表示できます。

with console.status("[bold cyan]考え中...[/bold cyan]"):
    result = Runner.run_sync(agent, messages)

これがあるだけで、ユーザーが「処理中なんだな」と安心できます。

チャットループの実装

基本的なチャットループを実装してみましょう。

main.py
import os
from dotenv import load_dotenv
from agents import Agent, Runner

# .envファイルから環境変数を読み込む
load_dotenv()


@app.command()
def chat(
    model: str = typer.Option("gpt-4o", "--model", "-m", help="使用するモデル"),
):
    """インタラクティブなチャットセッションを開始"""
    # APIキーの確認(python-dotenvで.envから読み込み済み)
    api_key = os.getenv("OPENAI_API_KEY")
    if not api_key:
        console.print("[red]OPENAI_API_KEYが設定されていません[/red]")
        return
    
    # エージェントを作成
    agent = Agent(
        name="Chat Agent",
        instructions="あなたは親切なアシスタントです。日本語で回答してください。",
        model=model,
    )
    
    # 会話履歴
    conversation_history: list = []
    
    console.print(Panel("AIチャットを開始します。'exit'で終了", border_style="cyan"))
    
    while True:
        try:
            # ユーザー入力
            user_input = input("あなた: ").strip()
            
            if not user_input:
                continue
            
            if user_input.lower() in ["exit", "quit", "q"]:
                console.print("[dim]チャットを終了します[/dim]")
                break
            
            if user_input == "/clear":
                conversation_history = []
                console.print("[dim]会話履歴をクリアしました[/dim]")
                continue
            
            # 会話履歴に追加
            conversation_history.append({"role": "user", "content": user_input})
            
            # エージェントを実行
            with console.status("[cyan]考え中...[/cyan]"):
                result = Runner.run_sync(agent, conversation_history)
            
            response_text = result.final_output
            
            # 会話履歴に応答を追加
            conversation_history.append({"role": "assistant", "content": response_text})
            
            # 応答を表示
            console.print()
            console.print("[bold blue]アシスタント[/bold blue]")
            console.print(Markdown(response_text))
            console.print()
        
        except KeyboardInterrupt:
            console.print("\n[dim]Ctrl+Cで終了します[/dim]")
            break

これで基本的なチャット機能が完成です!

prompt_toolkitでファイル補完を実装

ここからが面白いところです。@ に続けてファイルパスを入力すると、自動でファイル候補が補完される機能を実装します。

utils/completer.py
import os
import glob
from pathlib import Path

from prompt_toolkit import prompt as pt_prompt
from prompt_toolkit.completion import Completer, Completion
from prompt_toolkit.document import Document
from prompt_toolkit.styles import Style

# スタイル設定
PT_STYLE = Style.from_dict({
    'completion-menu.completion': 'bg:#333333 #ffffff',
    'completion-menu.completion.current': 'bg:#00aa00 #ffffff',
})


class FileCompleter(Completer):
    """@で始まるファイルパスを補完するCompleter"""
    
    def _get_file_meta(self, filepath: str) -> str:
        """ファイルの種類に応じたメタ情報を返す"""
        if os.path.isdir(filepath):
            return '📁 ディレクトリ'
        
        ext = Path(filepath).suffix.lower()
        meta_map = {
            '.py': '🐍 Python',
            '.js': '📜 JavaScript',
            '.json': '📋 JSON',
            '.md': '📝 Markdown',
            '.png': '🖼️ 画像',
            '.jpg': '🖼️ 画像',
        }
        return meta_map.get(ext, '📄 ファイル')
    
    def get_completions(self, document: Document, complete_event):
        text = document.text_before_cursor
        
        # @の位置を探す
        at_pos = text.rfind('@')
        if at_pos == -1:
            return
        
        # @以降のテキストを取得
        path_text = text[at_pos + 1:]
        
        # スペースが含まれていたら補完しない
        if ' ' in path_text:
            return
        
        # パスを展開してglobで検索
        pattern = os.path.expanduser(path_text) + '*'
        matches = glob.glob(pattern)
        
        for match in sorted(matches)[:20]:
            display = match
            if os.path.isdir(match):
                display += '/'
            
            yield Completion(
                display,
                start_position=-len(path_text),
                display_meta=self._get_file_meta(match)
            )


def get_user_input_with_completion() -> str:
    """ファイル補完付きでユーザー入力を取得"""
    return pt_prompt(
        'あなた: ',
        completer=FileCompleter(),
        style=PT_STYLE,
        complete_while_typing=True,
    )

@./ と入力すると、カレントディレクトリのファイル一覧が表示され、さらに入力を続けると絞り込まれていきます。絵文字でファイルタイプもわかるので視認性も良いです。

ファイルメンションの解析

@./file.py のようなメンションをパースして、ファイル内容をメッセージに含める処理を実装します。

utils/files.py
import os
import re
import base64
from pathlib import Path


def is_image_file(file_path: str) -> bool:
    """画像ファイルかどうかを判定"""
    ext = Path(file_path).suffix.lower()
    return ext in [".jpg", ".jpeg", ".png", ".gif", ".webp"]


def parse_file_mentions(text: str) -> tuple[str, list[dict]]:
    """テキスト内の@ファイルパスメンションを解析"""
    pattern = r'@((?:[~/.])?[^\s@]+)'
    
    mentions = []
    
    for match in re.finditer(pattern, text):
        file_path = match.group(1)
        expanded_path = os.path.expanduser(file_path)
        
        if Path(expanded_path).exists():
            mentions.append({
                "original": match.group(0),
                "path": expanded_path,
                "is_image": is_image_file(expanded_path)
            })
    
    # メンションをテキストから除去
    clean_text = text
    for mention in mentions:
        clean_text = clean_text.replace(mention["original"], "").strip()
    
    return clean_text, mentions


def get_image_base64(image_path: str) -> tuple[str, str]:
    """画像ファイルをBase64エンコード"""
    path = Path(image_path)
    ext = path.suffix.lower().lstrip(".")
    
    mime_map = {
        "jpg": "image/jpeg", "jpeg": "image/jpeg",
        "png": "image/png", "gif": "image/gif", "webp": "image/webp",
    }
    mime_type = mime_map.get(ext, "image/jpeg")
    
    with open(path, "rb") as f:
        image_data = base64.standard_b64encode(f.read()).decode("utf-8")
    
    return image_data, mime_type


def read_file_content(file_path: str) -> str:
    """テキストファイルの内容を読み込む"""
    with open(file_path, "r", encoding="utf-8") as f:
        return f.read()

正規表現で@から始まるパスを抽出し、存在確認してから処理します。

マルチモーダルメッセージの構築

画像やテキストファイルを含むメッセージを構築する関数です。

utils/files.py
def build_message_with_mentions(text: str, mentions: list[dict], console) -> dict | None:
    """ファイルメンションを含むメッセージを構築"""
    if not mentions:
        return {"role": "user", "content": text}
    
    # ファイル情報を表示
    for mention in mentions:
        file_name = Path(mention["path"]).name
        file_type = "画像" if mention["is_image"] else "テキスト"
        console.print(f"[dim]📎 {file_name} ({file_type}) を添付[/dim]")
    
    has_images = any(m["is_image"] for m in mentions)
    
    if has_images:
        # マルチモーダルメッセージ
        content = []
        
        # テキストがない場合のデフォルトメッセージ
        default_message = "添付ファイルについて説明してください。"
        if text:
            content.append({"type": "input_text", "text": text})
        else:
            content.append({"type": "input_text", "text": default_message})
        
        for mention in mentions:
            if mention["is_image"]:
                image_data, mime_type = get_image_base64(mention["path"])
                content.append({
                    "type": "input_image",
                    "image_url": f"data:{mime_type};base64,{image_data}",
                })
            else:
                file_content = read_file_content(mention["path"])
                file_name = Path(mention["path"]).name
                content.append({
                    "type": "input_text",
                    "text": f"\n--- {file_name} ---\n```\n{file_content}\n```\n"
                })
        
        return {"role": "user", "content": content}
    else:
        # テキストファイルのみ
        prompt_parts = [text] if text else ["以下のファイルについて分析してください。"]
        
        for mention in mentions:
            file_content = read_file_content(mention["path"])
            file_name = Path(mention["path"]).name
            prompt_parts.append(f"\n--- {file_name} ---\n```\n{file_content}\n```")
        
        return {"role": "user", "content": "\n".join(prompt_parts)}

画像が含まれている場合はマルチモーダル形式、テキストのみの場合は通常のテキストとしてメッセージを構築します。

Web検索ツールの追加

OpenAI Agent SDKには組み込みのWeb検索ツールがあります。

main.py
from agents import Agent, WebSearchTool

agent = Agent(
    name="Chat Agent",
    instructions="あなたは親切なアシスタントです。",
    model="gpt-4o",
    tools=[WebSearchTool()],  # Web検索を有効化
)

これだけで、AIが必要に応じてWeb検索を行い、最新情報を取得してくれます。「最近のニュースを教えて」みたいな質問にも対応できるようになります。

カスタムツールの作成

独自のツールを作成してエージェントに追加することもできます。

tools.py
from agents import function_tool
from datetime import datetime


@function_tool
def get_current_time() -> str:
    """現在の日時を取得します。"""
    return datetime.now().strftime("%Y年%m月%d日 %H:%M:%S")


@function_tool
def calculate(expression: str) -> str:
    """数式を計算します。
    
    Args:
        expression: 計算する数式(例: "2 + 3 * 4")
    
    Returns:
        計算結果
    """
    try:
        result = eval(expression)
        return f"{expression} = {result}"
    except Exception as e:
        return f"計算エラー: {e}"

エージェントにツールを登録するには、tools引数にリストで渡します。

main.py
from agents import Agent
from tools import get_current_time, calculate

agent = Agent(
    name="Chat Agent",
    instructions="あなたは親切なアシスタントです。",
    model="gpt-4o",
    tools=[get_current_time, calculate],
)

@function_toolデコレータを使うと、関数のdocstringからツールの説明が自動生成されます。引数の説明もArgsセクションに書いておけば、AIがそれを理解して適切に使ってくれます。

全体のコード構成

最終的なコードの構成例です。

ai-chat/
├── main.py          # Typerアプリのエントリーポイント
├── agents/
│   ├── __init__.py
│   └── chat_agent.py  # エージェントの定義
├── utils/
│   ├── __init__.py
│   ├── completer.py   # ファイル補完
│   └── files.py       # ファイル操作
└── requirements.txt

まとめ

  • TyperでCLIの引数・オプション管理が楽になる
  • Richでターミナル出力が見やすくなる
  • prompt_toolkitでファイル補完などインタラクティブな入力ができる
  • OpenAI Agent SDKでエージェントとツールの管理が簡単になる

この仕組みをベースに、用途に応じたツールを追加していけば、より便利なCLIツールに発展させられます。コードレビューエージェント、ドキュメント生成エージェント、データ分析エージェントなど、アイデア次第でいろいろ作れるはずです。

最後までお読みいただきありがとうございました!
ぜひ自分だけのCLIエージェントを作ってみてください!

👉 あわせて読みたい: 我らがkaznishiがhandoff時のコンテキストの渡され方を詳しく解説しています!

参考リンク

7
株式会社エクスプラザ

Discussion

ログインするとコメントできます
7