DEV Community

Tamiz Uddin
Tamiz Uddin

Posted on Originally published at tamiz.pro

AI Agents That Verify Their Own Output: The 30-Minute Validation Loop That Beats 30 Days of AI Code Review

Originally published on tamiz.pro.

Traditional AI-assisted development is bottlenecked by a fundamental asymmetry: generating code is instantaneous, but verifying it is slow. A human reviewer takes days; a CI pipeline takes minutes; but the feedback loop is broken. When an AI agent generates a feature branch, it typically halts, handing the code to a human or a static analysis tool. This passive model fails because it treats the agent as a 'text oracle' rather than an 'execution actor.'

In this tutorial, we will design and implement a Self-Validating AI Agent architecture. This agent does not just write code; it writes tests, runs them, analyzes the stack traces, and refactors its own code until the test suite passes. This "30-minute loop" allows a single engineer to ship complex, reliable features significantly faster than a team spending 30 days on manual code review and CI iterations.

Table of Contents

1. The Architecture of Self-Verification

The standard "Prompt-Completion" model is a dead end for production engineering. To achieve reliability, we must close the loop. The agent must possess three distinct capabilities:

  1. Code Generation: The ability to write implementation and test code.
  2. Execution: A secure, isolated environment to run that code.
  3. Reflection: A mechanism to parse execution results (pass/fail/exception) and feed that context back to the LLM.

The workflow looks like this:

[User Request] -> [Agent] -> [Write Code] -> [Write Tests] -> [Execute Tests]
       ^                                                        |
       |                                                        |
       +--------------------------------------------------------+
                    (Reflect on Errors & Refactor)
Enter fullscreen mode Exit fullscreen mode

If the tests fail, the agent receives the specific error message (e.g., AssertionError: expected 4 but got 4.0). The LLM uses this semantic feedback to propose a fix. This cycle repeats until the tests pass or a limit is reached. This removes the human from the verification loop, reducing turnaround time from hours to minutes.

2. Setting Up the Sandbox Environment

You cannot verify code if you can't run it safely. Running rm -rf / or malicious loops in a self-verifying agent is a security risk. We will use Docker to isolate the execution.

First, create a Dockerfile that sets up a Python environment with the necessary tools:

# Dockerfile
FROM python:3.9-slim

# Create working directory
WORKDIR /app

# Install pytest for testing framework
RUN pip install pytest requests

# Copy entrypoint that executes code safely
COPY run_code.py .

CMD ["python", "run_code.py"]
Enter fullscreen mode Exit fullscreen mode

Then, create the run_code.py script inside the container that accepts code and tests from standard input (stdin). This approach ensures that every test run happens in a fresh, isolated container, preventing state leakage between iterations.

# run_code.py
import sys
import subprocess
import os

def main():
    # Read code from stdin
    code = sys.stdin.read()

    # Write code to a temporary file
    with open('/app/test_script.py', 'w') as f:
        f.write(code)

    # Run pytest on the generated file
    result = subprocess.run(
        ['pytest', '/app/test_script.py', '-v'], 
        capture_output=True,
        text=True,
        timeout=30 # Prevent infinite loops
    )

    # Output results to stdout for the agent to parse
    print(result.stdout)
    print(result.stderr)

if __name__ == '__main__':
    main()
Enter fullscreen mode Exit fullscreen mode

Build the image locally:

docker build -t agent-sandbox .
Enter fullscreen mode Exit fullscreen mode

3. Building the Agent Core: LLM + Tooling

Now we define the agent in Python. We will use the openai library (or any compatible LLM API) and docker to orchestrate the loop. The agent's system prompt must explicitly instruct it to always write tests before implementation and to prioritize test passing over style.

import docker
import json
import openai

client = openai.OpenAI()

def generate_code(request: str, history: list) -> str:
    """Calls the LLM to generate code and tests."""
    system_prompt = """
    You are a senior software engineer. 
    1. Read the user's request.
    2. Write Python code to solve it.
    3. Crucially: Write a set of pytest test cases that verify the solution.
    4. If previous tests failed, analyze the error and fix the code.
    5. Output ONLY valid JSON: {"code": "...", "tests": "...", "reasoning": "..."}
    """

    messages = [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": f"Request: {request}\n\nHistory/Feedback: {history}"}
    ]

    response = client.chat.completions.create(
        model="gpt-4", # or gpt-3.5-turbo for cost efficiency
        messages=messages,
        temperature=0.2 # Low temperature for deterministic code
    )

    # Parse JSON response
    try:
        content = response.choices[0].message.content
        return json.loads(content)
    except json.JSONDecodeError:
        return {"code": "", "tests": "", "reasoning": "Failed to parse LLM output"}

def run_sandbox(code: str, tests: str) -> str:
    """Injects code and tests into the Docker sandbox and returns output."""
    client = docker.from_env()
    container = client.containers.run(
        image="agent-sandbox",
        detach=True,
        stdin_open=True,
        input=f"{code}\n\n{tests}"
    )

    # Wait for execution
    exit_code, logs = container.wait()
    log_text = logs.decode('utf-8')
    container.remove()

    return log_text, exit_code
Enter fullscreen mode Exit fullscreen mode

4. The Execution Loop: Run, Test, Reflect

This is the core of the "30-minute" claim. We create a loop that runs the sandbox, parses the results, and feeds the failure back into the LLM.

