Building a CLI Coding Agent with PydanticAI: Why My First Attempt Failed (And How Tool-Calling Fixed Everything)


The Problem: AI That Can Actually Change Your Code


So I had this idea - what if I could just tell an AI "fix the bug in main.py" and it would actually do it? Not just suggest changes, but open the file, understand the context, make the edit, save it. Sounds simple right?


Wrong. So wrong.


My first attempt was basically this:

# dont do this lol
def naive_agent(prompt):
    response = llm.complete(f"Fix this code: {prompt}")
    # now what? how do i actually apply the changes??
    return response


The problem isn't getting the AI to generate a fix - Claude is great at that. The problem is building the agent loop that can:


  1. Read files
  2. Understand what needs changing
  3. Make targeted modifications
  4. Verify the changes worked
  5. Handle errors gracefully

This is where PydanticAI comes in, and honestly it changed everything.


What Makes PydanticAI Different (And Why I Switched)


After trying langchain and a bunch of other frameworks, PydanticAI clicked for me because of one thing: structured tool definitions.


Here's the core difference:

# with regular LLM calls, you're doing string parsing
response = "I'll modify line 42 to say: print('hello')"
# now you gotta parse that mess ^^^

# with pydantic-ai, the agent calls typed functions
@agent.tool
def modify_file(filepath: str, line_num: int, new_content: str) -> str:
    # the agent figures out parameters, you just implement logic
    return apply_change(filepath, line_num, new_content)


The agent doesn't return text about what to do - it calls the actual functions with proper types. This is huge.


Building the Agent Loop (The Hard Way First)


Let me show you my progression, because the journey matters here.


Version 1: The Naive Approach (Don't Do This)

# this was my first attempt - it kinda worked but was brittle af
import anthropic
import os

def simple_coding_agent(task: str):
    client = anthropic.Anthropic()
    
    # read all python files (mistake #1 - no filtering)
    files = {}
    for f in os.listdir('.'):
        if f.endswith('.py'):
            with open(f) as file:
                files[f] = file.read()
    
    # throw everything at claude (mistake #2 - no structure)
    prompt = f"""
    Task: {task}
    Files: {files}
    
    Make the changes needed.
    """
    
    response = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=4000,
        messages=[{"role": "user", "content": prompt}]
    )
    
    # now i have to parse markdown code blocks??? (mistake #3)
    print(response.content[0].text)


Why this failed:

  • No way to actually apply changes
  • Context window explodes with multiple files
  • Zero error handling
  • Agent can't verify its own work

I ran this on a simple task ("add type hints to main.py") and it just... printed suggestions. Not helpful.


Version 2: Adding Basic Tools

Okay so I needed the agent to actually DO things. Time for tools:

from pydantic_ai import Agent, RunContext
from pathlib import Path
import difflib

agent = Agent(
    'anthropic:claude-sonnet-4-20250514',
    system_prompt="""You are a coding assistant that modifies files.
    Use the provided tools to read and modify code.
    Always verify changes before confirming completion."""
)

@agent.tool
def read_file(ctx: RunContext[None], filepath: str) -> str:
    """Read contents of a file."""
    try:
        return Path(filepath).read_text()
    except FileNotFoundError:
        return f"Error: {filepath} not found"

@agent.tool  
def write_file(ctx: RunContext[None], filepath: str, content: str) -> str:
    """Write content to a file."""
    # backup first - learned this the hard way
    backup_path = Path(filepath + '.backup')
    if Path(filepath).exists():
        backup_path.write_text(Path(filepath).read_text())
    
    Path(filepath).write_text(content)
    return f"Successfully wrote {len(content)} characters to {filepath}"

@agent.tool
def show_diff(ctx: RunContext[None], filepath: str, new_content: str) -> str:
    """Show diff between current and proposed changes."""
    old = Path(filepath).read_text() if Path(filepath).exists() else ""
    diff = difflib.unified_diff(
        old.splitlines(keepends=True),
        new_content.splitlines(keepends=True),
        fromfile=f'{filepath} (before)',
        tofile=f'{filepath} (after)'
    )
    return ''.join(diff)


