Part II: Discovery Through Software Engineering and Vibe Coding
Chapter 19: AI-Assisted Debugging

19.2 Self-Debugging Agents

"I do not need you to tell me what is wrong. Give me the traceback, the logs, and thirty seconds. I will tell you what is wrong, why it is wrong, and how to fix it. Then I will fix it. Then I will write a test to make sure it stays fixed."

An LLM Agent Who Skipped the Rubber Duck Stage

Prerequisites

This section builds on the fault localization and structured logging infrastructure from Section 19.1. You should also be familiar with the LLM tool-use patterns from Chapter 10: Prompting to Programming and the multi-agent orchestration patterns from Chapter 17: Multi-Agent Software Teams. The self-debugging agent we build here uses the same tool-calling architecture introduced in those chapters, applied to the specific domain of fault diagnosis and repair.

The Big Picture

A self-debugging agent is a large language model (LLM) that operates in a closed loop: observe failure, hypothesize root cause, generate a fix, run the test suite, and iterate until the tests pass or a budget is exhausted. This is the same observe-hypothesize-experiment-conclude cycle that defines scientific inquiry (see Chapter 2), now applied to code repair. The key design challenge is giving the agent the right context: not the entire codebase, but precisely the structured traces, suspicious lines, and test outputs that a human debugger would examine.

1. The Hypothesis-Rank-Verify Loop

When a test suite turns red at 2 a.m., no experienced developer reads the codebase from line one. She scans the traceback, forms a mental shortlist of suspects, and tests her top theory before touching anything else. That instinct (hypothesis first, evidence second) is exactly the protocol an effective self-debugging agent follows. The agent replaces intuition with structured evidence from the fault localizer and replaces experience with the LLM's training distribution.

In production systems, a single undetected regression can cascade into hours of downtime or corrupt data that takes days to recover. Automating the first pass of diagnosis turns that exposure from hours into minutes.

A self-debugging agent is an LLM-powered program that autonomously diagnoses and repairs software faults. It executes a structured loop: observe, hypothesize, patch, verify. Manual debugging typically consumes 30-50% of total engineering effort according to industry surveys, so an agent that handles routine faults frees developers to focus on design and architecture. The agent receives a curated context window: failing test output, ranked suspicious lines, and structured logs. It then generates candidate fixes, each tested against the real test suite in a sandboxed environment. This approach works best for well-tested codebases where failures produce clear, reproducible test output. For exploratory code with no test suite, interactive debugging or logging-based diagnosis remains more appropriate. In short: If your test suite can catch a bug, a self-debugging agent can close the loop from failure to fix without a human in the chair.

The Four Stages

The loop has four stages, illustrated in Figure 19.2: Figure 19.2.1 illustrates the hypothesis-rank-verify debugging loop.

hypothesis-rank-verify debugging loop
Figure 19.2.1: The hypothesis-rank-verify debugging loop, showing how the agent cycles through observation, hypothesis generation, statistical-semantic ranking, and patch verification until a fix passes all tests or the attempt budget is exhausted.
  1. Observe: Collect the failing test output, the traceback, the structured logs around the failure point, and the ranked list of suspicious lines produced by the Ochiai coefficient (a statistical measure that scores how strongly each source line correlates with failing tests) from Section 19.1.
  2. Hypothesize: Ask the LLM to generate \(k\) candidate root-cause explanations, each tied to a specific suspicious line or code region.
  3. Rank: Score each hypothesis by combining the Ochiai suspiciousness of the implicated lines with the LLM's own confidence estimate and the semantic plausibility of the explanation.
  4. Verify: For the top-ranked hypothesis, generate a targeted patch, apply it, re-run the failing test (plus the full suite to check for regressions), and observe the outcome. If the test passes, the bug is fixed. If it fails, update the ranking and try the next hypothesis.
Observe Traces, logs, Ochiai Hypothesize k root causes Rank Ochiai + confidence Verify Patch + test suite Tests pass Tests fail: retry with new evidence
Figure 19.2: The hypothesis-rank-verify loop. The agent observes failure evidence, generates ranked hypotheses, and patches the top candidate. If tests fail, the loop feeds new error output back to the Observe stage for the next attempt.
Key Insight: Context Engineering for Debugging

