🕌

AIコーディングエージェントベンチマーク、実装のすべて【実装編】

に公開

はじめに

前回の企画編では、AIコーディングエージェント(Codex、Claude Code、OpenCode)を公平に比較するベンチマークシステムの設計思想をお話ししました。

今回は、その設計を実際のコードに落とし込んでいきます。ただの「こう書きました」ではなく、なぜそう実装したのかどんな落とし穴があったのかどう解決したのかまで、実装の試行錯誤を含めてお伝えします。

企画編の記事もぜひご覧ください:

実際のコードはこちらで公開しています:
GitHub - coding-agent-bench


目次

  1. プロジェクト全体の構造
  2. 型定義:TypeScriptの力を最大限に活かす
  3. 統一インターフェース:アダプタパターンの実装
  4. エージェントアダプタの実装詳細
  5. テスト結果パースの工夫
  6. メインランナー:オーケストレーションの要
  7. Git操作とブランチ管理
  8. バグ状態のリセットスクリプト
  9. スコアリングとレポート生成
  10. 実運用での工夫とハマりポイント

1. プロジェクト全体の構造

まず、プロジェクトの全体像を見てみましょう。

coding-agent-bench/
├── src/
│   ├── types.ts           # 型定義(すべての基盤)
│   ├── utils.ts           # ユーティリティ関数
│   ├── git.ts             # Git操作
│   ├── runner.ts          # メインランナー
│   ├── score.ts           # スコアリング・レポート生成
│   └── adapters/          # エージェントアダプタ
│       ├── base.ts        # 共通インターフェース
│       ├── codex.ts       # Codex実装
│       ├── claudecode.ts  # Claude Code実装
│       └── opencode.ts    # OpenCode実装
├── tasks/                 # タスク定義(YAML)
├── scripts/               # バグリセットスクリプト
├── base-repo/             # テスト用リポジトリ
├── bench.config.ts        # 設定ファイル
└── results/               # 実行結果(自動生成)

設計の考え方

  • 関心の分離:Git操作、実行、評価、レポート生成を完全に分離
  • 単一責任の原則:各ファイルは1つの役割に集中
  • 依存方向runner.ts が頂点で、他のモジュールは独立

これにより、テストがしやすく、機能追加や変更が容易になっています。


2. 型定義:TypeScriptの力を最大限に活かす

すべての基盤となる型定義から見ていきましょう。TypeScriptの強力な型システムを活用することで、実行時エラーを防ぎ、IDEの補完を最大限に活用できます。

src/types.ts

export type Task = {
  task_id: string
  difficulty: "L1" | "L2" | "L3"  // Union型で厳密に制限
  context_paths: string[]
  requirements: string[]
  constraints: string[]
  success_criteria: {
    test_cmd: string
    must_pass: number
  }
  time_budget_min?: number  // オプショナル(デフォルト値を使う)
}

export type RunInput = {
  agent: "codex" | "claudecode" | "opencode"  // これも厳密に
  repoPath: string
  task: Task
  sessionId: string
  message: string
  timeBudgetMin: number
  resultsDir: string
}

export type RunOutput = {
  agent: RunInput["agent"]  // ← RunInputと同じ型を保証
  task_id: string
  startedAt: string  // ISO8601形式
  endedAt: string
  wallTimeSec: number
  exitCode: number | null  // null = タイムアウトやクラッシュ
  passedTests: number | null
  totalTests: number | null
  failedTests: number | null
  notes?: string  // エラーメッセージなど
}

export type SummaryRow = {
  agent: RunOutput["agent"]
  task_id: string
  wallTimeSec: number
  timeBudgetSec: number | null
  timeEfficiency: number | null  // 実測時間 / 予算時間
  exitCode: number | null
  status: "success" | "partial" | "failed"
  score: number
  testsAttempted: boolean
  testsPassed: boolean | null
  passedTests: number | null
  totalTests: number | null
  successRate: number | null  // 通過率
}

ポイント1:Union型で厳密に制限

difficulty: "L1" | "L2" | "L3"

文字列リテラルのUnion型を使うことで、タイポを防ぎ、不正な値を弾きます。

ポイント2:null許容型の活用

exitCode: number | null

「取得できなかった」と「0(成功)」を区別するため、nullを許容しています。これにより、データの欠損と正常終了を明確に区別できます。

ポイント3:型の再利用

agent: RunInput["agent"]  // RunInputのagent型を再利用

定義の重複を避け、一箇所の変更で全体に反映されるようにしています。

実装時の工夫

最初は agent: string としていましたが、これだと任意の文字列を受け入れてしまいます。実行時に「そんなエージェント知らない」というエラーになるより、型チェックで弾く方が断然良いですよね。

// ❌ 悪い例
const agent: string = "cladecode"  // タイポしてもコンパイル通る

// ✅ 良い例
const agent: "codex" | "claudecode" | "opencode" = "cladecode"
// → Type '"cladecode"' is not assignable to type...

3. 統一インターフェース:アダプタパターンの実装

異なるCLIを持つ3つのエージェントを統一的に扱うため、アダプタパターンを採用しました。

src/adapters/base.ts

import { RunInput, RunOutput } from "../types.js"

export interface AgentAdapter {
  run(input: RunInput): Promise<RunOutput>
}

たったこれだけ?

はい。シンプルですが、これがすべての基盤です。各エージェントのアダプタは、このインターフェースを実装するだけ。

なぜこのインターフェースなのか

  • 入力(RunInput):実行に必要なすべての情報を一箇所に
  • 出力(RunOutput):評価に必要なすべての情報を統一フォーマットで
  • 非同期(Promise):すべての処理は非同期(ファイルI/O、プロセス実行など)

