[KongchangAI]
· 4 min read· 2,044 words

Ditching LangChain: I Hand-Wrote a 270-Line ReAct Loop

Ditching LangChain: I Hand-Wrote a 270-Line ReAct Loop

The author deletes LangChain after two painful days and hand-writes a 270-line ReAct loop, arguing auditability comes first.

The author spends two days trying to make LangChain's AgentExecutor work as expected, but hits a triple dilemma: opacity, provider lock-in, and rapid API churn. He deletes LangChain and hand-writes a ~270-line ReAct loop whose core logic is only ~20 lines. He uses an LLMProvider adapter pattern to stay provider-agnostic and builds structured tracing before adding a second tool. Lessons: keep cross-cutting concerns in the core layer, and validate architecture with one tool first.

After Two Days of Wrestling with LangChain, I Deleted It

The author spent two days trying to make LangChain's AgentExecutor work as expected. The result? It did run — the agent called tools and produced answers. But the problem was this: he couldn't explain why the agent made the choices it did. When he switched LLM providers, the tracing system fell apart entirely. When he added a second tool, the dispatch logic got tangled in abstraction layers he hadn't written and didn't understand.

So he made a decision: delete LangChain and hand-write a ReAct loop in about 270 lines.

ReAct loop architecture diagram

This isn't a "frameworks are useless" article. The author readily admits that frameworks like LangChain and CrewAI offer real value — pre-built tool integrations, memory abstractions, callback systems, multi-agent orchestration. For projects that need to quickly integrate a large number of tools, or where the framework's design philosophy happens to match your needs, they're genuinely reasonable accelerators. But for the RepoPilot project, three things turned the framework into a burden.

The Triple Dilemma of Framework Failure

First, opacity. When AgentExecutor made decisions, the author couldn't easily see the reasoning behind them. Prompt chains, tool selection logic, intermediate parsing — all buried in code layers he hadn't written. Debugging meant reading the framework's source code rather than his own.

Second, provider lock-in. Although LangChain abstracts LLM providers, that abstraction has its own specific shape. When the author tried to switch from Gemini to a local Ollama model, the tracing system crashed outright — the framework's callback system was bound too deeply to its LLM abstraction layer, beyond his control.

Third, frequent API changes. LangChain's interfaces change quickly, and code written six months ago often needs rewriting. For a project planning long-term maintenance and expansion, this is a real liability.

Individually, none of these would be enough to sentence a framework to death. But together, they point to a clear answer: when a project's entire value lies in auditability and control, a black box is the wrong foundation.

What is LangChain's AgentExecutor? It's a high-level runtime wrapper LangChain provides for building agents. Developers pass in an LLM, a set of tools, and a prompt template, and AgentExecutor automatically takes over the entire loop: calling the model, parsing the model's tool-calling intent from its output, executing tools, stitching results back into the conversation history until the model gives a final answer. It also has built-in max iterations, exception handling, callback hooks, and more. The problem stems precisely from this "do-everything" approach: every step in the loop passes through multiple layers of internal abstraction (prompt assembly, output parsers, callback managers), code maintained by the framework rather than written by the user. When behavior doesn't match expectations, you can't just set a breakpoint in your own business code — you have to dig into the framework's source to understand how prompts were rewritten and how tool calls were parsed. This directly conflicts with the author's emphasis on "auditability."

Hand-Writing the ReAct Loop: 20 Lines of Core Logic

ReAct (Reason + Act) is essentially a minimalist pattern: the LLM reasons about what to do next → selects a tool and provides arguments → the tool executes and returns a result → the result is added to the conversation → loop until the LLM produces a final text response.

def run(self, goal: str) -> str:
    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": goal},
    ]

    for step in range(self.max_steps):
        response = self.llm.chat_with_tools(messages, self.tools.schemas())

        if not response.tool_calls:
            return response.content

        messages.append({"role": "assistant", "content": response.content})

        for call in response.tool_calls:
            result = self.tools.execute(call.name, call.arguments)
            messages.append({
                "role": "tool",
                "name": call.name,
                "content": json.dumps(result),
            })

    return self._request_final_answer(messages)

