Back to Insights
AI & Machine Learning•Beyond Autocomplete: Building AI Coding Agent Pipelines with TDD Evidence, Review Guardrails, and Multi-Agent Coordination•deep dive•October 12, 2026•14 min read

Building Autonomous AI Coding Agent Pipelines: TDD Evidence, Guardrails, and Multi-Agent Coordination

Move beyond autocomplete. Learn to build robust AI coding agent pipelines using Test-Driven Development evidence, LLM-based review guardrails, and deterministic multi-agent coordination for production-ready code.

T
Tamiz UddinFull-Stack Engineer

The current state of AI-assisted development is largely defined by autocomplete. Tools like GitHub Copilot and Cursor have revolutionized the speed at which we write code, but they remain passive assistants. They suggest; we decide. They complete; we verify. However, the next frontier of software engineering is not just faster typing—it is autonomous agency. We are moving from "AI that helps me write code" to "AI that verifies and ships features."

Building a pipeline where an AI agent does not just generate tokens, but actually engineers a feature end-to-end, requires a fundamental shift in architecture. It demands moving away from trust-based integration ("I hope this works") to evidence-based integration ("Here is the test that proves it works"). This deep dive explores the architectural patterns required to build these systems: Test-Driven Development (TDD) as a verification engine, LLM-based review guardrails to prevent hallucination-induced bugs, and multi-agent coordination to handle complex system design.

1. The Limitations of "Vibe Coding"

"Vibe coding"—writing software based on intuition and rapid AI generation without rigorous verification—works well for prototypes. It fails catastrophically in production. The core problem is that Large Language Models (LLMs) are probabilistic, not deterministic. They do not understand state; they predict the next likely token. An agent that simply writes a UserService and a UserRepository is operating in a vacuum. It has no feedback loop.

To build a production-grade pipeline, we must close the loop. The agent must not stop after code generation. It must stop only when the evidence is irrefutable. This evidence comes from three sources:

  1. Behavioral Evidence: Unit and integration tests that pass.
  2. Static Evidence: Linters, type checkers, and security scanners that pass.
  3. Logical Evidence: A review agent that verifies the logic aligns with the requirements.

2. TDD as a State Machine for Agents

Traditionally, Test-Driven Development is a human discipline. It forces you to write the test before the implementation, ensuring the code is actually testable. For AI agents, TDD is not a discipline; it is a control flow mechanism.

In an autonomous pipeline, the "Test" is the acceptance criterion. If the agent cannot write a failing test that clearly describes the requirement, it does not understand the requirement.

The TDD Loop in Code

Imagine a CodeAgent class. It does not have a single generate method. It has a refactor, implement, and verify lifecycle.

python
class FeatureAgent:
    def __init__(self, llm: BaseLLM, file_system: LocalFS, test_runner: ShellRunner):
        self.llm = llm
        self.fs = file_system
        self.test_runner = test_runner

    def execute_tdd_cycle(self, requirement: str):
        """
        Executes a TDD cycle: Red -> Green -> Refactor.
        """
        # 1. RED: Generate a failing test
        test_code = self._generate_test(requirement)
        self.fs.write_file("tests/test_feature.py", test_code)
        
        # Verify it actually fails (sanity check)
        result = self.test_runner.run("pytest tests/test_feature.py")
        if not result.has_failures():
            raise RuntimeError("Test passed immediately. Requirement is trivial or test is flawed.")
            
        # 2. GREEN: Implement the code to pass the test
        implementation = self._generate_implementation(requirement, test_code)
        self.fs.write_file("src/feature.py", implementation)
        
        # 3. VERIFY
        result = self.test_runner.run("pytest tests/test_feature.py")
        if not result.all_passed():
            # Feed error back into LLM for correction
            return self._auto_fix(result.output)
            
        # 4. REFACTOR (Optional)
        return True

The critical component here is the test_runner. The agent is not guessing that the code works. It is executing it. This shifts the architecture from a "Black Box" (input prompt -> output code) to a "Reinforcement Loop" (input prompt -> output code -> environment feedback -> error correction).

3. Review Guardrails: The LLM Critic

Even if the tests pass, the code might be bad. It might use deprecated APIs, ignore security best practices, or violate architectural patterns (e.g., a controller talking to the database). This is where the "Guardrail" agent comes in.

In this pipeline, we treat the LLM as a reviewer, not a coder. We use a distinct prompt context that focuses on critique. This is often called the "LLM-as-a-Judge" pattern.

Architecting the Critic

The Critic agent should be stateless and fast. It reads the diff generated by the TDD agent and outputs a structured JSON verdict.

json
{
  "severity": "high",
  "issue": "SQL Injection vulnerability",
  "location": "src/db.py:42",
  "suggestion": "Use parameterized queries instead of string interpolation."
}

By forcing the Critic to output structured data, we make the pipeline deterministic. The main orchestrator can then parse this JSON. If severity is "high", the pipeline halts and feeds the suggestion back to the TDD agent. This creates a "Constitutional AI" constraint, where the agent is bound by rules defined in the Critic's prompt.

4. Multi-Agent Coordination: The Orchestrator