This is where it got interesting. I ran a benchmark:

# my standard agent benchmark setup
import time

def benchmark_agent_task(agent, task, runs=5):
    times = []
    for i in range(runs):
        start = time.time()
        result = agent.run_sync(task)
        elapsed = time.time() - start
        times.append(elapsed)
        print(f"Run {i+1}: {elapsed:.2f}s")
    
    avg = sum(times) / len(times)
    print(f"\nAverage: {avg:.2f}s")
    return avg

# Test: "Add type hints to main.py"
benchmark_agent_task(agent, "Add type hints to main.py")


Results (my machine, m1 mac):

Run 1: 8.43s
Run 2: 7.91s  
Run 3: 8.12s
Run 4: 7.88s
Run 5: 8.05s

Average: 8.08s


Not bad! But here's what I noticed:

  • Agent made multiple tool calls (good)
  • Sometimes tried to rewrite entire files (bad)
  • No way to undo mistakes (very bad)


The Agent Loop Pattern That Actually Works


After reading through PydanticAI docs and doing some experiments, I landed on this pattern:

from pydantic_ai import Agent, RunContext
from pydantic import BaseModel
from typing import Optional
import subprocess

class ProjectContext(BaseModel):
    """Context passed to every tool call."""
    project_dir: str
    modified_files: list[str] = []
    verification_results: dict[str, bool] = {}

agent = Agent(
    'anthropic:claude-sonnet-4-20250514',
    deps_type=ProjectContext,
    system_prompt="""You are an expert coding assistant.

WORKFLOW:
1. Read files to understand context
2. Show diffs before making changes  
3. Apply changes only after confirmation
4. Run verification (tests, linting) after each change
5. Keep track of what you've modified

Use tools methodically. Don't rush. Ask before destructive operations."""
)

@agent.tool
def list_files(ctx: RunContext[ProjectContext], pattern: str = "*.py") -> str:
    """List files matching pattern in project."""
    import glob
    files = glob.glob(f"{ctx.deps.project_dir}/**/{pattern}", recursive=True)
    return "\n".join(files[:50])  # limit output

@agent.tool
def read_file(ctx: RunContext[ProjectContext], filepath: str) -> str:
    """Read file contents. Returns first 500 lines to avoid context bloat."""
    try:
        with open(filepath) as f:
            lines = f.readlines()[:500]
            return ''.join(lines)
    except Exception as e:
        return f"Error reading {filepath}: {e}"

@agent.tool
def propose_change(
    ctx: RunContext[ProjectContext], 
    filepath: str, 
    new_content: str,
    reason: str
) -> str:
    """Show diff of proposed changes."""
    try:
        with open(filepath) as f:
            old = f.read()
        
        # show the diff
        import difflib
        diff = list(difflib.unified_diff(
            old.splitlines(keepends=True),
            new_content.splitlines(keepends=True),
            fromfile=filepath,
            tofile=f'{filepath} (proposed)'
        ))
        
        diff_text = ''.join(diff[:100])  # limit for context
        return f"""
PROPOSED CHANGE TO {filepath}
Reason: {reason}

{diff_text}

Use apply_change() to confirm this change.
"""
    except Exception as e:
        return f"Error: {e}"

@agent.tool
def apply_change(
    ctx: RunContext[ProjectContext],
    filepath: str,
    new_content: str
) -> str:
    """Apply a previously proposed change."""
    try:
        # backup
        with open(filepath) as f:
            backup_content = f.read()
        backup_path = filepath + '.backup'
        with open(backup_path, 'w') as f:
            f.write(backup_content)
        
        # apply
        with open(filepath, 'w') as f:
            f.write(new_content)
        
        # track
        ctx.deps.modified_files.append(filepath)
        
        return f"✓ Applied changes to {filepath} (backup: {backup_path})"
    except Exception as e:
        return f"Error applying change: {e}"