このインターフェースのおかげで、メインランナーは各エージェントの詳細を知る必要がありません。


4. エージェントアダプタの実装詳細

それでは、実際のアダプタ実装を見ていきましょう。ここでは Claude Code のアダプタを例に解説します。

src/adapters/claudecode.ts

import path from "node:path"
import { execWithLog } from "../utils.js"
import { RunInput, RunOutput } from "../types.js"
import { parseTestResults } from "../score.js"
import cfg from "../../bench.config.js"

export default async function runClaudeCode(input: RunInput): Promise<RunOutput> {
  const startedAt = new Date()
  const logFile = path.join(input.resultsDir, `${input.task.task_id}.claudecode.log`)

  // 1️⃣ コマンドライン引数の生成
  const args = cfg.agents.claudecode.args(input.sessionId, input.message)
  
  let exitCode: number | null = null
  let notes: string | undefined

  try {
    // 2️⃣ エージェントの実行(タイムアウト付き)
    const { code, stderr } = await execWithLog(cfg.agents.claudecode.cmd, args, {
      cwd: cfg.agents.claudecode.workdir ?? input.repoPath,
      timeoutMs: input.timeBudgetMin * 60 * 1000,
      logFile,
    })
    exitCode = code
    
    // 3️⃣ エラーメッセージの収集
    if (code !== 0 && stderr) {
      notes = `Agent failed: ${stderr.slice(0, 200)}`
    }
  } catch (error) {
    // 4️⃣ クラッシュ時の処理
    exitCode = -1
    notes = `Crashed: ${error instanceof Error ? error.message : String(error)}`
  }

  // 5️⃣ 実行後のテスト評価
  let passed: number | null = null
  let total: number | null = null
  let failed: number | null = null

  try {
    const testResults = await parseTestResults(
      input.repoPath,
      cfg.repo.testCmd,
      logFile
    )
    total = testResults.total
    passed = testResults.passed
    failed = testResults.failed
  } catch (error) {
    notes = (notes ? notes + "; " : "") + "Test parsing failed"
  }

  // 6️⃣ 統一フォーマットで返す
  const endedAt = new Date()
  return {
    agent: "claudecode",
    task_id: input.task.task_id,
    startedAt: startedAt.toISOString(),
    endedAt: endedAt.toISOString(),
    wallTimeSec: Math.round((endedAt.getTime() - startedAt.getTime()) / 1000),
    exitCode,
    passedTests: passed,
    totalTests: total,
    failedTests: failed,
    notes,
  }
}

実装の工夫ポイント

1. タイムアウト処理

timeoutMs: input.timeBudgetMin * 60 * 1000

エージェントが無限ループに陥った場合でも、必ず終了させます。予算時間を超えたら強制終了。

2. エラーメッセージの切り詰め

notes = `Agent failed: ${stderr.slice(0, 200)}`

エラーメッセージが膨大になる場合があるため、最初の200文字だけ保存。詳細は個別のログファイルに記録されています。

3. 二段階エラーハンドリング

try {
  // エージェント実行
  const { code, stderr } = await execWithLog(...)
  exitCode = code
  
  if (code !== 0 && stderr) {
    notes = `Agent failed: ${stderr.slice(0, 200)}`
  }
} catch (error) {
  // プロセス起動すら失敗した場合
  exitCode = -1
  notes = `Crashed: ${error.message}`
}
  • エージェントが正常に起動して失敗 → exitCode に終了コードを記録
  • プロセス起動すら失敗 → exitCode = -1 で区別

4. テスト評価の失敗を致命的にしない

try {
  const testResults = await parseTestResults(...)
} catch (error) {
  notes = (notes ? notes + "; " : "") + "Test parsing failed"
}

テスト結果のパースに失敗しても、エージェントの実行結果は記録します。一部データが欠けても、他のデータは保存されるべきです。

ハマったポイント1:作業ディレクトリの問題

最初はすべてのエージェントを同じディレクトリで実行していましたが、Codexだけは別のディレクトリを要求することがわかりました。

cwd: cfg.agents.claudecode.workdir ?? input.repoPath

設定ファイルで workdir が指定されていればそれを使い、なければデフォルトのリポジトリパスを使います。

ハマったポイント2:ISO形式の日時

startedAt: startedAt.toISOString()

最初は new Date().toString() を使っていましたが、これだとタイムゾーンが曖昧で、国際的な環境で問題が起きました。ISO8601形式なら、どこでも同じ解釈になります。


5. テスト結果パースの工夫

テスト結果の取得は意外と難しい問題です。Jestの出力フォーマットは完璧ではなく、環境によって変わることもあります。

src/score.ts(抜粋)

const TESTS_LINE_RE = /Tests:\s+(?:(\d+)\s+passed,\s+)?(?:(\d+)\s+failed,\s+)?(\d+)\s+total/i