A single agent working in a monolithic prompt context will quickly hit the limits of its context window and become confused. Complex features (e.g., "Add a Stripe billing system") require separation of concerns.

We move to a Supervisor-Worker architecture.

  1. The Architect Agent: Reads the high-level requirement and breaks it down into a task list (e.g., "1. Create Stripe Client Wrapper", "2. Add Webhook Handler", "3. Update Database Schema").
  2. The Workers: Specialized agents (DB Agent, API Agent, Test Agent) that handle specific parts of the task.
  3. The Orchestrator: A deterministic Python/TypeScript script that manages the DAG (Directed Acyclic Graph) of tasks. It does not use an LLM for control flow. It uses the LLMs only for the work items.

Why Deterministic Orchestration Matters

You might think: "Let the LLM decide which agent to call next." This is a recipe for infinite loops and unpredictable costs. The LLM should not be the conductor; it should be the player. The conductor is your code.

typescript
async function runPipeline() {
    // 1. Plan
    const plan = await architectAgent.plan(featureDescription);
    
    // 2. Execute Tasks Sequentially or in Parallel
    for (const task of plan.tasks) {
        if (task.dependsOn.length === 0) {
            await workerAgent.execute(task);
        } else {
            // Check dependencies are met
            if (task.dependsOn.every(id => completedTasks.has(id))) {
                await workerAgent.execute(task);
            }
        }
    }
    
    // 3. Final Review
    const diff = git.getDiff();
    const review = await criticAgent.review(diff);
    
    if (review.blockers > 0) {
        // Trigger a fix loop
        await workerAgent.fix(review.issues);
    }
}

5. Implementing the Pipeline: A Concrete Example

Let's look at how this actually works in a real-world scenario. We are adding a "Forgot Password" endpoint to a Node.js/Express app.

Step 1: The Architect

Prompt: "Design a system for password reset tokens. Consider expiration (24h), security (SHA-256 hashing), and database schema changes."

Output (Architect Agent):

json
{
  "tasks": [
    { "id": "db", "desc": "Add 'reset_token_hash' and 'reset_expires_at' to users table" },
    { "id": "logic", "desc": "Create generateToken service" },
    { "id": "api", "desc": "Create /auth/forgot-password endpoint" }
  ]
}

Step 2: The TDD Worker (Database)

Prompt: "Write a test that ensures the database migration adds the new columns. Then write the migration file."

The worker writes tests/migration.test.ts:

typescript
test('db schema update', async () => {
  const schema = await getSchema();
  expect(schema.tables.users.columns).toContain('reset_token_hash');
});

It runs the test. It fails (Red). It writes the migration. It runs the test. It passes (Green).

Step 3: The TDD Worker (Logic)

Prompt: "Implement the token generation logic based on the test in tests/token.test.ts."

The worker generates the logic, ensuring the token is hashed with SHA-256 before storage.

Step 4: The Critic

Prompt: "Review the diff for the last two commits. Check for: 1. Is the token stored in plain text? 2. Is the expiration time respected in the query? 3. Are SQL injections possible?"

The Critic analyzes the code. It finds that the SELECT query in the API endpoint uses string interpolation for the expiration check. It flags this as a high severity issue.

Step 5: The Orchestrator

The orchestrator sees the high severity flag. It takes the Critic's suggestion: "Use parameterized queries." It passes this back to the API Worker. The Worker updates the code to use ? placeholders. The Critic re-reviews. It passes. The pipeline commits the changes.

6. Handling Edge Cases and Failure Modes

Building these pipelines is not plug-and-play. There are specific failure modes that will break your automation.

Context Rot

As the agent makes more changes, the context window fills with previous code. The agent begins to "forget" the requirements from the beginning of the prompt.

  • Solution: Sliding Window Memory. Summarize the conversation history into a "Current State" object. Pass this state into the next prompt, rather than the raw history.

The "Hallucinated Dependency" Problem

The agent writes import { RedisClient } from 'redis' but the project uses ioredis.

  • Solution: The TDD step must include a npm install or pip install check, or better yet, the Critic must have access to the package.json / requirements.txt and verify imports against the actual dependencies.

Infinite Loops

The agent fails the test, tries to fix it, fails again, and gets stuck in a cycle.

  • Solution: Circuit Breakers. Limit the number of retries (e.g., 3 attempts). If it fails 3 times, stop and raise a HumanInterventionRequired exception.

7. Security Considerations

Giving an AI agent write access to your codebase and execution privileges is terrifying.

  • Sandboxing: Always run the agent in an ephemeral Docker container. No agent should touch the developer's local environment directly.
  • Least Privilege: The agent should have read access to the codebase and write access to a specific branch or a temporary copy. It should not have push access to main. The final commit should be a PR generated by the pipeline, reviewed by a human, and merged manually (or via strict CI checks).
  • Prompt Injection: If the agent reads external data (like GitHub Issues or StackOverflow answers) and feeds it into the LLM, it is vulnerable to prompt injection. Sanitize all external inputs before they reach the LLM context.

8. The Future: Agentic SWE

We are currently in the early days of this. The "Agentic SWE" (Software Engineering) pipeline is the bridge to fully autonomous development. However, the bottleneck is not the LLM's intelligence; it is the LLM's reliability.

