OpenAI Agent SDKとTyperで作るCLI形式のAIチャットエージェント
はじめに
こんにちは!
株式会社エクスプラザのhyodoです!
ターミナルからAIチャットを使いたいと思ったことはありませんか?
ブラウザを開かなくても、いつものターミナルからサクッとAIに質問できる環境があれば、開発の効率がグッと上がります。今回は、OpenAI Agent SDKとTyperを組み合わせて、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アプリの骨格を作ります。
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)
これがあるだけで、ユーザーが「処理中なんだな」と安心できます。
チャットループの実装
基本的なチャットループを実装してみましょう。
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でファイル補完を実装
ここからが面白いところです。@ に続けてファイルパスを入力すると、自動でファイル候補が補完される機能を実装します。
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 のようなメンションをパースして、ファイル内容をメッセージに含める処理を実装します。
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()
正規表現で@から始まるパスを抽出し、存在確認してから処理します。
マルチモーダルメッセージの構築
画像やテキストファイルを含むメッセージを構築する関数です。
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検索ツールがあります。
from agents import Agent, WebSearchTool
agent = Agent(
name="Chat Agent",
instructions="あなたは親切なアシスタントです。",
model="gpt-4o",
tools=[WebSearchTool()], # Web検索を有効化
)
これだけで、AIが必要に応じてWeb検索を行い、最新情報を取得してくれます。「最近のニュースを教えて」みたいな質問にも対応できるようになります。
カスタムツールの作成
独自のツールを作成してエージェントに追加することもできます。
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引数にリストで渡します。
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時のコンテキストの渡され方を詳しく解説しています!
参考リンク
「プロダクトの力で、豊かな暮らしをつくる」をミッションに、法人向けに生成AIのPoC、コンサルティング〜開発を支援する事業を展開しております。 エンジニア募集しています。カジュアル面談応募はこちらから: herp.careers/careers/companies/explaza
Discussion