The difference between a debugging agent that works and one that hallucinates irrelevant fixes is the quality of its context window. Dumping the entire codebase into the prompt overwhelms the model. Instead, supply (1) the failing test and its exact output, (2) the top 10 suspicious lines with their Ochiai scores, (3) the 20 lines of source context around each suspicious line, and (4) the structured log events from the failing execution. This is the same context engineering principle from Chapter 11: Context Engineering at Repository Scale, applied to the debugging domain.

"""
Self-debugging agent: the hypothesis-rank-verify loop.
Reads fault localization output and structured logs, generates
root-cause hypotheses, and iteratively patches until tests pass.
"""
from dataclasses import dataclass, field
from anthropic import Anthropic


@dataclass
class DebugHypothesis:
    """A candidate root-cause explanation."""
    line_number: int
    source_text: str
    ochiai_score: float
    explanation: str
    confidence: float  # LLM's self-assessed confidence [0, 1]
    combined_score: float = 0.0

    def compute_combined_score(self, alpha: float = 0.6) -> None:
        """Blend Ochiai suspiciousness with LLM confidence.

        alpha controls the weight of the statistical signal vs.
        the LLM's semantic judgment. Higher alpha trusts the
        coverage data more; lower alpha trusts the LLM more.
        """
        self.combined_score = (
            alpha * self.ochiai_score
            + (1 - alpha) * self.confidence
        )


@dataclass
class DebugContext:
    """All evidence available to the debugging agent."""
    failing_test: str          # Test function source code
    test_output: str           # Captured stdout/stderr + traceback
    suspicious_lines: list[dict]  # From fault localizer (line, text, score)
    log_events: list[dict]     # Structured log entries from the failing run
    source_context: dict[int, list[str]]  # line_no -> surrounding lines


def format_debug_prompt(ctx: DebugContext) -> str:
    """Build the debugging prompt from structured evidence."""
    lines_section = "\n".join(
        f"  Line {l['line']}: (Ochiai={l['score']:.3f}) {l['text']}"
        for l in ctx.suspicious_lines[:10]
    )

    logs_section = "\n".join(
        f"  [{ev.get('level', 'info')}] {ev.get('event', '')} "
        f"| {', '.join(f'{k}={v}' for k, v in ev.items() if k not in ('level', 'event', 'timestamp'))}"
        for ev in ctx.log_events[-20:]  # Last 20 log events
    )

    context_section = ""
    for line_no, lines in ctx.source_context.items():
        context_section += f"\n  --- Around line {line_no} ---\n"
        for i, line in enumerate(lines):
            marker = " >> " if i == len(lines) // 2 else "    "
            context_section += f"  {marker}{line}\n"

    return f"""You are a debugging agent. A test is failing and you must identify the root cause.

## Failing Test
```python
{ctx.failing_test}
```

## Test Output (including traceback)
```
{ctx.test_output}
```

## Suspicious Lines (ranked by Ochiai coefficient)
{lines_section}

## Structured Log Events (from the failing execution)
{logs_section}

## Source Context (around suspicious lines)
{context_section}

## Your Task
Generate exactly 3 root-cause hypotheses. For each hypothesis:
1. Identify the specific line(s) at fault.
2. Explain WHY this line causes the observed failure.
3. Propose a concrete fix (the exact code change).
4. Rate your confidence from 0.0 to 1.0.

Format each hypothesis as:
HYPOTHESIS N:
LINE: 
EXPLANATION: 
FIX: 
CONFIDENCE: <0.0 to 1.0>
"""
Building a structured debugging prompt from Ochiai-ranked suspicious lines, structured log events, and surrounding source context, constraining the LLM to produce actionable, formatted hypotheses.

2. Parsing and Ranking Hypotheses

The LLM returns hypotheses as structured text. A parser extracts each one, computes a combined score blending Ochiai suspiciousness with the LLM's confidence, and sorts by decreasing score. The parameter \(\alpha\) controls this tradeoff: higher values favor the coverage data, lower values favor the LLM's reasoning. At \(\alpha = 0.6\), the statistical signal takes slight precedence, but the LLM can still promote a low-Ochiai line when semantic evidence is compelling.

$$\text{score}(h) = \alpha \cdot \text{Ochiai}(h.\text{line}) + (1 - \alpha) \cdot \text{confidence}(h)$$
"""
Parse LLM-generated hypotheses and rank by combined score.
"""
import re