export async function parseTestResults(
  repoPath: string,
  testCmd: string,
  logFile: string
): Promise<{ total: number | null; passed: number | null; failed: number | null }> {
  const jsonPath = path.join(repoPath, "test-results.json")
  const { execWithLog } = await import("./utils.js")

  const cmdParts = testCmd.split(" ").filter(Boolean)
  if (cmdParts.length === 0) {
    throw new Error("Test command is empty")
  }

  const [cmd, ...baseArgs] = cmdParts

  // 1️⃣ まずJSON出力を試す(理想的な方法)
  const tryJson = async () => {
    try {
      const { code } = await execWithLog(
        cmd,
        [...baseArgs, "--", "--json", `--outputFile=${jsonPath}`],
        { cwd: repoPath, logFile }
      )

      if (code === 0) {
        try {
          const raw = await fs.readFile(jsonPath, "utf-8")
          const results = JSON.parse(raw)
          const total =
            typeof results.numTotalTests === "number" ? results.numTotalTests : null
          const passed =
            typeof results.numPassedTests === "number" ? results.numPassedTests : null
          const failed =
            typeof results.numFailedTests === "number"
              ? results.numFailedTests
              : total !== null && passed !== null
              ? total - passed
              : null

          if (total !== null && total > 0) {
            return { total, passed, failed }
          }
        } catch {
          // JSON パースエラー → 次の方法へ
        }
      }
    } finally {
      await fs.rm(jsonPath, { force: true }).catch(() => {})
    }

    return null
  }

  const jsonResult = await tryJson()
  if (jsonResult) {
    return jsonResult
  }

  // 2️⃣ フォールバック:正規表現でパース
  const { code, stdout, stderr } = await execWithLog(cmd, baseArgs, {
    cwd: repoPath,
    logFile,
  })

  const match = TESTS_LINE_RE.exec(`${stdout}\n${stderr}`)
  if (match) {
    const total = parseInt(match[3], 10)
    const passedValue = match[1]
    const failedValue = match[2]

    // passed と failed を推測
    const passed =
      passedValue !== undefined && passedValue !== null && passedValue !== ""
        ? parseInt(passedValue, 10)
        : failedValue
        ? total - parseInt(failedValue, 10)
        : code === 0
        ? total
        : null

    const failed =
      failedValue !== undefined && failedValue !== null && failedValue !== ""
        ? parseInt(failedValue, 10)
        : passed !== null
        ? total - passed
        : code === 0
        ? 0
        : null

    return {
      total,
      passed,
      failed,
    }
  }

  // 3️⃣ 両方失敗した場合
  return {
    total: null,
    passed: null,
    failed: null,
  }
}

二段階フォールバック戦略

第1段階:JSON出力(理想的)

jest --json --outputFile=test-results.json

構造化されたデータなので、確実にパースできます。

第2段階:正規表現(フォールバック)

const TESTS_LINE_RE = /Tests:\s+(?:(\d+)\s+passed,\s+)?(?:(\d+)\s+failed,\s+)?(\d+)\s+total/i

標準出力から「Tests: 8 passed, 2 failed, 10 total」のような行を探します。

工夫ポイント

  1. 柔軟なパターンマッチング

    (?:(\d+)\s+passed,\s+)?
    

    passed が省略されている場合も対応(全てパスした場合、Jestは「10 passed」と表示せず「10 total」だけ表示することがある)

  2. 推測ロジック

    const passed =
      passedValue !== undefined ? parseInt(passedValue, 10)
      : failedValue ? total - parseInt(failedValue, 10)
      : code === 0 ? total
      : null
    
    • passed が明示されていればそれを使う
    • なければ total - failed で計算
    • それもなく、終了コードが0なら全テスト成功と推測
    • それでもダメなら null

ハマったポイント:色付きコード

最初、正規表現が全くマッチしませんでした。原因は、JestがANSIエスケープコードで色を付けていたこと。

