はじめに
前回の企画編では、AIコーディングエージェント(Codex、Claude Code、OpenCode)を公平に比較するベンチマークシステムの設計思想をお話ししました。
今回は、その設計を実際のコードに落とし込んでいきます。ただの「こう書きました」ではなく、なぜそう実装したのか、どんな落とし穴があったのか、どう解決したのかまで、実装の試行錯誤を含めてお伝えします。
企画編の記事もぜひご覧ください:
実際のコードはこちらで公開しています:
GitHub - coding-agent-bench
目次
- プロジェクト全体の構造
- 型定義:TypeScriptの力を最大限に活かす
- 統一インターフェース:アダプタパターンの実装
- エージェントアダプタの実装詳細
- テスト結果パースの工夫
- メインランナー:オーケストレーションの要
- Git操作とブランチ管理
- バグ状態のリセットスクリプト
- スコアリングとレポート生成
- 実運用での工夫とハマりポイント
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」のような行を探します。
工夫ポイント
-
柔軟なパターンマッチング
(?:(\d+)\s+passed,\s+)?
passed
が省略されている場合も対応(全てパスした場合、Jestは「10 passed」と表示せず「10 total」だけ表示することがある) -
推測ロジック
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
解決策:stdout
と stderr
の両方を検索し、色コードを無視する正規表現を使う。
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分以上かかっていました。
検討した解決策
-
並列実行:複数のエージェントを同時に実行
- メリット:高速化
- デメリット:システムリソースの競合で公平性が損なわれる
-
タスクの絞り込み:重要なタスクだけ実行
- メリット:時間短縮
- デメリット:網羅性が下がる
採用した解決策
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が優秀」といった、より実践的な知見が得られます。
なぜ役割別評価が重要か
-
適材適所のエージェント選択
- タスクの性質に応じて最適なエージェントを選べる
- チーム開発での役割分担の参考になる
-
エージェントの強み・弱みの可視化
- 単なる「総合スコア」では見えない特性を把握
- モデル更新による変化を細かく追跡
-
実務により近い評価
- 実際の開発は様々なタスクの組み合わせ
- 役割ごとの評価で、より現実的な判断材料を提供
-
教育・トレーニングへの応用
- エージェントの「苦手分野」を把握してプロンプトを改善
- 人間エンジニアのトレーニング教材としても活用可能
最も重要なのは、このシステムが「使い続けられる」こと。
複雑すぎず、メンテナンスが容易で、拡張性のある設計を心がけました。役割別評価も、この設計思想を維持しながら段階的に追加していく予定です。
おわりに
AIコーディングエージェントの評価は、単なる「動く/動かない」の判定ではありません。速度、正確性、安定性、そして公平性を総合的に評価する必要があります。
このベンチマークシステムが、皆さんのエージェント選択の一助となれば幸いです。
質問やフィードバックがあれば、ぜひコメントでお聞かせください!
関連記事
リポジトリ
実際のコードはこちらで公開しています:
GitHub - coding-agent-bench