def parse_hypotheses(
    llm_response: str,
    suspicious_lines: list[dict],
) -> list[DebugHypothesis]:
    """Extract structured hypotheses from LLM output."""
    hypotheses = []

    # Build a lookup from line number to Ochiai score
    ochiai_lookup = {l["line"]: l["score"] for l in suspicious_lines}

    # Parse each HYPOTHESIS block
    blocks = re.split(r"HYPOTHESIS\s+\d+:", llm_response)[1:]

    for block in blocks:
        line_match = re.search(r"LINE:\s*(\d+)", block)
        expl_match = re.search(r"EXPLANATION:\s*(.+?)(?=FIX:|$)", block, re.S)
        fix_match = re.search(r"FIX:\s*(.+?)(?=CONFIDENCE:|$)", block, re.S)
        conf_match = re.search(r"CONFIDENCE:\s*([\d.]+)", block)

        if not all([line_match, expl_match, fix_match, conf_match]):
            continue

        line_no = int(line_match.group(1))
        hypothesis = DebugHypothesis(
            line_number=line_no,
            source_text=ochiai_lookup.get(line_no, {}).get("text", ""),
            ochiai_score=ochiai_lookup.get(line_no, 0.0),
            explanation=expl_match.group(1).strip(),
            confidence=min(1.0, max(0.0, float(conf_match.group(1)))),
        )
        hypothesis.compute_combined_score(alpha=0.6)
        hypotheses.append(hypothesis)

    # Sort by combined score, descending
    hypotheses.sort(key=lambda h: h.combined_score, reverse=True)
    return hypotheses
Parsing structured HYPOTHESIS blocks from LLM output via regex, computing blended Ochiai-plus-confidence scores, and returning hypotheses sorted by decreasing combined rank.

Mental Model

Think of the combined scoring formula like a doctor triaging patients in an emergency room. The Ochiai score is the vital-signs monitor: objective, numerical, based on measurable data (which lines correlate with failure). The LLM confidence is the experienced nurse's gut feeling after talking to the patient: subjective but informed by years of pattern recognition. Neither signal alone is reliable. The monitor might flag elevated heart rate without knowing it is from caffeine, not a cardiac event. The nurse might miss a subtle lab anomaly. The triage protocol blends both, weighting the objective signal slightly higher (\(\alpha = 0.6\)) because it is reproducible, but allowing clinical judgment to override when the numbers do not tell the whole story.

3. Automated Patch Generation and Verification

Once hypotheses are ranked, the agent applies the top-ranked fix, runs the test suite, and observes the result. If the fix resolves the failing test without introducing regressions, the debugging session succeeds. If the fix fails (the test still fails, or new tests break), the agent moves to the next hypothesis. This verify-or-retry loop runs until either a fix succeeds or the hypothesis budget is exhausted.

Common Misconception

A common misconception is that a self-debugging agent can reliably fix any bug given enough iterations. In reality, these agents excel at well-localized faults with clear test signals (off-by-one errors, incorrect conditionals, wrong variable references) but struggle with bugs that span multiple files, require architectural changes, or stem from incorrect specifications rather than incorrect implementations. If the test itself encodes the wrong expectation, the agent will "fix" the code to match a broken test, compounding the problem rather than solving it.

"""
Patch-and-verify loop: apply each hypothesis's fix and test it.
"""
import subprocess
import shutil
from pathlib import Path


@dataclass
class PatchResult:
    """Outcome of applying and testing a patch."""
    hypothesis: DebugHypothesis
    patch_applied: bool
    target_test_passed: bool
    regression_tests_passed: bool
    error_output: str = ""