\x1b[32mTests:\x1b[0m 8 passed, 10 total

解決策:stdoutstderr の両方を検索し、色コードを無視する正規表現を使う。

const match = TESTS_LINE_RE.exec(`${stdout}\n${stderr}`)

6. メインランナー:オーケストレーションの要

すべてを統合するのがメインランナーです。ここがベンチマークの心臓部。

src/runner.ts(重要部分抜粋)

async function main() {
  console.log(pc.cyan("🚀 Coding Agent Benchmark Starting...\n"))

  // 1️⃣ 環境準備
  const repoPath = path.resolve(cfg.repo.path)
  const resultsRoot = path.resolve("results")
  const promptsRoot = path.resolve("prompts")
  await ensureDir(resultsRoot)
  await ensureDir(promptsRoot)

  const runStartedAt = new Date()
  const runStamp = runStartedAt.toISOString().replace(/[:.]/g, "-")
  const runDir = path.join(resultsRoot, runStamp)
  await ensureDir(runDir)

  console.log(pc.gray(`📁 Target repo: ${repoPath}`))
  console.log(pc.gray(`📊 Results root: ${resultsRoot}`))
  console.log(pc.gray(`📁 Current run dir: ${runDir}\n`))

  // 2️⃣ タスクとエージェントをシャッフル
  const tasks = shuffle(await loadTasks())
  console.log(pc.green(`✓ Loaded ${tasks.length} task(s)`))

  const agents = shuffle(["codex", "claudecode", "opencode"] as const)
  console.log(pc.green(`✓ Testing ${agents.length} agent(s): ${agents.join(", ")}\n`))

  const runLog: RunOutput[] = []
  const summaryRows: SummaryRow[] = []

  // 3️⃣ タスク × エージェントの全組み合わせを実行
  for (const task of tasks) {
    console.log(pc.bold(pc.blue(`\n📝 Task: ${task.task_id} (${task.difficulty})`)))

    for (const agent of agents) {
      console.log(pc.yellow(`  → Running ${agent}...`))

      // 4️⃣ Git ブランチ準備
      const branch = `bench/${task.task_id}/${agent}`
      const gitLog = path.join(runDir, `${task.task_id}.${agent}.git.log`)

      try {
        await prepareBranch(repoPath, branch, gitLog)
      } catch (error) {
        console.error(pc.red(`  ✗ Git preparation failed for ${agent}`))
        continue
      }

      const sessionId = await createSession(repoPath, agent, gitLog)

      // 5️⃣ プロンプト生成
      const message = [
        `# Task: ${task.task_id}`,
        `\n## Requirements`,
        ...task.requirements.map((r) => `- ${r}`),
        `\n## Constraints`,
        ...task.constraints.map((c) => `- ${c}`),
        `\n## Context Paths`,
        ...task.context_paths.map((p) => `- ${p}`),
        `\n## Additional Rules`,
        `- New dependencies: ${cfg.fairness.allowNewDeps ? "Allowed" : "Forbidden"}`,
        `- Network access: ${cfg.fairness.allowNetwork ? "Allowed" : "Forbidden"}`,
        `- Time budget: ${task.time_budget_min ?? cfg.fairness.timeBudgetMin} minutes`,
      ].join("\n")

      const input: RunInput = {
        agent,
        repoPath,
        task,
        sessionId,
        message,
        timeBudgetMin: task.time_budget_min ?? cfg.fairness.timeBudgetMin,
        resultsDir: runDir,
      }

      // 6️⃣ エージェント実行
      let out: RunOutput
      try {
        out = await adapters[agent](input)
        runLog.push(out)

        // 7️⃣ スコア計算
        const successRate =
          out.totalTests && out.totalTests > 0
            ? (out.passedTests ?? 0) / out.totalTests
            : null

        const timeBudgetSec = input.timeBudgetMin > 0 ? input.timeBudgetMin * 60 : null
        const timeEfficiency =
          timeBudgetSec && timeBudgetSec > 0 ? out.wallTimeSec / timeBudgetSec : null

        const testsAttempted = out.totalTests !== null && out.totalTests > 0
        const testsPassed = testsAttempted
          ? out.passedTests !== null && out.totalTests !== null && out.passedTests >= out.totalTests
          : null

        const status =
          out.exitCode === 0 && testsPassed === true
            ? "success"
            : out.exitCode === 0 || testsPassed === true
            ? "partial"
            : "failed"

        const score = status === "success" ? 1 : status === "partial" ? 0.5 : 0

        const row: SummaryRow = {
          agent: out.agent,
          task_id: out.task_id,
          wallTimeSec: out.wallTimeSec,
          timeBudgetSec,
          timeEfficiency,
          exitCode: out.exitCode,
          status,
          score,
          testsAttempted,
          testsPassed,
          passedTests: out.passedTests,
          totalTests: out.totalTests,
          successRate,
        }

        await appendSummaryRow(runDir, row)
        summaryRows.push(row)

        // 8️⃣ 実行結果の表示
        const statusIcon = out.exitCode === 0 ? pc.green("✓") : pc.red("✗")
        const testInfo =
          out.totalTests !== null
            ? `${out.passedTests}/${out.totalTests} tests`
            : "N/A"
        console.log(
          `    ${statusIcon} ${agent}: ${out.wallTimeSec}s, ${testInfo}`
        )
      } catch (error) {
        console.error(
          pc.red(
            `${agent} crashed: ${error instanceof Error ? error.message : String(error)}`
          )
        )
      }
    }
  }

  // 9️⃣ レポート生成
  await writeJson(path.join(runDir, "run.json"), runLog)
  await writeMarkdownSummary(runDir, summaryRows)
  await writeHtmlSummary(runDir, summaryRows)

  console.log(pc.bold(pc.green(`\n✅ Benchmark complete!`)))
  console.log(pc.cyan(`📄 Summary (CSV): ${path.join("results", runStamp, "summary.csv")}`))
  console.log(pc.cyan(`📄 Summary (Markdown): ${path.join("results", runStamp, "summary.md")}`))
  console.log(pc.cyan(`📄 Summary (HTML): ${path.join("results", runStamp, "summary.html")}`))
  console.log(pc.cyan(`📄 Full log: ${path.join("results", runStamp, "run.json")}`))
}

実装の工夫ポイント

1. シャッフルで公平性を担保

const tasks = shuffle(await loadTasks())
const agents = shuffle(["codex", "claudecode", "opencode"] as const)

実行順による有利不利を排除します。「最初に実行されたエージェントが速い」「システムキャッシュの恩恵を受ける」といった偏りを防ぎます。

2. タイムスタンプ付きディレクトリ

const runStamp = runStartedAt.toISOString().replace(/[:.]/g, "-")
const runDir = path.join(resultsRoot, runStamp)

実行のたびに新しいディレクトリを作成。過去の結果を上書きせず、いつでも過去の実行を参照できます。

results/
├── 2025-10-03T09-00-00-000Z/
├── 2025-10-03T10-30-00-000Z/
└── 2025-10-03T14-15-00-000Z/

3. 詳細なログ記録

const gitLog = path.join(runDir, `${task.task_id}.${agent}.git.log`)

各タスク・エージェントの組み合わせごとにログファイルを分離。問題が起きたときの追跡が容易です。

4. エラー時も継続

try {
  await prepareBranch(repoPath, branch, gitLog)
} catch (error) {
  console.error(pc.red(`  ✗ Git preparation failed for ${agent}`))
  continue  // ← 次のエージェントへ
}

1つのエージェントで問題が起きても、他のエージェントは実行されます。部分的な失敗でも、可能な限りデータを収集します。

5. ステータス判定のロジック

const status =
  out.exitCode === 0 && testsPassed === true
    ? "success"
    : out.exitCode === 0 || testsPassed === true
    ? "partial"
    : "failed"
  • success: 終了コードOK かつ 全テスト通過
  • partial: どちらか一方がOK
  • failed: 両方NG

これにより、「動いたけどテストが通らない」「テストは通ったけどクラッシュした」といった状況を区別できます。


7. Git操作とブランチ管理

各エージェントは独立したブランチで作業します。これにより、互いに干渉せず、結果を明確に分離できます。

src/git.ts

import { execWithLog } from "./utils.js"

export async function prepareBranch(repoPath: string, branch: string, log: string) {
  // 1️⃣ 作業ツリーをクリーンに
  await execWithLog("git", ["reset", "--hard"], { cwd: repoPath, logFile: log })
  await execWithLog("git", ["checkout", "main"], { cwd: repoPath, logFile: log })
  
  // 2️⃣ 既存ブランチを削除(存在する場合)
  await execWithLog("git", ["branch", "-D", branch], { cwd: repoPath, logFile: log })
  
  // 3️⃣ 新しいブランチを作成してチェックアウト
  await execWithLog("git", ["switch", "-c", branch], { cwd: repoPath, logFile: log })
}

export async function createSession(repoPath: string, agent: string, log: string): Promise<string> {
  // セッションIDは「bench-<agent>-<timestamp>」形式
  const sessionId = `bench-${agent}-${Date.now()}`
  return sessionId
}

ブランチ命名規則

bench/<task_id>/<agent>

例:

  • bench/fix-null-check/codex
  • bench/fix-null-check/claudecode
  • bench/add-health-endpoint/opencode

これにより:

  • どのタスクか一目でわかる
  • どのエージェントが作業したかわかる
  • Git GUI で視覚的に確認しやすい

工夫ポイント:git reset --hard の重要性

await execWithLog("git", ["reset", "--hard"], { cwd: repoPath, logFile: log })

これがないと、前回の実行の変更が残っていて、次の実行に影響します。毎回完全にクリーンな状態から始めることで、再現性を保証します。


8. バグ状態のリセットスクリプト

企画編で説明した「壊れた状態を再現する」仕組みの実装です。

scripts/reset-l1.sh

#!/bin/bash
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REPO_DIR="$ROOT_DIR/base-repo"
FIXTURE_DIR="$REPO_DIR/.bench-fixtures/l1"
VERIFY=${VERIFY:-false}

if [[ "${1:-}" == "--verify" ]]; then
  VERIFY=true
fi

if [[ ! -d "$REPO_DIR/.git" ]]; then
  echo "❌ base-repo is not a git repository. Run setup.sh first." >&2
  exit 1
fi

cd "$REPO_DIR"

# 1️⃣ メインブランチに戻る
echo "🔄 Resetting base-repo to main..."
git checkout main >/dev/null 2>&1
git reset --hard HEAD >/dev/null
git clean -fd >/dev/null
echo "✅ Repository reset"

# 2️⃣ バグ状態を適用
echo "⚙️  Applying L1 (null-check) bug fixture..."
rsync -a "$FIXTURE_DIR/" "$REPO_DIR/"
echo "✅ L1 fixture applied"

# 3️⃣ 検証モード:テストが失敗することを確認
if [[ "$VERIFY" == "true" ]]; then
  echo "🧪 Running tests (expecting failure)..."
  if NODE_ENV=test pnpm test -- parseUser >/dev/null; then
    echo "❌ Tests unexpectedly passed; fixture may be incorrect." >&2
    exit 1
  else
    echo "✅ Tests failed as expected for L1 bug"
  fi
fi

echo "ℹ️  L1 reset complete. Run pnpm bench to evaluate agents."

Fixtureディレクトリの構造

base-repo/.bench-fixtures/
├── l1/                # L1レベルのバグ
│   └── src/
│       └── utils/
│           └── parseUser.ts  # nullチェックが壊れたバージョン
├── l2/                # L2レベルのバグ
│   └── src/
│       └── server/
│           └── index.ts      # /health ルートが欠けたバージョン
└── l3/                # L3レベル(L1+L2の複合)
    └── src/
        ├── utils/
        │   └── parseUser.ts
        └── server/
            └── index.ts

工夫ポイント

1. rsync の使用

rsync -a "$FIXTURE_DIR/" "$REPO_DIR/"
  • -a:アーカイブモード(パーミッション、タイムスタンプを保持)
  • 末尾の /:ディレクトリの内容をコピー(ディレクトリ自体ではなく)

2. 検証モード

if NODE_ENV=test pnpm test -- parseUser >/dev/null; then
  echo "❌ Tests unexpectedly passed"
  exit 1
fi

fixtureが正しく適用されたか確認できます。「壊れているはず」なのにテストが通ったら、それ自体が問題です。

3. エラーハンドリング

set -euo pipefail
  • set -e:エラーが起きたら即座に終了
  • set -u:未定義変数を使おうとしたらエラー
  • set -o pipefail:パイプのどこかで失敗したら全体が失敗

これにより、スクリプトの途中で問題が起きても気づけます。


9. スコアリングとレポート生成

実行結果を集計して、わかりやすいレポートを生成します。

CSV形式(リアルタイム追記)

export async function appendSummaryRow(root: string, row: SummaryRow) {
  const f = path.join(root, "summary.csv")
  const header =
    "agent,task_id,status,score,wallTimeSec,timeBudgetSec,timeEfficiency,exitCode,testsAttempted,testsPassed,passedTests,totalTests,successRate\n"
  const line = [
    row.agent,
    row.task_id,
    row.status,
    row.score.toFixed(2),
    row.wallTimeSec,
    row.timeBudgetSec ?? "",
    row.timeEfficiency !== null ? row.timeEfficiency.toFixed(4) : "",
    row.exitCode ?? "",
    row.testsAttempted ? "true" : "false",
    row.testsPassed === null ? "" : row.testsPassed ? "true" : "false",
    row.passedTests ?? "",
    row.totalTests ?? "",
    row.successRate !== null ? row.successRate.toFixed(4) : "",
  ].join(",")

  try {
    await fs.access(f)
  } catch {
    await fs.writeFile(f, header)
  }

  await fs.appendFile(f, line + "\n")
}

ポイント:リアルタイム追記

各タスクが完了するたびにCSVに追記します。これにより:

  • 途中でクラッシュしても、それまでの結果は保存されている
  • 進行状況をリアルタイムで確認できる(別ウィンドウで tail -f summary.csv

Markdown形式(見やすい)

export async function writeMarkdownSummary(dir: string, rows: SummaryRow[]) {
  const statusTotals = summarizeStatuses(rows)
  const agentAggregates = aggregateAgents(rows)
  const taskAggregates = aggregateTasks(rows)

  const lines: string[] = []
  lines.push("# Benchmark Summary")
  lines.push("")

  // ステータスサマリ
  lines.push("## Status Summary")
  lines.push("")
  lines.push("| Success | Partial | Failed | Total |")
  lines.push("| --- | --- | --- | --- |")
  lines.push(
    `| ${statusTotals.success} | ${statusTotals.partial} | ${statusTotals.failed} | ${statusTotals.total} |`
  )
  lines.push("")

  // 実行結果詳細
  lines.push("## Run Results")
  lines.push("")
  lines.push(
    "| Agent | Task | Status | Score | Exit Code | Tests Attempted | Tests Passed | Tests (P/T) | Wall Time (s) | Time Budget (s) | Time Efficiency |"
  )
  lines.push(
    "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |"
  )

  for (const row of rows) {
    lines.push(
      `| ${row.agent} | ${row.task_id} | ${formatStatus(row.status)} | ${row.score.toFixed(
        2
      )} | ${formatExitCode(row.exitCode)} | ${formatBool(row.testsAttempted)} | ${formatBool(
        row.testsPassed
      )} | ${formatTests(row)} | ${row.wallTimeSec} | ${
        row.timeBudgetSec ?? "N/A"
      } | ${formatPercent(row.timeEfficiency, 1)} |`
    )
  }
  lines.push("")

  // エージェント別サマリ
  lines.push("## Agent Summary")
  // ... 省略 ...

  const content = lines.join("\n")
  await fs.writeFile(path.join(dir, "summary.md"), content)
}

出力例:

# Benchmark Summary

## Status Summary

| Success | Partial | Failed | Total |
| --- | --- | --- | --- |
| 8 | 1 | 0 | 9 |

## Run Results

| Agent | Task | Status | Score | Exit Code | ... |
| --- | --- | --- | --- | --- | --- |
| codex | fix-null-check | Success | 1.00 | 0 | ... |
| claudecode | fix-null-check | Success | 1.00 | 0 | ... |
| opencode | fix-null-check | Partial | 0.50 | 1 | ... |

HTML形式(ブラウザで見やすい)

export async function writeHtmlSummary(dir: string, rows: SummaryRow[]) {
  // ... データ集計 ...

  const html = `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>Benchmark Summary</title>
  <style>
    body { font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; padding: 1.5rem; line-height: 1.6; }
    h1, h2 { color: #0b3d62; }
    table { border-collapse: collapse; width: 100%; max-width: 1080px; margin: 1rem 0; }
    th, td { border: 1px solid #d0d7de; padding: 0.5rem 0.75rem; text-align: left; }
    th { background: #f6f8fa; font-weight: 600; }
    tbody tr:nth-child(even) { background: #f9fafb; }
  </style>
</head>
<body>
  <h1>Benchmark Summary</h1>
  <!-- テーブル生成 -->
</body>
</html>`

  await fs.writeFile(path.join(dir, "summary.html"), html)
}

工夫ポイント:軽量なCSS

外部CSSライブラリを使わず、インラインスタイルで完結。これにより:

  • ネットワーク接続不要
  • どこでも同じ見た目
  • ファイル1つで完結

10. 実運用での工夫とハマりポイント

実際に運用してみると、様々な問題に直面しました。その解決策を共有します。

ハマりポイント1:タイムアウト実装の難しさ

問題

最初は単純に setTimeout でプロセスを kill していましたが、これだと子プロセスが残り続けることがありました。

解決策

export async function execWithLog(
  cmd: string,
  args: string[],
  opts: { cwd?: string; timeoutMs?: number; logFile?: string } = {}
): Promise<{ code: number | null; stdout: string; stderr: string }> {
  const { cwd, timeoutMs = 0, logFile } = opts
  const p = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"] })

  let stdout = ""
  let stderr = ""

  // ログ記録
  const write = async (chunk: Buffer, stream: "stdout" | "stderr") => {
    const text = chunk.toString()
    if (stream === "stdout") stdout += text
    else stderr += text

    if (!logFile) return
    const line = `[${new Date().toISOString()}] ${stream}: ${text}`
    await fs.appendFile(logFile, line).catch(() => {})
  }

  p.stdout.on("data", (c) => void write(c, "stdout"))
  p.stderr.on("data", (c) => void write(c, "stderr"))

  // タイムアウト処理
  let to: NodeJS.Timeout | undefined
  if (timeoutMs > 0) {
    to = setTimeout(() => {
      console.error(pc.yellow(`⏱️  Timeout (${timeoutMs}ms) -> killing ${cmd}`))
      p.kill("SIGKILL")  // ← 強制終了
    }, timeoutMs)
  }

  const code: number | null = await new Promise((res) => p.on("close", res))
  if (to) clearTimeout(to)
  return { code, stdout, stderr }
}

ポイント

  • SIGKILL で強制終了(SIGTERM では無視される場合がある)
  • タイムアウト後も close イベントを待つ(プロセスのクリーンアップを確実に)

ハマりポイント2:ログファイルへの書き込み競合

問題

複数のプロセスが同じログファイルに書き込もうとすると、データが壊れることがありました。

解決策

const write = async (chunk: Buffer, stream: "stdout" | "stderr") => {
  const text = chunk.toString()
  if (stream === "stdout") stdout += text
  else stderr += text

  if (!logFile) return
  const line = `[${new Date().toISOString()}] ${stream}: ${text}`
  await fs.appendFile(logFile, line).catch(() => {})  // ← エラーを無視
}

書き込みに失敗しても、メインの処理は継続します。ログは「あると嬉しい」ものであって、「ないと困る」ものではありません。

ハマりポイント3:パフォーマンスの最適化

問題

最初は各タスクを順番に実行していたため、全体で30分以上かかっていました。

検討した解決策

  1. 並列実行:複数のエージェントを同時に実行

    • メリット:高速化
    • デメリット:システムリソースの競合で公平性が損なわれる
  2. タスクの絞り込み:重要なタスクだけ実行

    • メリット:時間短縮
    • デメリット:網羅性が下がる

採用した解決策

const agents = shuffle(["codex", "claudecode", "opencode"] as const)

シャッフルして、順序効果を排除しつつ、順次実行を維持。公平性を優先しました。

さらなる最適化

タスクレベルで並列実行しても良いかもしれません(各タスクは独立しているため)。これは将来の拡張ポイントです。

ハマりポイント4:環境変数の管理

問題

各エージェントが異なる環境変数を要求し、設定が煩雑になりました。

解決策:.env ファイルの活用

# .env
BENCH_REPO_PATH=./base-repo
BENCH_TEST_CMD="pnpm test"
BENCH_TIME_BUDGET_MIN=20

CODEX_CMD=codex
CLAUDECODE_CMD=claude
OPENCODE_CMD=opencode

config ファイルでの読み込み

// bench.config.ts
export default {
  repo: {
    path: process.env.BENCH_REPO_PATH ?? "./base-repo",
    testCmd: process.env.BENCH_TEST_CMD ?? "pnpm test",
  },
  agents: {
    codex: {
      cmd: process.env.CODEX_CMD ?? "codex",
      args: (_sessionId: string, message: string) => [
        "exec",
        "--experimental-json",
        "--skip-git-repo-check",
        message,
      ],
    },
    // ... 他のエージェント
  },
} as const

これにより:

  • 環境ごとの設定を簡単に切り替え可能
  • デフォルト値があるため、最小限の設定で動作
  • .env.example で必要な変数を文書化

まとめ:実装から学んだこと

1. 型安全性は開発速度を上げる

TypeScriptの厳密な型システムのおかげで、リファクタリングが安全かつ高速にできました。「この変更は大丈夫かな?」と心配する代わりに、コンパイラが教えてくれます。

2. エラーハンドリングは段階的に

すべてのエラーを同じように扱うのではなく、「致命的なエラー」と「記録して続行できるエラー」を区別しました。これにより、部分的な失敗でも最大限のデータを収集できます。

3. ログは未来の自分へのギフト

詳細なログを残すことで、数週間後に「なぜこの結果になったんだっけ?」という疑問に答えられます。ログファイルの命名規則も重要です。

4. シンプルさは強さ

複雑な機能より、シンプルで理解しやすい実装を優先しました。3ヶ月後に見ても理解できるコードが良いコードです。

5. 公平性は設計の最初から

「後で公平にする」のではなく、最初からシャッフルや独立した環境を組み込みました。後付けは難しいです。


次のステップ:将来の拡張案

このベンチマークシステムは、以下の方向で拡張できます:

1. より多くのエージェント

新しいエージェントが登場したら、アダプタを追加するだけで評価対象に含められます。

2. より複雑なタスク

マイクロサービス間の連携、データベースマイグレーション、パフォーマンス最適化など、より実践的なシナリオを追加できます。

3. 継続的評価

GitHub Actionsで定期実行し、モデル更新のトレンドを追跡できます。

4. Webダッシュボード

インタラクティブなダッシュボードで、結果を時系列で可視化し、エージェント間の比較を容易にします。

5. 役割別評価(次期バージョンで追加予定)

現在のベンチマークの課題

現在は「タスク全体の成功/失敗」で評価していますが、実際の開発では様々な役割があります。エージェントによって得意な役割が異なるため、役割ごとの評価ができると、より適切なエージェント選択が可能になります。

評価したい役割の例

📋 要件定義・仕様作成

  • 曖昧な要求から明確な仕様を作成できるか
  • エッジケースを洗い出せるか
  • 技術的な制約を考慮できるか

評価方法案

task_id: requirements-analysis
role: requirements
input:
  - "ユーザー管理機能を追加したい"
success_criteria:
  - 必要なAPIエンドポイントがリストアップされている
  - データモデルが定義されている
  - エラーハンドリングの方針が明記されている

🎨 UI/UX設計・実装

  • デザインシステムに沿ったコンポーネント作成
  • レスポンシブデザインへの対応
  • アクセシビリティの考慮

評価方法案

task_id: create-user-profile-page
role: ui-implementation
requirements:
  - "Material-UIを使用してユーザープロフィール画面を作成"
  - "モバイル・デスクトップ両対応"
success_criteria:
  - Lighthouse Accessibilityスコア90以上
  - 既存デザインシステムとの一貫性
  - Storybookでのビジュアルテスト通過

🧪 テスト設計・実装

  • 適切なテストカバレッジ
  • エッジケースのテスト
  • テストの可読性・保守性

評価方法案

task_id: write-tests-for-auth
role: test-implementation
context_paths:
  - src/auth/login.ts
requirements:
  - "login関数の単体テストを作成"
  - "成功・失敗・例外の各ケースをカバー"
success_criteria:
  - カバレッジ90%以上
  - テストの実行時間が1秒以内
  - 各テストケースに明確な説明コメント

👀 コードレビュー

  • バグの発見能力
  • セキュリティ脆弱性の指摘
  • パフォーマンスの問題点の指摘
  • コーディング規約の遵守確認

評価方法案

task_id: review-pull-request
role: code-review
input:
  - path: pull-requests/pr-123.diff
    intentional_issues:
      - SQL injection vulnerability
      - N+1 query problem
      - Missing error handling
success_criteria:
  - 意図的に仕込んだ問題の80%以上を指摘
  - 誤検知(false positive)が20%以下
  - 建設的なフィードバック(修正案の提示)

♻️ リファクタリング

  • コードの可読性向上
  • パフォーマンス最適化
  • 既存機能を壊さない変更

評価方法案

task_id: refactor-legacy-code
role: refactoring
context_paths:
  - src/legacy/user-service.ts
requirements:
  - "1000行の巨大関数を適切に分割"
  - "重複コードを削除"
  - "TypeScriptの型を適切に付与"
success_criteria:
  - 既存の全テストが通過
  - コードの複雑度(cyclomatic complexity)が50%削減
  - 関数の平均行数が30行以下

🐛 デバッグ・問題解決

  • バグの原因特定速度
  • 適切な修正方法の選択
  • 再発防止策の提案

評価方法案

task_id: debug-memory-leak
role: debugging
input:
  - bug_report: "本番環境でメモリ使用量が増え続ける"
  - log_files: ["app.log", "metrics.log"]
success_criteria:
  - 原因を特定し、説明できる
  - 修正パッチを提供
  - 再発防止のための監視アラートを提案

📚 ドキュメント作成

  • APIドキュメントの正確性
  • README・ガイドの充実度
  • コメントの適切性

評価方法案

task_id: document-api
role: documentation
context_paths:
  - src/api/
requirements:
  - "REST APIのOpenAPI仕様を作成"
  - "各エンドポイントの使用例を記載"
success_criteria:
  - 全エンドポイントが文書化されている
  - サンプルコードが実行可能
  - 初見のエンジニアが30分で使い始められる

実装の設計案

型定義の拡張

export type TaskRole = 
  | "requirements"      // 要件定義
  | "ui-implementation"  // UI実装
  | "test-implementation" // テスト実装
  | "code-review"       // コードレビュー
  | "refactoring"       // リファクタリング
  | "debugging"         // デバッグ
  | "documentation"     // ドキュメント作成
  | "general"           // 汎用(既存のタスク)

export type Task = {
  task_id: string
  difficulty: "L1" | "L2" | "L3"
  role: TaskRole  // ← 新しく追加
  context_paths: string[]
  requirements: string[]
  constraints: string[]
  success_criteria: {
    test_cmd?: string
    must_pass?: number
    custom_evaluator?: string  // ← カスタム評価スクリプト
  }
  time_budget_min?: number
}

カスタム評価器の例

// evaluators/code-review.ts
export async function evaluateCodeReview(
  output: string,
  expectedIssues: Issue[]
): Promise<{
  detectedIssues: Issue[]
  missedIssues: Issue[]
  falsePositives: Issue[]
  score: number
}> {
  // エージェントの出力から指摘された問題を抽出
  const detectedIssues = parseIssuesFromOutput(output)
  
  // 期待される問題と照合
  const missedIssues = expectedIssues.filter(
    expected => !detectedIssues.some(detected => isSameIssue(expected, detected))
  )
  
  const falsePositives = detectedIssues.filter(
    detected => !expectedIssues.some(expected => isSameIssue(expected, detected))
  )
  
  // スコア計算
  const detectionRate = detectedIssues.length / expectedIssues.length
  const precision = detectedIssues.length / (detectedIssues.length + falsePositives.length)
  const score = (detectionRate * 0.7 + precision * 0.3)
  
  return { detectedIssues, missedIssues, falsePositives, score }
}

レポートの拡張

役割別のサマリを追加:

## Role-Based Performance

| Agent | Requirements | UI | Testing | Review | Refactoring | Debugging | Documentation |
| --- | --- | --- | --- | --- | --- | --- | --- |
| codex | 0.85 | 0.92 | 0.78 | 0.65 | 0.88 | 0.75 | 0.70 |
| claudecode | 0.90 | 0.85 | 0.95 | 0.88 | 0.82 | 0.90 | 0.95 |
| opencode | 0.75 | 0.88 | 0.82 | 0.78 | 0.90 | 0.85 | 0.80 |

これにより、「UI実装はcodexが得意」「コードレビューはclaudecodeが優秀」といった、より実践的な知見が得られます。

なぜ役割別評価が重要か

  1. 適材適所のエージェント選択

    • タスクの性質に応じて最適なエージェントを選べる
    • チーム開発での役割分担の参考になる
  2. エージェントの強み・弱みの可視化

    • 単なる「総合スコア」では見えない特性を把握
    • モデル更新による変化を細かく追跡
  3. 実務により近い評価

    • 実際の開発は様々なタスクの組み合わせ
    • 役割ごとの評価で、より現実的な判断材料を提供
  4. 教育・トレーニングへの応用

    • エージェントの「苦手分野」を把握してプロンプトを改善
    • 人間エンジニアのトレーニング教材としても活用可能

最も重要なのは、このシステムが「使い続けられる」こと。

複雑すぎず、メンテナンスが容易で、拡張性のある設計を心がけました。役割別評価も、この設計思想を維持しながら段階的に追加していく予定です。


おわりに

AIコーディングエージェントの評価は、単なる「動く/動かない」の判定ではありません。速度、正確性、安定性、そして公平性を総合的に評価する必要があります。

このベンチマークシステムが、皆さんのエージェント選択の一助となれば幸いです。

質問やフィードバックがあれば、ぜひコメントでお聞かせください!


関連記事

リポジトリ

実際のコードはこちらで公開しています:
GitHub - coding-agent-bench

Discussion