AI エージェント開発実践 #7 実践プロジェクト:イシュートリアージエージェント

読了 6分

第6回までで、シリーズの部品はすべて揃いました。最後の記事では、その部品を束ねて実際に使えるエージェントを一つ完成させます。リポジトリに溜まる GitHub のイシューを読み、種類を分類し、ラベルと返信の下書きを提案するイシュートリアージエージェントです。

何を作るか #

オープンソースでも社内リポジトリでも、イシューは処理する速度より溜まる速度のほうが速いものです。トリアージ(triage)とは、もともと救急医療で押し寄せる患者を緊急度に応じて振り分ける作業を指す言葉で、開発では溜まっていくイシューを種類と優先度で分類する工程を意味します。具体的には、新しいイシューを読んでバグか機能要望か質問かを切り分け、ラベルを付け、必要なら最初の返信を書く仕事です。繰り返しが多いのに判断が必要なので、エージェントによく合います。

設計はシリーズの原則に従います。読み取りは自由に、書き込みは承認を経てです。イシューの取得と分析はエージェントが自分で行い、ラベルを実際に付けたりコメントを投稿したりする行動は、人が承認してはじめて実行されます。

ツール構成 #

ツールは四つです。GitHub REST API をそのまま使うので、トークン一つで足ります。

triage_tools.py
import requests

API = "https://api.github.com"
HEADERS = {"Authorization": f"Bearer {GITHUB_TOKEN}"}
MAX_BODY = 3000   # 第4回: ツール結果のダイエット

def list_open_issues(repo: str) -> str:
    """ラベルのないオープンなイシューを新しい順に最大10件返す。"""
    r = requests.get(f"{API}/repos/{repo}/issues",
                     params={"state": "open", "per_page": 30}, headers=HEADERS)
    r.raise_for_status()
    unlabeled = [i for i in r.json() if not i["labels"] and "pull_request" not in i]
    lines = [f"#{i['number']} {i['title']}" for i in unlabeled[:10]]
    return "\n".join(lines) or "ラベルのないオープンなイシューはありません。"

def get_issue(repo: str, number: int) -> str:
    """イシュー番号でタイトルと本文を取得する。分類の前に必ず本文を読む。"""
    r = requests.get(f"{API}/repos/{repo}/issues/{number}", headers=HEADERS)
    r.raise_for_status()
    issue = r.json()
    body = (issue["body"] or "")[:MAX_BODY]
    return f"タイトル: {issue['title']}\n本文:\n{body}"

def add_labels(repo: str, number: int, labels: list) -> str:
    """イシューにラベルを付ける。取り消せるが公開リポジトリに見える変更なので承認対象。"""
    r = requests.post(f"{API}/repos/{repo}/issues/{number}/labels",
                      json={"labels": labels}, headers=HEADERS)
    r.raise_for_status()
    return f"#{number} にラベル {labels} を付けました。"

def post_comment(repo: str, number: int, body: str) -> str:
    """イシューにコメントを投稿する。外部に公開される行動なので必ず承認対象。"""
    r = requests.post(f"{API}/repos/{repo}/issues/{number}/comments",
                      json={"body": body}, headers=HEADERS)
    r.raise_for_status()
    return f"#{number} にコメントを投稿しました。"

tools のスキーマ定義は第2回の原則どおりに書きます。特にラベルのパラメータは enum でリポジトリのラベル体系(bugenhancementquestiondocumentation)に固定します。自由な文字列のままにすると、存在しないラベルが生まれます。

承認ゲート #

書き込みツール二つは、実行前に人の確認を経ます。第2回で見たゲートをそのまま使います。デモではターミナル入力で十分です。

approval_gate.py
NEEDS_APPROVAL = {"add_labels", "post_comment"}

def execute_tool(block) -> dict:
    if block.name in NEEDS_APPROVAL:
        print(f"\n[承認リクエスト] {block.name} {block.input}")
        if input("実行しますか? (y/n) ").strip().lower() != "y":
            return {
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": "ユーザーが承認しませんでした。提案だけをまとめて報告してください。",
                "is_error": True,
            }
    try:
        return {"type": "tool_result", "tool_use_id": block.id,
                "content": run_tool(block.name, block.input)}
    except Exception as e:
        return {"type": "tool_result", "tool_use_id": block.id,
                "content": f"ツールの実行に失敗しました: {e}", "is_error": True}

