Agentic Coding Assistants: How AI Pair Programmers Actually Work
Inside the plan-read-edit-test loop that lets AI coding agents navigate real codebases, and the concrete ways they still fail.
An autocomplete-style assistant predicts the next few lines of code from what’s already on screen. An agentic coding assistant does something categorically different: it operates a loop. It reads files, forms a plan, makes edits across a codebase, runs your test suite, reads the failures, and edits again, without a human retyping the prompt at every step. The interesting engineering isn’t the code generation itself, which has been solid for a couple of years now — it’s the scaffolding that lets a model act inside a real repository without losing the thread or breaking things it never even looked at.
The Agent Loop
Strip away the product-specific branding and almost every agentic coding tool implements a variation on the same cycle:
1. Understand the task (parse the user's request, form a plan)
2. Gather context (search/read relevant files)
3. Propose an edit (a diff, not a full file rewrite, usually)
4. Apply the edit
5. Run verification (tests, linter, type checker, build)
6. Read the output (pass/fail, error messages, stack traces)
7. If failed: revise plan and go to 2. If passed: report or continue.
The loop terminates either when verification passes, when the model decides the task is done and reports back, or when it hits a step budget and a human has to intervene. That last exit condition matters: without a budget, a model that misdiagnoses a failure can spin through edit-test cycles indefinitely, burning tokens and, worse, leaving a trail of half-applied changes.
# A stripped-down sketch of the loop — real implementations add
# retries, step limits, and human-approval gates at each tool call.
def agent_loop(task, workspace, max_steps=25):
plan = model.plan(task, workspace.list_files())
for step in range(max_steps):
context = workspace.read(plan.next_targets())
edit = model.propose_edit(task, plan, context)
workspace.apply(edit)
result = workspace.run_tests()
if result.passed:
return model.summarize(task, edit, result)
plan = model.revise_plan(task, plan, result.failure_output)
raise StepBudgetExceeded(task)
Reading the Codebase Without Reading Everything
A model’s context window is finite, and a real repository is not. The scaffolding around the model, not the model itself, decides what it gets to see, and this decision is where most of the practical engineering effort goes.
The common strategies stack rather than compete:
- Structural search first. Grep-style search for symbol names, import statements, or config keys is cheap and precise; it’s the first move for “where is this function defined” rather than reading files speculatively.
- A file tree summary, not full file contents, to orient the model on project layout before it decides what to open.
- Targeted reads, often with line ranges, once search has narrowed down which files matter, instead of loading whole files by default.
- Retrieval over embeddings for large, unfamiliar codebases where symbol names alone don’t surface the relevant code — useful, but noisier than exact search, and worth treating as a fallback rather than a first resort.
The practical failure mode here is subtler than “ran out of context.” A model that never reads the test file for the function it’s editing, or never checks how a shared utility is called elsewhere, will produce a change that looks locally correct and breaks a caller three directories away. Good context-gathering isn’t about maximizing what gets read; it’s about reading the right handful of files before committing to an edit.
Making the Edit: Diffs, Not Rewrites
Early code-generation tools tended to regenerate whole files, which is wasteful and dangerous — it’s easy for a full rewrite to silently drop a comment, an unrelated helper function, or formatting a linter depends on. Agentic tools instead work in diffs: a small, anchored patch against the current file content.
--- a/src/billing/invoice.py
+++ b/src/billing/invoice.py
@@ -42,7 +42,10 @@ def calculate_total(line_items, tax_rate):
- subtotal = sum(item.price * item.quantity for item in line_items)
- return subtotal * (1 + tax_rate)
+ subtotal = sum(item.price * item.quantity for item in line_items)
+ if subtotal < 0:
+ raise ValueError("subtotal cannot be negative")
+ return round(subtotal * (1 + tax_rate), 2)
Diff-based editing has a secondary benefit: it composes naturally with version control. A well-scoped agent applies changes as a sequence of small, reviewable commits (or leaves the working tree in a state where git diff tells the whole story), rather than a single monolithic blob a reviewer has to reverse-engineer.
Running Tests as the Feedback Signal
The single biggest quality difference between “an LLM that writes code” and “an agent that writes code” is whether it closes the loop with your test suite. A model that edits calculate_total and immediately reads the pytest failure — an assertion mismatch, a type error, a fixture that broke — has a grounded, unambiguous signal to revise against. A model that just re-reads its own output and declares victory does not.
$ pytest tests/billing/ -x -q
FAILED tests/billing/test_invoice.py::test_negative_subtotal_raises
ValueError not raised - AssertionError
1 failed, 12 passed in 0.84s
This is also where exit codes and structured tool output matter more than they seem to. An agent parsing raw terminal text to decide “did this pass” is fragile; agents built around structured test runners (JSON test reporters, JUnit XML, linter output with machine-readable severities) make far fewer false “it works” claims.
Context Management at Scale
Long agent sessions accumulate an enormous amount of transient state: file contents read three steps ago, a failed approach already abandoned, tool output nobody needs anymore. Left unmanaged, this fills the context window with noise and pushes out the information that’s actually load-bearing for the current step.
Two mitigations show up repeatedly across implementations:
- Summarization/compaction. Periodically collapse the older parts of the transcript into a compact summary of what’s been tried and learned, keeping only the most recent steps verbatim.
- Sub-agents for isolated subtasks. Spin up a fresh, narrowly-scoped agent to investigate one question (“why does this test fail”) and return only its conclusion, rather than letting that investigation’s raw output pollute the parent’s context.
| Strategy | What it saves | What it costs |
|---|---|---|
| Full transcript, no pruning | Nothing lost | Context fills up fast; expensive |
| Summarization/compaction | Room for new work | Detail loss; can compound over long sessions |
| Sub-agents for sub-questions | Parent stays focused | Coordination overhead; results only as good as the sub-agent’s report |
| File-based scratch state | Survives session restarts | Requires the agent to remember to write it |
Tool Permissions
Because these agents can execute shell commands, edit files, and sometimes make network calls, the permission model around tool use is not an afterthought — it’s the difference between a productivity tool and a liability. Reasonable setups scope what an agent can do without asking:
# Illustrative permission policy, not any specific product's schema
allow:
- "Read(**)"
- "Bash(git diff:*)"
- "Bash(pytest:*)"
ask:
- "Bash(git push:*)"
- "Edit(**/migrations/**)"
deny:
- "Bash(rm -rf:*)"
- "Bash(curl:* | sh)"
The pattern worth internalizing: read-only and low-risk verification commands (diffing, testing, linting) can run unattended; anything that changes shared state outside the workspace (pushing to a remote, touching a database migration, deleting broadly) should stop and ask. Running the agent inside an isolated worktree or container, so a mistaken edit or command can’t reach the rest of the filesystem, is the cheap insurance that makes the “allow” list safe to widen in the first place.
Where They Fail
Hallucinated APIs. A model trained on a slightly older library version will confidently call a method that was renamed or removed. This is exactly why the test-and-observe step of the loop matters — a model that only generates and never runs is far more likely to ship this kind of error unnoticed.
Local correctness, global breakage. An edit that satisfies the tests it looked at can still break a caller the agent never read, especially in codebases with implicit contracts (shared config keys, a database column another service reads directly).
Context loss over long sessions. Past a certain session length, summarization inevitably drops a detail that turns out to matter, and the agent quietly re-derives an already-rejected approach.
Overconfident “done” reports. An agent that ran a subset of tests, or ran them against a stale build, can report success in good faith while the actual CI run fails. Treat an agent’s self-report as a claim to verify, not a fact.
Security blast radius. Shell execution is the most powerful and most dangerous tool in the loop. A permission policy that’s too permissive turns a coding assistant into a remote code execution surface for anything that can influence its input, including content it reads from the web or from a ticket description.
Key Takeaways
- The core mechanism is a loop: plan, gather context, edit, verify, revise — not a single generation step.
- Context-gathering strategy (search-first, targeted reads, embeddings as fallback) matters more to output quality than model choice once the model is capable enough.
- Diff-based edits compose with version control and avoid the silent damage of whole-file rewrites.
- Running the actual test suite and parsing structured output is what makes the loop self-correcting; without it, an agent is just a very fluent guesser.
- Long sessions need active context management (summarization, sub-agents) or they degrade.
- Tool permissions should default to read-only/verification actions allowed, state-changing actions gated, and destructive actions denied outright.
- Treat an agent’s own “it works” claim as something to verify, not a substitute for your CI.