By wrapping the LLM in a TDD loop, we are essentially using the compiler and the test runner as our guardrails. The LLM is the "brain," but the test runner is the "safety net."

For now, do not try to build a general-purpose agent that can do everything. Build specialized agents that do one thing well, and coordinate them with a deterministic orchestrator. This is the path to software that is not just written by AI, but verified by it.

Frequently Asked Questions

How much context window do I need for these agents?

For the TDD Worker, a 16k-32k context window is usually sufficient to hold the requirement, the current file, and the error output. For the Critic, you need enough space to hold the entire diff plus the project's style guide. 32k-128k is recommended for complex reviews.

Should I use a different LLM for the Critic than the Coder?

Yes. Using a different model (or even the same model with a temperature of 0.0 and a highly specific "judge" persona) prevents the agent from "rubber-stamping" its own work. A second opinion is always more valuable than a confirmation bias.

How do I handle multi-file changes?

The orchestrator must decompose the task. The TDD agent should not be asked to rewrite the whole backend. It should be asked to "Update the User model to support soft deletes" and then "Update the UserService to respect soft deletes". This keeps the context small and the diffs reviewable.

For more insights on AI engineering and developer tooling, check out Tamiz's Insights.


Multi-Agent Coordination Patterns

While a single agent can handle focused tasks, real-world codebases demand specialization. The most effective autonomous pipelines employ multi-agent architectures where distinct agents own different concerns.

The Role-Based Agent Mesh

arduino
┌─────────────────────────────────────────────────────────┐
│                    Orchestrator Agent                     │
│  (Task decomposition, state management, conflict res.)   │
└──────────────┬──────────────────────────────────────────┘
               │
    ┌──────────┼──────────────┬──────────────────┐
    ▼          ▼              ▼                  ▼
┌────────┐ ┌────────┐  ┌───────────┐    ┌────────────┐
│Coder   │ │Tester  │  │Reviewer   │    │Doc Writer  │
│Agent   │ │Agent   │  │Agent      │    │Agent       │
└────────┘ └────────┘  └───────────┘    └────────────┘

Each agent receives a narrow system prompt with domain-specific context, not the full codebase. The orchestrator manages the DAG of dependencies:

python
# orchestrator.py
from dataclasses import dataclass, field
from typing import Literal
from enum import Enum

class AgentRole(Enum):
    Coder = "coder"
    Tester = "tester"
    Reviewer = "reviewer"
    DocWriter = "doc_writer"

@dataclass
class TaskNode:
    id: str
    role: AgentRole
    description: str
    dependencies: list[str] = field(default_factory=list)
    status: Literal["pending", "in_progress", "completed", "failed"] = "pending"
    output: str | None = None

class Orchestrator:
    def __init__(self, agents: dict[AgentRole, "BaseAgent"]):
        self.agents = agents
        self.task_graph: dict[str, TaskNode] = {}
        self.execution_order: list[str] = []

    def add_task(self, task: TaskNode) -> None:
        self.task_graph[task.id] = task

    def topological_sort(self) -> list[str]:
        """Resolve execution order respecting dependencies."""
        visited = set()
        temp_visited = set()
        order = []

        def visit(node_id: str):
            if node_id in temp_visited:
                raise ValueError(f"Circular dependency detected at {node_id}")
            if node_id in visited:
                return
            temp_visited.add(node_id)
            node = self.task_graph[node_id]
            for dep in node.dependencies:
                visit(dep)
            temp_visited.remove(node_id)
            visited.add(node_id)
            order.append(node_id)

        for node_id in self.task_graph:
            visit(node_id)
        return order

    async def execute(self) -> dict[str, str]:
        """Execute tasks in dependency order."""
        results = {}
        for task_id in self.topological_sort():
            node = self.task_graph[task_id]
            node.status = "in_progress"

            # Gather context from dependencies
            dep_context = {
                dep_id: self.task_graph[dep_id].output
                for dep_id in node.dependencies
                if self.task_graph[dep_id].output
            }

            agent = self.agents[node.role]
            try:
                output = await agent.execute(
                    task_description=node.description,
                    context=dep_context
                )
                node.output = output
                node.status = "completed"
                results[task_id] = output
            except Exception as e:
                node.status = "failed"
                results[task_id] = f"FAILED: {e}"
                # Propagate failure to dependent tasks
                self._mark_dependents_failed(task_id)

        return results

    def _mark_dependents_failed(self, failed_id: str) -> None:
        for node in self.task_graph.values():
            if failed_id in node.dependencies and node.status == "pending":
                node.status = "failed"
                node.output = f"SKIPPED: dependency {failed_id} failed"

Inter-Agent Communication Protocol

Agents don't share memory directly—they exchange structured artifacts. This prevents context pollution and makes the pipeline auditable:

python
# agent_protocol.py
from pydantic import BaseModel, Field
from typing import Any

class Artifact(BaseModel):
    """Standardized output format between agents."""
    task_id: str
    author_role: str
    summary: str = Field(..., description="One-sentence summary of what was done")
    files_modified: list[str] = Field(default_factory=list)
    tests_added: list[str] = Field(default_factory=list)
    evidence: dict[str, Any] = Field(default_factory=dict)
    confidence: float = Field(..., ge=0.0, le=1.0)
    follow_up_needed: bool = False
    follow_up_reason: str | None = None