def apply_and_verify(
    source_file: Path,
    hypotheses: list[DebugHypothesis],
    test_command: list[str],
    target_test: str,
    max_attempts: int = 3,
) -> PatchResult | None:
    """Try each hypothesis's fix until one passes all tests.

    Args:
        source_file: Path to the file being debugged.
        hypotheses: Ranked list of hypotheses with proposed fixes.
        test_command: Command to run the full test suite.
        target_test: Specific test ID that is failing.
        max_attempts: Maximum number of hypotheses to try.

    Returns:
        PatchResult for the successful fix, or None if all fail.
    """
    # Save the original file for rollback
    backup = source_file.with_suffix(".bak")
    shutil.copy2(source_file, backup)

    for hypothesis in hypotheses[:max_attempts]:
        # Restore original before each attempt
        shutil.copy2(backup, source_file)

        # Apply the patch
        original_lines = source_file.read_text().splitlines()
        line_idx = hypothesis.line_number - 1  # 0-indexed

        if line_idx < 0 or line_idx >= len(original_lines):
            continue

        # Replace the faulty line with the fix
        patched_lines = original_lines.copy()
        patched_lines[line_idx] = hypothesis.explanation  # Simplified
        source_file.write_text("\n".join(patched_lines) + "\n")

        # Run the specific failing test first (fast feedback)
        target_result = subprocess.run(
            ["python", "-m", "pytest", target_test, "-x", "-q"],
            capture_output=True, text=True, timeout=60,
        )
        target_passed = target_result.returncode == 0

        if not target_passed:
            continue  # This fix did not resolve the failure

        # Run full suite to check for regressions
        full_result = subprocess.run(
            test_command,
            capture_output=True, text=True, timeout=300,
        )
        regression_passed = full_result.returncode == 0

        result = PatchResult(
            hypothesis=hypothesis,
            patch_applied=True,
            target_test_passed=target_passed,
            regression_tests_passed=regression_passed,
            error_output=full_result.stderr if not regression_passed else "",
        )

        if target_passed and regression_passed:
            backup.unlink()  # Clean up backup
            return result

    # All attempts failed; restore original
    shutil.copy2(backup, source_file)
    backup.unlink()
    return None
Iterating through ranked hypotheses with file backup and rollback: each candidate patch is applied, tested against the failing test for fast feedback, then validated against the full suite for regression safety.
Practical Example: The Conversational Repair Loop

Xia and Zhang (2023) showed that a simple conversational loop (send the LLM a failing test, receive a fix, report whether it worked, repeat) fixes 162 out of 337 bugs at an average cost of \$0.42 per bug. The key finding is that multi-turn feedback dramatically outperforms single-shot repair. When the first fix fails, the agent receives the new error message, which eliminates incorrect hypotheses and provides additional evidence for the correct one. Our hypothesis-rank-verify loop formalizes this insight: each failed attempt is not wasted; it narrows the search space.

4. Programmatic Debugging: Inspecting Runtime State

The patch-and-verify loop confirms whether a fix is correct, but when every generated patch keeps failing, the agent needs to look deeper into what the program is actually doing at runtime. Sometimes the agent needs more than static traces and coverage data. It needs to inspect runtime state: the values of variables at a specific point in execution, the contents of a data structure just before a crash, the return value of a function that behaves unexpectedly. Python offers several mechanisms for programmatic inspection. The built-in sys.settrace function lets you install a callback that fires on every line execution, providing access to local and global variables without requiring any external library. For richer interactive sessions, the debugpy library implements the Debug Adapter Protocol (DAP), a standard interface that lets editors and tools control debuggers programmatically, supporting breakpoints, stepping, and watch expressions. Both approaches let the agent inspect variables from Python code rather than a graphical user interface (GUI). The example below uses sys.settrace for lightweight, dependency-free inspection (a trace function is a Python callable that the interpreter invokes before executing each line, giving it access to the current stack frame and local variables).

"""
Programmatic debugging with debugpy: inspect runtime state from code.
The agent sets breakpoints and reads variable values without a GUI.
"""
import debugpy
import threading
import subprocess
import json


def inspect_at_breakpoint(
    script_path: str,
    breakpoint_line: int,
    variables_to_inspect: list[str],
    script_args: list[str] | None = None,
) -> dict:
    """Run a script with a breakpoint, inspecting variables when hit.

    This launches the script under debugpy, sets a breakpoint, and
    captures variable values when execution reaches that line.

    Args:
        script_path: Path to the Python script to debug.
        breakpoint_line: Line number where to pause execution.
        variables_to_inspect: Names of variables to capture.
        script_args: Optional command-line arguments for the script.

    Returns:
        Dict mapping variable names to their string representations.
    """
    captured = {}
    breakpoint_hit = threading.Event()

    def on_breakpoint(frame, event, arg):
        """Trace function that captures variables at the breakpoint."""
        if event == "line" and frame.f_lineno == breakpoint_line:
            for var_name in variables_to_inspect:
                if var_name in frame.f_locals:
                    captured[var_name] = repr(frame.f_locals[var_name])
                elif var_name in frame.f_globals:
                    captured[var_name] = repr(frame.f_globals[var_name])
                else:
                    captured[var_name] = ""
            breakpoint_hit.set()
        return on_breakpoint

    # Use sys.settrace for lightweight inspection
    import sys
    import runpy

    original_trace = sys.gettrace()
    sys.settrace(on_breakpoint)
    try:
        # Run the target script
        runpy.run_path(script_path, run_name="__main__")
    except Exception as exc:
        captured["__exception__"] = repr(exc)
    finally:
        sys.settrace(original_trace)

    return captured