実サービスなら input() の代わりに Slack の通知や Web UI の承認ボタンが入ります。構造は同じです。エージェントは承認を待ち、拒否されればその事実を読んで計画を変えます。

システムプロンプト #

行動ルールは第3回の原則どおり、具体的な行動として書きます。

triage_system.py
SYSTEM = """あなたは GitHub のイシュートリアージエージェントです。

作業手順:
1. list_open_issues でラベルのないイシューを確認し、処理計画を列挙する。
2. イシューごとに get_issue で本文を読んでから分類する。タイトルだけで分類しない。
3. 分類基準: エラーの再現内容があれば bug、新機能の提案なら enhancement、
   使い方の質問なら question、ドキュメントの改善なら documentation。
4. add_labels でラベルを提案する。確信がなければラベルを付けず、理由を報告する。
5. question のイシューにだけ返信の下書きを書き、post_comment で提案する。

ルール:
- 一度承認が拒否された行動は再試行しない。
- 最後に、処理したイシュー、付けたラベル、保留したイシューを表にまとめて報告する。
"""

ループは第1回の run_agent をそのまま使えば動きます。run_agent("owner/repo リポジトリの新しいイシューをトリアージしてください。") の一行で、エージェントが回り始めます。

評価 — ゴールデンセットで分類品質を測る #

LLM アプリ開発 第12回で答えの品質評価を扱いました。エージェントでも原理は同じで、評価の単位が「タスクの成功」に変わります。トリアージでいちばん測りやすいのは分類精度です。すでに人がラベルを付けた過去のイシューをゴールデンセットにします。

eval_triage.py
GOLDEN = [
    {"number": 101, "expected": ["bug"]},
    {"number": 95,  "expected": ["question"]},
    {"number": 88,  "expected": ["enhancement"]},
    # 過去のイシュー20件ほどあれば変化を検知するのに十分
]

def evaluate(repo: str) -> float:
    correct = 0
    for case in GOLDEN:
        proposed = triage_one(repo, case["number"])   # 承認ゲートなしで提案だけ受け取るモード
        if set(proposed) == set(case["expected"]):
            correct += 1
        else:
            print(f"#{case['number']}: 期待 {case['expected']} / 提案 {proposed}")
    return correct / len(GOLDEN)

print(f"分類精度: {evaluate('owner/repo'):.0%}")

この数字があれば、システムプロンプトの分類基準を直すときやモデルを替えるときに、良くなったのか悪くなったのかを勘ではなく数値で確認できます。プロンプトを直すたびに回す回帰テストというわけです。

ここからさらに進めるには #

  • 定期実行 — cron で一日一回回し、承認リクエストを Slack で受け取れば運用ツールになります。
  • サブエージェント — イシューが多い日は第5回の並列委譲で、イシューごとの分析を同時に回せます。
  • MCP サーバー化 — GitHub のツール四つを第6回のようにサーバーへ分離すれば、トリアージエージェント以外のクライアントでも同じツールを使えます。

シリーズを終えて #

七回にわたって、エージェントを入門レベルから実践レベルへ引き上げました。振り返るとこうなります。

  • ループは stop_reason のすべてとツールのエラーを処理してはじめて崩れません(第1回)。
  • エージェントの品質はツールの description、スキーマ、エラーメッセージで決まります(第2回)。
  • 計画を先に立て、変更後に検証するルールが自己修正を生みます(第3回)。
  • 長い作業はツール結果のダイエット、整理、圧縮、スクラッチパッドで持ちこたえます(第4回)。
  • コンテキストの隔離が必要なら、サブエージェントで仕事を分けます(第5回)。
  • 複数のクライアントが共有するツールは MCP サーバーへ分離します(第6回)。
  • そして取り消しにくい行動には、必ず人の承認を置きます(第7回)。

エージェント開発の重心は、目新しいデモではなく、失敗に耐えるループと良いツールと測定できる品質にあります。このシリーズがその土台になれば幸いです。ここまでご一緒いただき、ありがとうございました。

X