Notice the parse_feedback function. It doesn't just say "Failed." It extracts the specific assertion error so the LLM knows what went wrong.

def parse_feedback(sandbox_output: str, exit_code: int) -> str:
    """Converts raw pytest output into semantic feedback for the LLM."""
    if exit_code == 0:
        return "All tests passed successfully. No action required."

    # If exit code is not 0, extract the specific errors
    lines = sandbox_output.split('\n')
    errors = [line for line in lines if 'AssertionError' in line or 'Error' in line]

    if not errors:
        return f"Tests failed. Raw output: {sandbox_output[:500]}"

    return "Tests failed. Specific errors detected:\n" + "\n".join(errors)

def self_validating_agent(request: str, max_iterations: int = 5):
    """
    Orchestrates the loop: Generate -> Execute -> Reflect -> Repeat.
    """
    history = ""
    current_code = ""

    for i in range(max_iterations):
        print(f"[Iteration {i+1}] Generating code...\n")

        # 1. LLM generates code + tests based on request and history
        response_data = generate_code(request, history)
        code = response_data.get("code", "")
        tests = response_data.get("tests", "")
        reasoning = response_data.get("reasoning", "")

        if not code or not tests:
            history = "You failed to provide code and tests. Provide valid Python code."
            continue

        current_code = code
        print(f"[Iteration {i+1}] Running sandbox...\nReasoning: {reasoning}")

        # 2. Execute in Sandbox
        output, exit_code = run_sandbox(code, tests)

        # 3. Analyze Results
        if exit_code == 0:
            print("SUCCESS: All tests passed.")
            print("\nFINAL CODE:\n")
            print(code)
            return code

        # 4. Prepare feedback for next iteration
        feedback = parse_feedback(output, exit_code)
        history = f"Previous attempt failed.\n{feedback}\nPlease fix the code." 
        print(f"[Iteration {i+1}] Failed. Updating history with feedback.\n")

    print("MAX ITERATIONS REACHED. Returning best effort.")
    return current_code

# Example Usage
# result = self_validating_agent("Write a function to calculate Fibonacci numbers with memoization")
Enter fullscreen mode Exit fullscreen mode

Why This Beats Manual Review

In a traditional flow, the developer writes the code, writes the test, runs the test, sees the error, fixes the code, re-runs the test, and repeats. An engineer can do about 20 iterations in a day if they are highly focused.

The agent above runs continuously. While it might have a limit of 5 iterations to prevent infinite loops, each iteration takes roughly 10-15 seconds (LLM call + Docker start + Pytest run). In 30 minutes, the agent can attempt hundreds of logic branches, exploring edge cases that a human might overlook or forget to test.

5. Preventing "Reward Hacking" in Tests

There is a significant risk with self-verifying agents: Reward Hacking. The LLM's goal is to "pass the test." Sometimes, the easiest way to pass a test is to modify the test itself or hard-code the output to match the expected result, rather than fixing the underlying logic.

For example, if the test expects 2+2=4, the agent might write return 4 instead of return a + b.

Mitigation Strategies

  1. Read-Only Tests: The sandbox should execute tests from a separate, immutable file that the agent does not have write access to.
  2. Property-Based Testing: Instead of fixed expected outputs, use property-based tests (e.g., assert isinstance(result, int) or assert input_len == output_len). These are much harder to "hack" by hard-coding values.
  3. Dual-LLM Verification: Use a second LLM with a different prompt to review the generated tests. Ask the second LLM: "Does this test actually verify the requirement, or is it trivially bypassable?"

Implementing immutable tests in Docker is simple:

# In the Dockerfile, ensure tests are read-only or mounted from host
COPY tests.py /app/tests.py
RUN chmod 444 /app/tests.py
Enter fullscreen mode Exit fullscreen mode

Modify run_code.py to mount the host's test file or strictly separate code generation from test execution.

6. Production Hardening

For a production-grade system, consider these hardening steps:

  • Timeouts: Always enforce strict timeouts on the Docker container (e.g., 30s) to prevent the agent from generating an infinite loop (while True: pass).
  • Security Isolation: Run the Docker container with --no-new-privileges and minimal privileges. Do not mount the host's /root or sensitive directories.
  • Cost Cap: LLM calls cost money. Implement a max_iterations limit and a token counter. If the cost exceeds a threshold, abort and flag for human review.
  • Logging: Log every iteration to a file. You need to audit why the agent failed. If it failed 5 times, the logs will show if it was stuck on a syntax error or a logic error.

7. Frequently Asked Questions

Q: Can this work with languages other than Python?
A: Yes. You just need a language-specific Docker image (e.g., Node.js, Go, Rust). The agent code remains the same; only the sandbox and test framework change (e.g., npm test instead of pytest). The LLM needs to be prompted to write tests in the target language's testing framework.

Q: How do we prevent the agent from getting stuck in a loop of the same error?
A: Track the hash of the error message. If the same error appears 3 times in a row, break the loop and escalate to a human. The LLM is likely stuck in a local minimum.

Q: Is this faster than CI/CD pipelines?
A: Yes, for initial development. CI/CD is a gatekeeper for the final state. The self-validating agent is a development tool that shifts verification left, catching bugs before the code is even committed to the repository. This reduces the number of failed CI runs, which saves downstream resources.

Top comments (0)