<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: ujma mulani</title>
    <description>The latest articles on DEV Community by ujma mulani (@ujma21).</description>
    <link>https://dev.to/ujma21</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4188575%2F5c6c66d2-d759-4988-b0e3-4b14e2614c8f.jpg</url>
      <title>DEV Community: ujma mulani</title>
      <link>https://dev.to/ujma21</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ujma21"/>
    <language>en</language>
    <item>
      <title>Why I Hand-Rolled a ReAct Loop Instead of Using LangChain</title>
      <dc:creator>ujma mulani</dc:creator>
      <pubDate>Fri, 16 Oct 2026 12:08:14 +0000</pubDate>
      <link>https://dev.to/ujma21/why-i-hand-rolled-a-react-loop-instead-of-using-langchain-509k</link>
      <guid>https://dev.to/ujma21/why-i-hand-rolled-a-react-loop-instead-of-using-langchain-509k</guid>
      <description>&lt;p&gt;I spent two days trying to make LangChain's AgentExecutor do what I wanted.&lt;br&gt;
It worked. Sort of. The agent called tools. It produced answers. But I couldn't explain why it made the choices it made. When I tried to swap the LLM provider, the tracing broke. When I tried to add a second tool, the dispatch logic got tangled in layers of abstraction I didn't write and couldn't easily inspect.&lt;br&gt;
So I deleted it. I wrote a ~270-line &lt;strong&gt;ReAct loop&lt;/strong&gt; instead.&lt;br&gt;
This post is about that decision, the architecture that came out of it, and the one thing most agent tutorials skip entirely: &lt;strong&gt;observability&lt;/strong&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What I Was Building&lt;/strong&gt;&lt;br&gt;
The project is called &lt;strong&gt;RepoPilot&lt;/strong&gt;. The end goal is a CI-fixing agent: given a failing GitHub Actions run, it should eventually diagnose the failure and propose a fix that a human can approve.&lt;br&gt;
But before any of that, I needed a foundation. An agent that could:&lt;br&gt;
•Inspect a local Git repository through controlled tools&lt;br&gt;
•Talk to an LLM through a provider-agnostic interface&lt;br&gt;
•Record every decision it made, so I could debug it later&lt;br&gt;
That's Phase 1. This post is about how I built it — and why the framework wasn't the right tool.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The Case for LangChain (And Why It Didn't Hold)&lt;/strong&gt;&lt;br&gt;
LangChain, CrewAI, and similar frameworks offer real value:&lt;br&gt;
•Pre-built tool integrations&lt;br&gt;
•Memory abstractions&lt;br&gt;
•Callback systems for observability&lt;br&gt;
•Multi-agent orchestration out of the box&lt;br&gt;
For a certain kind of project — one that needs many integrations quickly, or one where the framework's opinions match yours — they're a legitimate accelerator.&lt;br&gt;
But for my project, three things went wrong.&lt;br&gt;
&lt;strong&gt;1. Opacity&lt;/strong&gt;&lt;br&gt;
When AgentExecutor made a decision, I couldn't easily see why. The chain of prompts, the tool selection logic, the intermediate parsing — it was all happening inside layers I hadn't written. Debugging meant reading framework source code instead of my own.&lt;br&gt;
&lt;strong&gt;2. Provider Lock-In&lt;/strong&gt;&lt;br&gt;
LangChain abstracts LLM providers, but the abstraction has its own shape. When I tried to swap Gemini for a local Ollama model, tracing broke. The framework's callback system was tied to the LLM abstraction in ways I didn't fully control.&lt;br&gt;
&lt;strong&gt;3. Framework Churn&lt;/strong&gt;&lt;br&gt;
LangChain's API changes frequently. Code from six months ago often needs rewriting. For a project I plan to maintain and extend, that's a liability.&lt;br&gt;
None of these are dealbreakers on their own. Together, they pointed to a clear answer: for a project whose entire value is auditability and control, a black box is the wrong foundation.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The ReAct Loop, Hand-Rolled&lt;/strong&gt;&lt;br&gt;
ReAct (Reason + Act) is a simple pattern:&lt;br&gt;
1.The LLM reasons about what to do next.&lt;br&gt;
2.It picks a tool and provides arguments.&lt;br&gt;
3.The tool runs and returns a result.&lt;br&gt;
4.The result is added to the conversation.&lt;br&gt;
5.Repeat until the LLM produces a final text response.&lt;br&gt;
That's it. Here's the core of my implementation:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzo3i4lolhycbe2hqrx34.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzo3i4lolhycbe2hqrx34.png" alt="This diagram shows an AI agent loop that takes a goal, uses tools to gather information, observes the results, and repeats the process until the task is complete" width="799" height="436"&gt;&lt;/a&gt;&lt;br&gt;
python&lt;br&gt;
def run(self, goal: str) -&amp;gt; str:&lt;br&gt;
    messages = [&lt;br&gt;
        {"role": "system", "content": SYSTEM_PROMPT},&lt;br&gt;
        {"role": "user", "content": goal},&lt;br&gt;
    ]&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Roughly 20 lines of logic. It does everything I need:&lt;br&gt;
•&lt;strong&gt;Bounded iterations&lt;/strong&gt; via max_steps&lt;br&gt;
•&lt;strong&gt;Tool dispatch&lt;/strong&gt; through a registry&lt;br&gt;
•&lt;strong&gt;Structured messages&lt;/strong&gt; that I can inspect at any point&lt;br&gt;
•&lt;strong&gt;A final-answer fallback&lt;/strong&gt; if the step limit is reached&lt;br&gt;
I wrote it. I understand every line. When something goes wrong, I know exactly where to look.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The Provider Abstraction&lt;/strong&gt;&lt;br&gt;
One design decision I got right early: core/ never imports a provider SDK.&lt;br&gt;
The agent talks to an interface, not to Gemini or OpenAI or Ollama directly.&lt;br&gt;
python&lt;br&gt;
class LLMProvider(ABC):&lt;br&gt;
    &lt;a class="mentioned-user" href="https://dev.to/abstractmethod"&gt;@abstractmethod&lt;/a&gt;&lt;br&gt;
    def chat(self, messages: list[dict]) -&amp;gt; str: ...&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@abstractmethod
def chat_with_tools(self, messages: list[dict], tools: list[dict]) -&amp;gt; NormalizedResponse: ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The Gemini adapter implements this. Swapping providers means writing a new adapter — not rewriting the agent.&lt;br&gt;
I can develop against a free cloud API and later switch to a local model by changing one environment variable. The agent doesn't know the difference.&lt;br&gt;
&lt;strong&gt;Why this matters:&lt;/strong&gt; The moment I wrote from openai import OpenAI inside core/, I would have locked myself in. The adapter pattern cost me about 50 extra lines. It bought me flexibility I'll need in Phase 2.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Observability: The Part Most Tutorials Skip&lt;/strong&gt;&lt;br&gt;
Here's the thing I built before adding a second tool: tracing.&lt;br&gt;
Every agent run produces a structured trace. Every LLM call, every tool invocation, every result, every error. Not log lines — a data model.&lt;br&gt;
python&lt;br&gt;
@dataclass&lt;br&gt;
class AgentStep:&lt;br&gt;
    step_number: int&lt;br&gt;
    timestamp: float&lt;br&gt;
    type: str              # "llm_call" | "tool_call" | "tool_result" | "error"&lt;br&gt;
    model: str | None&lt;br&gt;
    tool_name: str | None&lt;br&gt;
    arguments: dict | None&lt;br&gt;
    result: Any | None&lt;br&gt;
    error: str | None&lt;br&gt;
    duration_ms: float&lt;br&gt;
At the end of every run, RepoPilot prints a summary:&lt;br&gt;
text&lt;br&gt;
─────────────────────────────────────────────&lt;br&gt;
RepoPilot Run Summary&lt;br&gt;
─────────────────────────────────────────────&lt;br&gt;
Goal:              Check the repository status&lt;br&gt;
Total steps:       4&lt;br&gt;
LLM calls:         2&lt;br&gt;
Tool calls:        1&lt;br&gt;
Errors:            0&lt;br&gt;
Total duration:    78.41s&lt;br&gt;
─────────────────────────────────────────────&lt;br&gt;
And it writes the full trace to .runs/{run_id}.json.&lt;br&gt;
This is what makes the agent &lt;strong&gt;debuggable&lt;/strong&gt;. When a run fails, I don't guess. I open the trace and see exactly what happened: which prompt went in, which tool was called, what came back, and where the loop broke.&lt;br&gt;
&lt;strong&gt;Why I built this before adding more tools:&lt;/strong&gt; If I had 5 tools and a broken loop, I wouldn't know which was the problem. With tracing, I can see the loop working perfectly while a tool misbehaves — or vice versa.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What I Got Wrong&lt;/strong&gt;&lt;br&gt;
Two honest mistakes worth sharing.&lt;br&gt;
&lt;strong&gt;1. I put tracing in the provider adapter first.&lt;/strong&gt;&lt;br&gt;
My first version recorded LLM calls inside the Gemini adapter. It seemed logical — that's where the API calls happen. But when I tried to swap providers, tracing would be lost. I moved it to the core loop instead. Now tracing works identically regardless of which provider is running.&lt;br&gt;
Lesson: Put cross-cutting concerns (tracing, safety, approval) in the core layer, not in the provider layer.&lt;br&gt;
&lt;strong&gt;2. I started with 5 tools.&lt;/strong&gt;&lt;br&gt;
In my first draft, I wrote git_status, git_diff, read_file, list_files, and search_code all at once. Then I spent a day debugging tool schemas and error handling — before I even knew if my ReAct loop was correct.&lt;br&gt;
I cut back to one tool: git_status. Just enough to prove the loop worked.&lt;br&gt;
Lesson: One tool is enough to prove the architecture. Features come after the foundation is solid.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The Result&lt;/strong&gt;&lt;br&gt;
Phase 1 is complete. The agent:&lt;br&gt;
•Talks to Gemini through a provider-agnostic interface&lt;br&gt;
•Calls a read-only git_status tool&lt;br&gt;
•Records a structured JSON trace of every run&lt;br&gt;
•Passes 68 automated tests&lt;br&gt;
•Runs end-to-end on a real local repository&lt;br&gt;
It's not a CI-fixing agent yet. It's the foundation for one. But the foundation is clean, tested, and — most importantly — understandable.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What's Next&lt;/strong&gt;&lt;br&gt;
Phase 2 is where RepoPilot earns its name:&lt;br&gt;
•Parse GitHub Actions logs deterministically (no LLM for known failure patterns)&lt;br&gt;
•Escalate to the LLM only when the pattern is ambiguous&lt;br&gt;
•Propose a fix as a diff, but never apply it without human approval&lt;br&gt;
•Trace every decision&lt;br&gt;
The deterministic-first design is the differentiator. Most CI-fixing agents throw logs at an LLM and hope. RepoPilot will classify failures with regex first, and only use the LLM when it has to.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The Takeaway&lt;/strong&gt;&lt;br&gt;
Frameworks are not wrong. They're just not always right.&lt;br&gt;
If you're building a quick prototype, use LangChain. If you're building a system whose value is in its auditability, control, and safety guarantees — &lt;strong&gt;write the loop yourself&lt;/strong&gt;. It's ~100 lines. You'll understand every one of them.&lt;br&gt;
And build observability first. Not because it's fun, but because it's the difference between an agent you can debug and an agent you can only hope works.&lt;/p&gt;




&lt;p&gt;The code is at &lt;a href="https://github.com/Ujma21/RepoPilot.git" rel="noopener noreferrer"&gt;https://github.com/Ujma21/RepoPilot.git&lt;/a&gt; . Phase 2 is in progress. If you're building agents and hit the same framework wall I did, I'd love to hear how you solved it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