class ReviewArtifact(Artifact):
    """Specialized artifact from the reviewer agent."""
    verdict: Literal["approved", "changes_requested", "blocked"]
    critical_issues: list[str] = Field(default_factory=list)
    suggestions: list[str] = Field(default_factory=list)
    security_concerns: list[str] = Field(default_factory=list)

The Tester agent receives the Coder's artifact and produces its own:

python
# tester_agent.py
class TesterAgent(BaseAgent):
    def __init__(self, model: str = "gpt-4", test_runner: TestRunner | None = None):
        super().__init__(model=model)
        self.test_runner = test_runner or LocalTestRunner()

    async def execute(self, task_description: str, context: dict[str, str]) -> Artifact:
        # Extract the code changes from the coder's output
        code_context = context.get("coder_output", "")

        # Generate tests if none exist
        test_plan = await self._generate_test_plan(task_description, code_context)

        # Write test files
        test_files = self._write_tests(test_plan)

        # Execute the test suite
        results = await self.test_runner.run(
            test_files=test_files,
            coverage_threshold=80.0
        )

        return Artifact(
            task_id=self.current_task_id,
            author_role="tester",
            summary=f"Generated {len(test_files)} test files, {results.passed} passed, {results.failed} failed",
            files_modified=[f.path for f in test_files],
            tests_added=[t.name for t in test_files],
            evidence={
                "coverage_percent": results.coverage,
                "pass_rate": results.pass_rate,
                "failed_tests": [f"{t.name}: {t.error}" for t in results.failures],
            },
            confidence=min(results.pass_rate / 100.0, 1.0),
            follow_up_needed=results.failed > 0,
            follow_up_reason=f"{results.failed} tests failing" if results.failed > 0 else None,
        )

    async def _generate_test_plan(self, description: str, code: str) -> TestPlan:
        prompt = f"""
Given this task description:
{description}

And this implementation:
{code}

Generate a comprehensive test plan covering:
1. Happy path scenarios
2. Edge cases (null, empty, boundary values)
3. Error handling paths
4. Concurrency/race conditions if applicable

Return as a structured test plan.
"""
        response = await self._call_model(prompt)
        return TestPlan.from_response(response)

Conflict Resolution in Concurrent Edits

When multiple agents modify the same file (e.g., Coder adds a method, DocWriter updates the docstring), a merge coordinator resolves conflicts:

python
# merge_coordinator.py
from dataclasses import dataclass
from pathlib import Path

@dataclass
class EditRegion:
    file_path: str
    start_line: int
    end_line: int
    agent_role: str
    content: str

class MergeCoordinator:
    """Detect and resolve overlapping edits from multiple agents."""

    def detect_conflicts(self, edits: list[EditRegion]) -> list[tuple[EditRegion, EditRegion]]:
        conflicts = []
        for i, edit_a in enumerate(edits):
            for j, edit_b in enumerate(edits):
                if i >= j:
                    continue
                if self._overlaps(edit_a, edit_b):
                    conflicts.append((edit_a, edit_b))
        return conflicts

    def _overlaps(self, a: EditRegion, b: EditRegion) -> bool:
        if a.file_path != b.file_path:
            return False
        return a.start_line <= b.end_line and b.start_line <= a.end_line

    def resolve(self, file_path: str, edits: list[EditRegion]) -> str:
        """
        Strategy: merge non-overlapping regions in order.
        For overlapping regions, use the agent with higher priority.
        """
        priority = {"coder": 3, "tester": 2, "doc_writer": 1, "reviewer": 0}
        sorted_edits = sorted(
            [e for e in edits if e.file_path == file_path],
            key=lambda e: (e.start_line, -priority.get(e.agent_role, 0))
        )

        original = Path(file_path).read_text().splitlines()
        result = list(original)

        # Apply edits from bottom to top to preserve line numbers
        for edit in reversed(sorted_edits):
            result[edit.start_line - 1 : edit.end_line] = edit.content.splitlines()

        return "\n".join(result)

Guardrails: Safety, Quality, and Bounded Autonomy

Autonomous agents are powerful but dangerous. Without guardrails, an agent might delete production code, introduce security vulnerabilities, or enter infinite loops. Guardrails operate at multiple levels:

Level 1: Pre-Execution Validation

Before any agent touches the codebase, validate the intent:

python
# guardrails/pre_execution.py
from pydantic import BaseModel, field_validator
from typing import Literal

class TaskRequest(BaseModel):
    description: str
    files_affected: list[str]
    estimated_complexity: Literal["trivial", "simple", "moderate", "complex", "architectural"]

    @field_validator("files_affected")
    @classmethod
    def validate_file_paths(cls, files: list[str]) -> list[str]:
        """Reject paths that touch sensitive areas."""
        forbidden_patterns = [
            "**/secrets/**",
            "**/.env*",
            "**/credentials/**",
            "**/migrations/production/**",
            "**/*.key",
            "**/*.pem",
        ]
        from pathlib import Path
        for f in files:
            for pattern in forbidden_patterns:
                if Path(f).match(pattern):
                    raise ValueError(f"Agent cannot modify sensitive file: {f}")
        return files

    @field_validator("estimated_complexity")
    @classmethod
    def validate_complexity(cls, complexity: str) -> str:
        """Architectural changes require human approval."""
        if complexity == "architectural":
            raise ValueError(
                "Architectural changes require human review before execution. "
                "Break this into smaller tasks or request approval."
            )
        return complexity

