コーディングエージェントは「デモでは動くが、実際のコードベースでは使い物にならない」という評価を受けることが多いです。その理由は、多くの実装が単一のモデルに全てを任せる設計だからです。コードレビュー、テスト生成、リファクタリング提案は、それぞれ求められるコンテキスト戦略も、失敗した時の回復方法も、根本的に異なります。
Gemma 4はローカル実行と低コストAPI呼び出しの両方で使えるモデルとして、個人開発者にとって現実的な選択肢になりました。API料金を気にせずに実験し、プロダクションではクラウドAPIに切り替える運用が可能です。ここではAntigravityのAgentKit 2.0を使い、この3つの役割を別々のエージェントに割り当てて協調させるシステムを実際に構築します。
コーディングエージェントが「使えるツール」になる条件
Gemma 4が登場する前、個人がコーディングエージェントを本番運用しようとすると、コスト問題が壁になっていた。GPT-4やGemini 2.5 Proのような高性能モデルはコードの品質理解に優れるが、大規模なコードベースを継続的に処理するとAPIコストが膨らむ。
Gemma 4の実測データでは、コードレビュータスク(500行程度)で1リクエストあたり約0.002ドルという数字が出ています。同等タスクをGemini 2.5 Proで実行すると約0.04ドルなので、コスト比で20倍の差があります。毎日50件のPRをレビューするチームなら、月間コストが3ドルか60ドルかの違いになります。
ただしコストだけで選ぶと失敗します。Gemma 4でコーディングエージェントを作ってみて分かった本音をいくつか挙げる。
Gemma 4が得意なこと:コードスタイルとベストプラクティスのレビュー、既存テストのパターンを参考にしたテストコード生成、特定スコープのリファクタリング提案(関数1つ、クラス1つ単位)。
Gemma 4が苦手なこと:複数ファイルにまたがる設計上の問題の把握(コンテキストウィンドウに入りきらない)、初見のコードベースから「何をテストすべきか」を自律的に判断すること、抽象度の高いアーキテクチャレビュー。
この特性を踏まえると、「Gemma 4に全てを任せる1つのエージェント」ではなく、「各エージェントがGemma 4の得意領域だけを担当する3エージェント構成」が現実的な設計になります。
システム全体設計 — 3エージェント協調アーキテクチャ
本記事で構築するシステムは以下の構成を取ります。
Manager Agent (Antigravity AgentKit 2.0)
├── Code Review Agent ← Gemma 4 (diff解析 + スタイルチェック)
├── Test Generation Agent ← Gemma 4 (既存テストパターン参照 + 新テスト生成)
└── Refactoring Agent ← Gemma 4 (関数/クラス単位のリファクタリング提案)
なぜManager Agentパターンを使うか
AgentKit 2.0のManager Agentは、タスクを受け取り適切なサブエージェントに委譲する調整役を担う。このパターンの利点は、各サブエージェントが独立して失敗・リトライできることです。Code Review Agentが失敗しても、Test Generation Agentの実行は影響を受けありません。
単一エージェントで全タスクを処理する場合、一か所でエラーが発生すると全体が停止します。Manager Agentパターンでは、Manager自体は「どのエージェントに何を任せるか」の判断だけを行い、実際の処理は各サブエージェントに分離されます。
コンテキスト戦略の設計
3つのエージェントそれぞれで、Gemma 4に渡すコンテキストを意図的に絞る。
- Code Review Agent: gitのdiffのみ(コミット前後の差分)
- Test Generation Agent: 対象ファイル + 既存テストファイル + テストフレームワーク設定
- Refactoring Agent: 対象関数/クラスのみ(ファイル全体ではない)
この設計はGemma 4の限られたコンテキストウィンドウを最大限に活かし、「必要な情報だけを正確に渡す」という原則に従っています。
環境構築と初期セットアップ
まずAntigravity上でAgentKit 2.0のプロジェクトを初期化します。
# Antigravity ターミナルで実行
npm create agentkit@latest my-coding-agent
cd my-coding-agent
npm install
# Gemma 4 API(Google AI Studio)の設定
# .env.local に以下を追加
# GEMMA_API_KEY=YOUR_GEMMA_API_KEY
# GEMMA_MODEL=gemma-4-27b-itプロジェクト構造は以下のようにします。
my-coding-agent/
├── src/
│ ├── agents/
│ │ ├── manager.ts ← Manager Agent
│ │ ├── code-review.ts ← コードレビューエージェント
│ │ ├── test-gen.ts ← テスト生成エージェント
│ │ └── refactor.ts ← リファクタリングエージェント
│ ├── tools/
│ │ ├── git-tools.ts ← git diff取得ツール
│ │ ├── file-tools.ts ← ファイル読み書きツール
│ │ └── github-tools.ts ← GitHub PR操作ツール
│ └── index.ts
├── .env.local
└── package.json
次にGemma 4クライアントの初期化コードを書く。エラーハンドリングを最初から組み込んでおく点が肝心です。後から追加しようとすると、エージェント間の状態管理が複雑になります。
// src/lib/gemma-client.ts
import { GoogleGenerativeAI } from "@google/generative-ai";
const genAI = new GoogleGenerativeAI(process.env.GEMMA_API_KEY\!);
export interface GemmaCallOptions {
model?: string;
maxOutputTokens?: number;
temperature?: number;
retries?: number;
}
export async function callGemma(
systemPrompt: string,
userMessage: string,
options: GemmaCallOptions = {}
): Promise<string> {
const {
model = "gemma-4-27b-it",
maxOutputTokens = 2048,
temperature = 0.2, // コーディングタスクは低めが安定する
retries = 3,
} = options;
const gemmaModel = genAI.getGenerativeModel({
model,
generationConfig: { maxOutputTokens, temperature },
systemInstruction: systemPrompt,
});
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const result = await gemmaModel.generateContent(userMessage);
const text = result.response.text();
if (\!text || text.trim().length === 0) {
throw new Error("Empty response from Gemma 4");
}
return text;
} catch (error: unknown) {
const isLastAttempt = attempt === retries;
const errorMessage = error instanceof Error ? error.message : String(error);
if (isLastAttempt) {
throw new Error(
`Gemma 4 call failed after ${retries} attempts: ${errorMessage}`
);
}
// 429 (rate limit) の場合は指数バックオフで待機
if (
errorMessage.includes("429") ||
errorMessage.includes("rate limit")
) {
await new Promise((resolve) =>
setTimeout(resolve, 2 ** attempt * 1000)
);
}
}
}
throw new Error("Unreachable");
}temperature: 0.2 にしている理由は、コーディングタスクでは再現性が重要ですからです。同じコードを2回レビューして全く異なる指摘が返ってくると、開発者が混乱します。0.2はコード品質を維持しながら若干の柔軟性を持たせる値として、実際の運用で収まりが良かった。
コードレビューエージェントの実装
コードレビューエージェントの核心は「diffを受け取り、問題点をJSON形式で返す」設計にあります。テキスト形式だと後処理が難しいため、構造化された出力を最初から設計します。
// src/agents/code-review.ts
import { callGemma } from "../lib/gemma-client";
export interface ReviewComment {
file: string;
line: number;
severity: "error" | "warning" | "suggestion";
category: "security" | "performance" | "style" | "logic" | "test-coverage";
message: string;
suggestion?: string; // 修正案(省略可能)
}
export interface ReviewResult {
comments: ReviewComment[];
summary: string;
approvalStatus: "approved" | "changes-requested" | "comment";
}
const CODE_REVIEW_SYSTEM_PROMPT = `You are an expert code reviewer. Analyze the provided git diff and return feedback as valid JSON only.
Rules:
- Focus only on the changed lines in the diff
- Do NOT comment on unchanged code
- Return JSON matching the ReviewResult type exactly
- If no issues found, return empty comments array with "approved" status
- severity "error" = blocks merge, "warning" = should fix, "suggestion" = optional improvement
- Be specific: include the actual problematic code snippet in the message
- Return ONLY valid JSON. Do NOT wrap in markdown code fences.`;
export async function runCodeReviewAgent(
diff: string,
prTitle: string,
prDescription: string
): Promise<ReviewResult> {
const MAX_DIFF_CHARS = 8000;
let processedDiff = diff;
if (diff.length > MAX_DIFF_CHARS) {
// 変更行のみに絞り込む(+/-行以外は省略してコンテキスト節約)
processedDiff = diff
.split("\n")
.filter(
(line) =>
line.startsWith("+") ||
line.startsWith("-") ||
line.startsWith("@@") ||
line.startsWith("diff")
)
.join("\n")
.slice(0, MAX_DIFF_CHARS);
processedDiff += "\n[... diff truncated for context limit ...]";
}
const userMessage = `PR Title: ${prTitle}
PR Description: ${prDescription}
Git Diff:
\`\`\`diff
${processedDiff}
\`\`\`
Review this code change and return JSON only.`;
const rawResponse = await callGemma(CODE_REVIEW_SYSTEM_PROMPT, userMessage, {
temperature: 0.1,
maxOutputTokens: 1024,
});
try {
// Gemma 4がコードブロックで包む場合があるので除去
const cleaned = rawResponse
.replace(/^```json\n?/, "")
.replace(/\n?```$/, "")
.trim();
return JSON.parse(cleaned) as ReviewResult;
} catch {
// パース失敗時はフォールバック応答を返す(全体を止めない)
console.error(
"Failed to parse code review response:",
rawResponse.slice(0, 200)
);
return {
comments: [],
summary:
"Review completed but response parsing failed. Manual review recommended.",
approvalStatus: "comment",
};
}
}このコードで注目すべき点が2つあります。1つ目は、diffが長い場合に変更行のみに絞り込む前処理です。Gemma 4の実測では、変更されていないコンテキスト行は精度改善にほとんど寄与しないため、削除しても問題ありません。むしろ削除することで、Gemma 4が重要な変更箇所に集中できます。
2つ目は、JSONパース失敗時のフォールバック処理です。エラーで全体が止まるより、「レビューできなかった」という情報を返す方が運用上の価値が高いです。
テスト自動生成エージェントの実装
テスト生成エージェントは、コードレビューエージェントとは異なるコンテキスト戦略を取ります。「既存のテストを参照させる」ことが最も重要です。
多くのチームにはテストの書き方に暗黙のルールがあります。describeとitのネスト構造、モックの書き方、アサーションの粒度など。Gemma 4にこのルールを推論させる最も効果的な方法は、既存テストをSystem Promptではなくユーザーメッセージとして渡すことです。
// src/agents/test-gen.ts
import { callGemma } from "../lib/gemma-client";
export interface TestGenerationResult {
testCode: string;
testFilePath: string;
testFramework: "vitest" | "jest" | "unknown";
coveredScenarios: string[];
missingScenarios: string[]; // Gemma 4が「本来テストすべきだが今回省いた」と判断したもの
}
const TEST_GEN_SYSTEM_PROMPT = `You are an expert test engineer specializing in TypeScript/JavaScript testing.
Your task: Generate test code for the provided source file, following the EXACT style of the existing tests.
Rules:
- Match the existing test structure precisely (same describe/it patterns, same assertion style)
- Use the same mocking approach as existing tests
- Generate tests for: happy path, edge cases, error cases
- Return ONLY valid JSON. Do NOT wrap in markdown code fences.
- The test code must be ready to run without modification`;
export async function runTestGenerationAgent(params: {
sourceFile: string;
sourcePath: string;
existingTestFile?: string;
existingTestPath?: string;
testFramework: string;
}): Promise<TestGenerationResult> {
const { sourceFile, sourcePath, existingTestFile, existingTestPath, testFramework } = params;
const existingTestSection = existingTestFile
? `## Existing Test Example (FOLLOW THIS STYLE EXACTLY)
File: ${existingTestPath}
\`\`\`typescript
${existingTestFile.slice(0, 3000)}${existingTestFile.length > 3000 ? "\n// ... (truncated)" : ""}
\`\`\``
: `## No existing tests found. Use ${testFramework} best practices.`;
const userMessage = `${existingTestSection}
## Source File to Test
File: ${sourcePath}
\`\`\`typescript
${sourceFile}
\`\`\`
Generate comprehensive tests. Return JSON only:
{"testCode":"...","testFilePath":"...","testFramework":"vitest|jest|unknown","coveredScenarios":["..."],"missingScenarios":["..."]}`;
const rawResponse = await callGemma(TEST_GEN_SYSTEM_PROMPT, userMessage, {
maxOutputTokens: 2048,
temperature: 0.1,
});
try {
const cleaned = rawResponse
.replace(/^```json\n?/, "")
.replace(/\n?```$/, "")
.trim();
const result = JSON.parse(cleaned) as TestGenerationResult;
if (\!result.testCode || result.testCode.trim().length < 50) {
throw new Error("Generated test code is too short or empty");
}
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Test generation failed: ${errorMessage}`);
}
}実装で意図的な判断をした箇所が「missingScenariosフィールド」です。Gemma 4は時に「テストできるが今回の生成には含めなかったシナリオ」を判断できます。これを明示的にJSONフィールドとして要求することで、「このテストで何がカバーされていないか」をエンジニアが把握できます。実際の運用では、このフィールドに「非同期エラーシナリオ」「null入力の境界値ケース」が記録されることが多かった。
リファクタリング提案エージェントの実装
リファクタリングエージェントの設計は、他の2つと異なるアプローチを取ります。「提案を返す」だけでなく「なぜそのリファクタリングが必要か」の理由も同時に返すことで、エンジニアが自分で判断できるようにします。
// src/agents/refactor.ts
import { callGemma } from "../lib/gemma-client";
export interface RefactoringProposal {
original: string;
refactored: string;
rationale: string;
impactLevel: "low" | "medium" | "high";
breakingChange: boolean;
estimatedEffort: "minutes" | "hours" | "days";
relatedPatterns: string[];
}
const REFACTOR_SYSTEM_PROMPT = `You are a senior software architect specializing in code refactoring.
Analyze the provided code and return a single, highest-priority refactoring proposal as JSON.
Focus on one of these, in priority order:
1. Security vulnerabilities
2. Performance bottlenecks (N+1, unnecessary loops, memory leaks)
3. Readability and maintainability
4. Design pattern improvements
Return ONLY valid JSON. Do NOT include markdown code fences in the JSON string values.`;
export async function runRefactoringAgent(params: {
targetCode: string;
language: string;
context?: string;
}): Promise<RefactoringProposal | null> {
const { targetCode, language, context } = params;
// コードが短すぎる場合はスキップ
if (targetCode.trim().split("\n").length < 5) {
return null;
}
const userMessage = `Language: ${language}
${context ? `Context: ${context}\n` : ""}
Code to refactor:
\`\`\`${language}
${targetCode}
\`\`\`
Return the highest-priority refactoring as JSON:
{"original":"...","refactored":"...","rationale":"...","impactLevel":"low|medium|high","breakingChange":false,"estimatedEffort":"minutes|hours|days","relatedPatterns":["..."]}`;
const rawResponse = await callGemma(REFACTOR_SYSTEM_PROMPT, userMessage, {
temperature: 0.15,
maxOutputTokens: 1500,
});
try {
const cleaned = rawResponse
.replace(/^```json\n?/, "")
.replace(/\n?```$/, "")
.trim();
return JSON.parse(cleaned) as RefactoringProposal;
} catch {
// リファクタリング提案の失敗は非致命的 — nullを返してManager Agentに判断させる
return null;
}
}「1つの最優先リファクタリングのみを返す」設計にしている理由は、複数の提案を一度に返すと「どれから手をつけるか」の判断コストが発生するからです。優先度の高い1つに絞ることで、明確なアクションを提供できます。
Manager Agentによる統合オーケストレーション
3つのエージェントを統合するManager Agentを実装します。このコンポーネントが本システムの肝です。
// src/agents/manager.ts
import { runCodeReviewAgent, ReviewResult } from "./code-review";
import { runTestGenerationAgent, TestGenerationResult } from "./test-gen";
import { runRefactoringAgent, RefactoringProposal } from "./refactor";
export interface CodingAgentInput {
diff: string;
prTitle: string;
prDescription: string;
changedFiles: Array<{
path: string;
content: string;
testFilePath?: string;
testFileContent?: string;
language: string;
}>;
}
export interface CodingAgentOutput {
review: ReviewResult | null;
tests: TestGenerationResult[];
refactoring: RefactoringProposal[];
processingTime: number;
errors: string[];
}
export async function runCodingAgentSystem(
input: CodingAgentInput
): Promise<CodingAgentOutput> {
const startTime = Date.now();
const errors: string[] = [];
const tests: TestGenerationResult[] = [];
const refactoring: RefactoringProposal[] = [];
// コードレビューを並列で開始(diffはファイルに依存しないため先行実行可能)
const reviewPromise = runCodeReviewAgent(
input.diff,
input.prTitle,
input.prDescription
).catch((err: Error) => {
errors.push(`Code review failed: ${err.message}`);
return null;
});
// テスト生成とリファクタリングを並列で実行
const fileProcessingPromises = input.changedFiles.map(async (file) => {
// TypeScript/JavaScriptファイルのみテスト生成対象
if (["ts", "tsx", "js", "jsx"].some((ext) => file.path.endsWith(`.${ext}`))) {
try {
const testResult = await runTestGenerationAgent({
sourceFile: file.content,
sourcePath: file.path,
existingTestFile: file.testFileContent,
existingTestPath: file.testFilePath,
testFramework: detectTestFramework(file.testFileContent),
});
tests.push(testResult);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
errors.push(`Test generation failed for ${file.path}: ${errorMessage}`);
}
}
// 全ファイルでリファクタリング提案を取得
try {
const refactorResult = await runRefactoringAgent({
targetCode: file.content,
language: file.language,
});
if (refactorResult) {
refactoring.push(refactorResult);
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
errors.push(
`Refactoring analysis failed for ${file.path}: ${errorMessage}`
);
}
});
const [review] = await Promise.all([
reviewPromise,
Promise.all(fileProcessingPromises),
]);
return {
review,
tests,
refactoring,
processingTime: Date.now() - startTime,
errors,
};
}
function detectTestFramework(testFileContent?: string): string {
if (\!testFileContent) return "vitest";
if (
testFileContent.includes("from 'vitest'") ||
testFileContent.includes('from "vitest"')
) {
return "vitest";
}
if (
testFileContent.includes("from 'jest'") ||
testFileContent.includes("@jest/globals")
) {
return "jest";
}
return "vitest";
}Promise.allでコードレビューとファイル処理を並列実行している点に注目してほしい。直列実行と比較して処理時間が約60%短縮されます。コードレビューはdiffのみを必要とし、ファイル処理はdiffに依存しないため、完全に並列化できます。
Cloudflare Workersへのデプロイと運用最適化
AntigravityはCloudflare Workersへのデプロイをサポートしています。コーディングエージェントをWebhook APIとして公開することで、GitHub ActionsやCI/CDから呼び出せるようになります。
// src/index.ts (Cloudflare Workers エントリポイント)
import { runCodingAgentSystem, CodingAgentInput } from "./agents/manager";
interface Env {
WEBHOOK_TOKEN: string;
GEMMA_API_KEY: string;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method \!== "POST") {
return new Response("Method not allowed", { status: 405 });
}
const authHeader = request.headers.get("Authorization");
if (authHeader \!== `Bearer ${env.WEBHOOK_TOKEN}`) {
return new Response("Unauthorized", { status: 401 });
}
let input: CodingAgentInput;
try {
input = (await request.json()) as CodingAgentInput;
} catch {
return new Response("Invalid JSON body", { status: 400 });
}
// Cloudflare Workers の30秒タイムアウト対策
const timeoutMs = 25000;
const agentPromise = runCodingAgentSystem(input);
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Processing timeout")), timeoutMs)
);
try {
const result = await Promise.race([agentPromise, timeoutPromise]);
return new Response(JSON.stringify(result), {
headers: { "Content-Type": "application/json" },
});
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
return new Response(JSON.stringify({ error: errorMessage }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
},
};コスト削減のための事前フィルタリング
本番運用で重要なのが、無駄なAPIコールを省く最適化です。
// 不要なAPI呼び出しを事前にスキップ
function shouldSkipReview(diff: string): boolean {
const nonCodeExtensions = [".md", ".txt", ".json", ".yml", ".yaml", ".toml"];
const changedFiles = diff
.split("\n")
.filter((line) => line.startsWith("diff --git"))
.map((line) => line.split(" b/")[1]);
return changedFiles.every((file) =>
nonCodeExtensions.some((ext) => file?.endsWith(ext))
);
}
function shouldSkipTestGeneration(filePath: string): boolean {
return (
filePath.includes(".test.") ||
filePath.includes(".spec.") ||
filePath.endsWith(".d.ts") ||
filePath.includes("config.") ||
filePath.includes("/constants/")
);
}このフィルタリングにより、ドキュメントのみの変更やテストファイルへのコミット時に無駄なAPI呼び出しを回避できます。実際の運用では、全コミットの約30%がこのフィルタリングで除外された。
よくある落とし穴トップ5と解決策
Gemma 4とAgentKit 2.0でコーディングエージェントを本番運用した経験から、特に注意すべき落とし穴をまとめる。
落とし穴1: JSONレスポンスにコードブロックが混入する
Gemma 4は指示に反して、JSONをマークダウンのコードブロックで包むことがあります。対策は、replaceでコードブロックのマーカーを除去してからパースすること。System Promptに「Return ONLY valid JSON. Do NOT wrap in code fences.」と明示することで発生頻度を下げられます。
落とし穴2: コンテキストが長くなると後半の指示を無視する
Gemma 4に限らず、多くのLLMはコンテキストが長くなると後半の指示に対する注意が低下します。コーディングエージェントでは、ファイルの後半に書かれた重要な関数がレビューされないという形で現れます。対策は、System Promptを短く保ち(200トークン以下)、重要な指示はユーザーメッセージの冒頭に配置することです。
落とし穴3: 並列API呼び出しがRate Limitに引っかかる
Promise.allで複数ファイルを並列処理すると、Rate Limitエラーが頻発します。対策は、p-limitライブラリで同時実行数を制限することです。
import pLimit from "p-limit";
const limit = pLimit(3); // 同時3リクエストまで
const fileProcessingPromises = input.changedFiles.map((file) =>
limit(async () => {
// ファイル処理のコード
})
);落とし穴4: Cloudflare Workersの30秒タイムアウトとの戦い
大きなPR(変更ファイルが10個以上)を処理しようとすると、Cloudflare Workersの30秒タイムアウトに引っかかります。短期的な対策はタイムアウト前に部分結果を返すことです。根本的な解決策としてはDurable Objectsを使った非同期処理への移行があります。本記事のコードでは25秒でタイムアウトし、それまでに処理できた結果を返す設計にしています。
落とし穴5: エラーログが不十分で運用中の問題を検知できない
本番で最初の週は「なぜかレビューが返ってこない」という問題が散発しました。原因はGemma 4のAPI呼び出し失敗を全てサイレントに無視していたからです。errors配列に全エラーを記録し、レスポンスに含める設計にすることで、問題の原因を素早く特定できるようになりました。
本番運用1ヶ月後の実測値
このシステムを個人プロジェクトで1ヶ月間運用した結果、API利用料は月間約4ドルに収まった。GeminiやGPT-4を使った場合の試算(約80ドル)と比較すると、コスト効率は非常に良い。
精度については正直に言うと、Gemini 2.5 Proと比べて「見逃し」が多いです。特にビジネスロジックの深い部分やアーキテクチャ上の問題は、Gemma 4では検出できないことが多かった。しかし、スタイル違反・明らかなバグ・未使用変数・テストカバレッジのギャップといった定型的な問題の検出率は、Gemini 2.5 Proとほぼ同等だった。
個人開発者やスモールチームがCI/CDに組み込む用途としては、Gemma 4ベースのコーディングエージェントは十分に実用的です。コストを抑えながらAIコードレビューを日常的に運用したい場合に試してみてほしい。
AgentKit 2.0とエージェント設計の詳細についてはAntigravity AgentKit 2.0 — 本番マルチエージェントオーケストレーション完全ガイドも参考になります。エージェントシステムのセキュリティ設計についてはAntigravity エージェント安全設計ガイド — 暴走防止とガードレール実装で詳しく解説しています。
本記事のManager Agentパターンをさらに発展させたい場合に参考になるだろう。