# Example usage: inspect variables at a suspicious line
results = inspect_at_breakpoint(
    script_path="app/order_processor.py",
    breakpoint_line=42,
    variables_to_inspect=["total", "discount", "item_count"],
)
# results might be:
# {"total": "135.0", "discount": "15.0", "item_count": "12"}
Using Python's sys.settrace to set a virtual breakpoint and capture variable values at a suspicious line, giving the debugging agent runtime state without requiring an interactive GUI debugger.

5. Multi-Agent Debugging Teams

Programmatic inspection gives a single agent sharper eyes, but some bugs are too tangled for one agent to localize, explain, patch, and verify alone. The single-agent loop from sections 1 through 4 packs localization, explanation, patching, and verification into one prompt, which can exceed the model's effective attention span on complex faults. Complex bugs benefit from specialized perspectives. Drawing on the multi-agent patterns from Chapter 17: Multi-Agent Software Teams, debugging decomposes into four specialized roles, each handled by a separate agent with its own system prompt and tool access.

Real-World Application: GitHub Copilot Workspace
Real-World Application: GitHub Copilot Workspace

Checkpoint

So far: a self-debugging agent observes failure evidence, generates ranked hypotheses by blending statistical suspiciousness with LLM confidence, and iteratively patches and tests until a fix passes; splitting these responsibilities across four specialized agents (Localizer, Explainer, Patcher, Verifier) lets each focus on one subtask with a tailored prompt and tool set.

"""
Multi-agent debugging team: four specialized agents coordinate
to localize, explain, patch, and verify a bug fix.
"""
from dataclasses import dataclass
from anthropic import Anthropic

client = Anthropic()


@dataclass
class AgentRole:
    """Configuration for a specialized debugging agent."""
    name: str
    system_prompt: str
    model: str = "claude-sonnet-4-20250514"


DEBUGGING_TEAM = {
    "localizer": AgentRole(
        name="Localizer",
        system_prompt="""You are a fault localization specialist. Given test
results and coverage data, you identify the most suspicious source lines.
You produce ranked lists with Ochiai scores and brief justifications.
You never propose fixes; that is the Patcher's job.""",
    ),
    "explainer": AgentRole(
        name="Explainer",
        system_prompt="""You are a root-cause analyst. Given suspicious lines,
structured logs, and source context, you explain WHY the bug occurs.
You trace data flow, identify incorrect assumptions, and produce clear
causal chains from the root cause to the observed symptom.
You never write code; that is the Patcher's job.""",
    ),
    "patcher": AgentRole(
        name="Patcher",
        system_prompt="""You are a code repair specialist. Given a root-cause
explanation and the surrounding source code, you generate a minimal,
correct patch. You follow the project's coding style. You change as
few lines as possible. You add a comment explaining the fix.""",
    ),
    "verifier": AgentRole(
        name="Verifier",
        system_prompt="""You are a verification specialist. You apply patches,
run test suites, and analyze results. You check for both fix correctness
(does the failing test pass?) and regression safety (do all other tests
still pass?). You report pass/fail with detailed evidence.""",
    ),
}