About 20 lines of core logic cover everything needed: bounded iteration via max_steps, tool dispatch through a registry, structured messages that can be inspected at any time, and a final-answer fallback when the step limit is reached.

The author writes: "I wrote it. I understand every line. When something breaks, I know exactly where to look."

The Costs and Benefits of Provider Abstraction

One design decision that was right from the start: the core layer never imports any provider's SDK. The agent talks to an interface, not directly to Gemini, OpenAI, or Ollama.

class LLMProvider(ABC):
    @abstractmethod
    def chat(self, messages: list[dict]) -> str: ...

    @abstractmethod
    def chat_with_tools(self, messages: list[dict], tools: list[dict]) -> NormalizedResponse: ...

A Gemini adapter implements this interface, and switching providers simply means writing a new adapter rather than rewriting the agent. Developers can build against a free cloud API and later switch to a local model by changing one environment variable — the agent remains completely unaware.

This adapter pattern costs about 50 extra lines of code, but it buys the flexibility required for Phase 2. The author emphasizes: the moment you write from openai import OpenAI in the core layer, you've locked yourself in.

The adapter pattern and dependency inversion. The adapter pattern is one of the classic design patterns: define a stable abstract interface, and write an "adapter" for each concrete implementation that translates a third-party API into that interface. The author's approach is essentially the dependency inversion principle in practice — high-level modules (the agent's core loop) don't depend on low-level modules (vendor SDKs); both depend on the abstract interface LLMProvider. The direct benefit: the core logic is completely unaware of "who is providing the reasoning capability," so switching costs are reduced to writing a single new adapter class. The cost is having to normalize different vendors' response formats into a unified NormalizedResponse, including differences in tool-call fields (such as OpenAI's tool_calls versus Gemini's functionCall). While this normalization layer adds about 50 lines, it isolates change at the boundary, preventing it from polluting the core loop.

Observability: The Critical Step Most Tutorials Skip

The author built the tracing system before adding a second tool. Every agent run produces structured trace data — every LLM call, tool call, result, and error recorded as data models rather than log lines.

@dataclass
class AgentStep:
    step_number: int
    timestamp: float
    type: str              # "llm_call" | "tool_call" | "tool_result" | "error"
    model: str | None
    tool_name: str | None
    arguments: dict | None
    result: Any | None
    error: str | None
    duration_ms: float

After each run, RepoPilot prints a summary (goal, total steps, LLM call count, tool call count, error count, total duration) and writes the full trace to .runs/{run_id}.json.

This is what makes the agent debuggable. When a run fails, there's no guessing — open the trace file and see exactly what happened: which prompt was sent, which tool was called, what it returned, and where the loop broke.

Why did the author build tracing before adding more tools? His reasoning is practical: with 5 tools and a problematic loop, you have no idea which link in the chain is failing. With tracing, you can see that the loop itself runs perfectly while a particular tool misbehaves — or vice versa.

What is observability, and why does it matter especially for agents? Observability comes from cybernetics and refers to the ability to infer a system's internal state solely from its external output. In software engineering, it typically rests on three pillars: logs, metrics, and traces. For traditional programs, a debugger can pause execution and inspect variables. But an LLM agent's decision path is generated by a probabilistic model — fundamentally a non-reproducible black box where the same input can produce different tool-call sequences. Post-hoc tracing therefore becomes the only effective diagnostic tool. The author defines a structured AgentStep with @dataclass rather than printing string logs, and the key difference is: structured data can be queried, aggregated, and replayed programmatically — for example, checking whether "the tool call at step 3 took 1200ms and timed out," or comparing call-sequence differences between two runs. This is exactly the dividing line that turns an agent from "pray it works" into "something you can debug."