Level 2: Runtime Circuit Breakers

Monitor agent behavior during execution and halt on anomalies:

python
# guardrails/circuit_breaker.py
import time
from collections import deque
from dataclasses import dataclass

@dataclass
class CircuitState:
    failures: int = 0
    last_failure_time: float = 0
    is_open: bool = False
    opened_at: float = 0

class CircuitBreaker:
    """Halt agent execution when failure patterns are detected."""

    def __init__(
        self,
        failure_threshold: int = 3,
        recovery_timeout: float = 60.0,
        max_context_window: int = 50000,
        max_tool_calls_per_task: int = 20,
    ):
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.max_context_window = max_context_window
        self.max_tool_calls_per_task = max_tool_calls_per_task
        self.states: dict[str, CircuitState] = {}
        self.call_counts: dict[str, int] = {}
        self.history: deque[dict] = deque(maxlen=100)

    def record_tool_call(self, agent_id: str, tool_name: str, context_size: int) -> None:
        self.call_counts[agent_id] = self.call_counts.get(agent_id, 0) + 1
        self.history.append({
            "agent_id": agent_id,
            "tool": tool_name,
            "context_size": context_size,
            "timestamp": time.time(),
        })

    def check(self, agent_id: str) -> tuple[bool, str | None]:
        """Returns (can_proceed, reason_if_blocked)."""
        # Check tool call limit
        calls = self.call_counts.get(agent_id, 0)
        if calls >= self.max_tool_calls_per_task:
            return False, f"Agent {agent_id} exceeded {self.max_tool_calls_per_task} tool calls"

        # Check context window
        if self.history:
            recent = list(self.history)[-10:]
            avg_context = sum(e["context_size"] for e in recent) / len(recent)
            if avg_context > self.max_context_window:
                return False, f"Context window growing unbounded (avg: {avg_context})"

        # Check circuit breaker state
        state = self.states.get(agent_id)
        if state and state.is_open:
            if time.time() - state.opened_at > self.recovery_timeout:
                state.is_open = False
                state.failures = 0
                return True, None
            return False, f"Circuit breaker open for {agent_id}, retry after {self.recovery_timeout}s"

        return True, None

    def record_failure(self, agent_id: str) -> None:
        state = self.states.setdefault(agent_id, CircuitState())
        state.failures += 1
        state.last_failure_time = time.time()
        if state.failures >= self.failure_threshold:
            state.is_open = True
            state.opened_at = time.time()

    def record_success(self, agent_id: str) -> None:
        state = self.states.get(agent_id)
        if state:
            state.failures = max(0, state.failures - 1)

Level 3: Post-Execution Verification

After an agent completes a task, independently verify the result:

python
# guardrails/post_execution.py
import subprocess
from pathlib import Path
from dataclasses import dataclass

@dataclass
class VerificationResult:
    passed: bool
    checks_run: list[str]
    failures: list[str]
    metrics: dict[str, float]

class PostExecutionVerifier:
    """Independent verification of agent output."""

    def __init__(self, project_root: Path):
        self.project_root = project_root

    def verify(self, changed_files: list[str], tests_to_run: list[str] | None = None) -> VerificationResult:
        checks = []
        failures = []
        metrics = {}

        # 1. Type checking
        type_result = self._run_type_check()
        checks.append("type_check")
        if not type_result.passed:
            failures.append(f"Type errors: {type_result.output[:500]}")

        # 2. Linting
        lint_result = self._run_linter(changed_files)
        checks.append("lint")
        if not lint_result.passed:
            failures.append(f"Lint errors: {lint_result.output[:500]}")

        # 3. Unit tests
        test_result = self._run_tests(tests_to_run)
        checks.append("unit_tests")
        metrics["tests_passed"] = test_result.passed
        metrics["tests_failed"] = test_result.failed
        if test_result.failed > 0:
            failures.append(f"{test_result.failed} tests failing")

        # 4. Security scan
        security_result = self._run_security_scan(changed_files)
        checks.append("security_scan")
        metrics["security_issues"] = security_result.critical_count
        if security_result.critical_count > 0:
            failures.append(f"{security_result.critical_count} critical security issues")

        # 5. Diff size check (prevent massive refactors)
        diff_size = self._get_diff_size(changed_files)
        checks.append("diff_size")
        metrics["lines_changed"] = diff_size
        if diff_size > 500:
            failures.append(f"Diff too large ({diff_size} lines). Break into smaller tasks.")

        return VerificationResult(
            passed=len(failures) == 0,
            checks_run=checks,
            failures=failures,
            metrics=metrics,
        )

    def _run_type_check(self) -> subprocess.CompletedProcess:
        return subprocess.run(
            ["mypy", "--strict", "."],
            capture_output=True, text=True, cwd=self.project_root, timeout=120
        )

    def _run_linter(self, files: list[str]) -> subprocess.CompletedProcess:
        return subprocess.run(
            ["ruff", "check", *files],
            capture_output=True, text=True, cwd=self.project_root, timeout=60
        )

    def _run_tests(self, tests: list[str] | None) -> TestResult:
        cmd = ["pytest", "-q", "--tb=short"]
        if tests:
            cmd.extend(tests)
        result = subprocess.run(cmd, capture_output=True, text=True, cwd=self.project_root, timeout=300)
        return self._parse_test_output(result.stdout)

    def _run_security_scan(self, files: list[str]) -> SecurityResult:
        return subprocess.run(
            ["bandit", "-r", *files, "-f", "json"],
            capture_output=True, text=True, cwd=self.project_root, timeout=120
        )

    def _get_diff_size(self, files: list[str]) -> int:
        result = subprocess.run(
            ["git", "diff", "--stat", *files],
            capture_output=True, text=True, cwd=self.project_root
        )
        # Parse "X insertions(+), Y deletions(-)"
        import re
        match = re.search(r"(\d+) insertions?\(\+\), (\d+) deletions?\(-\)", result.stdout)
        if match:
            return int(match.group(1)) + int(match.group(2))
        return 0

