38.2 The "diff generation" agent pattern

Overview and links for this section of the guide.

The Concept

The diff generation agent produces actual code changes, but never applies them directly. It outputs a structured diff that humans review and apply.

Agent Input:  Approved plan for "Fix session timeout"
Agent Output: 
  FILE: src/auth/session.ts
  <<<<<<< SEARCH
  const timeout = 3600000;
  =======
  const timeout = config.session.timeout ?? 3600000;
  >>>>>>> REPLACE

Diff Format

We use Search/Replace blocks instead of unified diffs because LLMs can't reliably track line numbers:

// search-replace-format.ts
interface DiffBlock {
  file: string;
  blocks: Array<{
    search: string;   // Exact text to find
    replace: string;  // Replacement text
  }>;
}

// Why this format works:
// ✅ No line numbers to get wrong
// ✅ Unambiguous matching
// ✅ Easy to validate before applying
// ✅ Human readable

// Example
const diff: DiffBlock = {
  file: "src/auth/session.ts",
  blocks: [
    {
      search: `const timeout = 3600000;`,
      replace: `const timeout = config.session.timeout ?? 3600000;`
    },
    {
      search: `import { db } from './db';`,
      replace: `import { db } from './db';\nimport { config } from '../config';`
    }
  ]
};

Implementation

// diff-agent.ts
const DIFF_GENERATION_PROMPT = `You are a code modification agent.
Given an approved plan, generate precise code changes.

Rules:
1. Use Search/Replace format ONLY
2. Search text must be EXACT (including whitespace)
3. Provide ONLY the minimal change needed
4. Include imports if new dependencies are added
5. Preserve existing code style

Output format for each change:
FILE: path/to/file.ts
<<<<<<< SEARCH
exact existing code
=======
replacement code
>>>>>>> REPLACE

Generate all changes now:`;

export class DiffGenerationAgent {
  async generateDiff(plan: ApprovedPlan): Promise {
    // First, read all relevant files
    const fileContents = await Promise.all(
      plan.affectedFiles.map(f => this.readFile(f.path))
    );
    
    const context = plan.affectedFiles.map((f, i) => 
      `FILE: ${f.path}\n\`\`\`\n${fileContents[i]}\n\`\`\``
    ).join('\n\n');
    
    const response = await this.model.generateContent({
      systemInstruction: DIFF_GENERATION_PROMPT,
      contents: [{
        role: 'user',
        parts: [{
          text: `PLAN:\n${JSON.stringify(plan)}\n\nCURRENT FILES:\n${context}`
        }]
      }]
    });
    
    return this.parseDiff(response.response.text());
  }
  
  private parseDiff(output: string): DiffBlock[] {
    const blocks: DiffBlock[] = [];
    const filePattern = /FILE: (.+?)\n/g;
    const diffPattern = /<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE/g;
    
    // Split by FILE markers
    const sections = output.split(/FILE: /);
    
    for (const section of sections) {
      if (!section.trim()) continue;
      
      const lines = section.split('\n');
      const file = lines[0].trim();
      const content = lines.slice(1).join('\n');
      
      const diffBlocks: Array<{search: string; replace: string}> = [];
      let match;
      
      while ((match = diffPattern.exec(content)) !== null) {
        diffBlocks.push({
          search: match[1],
          replace: match[2]
        });
      }
      
      if (diffBlocks.length > 0) {
        blocks.push({ file, blocks: diffBlocks });
      }
    }
    
    return blocks;
  }
}

Applying Diffs

// diff-applier.ts
interface ApplyResult {
  success: boolean;
  file: string;
  error?: string;
  backup?: string;
}

export class DiffApplier {
  async apply(
    diffs: DiffBlock[], 
    options: { dryRun?: boolean; createBackup?: boolean } = {}
  ): Promise {
    const results: ApplyResult[] = [];
    
    for (const diff of diffs) {
      const result = await this.applyToFile(diff, options);
      results.push(result);
      
      if (!result.success) {
        // Stop on first failure
        break;
      }
    }
    
    return results;
  }
  
  private async applyToFile(
    diff: DiffBlock, 
    options: { dryRun?: boolean; createBackup?: boolean }
  ): Promise {
    try {
      let content = await fs.readFile(diff.file, 'utf8');
      const original = content;
      
      for (const block of diff.blocks) {
        // Verify search text exists
        if (!content.includes(block.search)) {
          return {
            success: false,
            file: diff.file,
            error: `Search text not found: "${block.search.slice(0, 50)}..."`
          };
        }
        
        // Check for ambiguous matches
        const occurrences = content.split(block.search).length - 1;
        if (occurrences > 1) {
          return {
            success: false,
            file: diff.file,
            error: `Ambiguous: found ${occurrences} matches`
          };
        }
        
        // Apply replacement
        content = content.replace(block.search, block.replace);
      }
      
      if (options.dryRun) {
        console.log(`[DRY RUN] Would modify: ${diff.file}`);
        return { success: true, file: diff.file };
      }
      
      // Create backup
      if (options.createBackup) {
        await fs.writeFile(`${diff.file}.backup`, original);
      }
      
      // Write changes
      await fs.writeFile(diff.file, content);
      
      return { success: true, file: diff.file, backup: options.createBackup ? `${diff.file}.backup` : undefined };
      
    } catch (e) {
      return { success: false, file: diff.file, error: e.message };
    }
  }
}
Always Dry Run First

Run with dryRun: true before actually applying. This catches "search text not found" errors before modifying any files.

Where to go next