Coding agents have a reputation for working great in demos but falling apart on real codebases. The usual culprit is a design that delegates everything to a single model call. Code review, test generation, and refactoring suggestions each demand different context strategies and different recovery paths when something goes wrong.
Gemma 4 has become a practical choice for solo developers because it runs locally and offers low-cost API calls. You can experiment without watching your bill, then switch to the cloud API when you're ready for production. This guide walks through building a system using Antigravity's AgentKit 2.0 that assigns each of those three responsibilities to a dedicated agent and coordinates them together.
What Makes a Coding Agent Actually Useful
Before Gemma 4, running a coding agent in production as a solo developer meant facing a cost wall. High-capability models like GPT-4 or Gemini 2.5 Pro understand code well, but continuously processing a large codebase gets expensive fast.
In practice, a code review task covering around 500 lines costs roughly $0.002 per request with Gemma 4. The same task with Gemini 2.5 Pro runs about $0.04 — a 20x difference. For a team reviewing 50 PRs a day, that's the difference between $3 and $60 a month.
But choosing purely on cost leads to disappointment. Here's an honest breakdown from building this system:
Where Gemma 4 performs well: code style and best practice reviews, test code generation that mirrors existing test patterns, and focused refactoring suggestions at the function or class level.
Where Gemma 4 struggles: cross-file architectural issues (the context window fills up fast), independently judging what deserves test coverage in an unfamiliar codebase, and high-abstraction architecture reviews.
Given these characteristics, "one agent using Gemma 4 for everything" is the wrong architecture. A "three-agent setup where each agent only works in Gemma 4's strong zone" is the right one.
System Architecture — Three-Agent Collaboration
The system built in this guide looks like this:
Manager Agent (Antigravity AgentKit 2.0)
├── Code Review Agent ← Gemma 4 (diff analysis + style checks)
├── Test Generation Agent ← Gemma 4 (pattern-guided test generation)
└── Refactoring Agent ← Gemma 4 (function/class-scoped suggestions)
Why the Manager Agent pattern
AgentKit 2.0's Manager Agent acts as a coordinator — it receives a task, delegates to the right subagent, and collects results. The key benefit is that each subagent can fail and retry independently. If the Code Review Agent fails, Test Generation still proceeds.
With a single-agent design, one failure stops everything. The Manager Agent pattern separates "which agent handles what" from "how each agent does its job."
Context strategy by agent
Each agent deliberately constrains what it sends to Gemma 4:
- Code Review Agent: git diff only (before and after the change)
- Test Generation Agent: target file + existing test file + test framework config
- Refactoring Agent: target function or class only (not the entire file)
This maximizes Gemma 4's limited context window by following a strict principle: send only what's necessary.
Environment Setup
Initialize an AgentKit 2.0 project inside Antigravity:
npm create agentkit@latest my-coding-agent
cd my-coding-agent
npm install
# Add to .env.local:
# GEMMA_API_KEY=YOUR_GEMMA_API_KEY
# GEMMA_MODEL=gemma-4-27b-itProject structure:
my-coding-agent/
├── src/
│ ├── agents/
│ │ ├── manager.ts
│ │ ├── code-review.ts
│ │ ├── test-gen.ts
│ │ └── refactor.ts
│ ├── tools/
│ │ ├── git-tools.ts
│ │ ├── file-tools.ts
│ │ └── github-tools.ts
│ └── index.ts
├── .env.local
└── package.json
Build the Gemma 4 client first, with error handling wired in from the start. Retrofitting error handling into an agent system is painful because state management between agents gets complicated quickly.
// 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, // Low temperature for reproducible coding tasks
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}`
);
}
// Exponential backoff for rate limit errors
if (errorMessage.includes("429") || errorMessage.includes("rate limit")) {
await new Promise((resolve) =>
setTimeout(resolve, 2 ** attempt * 1000)
);
}
}
}
throw new Error("Unreachable");
}The temperature: 0.2 choice deserves explanation. Code review needs to be reproducible — reviewing the same diff twice and getting contradictory feedback erodes developer trust quickly. 0.2 maintains output quality while allowing enough variance to avoid robotic-sounding feedback.
Code Review Agent Implementation
The core contract of the Code Review Agent is simple: receive a diff, return structured feedback as JSON. Unstructured text output creates downstream parsing nightmares.
// 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
- 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) {
// Strip unchanged context lines — they don't improve Gemma 4's accuracy
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 sometimes wraps JSON in code fences despite instructions
const cleaned = rawResponse
.replace(/^```json\n?/, "")
.replace(/\n?```$/, "")
.trim();
return JSON.parse(cleaned) as ReviewResult;
} catch {
// Return a graceful fallback rather than crashing the whole pipeline
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",
};
}
}Two design decisions worth highlighting: stripping unchanged context lines before sending to Gemma 4, and returning a graceful fallback on JSON parse failure. The first keeps the model focused on what actually changed. The second means a broken review response doesn't take down test generation or refactoring for the same PR.
Test Generation Agent Implementation
The test generation agent uses a fundamentally different context strategy. The most important variable isn't the source file — it's the existing test file.
Every team has implicit conventions for tests: how describe and it blocks are nested, how mocks are written, what level of assertion granularity is expected. The most effective way to get Gemma 4 to follow those conventions is to show them by example in the user message — not the 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[]; // Scenarios Gemma 4 identified but chose not to generate
}
const TEST_GEN_SYSTEM_PROMPT = `You are an expert test engineer specializing in TypeScript/JavaScript testing.
Your task: Generate test code following the EXACT style of the existing tests.
Rules:
- Match the existing test structure precisely
- Use the same mocking approach as existing tests
- Cover: 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}`);
}
}The missingScenarios field is a deliberate design choice. By explicitly asking Gemma 4 to surface what it chose not to test, you give reviewers a concrete checklist of gaps. In practice, async error scenarios and null input boundary cases appear in this field frequently — exactly the kind of thing that bites you in production.
Refactoring Agent Implementation
The refactoring agent takes a different approach: it returns not just the refactored code, but the reason behind the change. This lets engineers make the call themselves rather than blindly applying AI suggestions.
// 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 code and return a single, highest-priority refactoring proposal as JSON.
Priority order:
1. Security vulnerabilities
2. Performance bottlenecks (N+1, memory leaks, unnecessary loops)
3. Readability and maintainability
4. Design pattern improvements
Return ONLY valid JSON. Do NOT include markdown code fences in JSON 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; // Not worth refactoring
}
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 {
return null; // Non-fatal — let Manager Agent handle the absence
}
}Returning a single highest-priority proposal is intentional. Multiple suggestions create decision overhead: "which one do I fix first?" One clear priority eliminates that friction and makes the agent's output immediately actionable.
Manager Agent: Tying It All Together
// 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[] = [];
// Fire code review immediately — it only needs the diff, not individual files
const reviewPromise = runCodeReviewAgent(
input.diff,
input.prTitle,
input.prDescription
).catch((err: Error) => {
errors.push(`Code review failed: ${err.message}`);
return null;
});
// Process each file in parallel
const fileProcessingPromises = input.changedFiles.map(async (file) => {
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";
}Running code review in parallel with file processing cuts total execution time by roughly 60% compared to sequential processing. Both operations are independent — the review only needs the diff, and file processing doesn't depend on the review result.
Deploying to Cloudflare Workers
Antigravity's Cloudflare Workers support lets you expose the coding agent as a webhook API, callable from GitHub Actions or any CI/CD pipeline.
// src/index.ts
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 });
}
// Race against Cloudflare's 30-second timeout
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" },
});
}
},
};Pre-filtering to cut API costs
About 30% of commits in practice don't warrant AI review — documentation updates, config changes, type definition files. Filtering these out before making any API calls keeps costs down significantly.
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))
);
}Five Production Pitfalls and How to Avoid Them
Pitfall 1: JSON wrapped in code fences
Gemma 4 sometimes wraps JSON responses in markdown code fences despite explicit instructions not to. The fix is defensive parsing — strip code fence markers before parsing, and add "Do NOT wrap in markdown code fences" to your system prompt.
Pitfall 2: Attention degradation on long context
Like most LLMs, Gemma 4's attention on later instructions weakens as context grows. In practice, functions in the second half of a file get reviewed less thoroughly. Keep system prompts under 200 tokens and put critical instructions at the beginning of the user message.
Pitfall 3: Rate limits from parallel requests
Promise.all fires all requests simultaneously and often triggers rate limits. Use p-limit to cap concurrency:
import pLimit from "p-limit";
const limit = pLimit(3); // Max 3 concurrent requests
const fileProcessingPromises = input.changedFiles.map((file) =>
limit(async () => {
// file processing
})
);Pitfall 4: Cloudflare Workers' 30-second timeout
PRs touching more than 10 files will hit the timeout. The short-term fix (used in this article's code) is racing against a 25-second deadline and returning partial results. The proper long-term solution is moving to Durable Objects for async processing.
Pitfall 5: Silent failures killing observability
The first week in production, reviews were silently disappearing. The cause was catching all Gemma 4 errors and returning empty results with no logging. Always surface errors in the response body — the errors array in the output type exists exactly for this purpose.
One Month of Production Data
Running this system on a personal project for a month kept the API bill around $4. The equivalent with Gemini 2.5 Pro would have been roughly $80.
On accuracy: Gemma 4 misses things that Gemini 2.5 Pro would catch, particularly deep business logic issues and architectural problems. But for style violations, obvious bugs, unused variables, and test coverage gaps — routine problems that make up the bulk of real PR review work — Gemma 4's detection rate was comparable.
For solo developers and small teams integrating AI review into CI/CD, a Gemma 4-based coding agent is a genuinely practical option. Give it a shot if you want daily AI code review without the monthly bill.
For a deeper dive into AgentKit 2.0 and orchestration patterns, see Antigravity AgentKit 2.0 — Production Multi-Agent Orchestration Guide. For safety guardrails in agent systems, Antigravity Agent Safety Design Guide covers the essential patterns.