Level 4: Human-in-the-Loop Gates

Not every change should proceed autonomously. Define escalation policies:

python
# guardrails/escalation.py
from enum import Enum

class RiskLevel(Enum):
    LOW = "low"          # Auto-approve
    MEDIUM = "medium"    # Auto-approve with notification
    HIGH = "high"        # Require human approval
    CRITICAL = "critical" # Block entirely

class EscalationPolicy:
    """Determine when to escalate to human review."""

    def assess(self, verification: VerificationResult, task_metadata: dict) -> RiskLevel:
        # Critical: security issues or test failures
        if verification.metrics.get("security_issues", 0) > 0:
            return RiskLevel.CRITICAL
        if verification.metrics.get("tests_failed", 0) > 0:
            return RiskLevel.HIGH

        # High: large diffs or changes to critical files
        if verification.metrics.get("lines_changed", 0) > 200:
            return RiskLevel.HIGH
        if any(f.startswith("auth/") or f.startswith("payments/") for f in task_metadata.get("files", [])):
            return RiskLevel.HIGH

        # Medium: moderate changes
        if verification.metrics.get("lines_changed", 0) > 50:
            return RiskLevel.MEDIUM

        return RiskLevel.LOW

TDD Evidence: Proving Correctness with Automated Tests

The strongest guarantee an AI agent can provide is evidence—concrete, reproducible proof that the code works. This section covers building a TDD-first pipeline where tests are generated, executed, and archived as evidence.

The TDD Agent Loop

scss
┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│  1. SPEC     │────▶│  2. TESTS    │────▶│  3. RED      │
│  (Intent)    │     │  (Generate)  │     │  (Verify     │
│              │     │              │     │   failure)   │
└──────────────┘     └──────────────┘     └──────┬───────┘
                                                  │
┌──────────────┐     ┌──────────────┐     ┌──────▼───────┐
│  6. EVIDENCE │◀────│  5. GREEN    │◀────│  4. CODE     │
│  (Archive)   │     │  (All pass)  │     │  (Implement) │
└──────────────┘     └──────────────┘     └──────────────┘

Implementing the TDD Pipeline

python
# tdd_pipeline.py
from dataclasses import dataclass
from pathlib import Path
import json
import hashlib
from datetime import datetime

@dataclass
class TDDEvidence:
    """Immutable record of TDD execution."""
    task_id: str
    timestamp: str
    spec_hash: str
    test_files: list[str]
    implementation_files: list[str]
    red_phase_output: str       # Tests failing before implementation
    green_phase_output: str     # Tests passing after implementation
    coverage: float
    test_count: int
    pass_count: int
    verification_hash: str      # SHA256 of all evidence combined