Two Lessons Worth Sharing

First, tracing logic initially lived in the provider adapter. The first version put LLM call logging inside the Gemini adapter, which seemed reasonable — that's where API calls actually happen. But switching providers meant losing tracing. He later moved tracing into the core loop, and now tracing behaves identically regardless of which provider is running. The lesson: cross-cutting concerns (tracing, security, approvals) belong in the core layer, not the provider layer.

Second, he wrote 5 tools right out of the gate. The initial draft included git_status, git_diff, read_file, list_files, and search_code. He ended up spending an entire day debugging tool schemas and error handling — at a point when he wasn't even sure the ReAct loop itself was correct. He eventually cut it down to a single tool, git_status, just enough to prove the loop worked. The lesson: one tool is enough to validate the architecture; feature expansion should wait until the infrastructure is solid.

Cross-cutting concerns. This is a software architecture term referring to functionality that scatters across multiple modules and doesn't naturally belong to any single responsibility — logging, tracing, authentication, transactions, caching. Their defining trait: if you place them according to the intuition of "whoever calls it owns it," they end up duplicated across every concrete implementation, and any new implementation easily forgets them — exactly as the author's tracing disappeared when he switched providers after writing it into the Gemini adapter. The classic solution is aspect-oriented programming (AOP) thinking, or lifting them up to the core layer for unified handling. The author's lesson confirms this: tracing logic in the core loop behaves consistently no matter which provider sits underneath; conversely, pushing cross-cutting concerns down to the adapter layer means betting consistency on "every adapter author remembering to implement it" — which is bound to fail over time.

Phase 1 Complete: An Understandable Foundation

Current state: the agent talks to Gemini through a provider-agnostic interface, calls a read-only git_status tool, records structured JSON traces of every run, passes 68 automated tests, and runs end-to-end against real local repositories.

This isn't a CI-fixing agent yet, but it's the foundation for building one. And that foundation is clean, tested, and most importantly — understandable.

According to the author, Phase 2 is where RepoPilot earns its name: parsing GitHub Actions logs deterministically (no LLM for known failure patterns), escalating to the LLM only when patterns are ambiguous, proposing fixes as diffs but never applying them without human approval, and tracing every decision. He believes deterministic-first design is the differentiator — most CI-fixing agents just throw logs at an LLM and hope, whereas RepoPilot classifies failures with regex first and only escalates to the LLM when necessary.

Key Takeaways

Frameworks aren't wrong — they just aren't always right. LangChain is fine for rapid prototyping; but if you're building a system whose value lies in auditability, control, and safety guarantees — write the loop yourself. About 100 lines of code, and you'll understand every one of them.

And build observability first. Not because it's fun, but because it determines whether your agent is something you can debug or something you can only pray works.

One-line Summary

The author spends two painful days with LangChain, deletes the framework, and hand-writes a 270-line ReAct loop to argue auditability comes first.

Paragraph Summary

The author spends two days trying to get LangChain's AgentExecutor working as expected. Though it functions, he ends up stuck in a debugging morass due to a triple dilemma: opacity (can't see why decisions were made), provider lock-in (tracing collapses when switching to Ollama), and rapid API churn (interfaces change every six months). So he deletes LangChain and hand-writes a ~270-line ReAct loop with just ~20 lines of core logic covering bounded iteration, tool dispatch, structured messages, and final-answer fallback. He achieves provider-agnosticism through an LLMProvider adapter pattern, with the core layer never importing vendor SDKs, and builds a structured tracing system before adding a second tool, recording every LLM call, tool call, and error as data models. Lessons learned include: cross-cutting concerns belong in the core layer, not the adapter layer; validate the architecture with one tool before expanding. The conclusion: frameworks aren't wrong, just not always right — when a system's value lies in auditability, control, and safety, writing your own loop is more worthwhile.

#LangChain#ReAct loop#AI agent#provider abstraction#observability
Share:

Related articles