def run_debugging_team(
    failing_test: str,
    test_output: str,
    source_file: str,
    source_code: str,
    coverage_data: dict,
    log_events: list[dict],
    max_rounds: int = 3,
) -> dict:
    """Orchestrate the multi-agent debugging team.

    The agents communicate through a shared context dictionary,
    each adding their contribution in sequence.
    """
    context = {
        "failing_test": failing_test,
        "test_output": test_output,
        "source_file": source_file,
        "source_code": source_code,
        "coverage_data": coverage_data,
        "log_events": log_events,
        "round": 0,
        "status": "in_progress",
    }

    for round_num in range(max_rounds):
        context["round"] = round_num + 1

        # Step 1: Localizer identifies suspicious lines
        loc_response = client.messages.create(
            model=DEBUGGING_TEAM["localizer"].model,
            max_tokens=2000,
            system=DEBUGGING_TEAM["localizer"].system_prompt,
            messages=[{
                "role": "user",
                "content": (
                    f"Test output:\n{context['test_output']}\n\n"
                    f"Coverage data:\n{context['coverage_data']}\n\n"
                    f"Source code:\n{context['source_code']}"
                ),
            }],
        )
        context["localization"] = loc_response.content[0].text

        # Step 2: Explainer forms root-cause hypothesis
        exp_response = client.messages.create(
            model=DEBUGGING_TEAM["explainer"].model,
            max_tokens=2000,
            system=DEBUGGING_TEAM["explainer"].system_prompt,
            messages=[{
                "role": "user",
                "content": (
                    f"Suspicious lines:\n{context['localization']}\n\n"
                    f"Log events:\n{context['log_events']}\n\n"
                    f"Source:\n{context['source_code']}"
                ),
            }],
        )
        context["explanation"] = exp_response.content[0].text

        # Step 3: Patcher generates a fix
        patch_response = client.messages.create(
            model=DEBUGGING_TEAM["patcher"].model,
            max_tokens=2000,
            system=DEBUGGING_TEAM["patcher"].system_prompt,
            messages=[{
                "role": "user",
                "content": (
                    f"Root cause:\n{context['explanation']}\n\n"
                    f"Source:\n{context['source_code']}"
                ),
            }],
        )
        context["patch"] = patch_response.content[0].text

        # Step 4: Verifier tests the fix
        # (In production, this agent would apply the patch and run pytest)
        ver_response = client.messages.create(
            model=DEBUGGING_TEAM["verifier"].model,
            max_tokens=1000,
            system=DEBUGGING_TEAM["verifier"].system_prompt,
            messages=[{
                "role": "user",
                "content": (
                    f"Proposed patch:\n{context['patch']}\n\n"
                    f"Failing test:\n{context['failing_test']}\n\n"
                    "Analyze whether this patch correctly addresses "
                    "the root cause and is unlikely to cause regressions."
                ),
            }],
        )
        context["verification"] = ver_response.content[0].text

        # Check if verifier approved
        if "APPROVED" in context["verification"].upper():
            context["status"] = "fixed"
            break

        # Feed failure info back for next round
        context["test_output"] += (
            f"\n\n--- Round {round_num + 1} failed ---\n"
            f"Patch attempted:\n{context['patch']}\n"
            f"Verification:\n{context['verification']}"
        )

    return context
Orchestrating a four-agent debugging team (Localizer, Explainer, Patcher, Verifier) through sequential rounds, with failed-round context appended to the next iteration's input.
Library Shortcut: SWE-agent and Aider

The multi-agent debugging loop we built from scratch above is available as a production tool. SWE-agent (Princeton NLP, 2024) wraps an LLM with file browsing, code search, and test-running tools in a single agent that resolves GitHub issues end-to-end. Aider provides a conversational coding assistant with built-in test-run-and-fix loops. Both tools implement the same core pattern (observe failure, hypothesize, patch, verify) with production-grade error handling, git integration, and cost management. SWE-agent resolves 12-23% of real GitHub issues on SWE-bench, a benchmark of 2,294 real GitHub issues drawn from popular Python repositories, used to evaluate automated program repair agents (circa 2024; its successor SWE-agent 2.0 and competing frameworks such as Agentless and OpenHands have since pushed these numbers substantially higher on the curated SWE-bench Verified subset); our from-scratch version above is roughly 150 lines where SWE-agent is 5,000+.

6. Root-Cause Extraction from Structured Logs

Whether the debugging work is split across a team of agents or handled by one, every agent in the pipeline depends on the quality of the evidence it receives, and the richest vein of evidence is often the execution log itself. The structured logs from Section 19.1 become particularly valuable when the agent can query them semantically. Instead of scanning raw text for keywords, the agent asks questions like "what was the value of discount in the last order.bulk_discount_applied event before the failure?" This requires a thin query layer over the log store.

"""
Semantic log querying: let the debugging agent ask structured
questions about log events from the failing execution.
"""
from datetime import datetime