class TDDPipeline:
    def __init__(self, project_root: Path, evidence_dir: Path):
        self.project_root = project_root
        self.evidence_dir = evidence_dir
        self.evidence_dir.mkdir(parents=True, exist_ok=True)

    async def execute(self, spec: str, target_files: list[str]) -> TDDEvidence:
        # Phase 1: Generate tests from spec
        test_files = await self._generate_tests(spec)

        # Phase 2: Verify tests fail (RED)
        red_output = self._run_tests(test_files)
        assert not red_output.all_passed, "Tests should fail before implementation (RED phase)"

        # Phase 3: Generate implementation
        impl_files = await self._generate_implementation(spec, test_files)

        # Phase 4: Verify tests pass (GREEN)
        green_output = self._run_tests(test_files)
        assert green_output.all_passed, "Tests should pass after implementation (GREEN phase)"

        # Phase 5: Archive evidence
        evidence = TDDEvidence(
            task_id=hashlib.sha256(spec.encode()).hexdigest()[:12],
            timestamp=datetime.utcnow().isoformat(),
            spec_hash=hashlib.sha256(spec.encode()).hexdigest(),
            test_files=[str(f.relative_to(self.project_root)) for f in test_files],
            implementation_files=[str(f.relative_to(self.project_root)) for f in impl_files],
            red_phase_output=red_output.raw_output,
            green_phase_output=green_output.raw_output,
            coverage=green_output.coverage,
            test_count=green_output.total_tests,
            pass_count=green_output.passed_tests,
            verification_hash="",  # Computed below
        )

        # Compute verification hash
        evidence_payload = json.dumps({
            "spec_hash": evidence.spec_hash,
            "test_files": evidence.test_files,
            "red_output": evidence.red_phase_output,
            "green_output": evidence.green_phase_output,
            "coverage": evidence.coverage,
        }, sort_keys=True)
        evidence.verification_hash = hashlib.sha256(evidence_payload.encode()).hexdigest()

        # Persist evidence
        self._archive_evidence(evidence)

        return evidence

    async def _generate_tests(self, spec: str) -> list[Path]:
        """Generate test files from specification."""
        # Implementation would call LLM with test-generation prompt
        # Returns list of created test file paths
        ...

    async def _generate_implementation(self, spec: str, test_files: list[Path]) -> list[Path]:
        """Generate implementation that satisfies tests."""
        # Implementation would call LLM with the spec and test context
        # Returns list of created/modified implementation file paths
        ...

    def _run_tests(self, test_files: list[Path]) -> TestRunResult:
        """Execute tests and capture full output."""
        import subprocess
        test_paths = [str(f.relative_to(self.project_root)) for f in test_files]
        result = subprocess.run(
            ["pytest", *test_paths, "--tb=long", "--cov", "--cov-report=term-missing"],
            capture_output=True, text=True, cwd=self.project_root, timeout=300
        )
        return self._parse_result(result)

    def _archive_evidence(self, evidence: TDDEvidence) -> None:
        """Persist evidence as a JSON file for audit trail."""
        evidence_path = self.evidence_dir / f"{evidence.task_id}.json"
        evidence_path.write_text(json.dumps(
            {k: v for k, v in evidence.__dict__.items()},
            indent=2, default=str
        ))

Evidence Verification (Reproducibility)

Anyone can independently verify that the agent's work was correct:

python
# evidence_verifier.py
import json
import hashlib
from pathlib import Path

class EvidenceVerifier:
    """Independently verify TDD evidence integrity."""

    def verify(self, evidence_path: Path) -> VerificationReport:
        evidence = TDDEvidence.from_json(evidence_path.read_text())

        # 1. Verify hash integrity
        payload = json.dumps({
            "spec_hash": evidence.spec_hash,
            "test_files": evidence.test_files,
            "red_output": evidence.red_phase_output,
            "green_output": evidence.green_phase_output,
            "coverage": evidence.coverage,
        }, sort_keys=True)
        computed_hash = hashlib.sha256(payload.encode()).hexdigest()

        if computed_hash != evidence.verification_hash:
            return VerificationReport(
                valid=False,
                reason="Evidence hash mismatch - possible tampering"
            )

        # 2. Verify test files exist
        for test_file in evidence.test_files:
            if not Path(test_file).exists():
                return VerificationReport(
                    valid=False,
                    reason=f"Test file missing: {test_file}"
                )

        # 3. Verify test count matches
        test_content = "\n".join(Path(f).read_text() for f in evidence.test_files)
        actual_test_count = test_content.count("def test_")
        if actual_test_count != evidence.test_count:
            return VerificationReport(
                valid=False,
                reason=f"Test count mismatch: expected {evidence.test_count}, found {actual_test_count}"
            )

        return VerificationReport(
            valid=True,
            reason="All evidence checks passed",
            metrics={
                "coverage": evidence.coverage,
                "tests": evidence.test_count,
                "passed": evidence.pass_count,
            }
        )

Evidence-Backed Confidence Scoring

Combine multiple evidence signals into a single confidence score:

python
# confidence_scorer.py
from dataclasses import dataclass

@dataclass
class ConfidenceScore:
    overall: float          # 0.0 to 1.0
    components: dict[str, float]
    deductions: list[str]

class ConfidenceScorer:
    """Calculate evidence-backed confidence in agent output."""

    def score(self, evidence: TDDEvidence, verification: VerificationResult) -> ConfidenceScore:
        components = {}
        deductions = []

        # Test pass rate (weight: 0.35)
        if evidence.test_count > 0:
            pass_rate = evidence.pass_count / evidence.test_count
            components["test_pass_rate"] = pass_rate
        else:
            components["test_pass_rate"] = 0.0
            deductions.append("No tests executed")

        # Coverage (weight: 0.20)
        coverage_score = min(evidence.coverage / 80.0, 1.0)  # 80% = perfect score
        components["coverage"] = coverage_score
        if evidence.coverage < 60:
            deductions.append(f"Low coverage: {evidence.coverage:.1f}%")

        # Verification checks (weight: 0.25)
        checks_passed = len(verification.checks_run) - len(verification.failures)
        checks_total = len(verification.checks_run)
        verification_score = checks_passed / checks_total if checks_total > 0 else 0.0
        components["verification"] = verification_score

        # Diff size appropriateness (weight: 0.10)
        lines = verification.metrics.get("lines_changed", 0)
        if lines <= 50:
            diff_score = 1.0
        elif lines <= 200:
            diff_score = 0.7
        elif lines <= 500:
            diff_score = 0.4
        else:
            diff_score = 0.1
            deductions.append(f"Large diff: {lines} lines")
        components["diff_size"] = diff_score

        # TDD compliance (weight: 0.10)
        # Did the RED phase actually fail?
        tdd_score = 1.0 if "FAILED" in evidence.red_phase_output else 0.0
        if tdd_score == 0.0:
            deductions.append("RED phase did not show failures - TDD compliance questionable")
        components["tdd_compliance"] = tdd_score

        # Weighted sum
        weights = {
            "test_pass_rate": 0.35,
            "coverage": 0.20,
            "verification": 0.25,
            "diff_size": 0.10,
            "tdd_compliance": 0.10,
        }
        overall = sum(components[k] * weights[k] for k in weights)

        return ConfidenceScore(
            overall=round(overall, 3),
            components=components,
            deductions=deductions,
        )