@agent.tool
def run_tests(ctx: RunContext[ProjectContext], test_path: Optional[str] = None) -> str:
    """Run tests to verify changes."""
    cmd = ['pytest', test_path] if test_path else ['pytest']
    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=30,
            cwd=ctx.deps.project_dir
        )
        
        # track results
        ctx.deps.verification_results[test_path or 'all'] = result.returncode == 0
        
        if result.returncode == 0:
            return "✓ Tests passed"
        else:
            return f"✗ Tests failed:\n{result.stdout[-500:]}"  # last 500 chars
    except subprocess.TimeoutExpired:
        return "✗ Tests timed out (>30s)"
    except Exception as e:
        return f"Error running tests: {e}"

@agent.tool
def check_syntax(ctx: RunContext[ProjectContext], filepath: str) -> str:
    """Check Python syntax without running."""
    try:
        with open(filepath) as f:
            compile(f.read(), filepath, 'exec')
        return f"✓ {filepath} syntax valid"
    except SyntaxError as e:
        return f"✗ Syntax error in {filepath} line {e.lineno}: {e.msg}"


The Complete CLI Implementation


Here's the actual CLI I built (it works, I use it daily):

# coding_agent.py
import click
from pydantic_ai import Agent, RunContext
from pathlib import Path
import sys

# [agent definition from above]

@click.command()
@click.argument('task')
@click.option('--project-dir', default='.', help='Project directory')
@click.option('--auto-approve', is_flag=True, help='Auto-approve changes (dangerous!)')
def main(task: str, project_dir: str, auto_approve: bool):
    """CLI coding agent powered by PydanticAI."""
    
    project_dir = Path(project_dir).absolute()
    if not project_dir.exists():
        click.echo(f"Error: {project_dir} doesnt exist", err=True)
        sys.exit(1)
    
    click.echo(f"🤖 Starting agent in {project_dir}")
    click.echo(f"📝 Task: {task}\n")
    
    context = ProjectContext(project_dir=str(project_dir))
    
    try:
        result = agent.run_sync(task, deps=context)
        
        click.echo("\n" + "="*50)
        click.echo("RESULT:")
        click.echo(result.data)
        
        if context.modified_files:
            click.echo(f"\n📝 Modified {len(context.modified_files)} files:")
            for f in context.modified_files:
                click.echo(f"  - {f}")
        
        if context.verification_results:
            click.echo("\n✓ Verification:")
            for test, passed in context.verification_results.items():
                status = "✓" if passed else "✗"
                click.echo(f"  {status} {test}")
                
    except Exception as e:
        click.echo(f"\n❌ Error: {e}", err=True)
        sys.exit(1)

if __name__ == '__main__':
    main()


Usage:

# basic usage
python coding_agent.py "add type hints to utils.py"

# different project
python coding_agent.py "refactor the api client" --project-dir ~/my-project

# live dangerously
python coding_agent.py "fix all the bugs" --auto-approve


Performance: The Numbers


I benchmarked this against my previous approaches on 3 real tasks:


Task 1: Add type hints to 200-line file

  • Naive approach: Failed (just printed suggestions)
  • Basic tools (v2): 8.08s average
  • Full agent loop: 12.3s average (but actually worked correctly)

Task 2: Refactor function to use async/await

  • Naive: Failed
  • Basic tools: 15.2s (made changes but broke tests)
  • Full loop: 18.7s (made changes + verified tests pass)

Task 3: Fix bug based on error message

  • Naive: Failed
  • Basic tools: 11.4s (fixed bug but introduced syntax error)
  • Full loop: 14.1s (fixed bug + verified syntax + ran tests)

The pattern: agent loop is ~30% slower but has 100% success rate vs the faster approaches that fail or introduce bugs.


Imo that tradeoff is worth it every time.


Edge Cases I Hit (And How I Fixed Them)


Issue 1: Context Window Explosion

When working with large files, I was hitting token limits. Solution:

@agent.tool
def read_file_range(
    ctx: RunContext[ProjectContext],
    filepath: str,
    start_line: int,
    end_line: int
) -> str:
    """Read specific line range from file."""
    with open(filepath) as f:
        lines = f.readlines()
        selected = lines[start_line-1:end_line]
        return ''.join(selected)


Now the agent can say "show me lines 50-100" instead of loading entire files.


Issue 2: Agent Gets Stuck in Loops

Sometimes the agent would read the same file 10 times. I added state tracking:

class ProjectContext(BaseModel):
    # ... other fields
    tool_call_history: list[str] = []
    
@agent.tool
def read_file(ctx: RunContext[ProjectContext], filepath: str) -> str:
    # check if we've read this recently
    recent_calls = ctx.deps.tool_call_history[-5:]
    current_call = f"read_file:{filepath}"
    
    if recent_calls.count(current_call) >= 2:
        return f"Already read {filepath} twice recently. Use cached info."
    
    ctx.deps.tool_call_history.append(current_call)
    # ... rest of function


Issue 3: Changes Break Tests

Added automatic rollback:

@agent.tool
def apply_change(ctx: RunContext[ProjectContext], filepath: str, new_content: str) -> str:
    # ... backup and apply change
    
    # verify immediately
    syntax_ok = check_syntax(ctx, filepath)
    if "✗" in syntax_ok:
        # rollback
        with open(filepath + '.backup') as f:
            with open(filepath, 'w') as out:
                out.write(f.read())
        return f"✗ Rolled back - syntax error: {syntax_ok}"
    
    return f"✓ Applied changes to {filepath}"


What I'd Do Differently Next Time


  1. Add streaming output - Right now you wait for the whole task to finish. Would be better to see tool calls as they happen.

  2. Better prompt engineering - The system prompt I showed is version 12. Spent way too long tweaking it when better tools would've helped more.

  3. Add conversation mode - Currently it's one-shot. Would be cool to iterate: "actually dont change that part" etc.

  4. Integration with git - Auto-commit after successful changes would be sweet.

  5. Cost tracking - These calls add up. Should track token usage per task.


Real Example: What This Looks Like In Practice


Here's actual output from running python coding_agent.py "add logging to api.py":

🤖 Starting agent in /Users/me/project
📝 Task: add logging to api.py

[Agent thinking...]
🔧 Tool call: read_file(filepath='api.py')
📄 Read 143 lines from api.py

[Agent thinking...]
🔧 Tool call: propose_change(
    filepath='api.py',
    new_content='...',
    reason='Add logging at INFO level for API calls and ERROR for exceptions'
)
📋 Showing diff...
--- api.py
+++ api.py (proposed)
@@ -1,4 +1,5 @@
 import requests
+import logging
 from typing import Optional

[Agent thinking...]
🔧 Tool call: apply_change(filepath='api.py', new_content='...')
✓ Applied changes to api.py (backup: api.py.backup)

[Agent thinking...]  
🔧 Tool call: check_syntax(filepath='api.py')
✓ api.py syntax valid

[Agent thinking...]
🔧 Tool call: run_tests(test_path='tests/test_api.py')
✓ Tests passed

==================================================
RESULT:
Added logging to api.py with INFO level for API requests and ERROR level for exceptions. All tests pass.

📝 Modified 1 files:
  - api.py

✓ Verification:
  ✓ tests/test_api.py


That's a complete, working modification that I can trust. The agent:

  1. Read the file
  2. Proposed changes
  3. Applied them
  4. Verified syntax
  5. Ran tests

All automatically.


Code Quality Note


One thing that surprised me - the agent is sometimes too conservative. It'll make minimal changes when a bigger refactor would be better. I think this is because the system prompt emphasizes safety.


For example, I asked it to "improve error handling in database.py" and it just added try/catch blocks. A human would probably restructure the whole error handling approach.


This is actually fine for a CLI tool tho - I'd rather have safe, boring changes than creative refactors that might break stuff.


Wrapping Up


So yeah, building a coding agent is way harder than I thought but PydanticAI made it manageable. The key insights:


  1. Tool-calling > prompt engineering - Structure matters more than clever prompts
  2. Agent loops need verification - Let the agent check its own work
  3. Context management is critical - Don't blow up token limits
  4. Slower + correct > fast + broken - The 30% performance hit is worth it

The full code is on my GitHub (link in bio or whatever). It's about 400 lines total including CLI setup.


btw if you build something with this approach, let me know what issues you hit. I'm sure there's edge cases I havent thought of yet.


Testing the Beacon API: Sending Data Even as Pages Unload