class LogQueryEngine:
    """Query structured log events for debugging context."""

    def __init__(self, events: list[dict]):
        self.events = events

    def filter_by_event(self, event_name: str) -> list[dict]:
        """Get all log entries matching an event name pattern."""
        return [
            e for e in self.events
            if event_name in e.get("event", "")
        ]

    def get_field_values(
        self, event_name: str, field: str
    ) -> list:
        """Extract a specific field from matching events."""
        return [
            e[field]
            for e in self.filter_by_event(event_name)
            if field in e
        ]

    def events_before_error(
        self, n: int = 10
    ) -> list[dict]:
        """Get the last N events before the first error-level event."""
        for i, event in enumerate(self.events):
            if event.get("level") in ("error", "critical", "exception"):
                start = max(0, i - n)
                return self.events[start:i + 1]
        return self.events[-n:]  # No error found; return last N

    def trace_field_evolution(
        self, field: str
    ) -> list[tuple[str, object]]:
        """Track how a field's value changes across events.

        Returns list of (event_name, field_value) showing
        the progression of a variable through the execution.
        """
        evolution = []
        for event in self.events:
            if field in event:
                evolution.append((event.get("event", "unknown"), event[field]))
        return evolution


# Example: trace how 'total' evolves through an order processing run
engine = LogQueryEngine(log_events)
total_history = engine.trace_field_evolution("total")
# [("order.item_added", 50.0), ("order.item_added", 100.0),
#  ("order.bulk_discount_applied", 90.0), ("order.completed", 90.0)]

pre_error = engine.events_before_error(n=5)
# The 5 events leading up to the error, giving temporal context
A semantic log query engine exposing filter_by_event, trace_field_evolution, and events_before_error methods so the debugging agent can ask structured questions about execution history.
Research Frontier: Scaling Self-Debugging with SWE-bench Verified

SWE-bench Verified (Chowdhury et al., 2024), developed jointly by OpenAI and the original SWE-bench team at Princeton NLP, introduced a human-validated subset of 500 real GitHub issues, filtering out ambiguous or under-specified problems from the original SWE-bench. This curated benchmark revealed that earlier agent scores were inflated by noisy evaluation. On the verified set, frontier systems such as Amazon Q Developer Agent and Anthropic's agentic coding setup achieved 40-50% resolve rates by mid-2025, up from the 12-23% range on the original benchmark just a year prior (as of early 2026, frontier systems have surpassed 60% on SWE-bench Verified, driven by improved planning, multi-step tool orchestration, and longer context windows). The gains come not from better LLMs alone but from improved tool orchestration: agents now plan multi-file edits, run targeted test subsets to conserve budget, and use repository-level code search to gather context before generating patches. RepairAgent (Bouzenia et al., 2024) demonstrated the value of this tool-use paradigm on Defects4J, a curated collection of reproducible bugs from real Java projects used to benchmark automated repair tools (164 bugs fixed), and newer systems extend it with retrieval-augmented context selection and iterative refinement loops that mirror the hypothesis-rank-verify pattern taught in this section.

Fun Note: The Rubber Duck Upgrade

Rubber duck debugging works because explaining the problem to an inanimate object forces you to articulate your assumptions. A self-debugging agent is the rubber duck that talks back. It listens to your traceback, asks "have you considered that the discount is applied before the quantity check on line 42?", and then fixes it while you are still formulating your next sentence. The pedagogical insight remains: articulating the problem is half the solution. The engineering insight is that LLMs are now good enough to provide the other half.

Try It: Build a Minimal Self-Debugging Loop

Construct a working self-debugging agent in under 100 lines using only the Python standard library and an LLM API client.

  1. Create the bug: Write a small Python module (calculator.py) with a function divide_items(total, count) that contains a deliberate off-by-one error (e.g., return total / (count + 1) instead of return total / count). Write a pytest test that calls divide_items(100, 4) and asserts the result equals 25.0.
  2. Capture the failure: Run pytest --tb=long -q via subprocess.run, capture stdout and stderr, and read the source of calculator.py into a string.
  3. Prompt the LLM: Send the failing test output and the full source to an LLM with a system prompt asking it to identify the faulty line and return the corrected version in a structured format (e.g., LINE: 5 / FIX: return total / count).
  4. Apply and verify: Parse the LLM response, replace the identified line in the source file, write it back, and re-run pytest. Print whether the fix succeeded or failed.
  5. Extend: Add a rollback mechanism (copy the original file before patching, restore on failure) and support for up to three retry rounds where each round sends the new error output back to the LLM.

Exercise 19.2.1

A self-debugging agent generates three hypotheses for a failing test. Hypothesis A targets line 17 (Ochiai score 0.85, LLM confidence 0.40). Hypothesis B targets line 31 (Ochiai score 0.30, LLM confidence 0.95). Using the blending formula \(\text{score}(h) = 0.6 \cdot \text{Ochiai} + 0.4 \cdot \text{confidence}\), which hypothesis ranks higher? Now suppose you switch to \(\alpha = 0.3\). Does the ranking change? What does this tell you about when to trust statistical signals versus semantic reasoning?

Hint

Compute both scores at \(\alpha = 0.6\): A gets \(0.6 \times 0.85 + 0.4 \times 0.40 = 0.67\), B gets \(0.6 \times 0.30 + 0.4 \times 0.95 = 0.56\). Then recompute at \(\alpha = 0.3\) and see which one flips to the top. The crossover point where both hypotheses tie is the value of \(\alpha\) where neither signal dominates.

Step-Through: Hypothesis Ranking with Combined Scores

Trace through the ranking process with three concrete hypotheses from a failing test_apply_discount test:

Input hypotheses (after LLM generation and Ochiai lookup):
H1: line 42, Ochiai = 0.92, confidence = 0.30
H2: line 18, Ochiai = 0.55, confidence = 0.80
H3: line 7, Ochiai = 0.40, confidence = 0.90

Step 1: Compute combined scores (\(\alpha = 0.6\)):
H1: \(0.6 \times 0.92 + 0.4 \times 0.30 = 0.552 + 0.120 = 0.672\)
H2: \(0.6 \times 0.55 + 0.4 \times 0.80 = 0.330 + 0.320 = 0.650\)
H3: \(0.6 \times 0.40 + 0.4 \times 0.90 = 0.240 + 0.360 = 0.600\)

Step 2: Sort descending: H1 (0.672) > H2 (0.650) > H3 (0.600).

Step 3: Verify top hypothesis first. The agent patches line 42, runs pytest test_apply_discount -x -q. Suppose the test still fails.

Step 4: Try next. The agent restores the original file, patches line 18, re-runs. The test passes. The agent then runs the full suite: all 47 tests pass. Result: H2's fix is accepted. Notice that the statistical top pick (H1, highest Ochiai) was wrong; the blended ranking kept H2 close enough to try on the second attempt.

Real-World Application: GitHub Copilot Workspace

GitHub Copilot Workspace (launched 2024) reportedly uses a self-debugging loop internally when generating multi-file pull requests from issue descriptions. Based on public demonstrations, the system runs the repository's test suite in a cloud sandbox; if tests fail, it reads the failure output and iterates on its own patch, typically resolving regressions within two to three rounds without human intervention. This is the same observe-hypothesize-patch-verify cycle described in this section, deployed at scale across millions of repositories.

Lab: Build and Benchmark a Self-Debugging Agent

Goal: Measure how repair success rate changes with context quality and retry budget.
Tools: Python 3.10+, pytest, the anthropic SDK (or any LLM API client), and a small codebase with intentional bugs.
Setup (5 min): Create a Python module with five functions, each containing one seeded bug (off-by-one, wrong operator, swapped arguments, missing boundary check, incorrect default value). Write a passing test for the correct behavior of each function.
Experiment (20 min): Implement the minimal self-debugging loop from the "Try It" callout above. Run the agent against all five bugs under three conditions: (a) prompt includes only the traceback, (b) prompt includes traceback plus source code, (c) prompt includes traceback, source code, and Ochiai-ranked suspicious lines. For each condition, allow up to three retry rounds.
What to vary: The amount of context provided (conditions a, b, c) and the retry budget (1, 2, or 3 rounds).
What to observe: Record fix success (pass/fail), number of rounds needed, and total tokens consumed. Plot a 3x3 grid (context level vs. retry budget) showing success rate. You should see that richer context reduces the number of retries needed and that diminishing returns set in after two rounds for well-localized bugs.

Exercises

  1. Conceptual: The combined scoring formula uses \(\alpha = 0.6\) to weight the Ochiai score above the LLM's confidence. Under what circumstances would you want to increase \(\alpha\) (trust statistics more)? When would you decrease it (trust the LLM more)? Consider the size of the test suite, the quality of the coverage data, and the complexity of the bug.
  2. Coding: Extend the LogQueryEngine to support a correlate_fields method that identifies pairs of fields whose values change together across events (e.g., when quantity increases, discount should also increase). Use Pearson correlation (a measure of linear association between two variables, ranging from -1 to +1) on numeric fields.
  3. Analysis: Compare the single-agent approach (one LLM does everything) with the four-agent team approach. What are the tradeoffs in terms of token cost, latency, fix quality, and debuggability of the debugging process itself? Design an experiment to measure these tradeoffs on a set of 20 known bugs.