Putting It All Together: The Complete Pipeline

Here's how all the components integrate into a production-ready autonomous coding pipeline:

python
# pipeline.py
from pathlib import Path
from dataclasses import dataclass
from enum import Enum
import asyncio

class PipelinePhase(Enum):
    SPEC_VALIDATION = "spec_validation"
    TASK_DECOMPOSITION = "task_decomposition"
    TDD_GENERATION = "tdd_generation"
    IMPLEMENTATION = "implementation"
    VERIFICATION = "verification"
    REVIEW = "review"
    EVIDENCE_ARCHIVE = "evidence_archive"
    ESCALATION_CHECK = "escalation_check"

@dataclass
class PipelineResult:
    success: bool
    phase_reached: PipelinePhase
    evidence: TDDEvidence | None
    confidence: ConfidenceScore | None
    risk_level: RiskLevel
    requires_human_review: bool
    summary: str

class AutonomousCodingPipeline:
    def __init__(
        self,
        project_root: Path,
        model_provider: ModelProvider,
        human_approver: HumanApprover | None = None,
    ):
        self.project_root = project_root
        self.model_provider = model_provider
        self.human_approver = human_approver

        # Initialize components
        self.orchestrator = Orchestrator(agents={
            AgentRole.Coder: CoderAgent(model_provider),
            AgentRole.Tester: TesterAgent(model_provider),
            AgentRole.Reviewer: ReviewerAgent(model_provider),
            AgentRole.DocWriter: DocWriterAgent(model_provider),
        })
        self.tdd_pipeline = TDDPipeline(project_root, project_root / ".evidence")
        self.circuit_breaker = CircuitBreaker()
        self.verifier = PostExecutionVerifier(project_root)
        self.escalation = EscalationPolicy()
        self.confidence_scorer = ConfidenceScorer()
        self.merge_coordinator = MergeCoordinator()

    async def run(self, spec: str) -> PipelineResult:
        """Execute the full autonomous coding pipeline."""
        phases_completed = []

        try:
            # Phase 1: Validate the specification
            task_request = self._validate_spec(spec)
            phases_completed.append(PipelinePhase.SPEC_VALIDATION)

            # Phase 2: Decompose into subtasks
            tasks = await self._decompose(spec, task_request)
            phases_completed.append(PipelinePhase.TASK_DECOMPOSITION)

            # Phase 3-4: Execute TDD loop for each task
            all_evidence = []
            for task in tasks:
                can_proceed, reason = self.circuit_breaker.check(task.id)
                if not can_proceed:
                    return PipelineResult(
                        success=False,
                        phase_reached=PipelinePhase.IMPLEMENTATION,
                        evidence=None,
                        confidence=None,
                        risk_level=RiskLevel.CRITICAL,
                        requires_human_review=True,
                        summary=f"Circuit breaker triggered: {reason}",
                    )

                evidence = await self.tdd_pipeline.execute(task.description, task.files)
                all_evidence.append(evidence)
                phases_completed.append(PipelinePhase.TDD_GENERATION)

            # Phase 5: Post-execution verification
            changed_files = [f for e in all_evidence for f in e.implementation_files]
            verification = self.verifier.verify(changed_files)
            phases_completed.append(PipelinePhase.VERIFICATION)

            # Phase 6: Code review
            review = await self._run_review(all_evidence, verification)
            phases_completed.append(PipelinePhase.REVIEW)

            # Phase 7: Archive evidence
            combined_evidence = self._combine_evidence(all_evidence)
            phases_completed.append(PipelinePhase.EVIDENCE_ARCHIVE)

            # Phase 8: Escalation check
            risk_level = self.escalation.assess(verification, {"files": changed_files})
            confidence = self.confidence_scorer.score(combined_evidence, verification)
            phases_completed.append(PipelinePhase.ESCALATION_CHECK)

            requires_human = risk_level in (RiskLevel.HIGH, RiskLevel.CRITICAL)
            if requires_human and self.human_approver:
                approved = await self.human_approver.request_approval(
                    summary=confidence,
                    evidence=combined_evidence,
                    risk_level=risk_level,
                )
                if not approved: