<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[The Stack Depth — Fullstack Engineering, System Design & AI]]></title><description><![CDATA[Deep dives into fullstack engineering, system design, and AI. No fluff — just the decisions, the tradeoffs, and the reasoning behind production systems.]]></description><link>https://blog.divyampatro.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6a0d3df07e4b2e77c048b065/7a1530c5-2524-4754-af9e-8c1ad34201a9.png</url><title>The Stack Depth — Fullstack Engineering, System Design &amp; AI</title><link>https://blog.divyampatro.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Mon, 07 Sep 2026 09:01:44 GMT</lastBuildDate><atom:link href="https://blog.divyampatro.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[ClearCode Part 5: Semantic Caching, Incremental Indexing, and the Hardest Part of Caching]]></title><description><![CDATA[Where Part 4 left us
Part 4 built the tasks layer: structured plan generation with Pydantic, human-in-the-loop approval before anything runs, serial execution with dependency resolution, an LLM-as-jud]]></description><link>https://blog.divyampatro.dev/clearcode-part-5-semantic-caching-incremental-indexing-and-the-hardest-part-of-caching</link><guid isPermaLink="true">https://blog.divyampatro.dev/clearcode-part-5-semantic-caching-incremental-indexing-and-the-hardest-part-of-caching</guid><category><![CDATA[Redis]]></category><category><![CDATA[redis-vl]]></category><category><![CDATA[semantic cache]]></category><category><![CDATA[langchain]]></category><category><![CDATA[RAG ]]></category><dc:creator><![CDATA[Divyam Patro]]></dc:creator><pubDate>Sun, 19 Jul 2026 04:29:33 GMT</pubDate><content:encoded><![CDATA[<h2>Where Part 4 left us</h2>
<p>Part 4 built the tasks layer: structured plan generation with Pydantic, human-in-the-loop approval before anything runs, serial execution with dependency resolution, an LLM-as-judge verifying each task's output, and crash recovery that resets incomplete tasks on restart. ClearCode could take a goal, break it into steps, and implement it autonomously.</p>
<p>Part 5 is about making it fast. And about the harder problem that fast creates.</p>
<hr />
<h2>The problem with asking the same question twice</h2>
<p>Every <code>/ask</code> query goes through the same pipeline: embed the question, retrieve relevant chunks, invoke the agent with that context, stream tool calls until an answer emerges, return the result. For a complex question, that might be four or five tool calls and two LLM completions. For a simple one, it is still at least one embedding call and one completion.</p>
<p>If you ask the same question twice — or a slightly different version of the same question — you pay the full price both times.</p>
<p>This compounds quickly in practice. You ask the same types of questions about a codebase repeatedly. "Where is X defined?", "What does Y do?", "How is Z called?" drift in phrasing but converge on the same answer. The agent does the same work, produces the same output, and charges you again.</p>
<p>The fix is a cache. But an exact-match cache fails the moment the phrasing changes. "What does <code>index_codebase</code> do?" and "Can you explain what <code>index_codebase</code> does?" should hit the same entry. They will not under an exact-match scheme.</p>
<p>The right cache for a language model application is a <strong>semantic cache</strong>: one that measures similarity between queries in embedding space and returns the stored answer if the new question is close enough to one already answered.</p>
<hr />
<h2>1. Semantic cache: why cosine similarity, not exact match</h2>
<p>The cache stores <code>(question_embedding, answer)</code> pairs. On a new query, it embeds the question, finds the nearest stored embedding by cosine similarity, and returns the associated answer if the similarity exceeds a configurable threshold. Below the threshold, it falls through to the agent.</p>
<pre><code class="language-python">async def get(self, query: str, domain: str, model: str) -&gt; str | None:
    hit = await self._top_match(query, domain=domain, model=model)
    if hit is None:
        return None
    similarity = 1 - float(hit["vector_distance"])
    if similarity &gt;= self.threshold:
        return hit["response"]
    return None
</code></pre>
<p>The threshold is the key tuning parameter. Too high and you only hit on near-exact phrasing — the cache becomes useless. Too low and you start returning cached answers for questions that are only superficially similar, which is worse than no cache at all. <strong>0.85 cosine similarity</strong> is the default, chosen empirically. It catches rephrasings and synonym variations while rejecting questions that are genuinely different.</p>
<p>The embeddings come from the same model used for retrieval — <code>text-embedding-3-small</code> by default. This is intentional. The cache and the retrieval layer operate in the same vector space. Switching embedding models requires rebuilding both, but they are always consistent with each other.</p>
<hr />
<h2>2. Redis + RedisVL</h2>
<p>The backing store is Redis with a RedisVL HNSW vector index. Redis was the natural choice: fast, widely deployed, and RedisVL gives it native approximate nearest-neighbour search without requiring a separate vector database.</p>
<p>The index schema is defined once at startup:</p>
<pre><code class="language-python">def _build_index_schema(dims: int) -&gt; dict:
    return {
        "index": {"name": "semantic_cache", "prefix": "cache:"},
        "fields": [
            {
                "name": "query_vector",
                "type": "vector",
                "attrs": {
                    "dims": dims,
                    "algorithm": "HNSW",
                    "distance_metric": "cosine",
                    "datatype": "float32",
                },
            },
            {"name": "response",   "type": "text"},
            {"name": "query_text", "type": "text"},
            {"name": "domain",     "type": "tag"},
            {"name": "model",      "type": "tag"},
            {"name": "created_at", "type": "numeric"},
        ],
    }
</code></pre>
<p><code>overwrite=False</code> on <code>index.create()</code> means the index is reused across restarts rather than wiped. Cache entries survive a REPL restart — you do not pay embedding costs again on the first query of a new session if you already asked the same question before.</p>
<p>One correctness detail worth naming: <code>embed_query</code> on LangChain's embedder is a synchronous function. Calling it directly from an <code>async</code> method blocks the event loop. The fix is <code>asyncio.to_thread</code>:</p>
<pre><code class="language-python">async def _embed(self, text: str) -&gt; list[float]:
    return await asyncio.to_thread(self.embedder.embed_query, text)
</code></pre>
<p>This runs the sync call in a threadpool executor and awaits the result without blocking. Missing this produces a <code>TypeError</code> and a confusing error message that does not point at the actual cause.</p>
<hr />
<h2>3. Domain and model isolation</h2>
<p>A single Redis instance can serve multiple repos. Without isolation, cached answers from one codebase would contaminate queries about another — both happening to use the same question phrasing.</p>
<p>Each repo gets a <strong>domain</strong>: the first 16 characters of the SHA-256 hash of its absolute path.</p>
<pre><code class="language-python">def get_repo_domain(repo_path: str) -&gt; str:
    return hashlib.sha256(repo_path.encode()).hexdigest()[:16]
</code></pre>
<p>Every cache entry is tagged with both <code>domain</code> and <code>model</code>. The <code>VectorQuery</code> filters on both before scoring similarity:</p>
<pre><code class="language-python">q.set_filter((Tag("domain") == domain) &amp; (Tag("model") == model))
</code></pre>
<p>Two repos on the same Redis instance never share entries. And switching from <code>gpt-4o</code> to <code>claude-opus-4</code> does not cause the cache to serve answers generated by a different model. Different models have different capabilities and different failure modes. An answer correct for one may not be for another.</p>
<hr />
<h2>4. The watcher → cache bridge</h2>
<p>A cached answer that is no longer correct is worse than no cache at all.</p>
<p>Ask "What does <code>process_payment</code> do?", get an accurate summary, cache it. Then a <code>/plan</code> task rewrites <code>process_payment</code> completely. The next time you ask, the cache returns the old answer without checking the codebase. You are now confidently wrong.</p>
<p>The solution is to invalidate the cache whenever the codebase changes. ClearCode already has a filesystem watcher that fires on create, modify, rename, or delete. The cache invalidation just needs to be wired to that callback.</p>
<p>The wiring has one complication: the watchdog observer runs in a <strong>background daemon thread</strong>. The asyncio event loop — which owns the Redis client — runs in the <strong>main thread</strong>. You cannot call an <code>async</code> function from a background thread directly.</p>
<p>The bridge is <code>asyncio.run_coroutine_threadsafe</code>:</p>
<pre><code class="language-python">loop = asyncio.get_running_loop()

def invalidate_cache_on_change():
    if semantic_cache is not None:
        asyncio.run_coroutine_threadsafe(
            semantic_cache.invalidate_domain(cache_domain), loop
        )
</code></pre>
<p>This submits the coroutine to the event loop from the watcher thread and returns a <code>Future</code>. The event loop picks it up when it is next free. The callback takes no arguments — it only needs to signal that <em>something</em> changed, not what.</p>
<p>From this point forward, any file change in the project directory triggers cache invalidation for that repo's domain automatically. A <code>/reindex</code> command does the same explicitly.</p>
<hr />
<h2>5. The bug: <code>invalidate_domain</code> was silently doing nothing</h2>
<p>This is the one worth naming carefully, because it is exactly the kind of bug that passes every integration test you write — right up until you write a test that checks the right thing.</p>
<p><code>invalidate_domain</code> is supposed to find all cache entries for a given domain and delete them. The implementation used RedisVL's <code>FilterQuery</code> to locate entries by their <code>domain</code> tag, then called <code>client.delete()</code> on the resulting keys:</p>
<pre><code class="language-python">results = await self.index.query(fq)
if not results:
    return
keys = [self.index.key(r["id"]) for r in results]  # ← the bug
await self.client.delete(*keys)
</code></pre>
<p>The bug is in that one line. <code>r["id"]</code> in a RedisVL query result is already the full Redis key — prefix included. For this index, a stored key looks like <code>cache:abc123def456</code>. So <code>r["id"]</code> is <code>"cache:abc123def456"</code>.</p>
<p><code>self.index.key(r["id"])</code> prepends the index prefix to whatever you pass it. That produces <code>"cache:cache:abc123def456"</code>.</p>
<p><code>client.delete("cache:cache:abc123def456")</code> deletes zero keys. Redis returns success — you are allowed to delete a key that does not exist. The function logs "Invalidated N cache entries" and exits cleanly. Nothing is actually deleted.</p>
<p>The fix is one word:</p>
<pre><code class="language-python">keys = [r["id"] for r in results]  # r["id"] is already the full key
</code></pre>
<p>What made this dangerous: the logging told you invalidation succeeded. The code path had no exception to catch. Entries survived silently, and subsequent queries returned stale cached answers with no indication that something was wrong.</p>
<p>The tests caught it. After calling <code>invalidate_domain</code>, the next <code>get()</code> on the same query should return <code>None</code>. It did not. That assertion failure is what led to the fix.</p>
<hr />
<h2>6. Incremental indexing and watcher improvements</h2>
<p>Two related improvements went in alongside the cache.</p>
<h3>Incremental indexing in ChromaDB</h3>
<p>The original implementation re-embedded every source file on every startup. 300 files, restart the REPL, pay 300 embedding API calls — even if nothing had changed since yesterday.</p>
<p>The fix is mtime-based tracking. Each indexed chunk stores the <code>mtime</code> of the file it came from. On startup, the indexer compares current file mtimes against stored ones:</p>
<ul>
<li><p>File is new → embed and index.</p>
</li>
<li><p>File has the same mtime → skip entirely. No API call.</p>
</li>
<li><p>File has a newer mtime → delete old chunks for that file, re-embed, re-index.</p>
</li>
<li><p>File no longer exists → prune its chunks from the collection.</p>
</li>
</ul>
<p>A repo with 300 unchanged files now starts in the time it takes to check 300 mtimes. The first run still pays the full embedding cost. Every run after costs nothing for files that have not changed.</p>
<h3>Watcher backend dispatch</h3>
<p>The original watcher hardcoded ChromaDB. If you switched to Qdrant in config, file changes updated the ChromaDB index nobody was querying — not the Qdrant index that was.</p>
<p>The fix routes through the factory at call time:</p>
<pre><code class="language-python">def _run(self, action: str, filepath: str) -&gt; None:
    from clearcode.context.indexers.factory import (
        get_single_file_indexer, get_file_remover
    )
    if action == "delete":
        get_file_remover()(filepath)
    else:
        get_single_file_indexer()(filepath)
    if self.on_change is not None:
        self.on_change()
</code></pre>
<p>Switching backends in config is now truly a one-line change — the watcher follows automatically, and the cache invalidation callback fires regardless of which backend is active.</p>
<hr />
<h2>7. <code>handle_query</code> returns a tuple</h2>
<p><code>handle_query</code> used to return a <code>str</code>. It now returns <code>(str, bool)</code> — the answer and whether it came from cache.</p>
<pre><code class="language-python">async def handle_query(...) -&gt; tuple[str, bool]:
    ...
    if cached_response is not None:
        return cached_response, True
    ...
    return answer, False
</code></pre>
<p>This lets <code>main.py</code> print a visual indicator on cache hits without embedding cache-awareness into the orchestrator beyond what it needs to do its job:</p>
<pre><code class="language-plaintext">&gt; /ask Can you explain what index_codebase does?

Searching for: Can you explain what index_codebase does?...
⚡ cache hit

index_codebase scans all source files in the repo, parses them with
tree-sitter for AST-aware chunking, and upserts the resulting chunks
into the configured vector store backend. On startup and /reindex it
skips files whose mtime hasn't changed, so only new or modified files
are re-embedded.
</code></pre>
<p>The <code>⚡ cache hit</code> line tells you the answer came from Redis. No LLM was invoked. No tools were called.</p>
<hr />
<h2>8. The test suite</h2>
<p>The changes in Part 5 are tested end-to-end against a live Redis instance. Nineteen assertions cover the full cache workflow:</p>
<ol>
<li><p><code>build_semantic_cache()</code> returns a <code>SemanticCache</code></p>
</li>
<li><p>Threshold loaded from config correctly</p>
</li>
<li><p><code>get()</code> returns <code>None</code> on a cold cache</p>
</li>
<li><p>Exact query returns cached answer after <code>put()</code></p>
</li>
<li><p>Semantically similar query hits cache above 0.85 threshold</p>
</li>
<li><p>Similar query returns the same stored answer</p>
</li>
<li><p>Unrelated query misses (pasta carbonara)</p>
</li>
<li><p>Different <code>domain</code> misses on identical query</p>
</li>
<li><p>Different <code>model</code> tag misses on identical query</p>
</li>
<li><p><code>invalidate_domain()</code> deletes all domain entries</p>
</li>
<li><p>After invalidation, exact query returns <code>None</code></p>
</li>
<li><p>After invalidation, similar query returns <code>None</code></p>
</li>
<li><p><code>handle_query</code> calls the agent exactly once on miss</p>
</li>
<li><p><code>handle_query</code> returns the agent's answer on miss</p>
</li>
<li><p><code>handle_query</code> reports <code>from_cache=False</code> on miss</p>
</li>
<li><p><code>handle_query</code> returns the cached answer on hit</p>
</li>
<li><p>Agent NOT called again on cache hit</p>
</li>
<li><p><code>handle_query</code> reports <code>from_cache=True</code> on hit</p>
</li>
<li><p>Watcher callback correctly invalidates via <code>run_coroutine_threadsafe</code></p>
</li>
</ol>
<p>Tests 10–12 failed on the first run. That is how the double-prefix bug was found.</p>
<hr />
<h2>Known flaws</h2>
<p><strong>Cache not invalidated at</strong> <code>/plan</code> <strong>start.</strong> The watcher handles file changes made by the executor's <code>write_file</code> calls. But the cache is not cleared at the start of a <code>/plan</code> run. If a task rewrites a file whose answer is already cached, the stale answer survives until the watcher fires — a 1.5-second window after each file write.</p>
<p><strong>Redis required, no in-process fallback.</strong> <code>build_semantic_cache()</code> degrades gracefully when Redis is unreachable — it catches the failure and returns <code>None</code>, falling back to uncached agent calls. But there is no in-process fallback. If you want caching without Redis, there is currently no option.</p>
<p><strong>Cache size is unbounded.</strong> TTL controls individual entry expiry, but there is no maximum entry count or LRU eviction. For long-running sessions against a large codebase, Redis memory usage grows without bound until invalidation clears the domain.</p>
<p><strong>Embedding cost on every miss.</strong> A cache miss still embeds the query for the similarity search before falling through to the agent. A cold cache costs one extra embedding call per query. At <code>text-embedding-3-small</code> rates this is negligible, but it is nonzero.</p>
<p><code>invalidate_domain</code> <strong>truncates at 10,000 entries.</strong> <code>FilterQuery</code> is called with <code>num_results=10_000</code>. For a domain with more than 10,000 cached entries, invalidation silently leaves some stale entries behind.</p>
<hr />
<h2>Where Part 5 leaves things</h2>
<p>ClearCode now has a semantic cache that returns answers in milliseconds when you ask a question it has already answered, invalidates automatically when any source file changes, and isolates entries per repo and per model so nothing cross-contaminates.</p>
<p>The incremental indexer means repeated startups cost nothing for files that have not changed. The watcher dispatches correctly to whichever backend is configured.</p>
<p>One real bug was found in testing and fixed before this post was written. Five known flaws are documented above rather than hidden.</p>
<p>The next layer is safety and observability: guardrails on what the agent is allowed to do, structured logging that makes the tool call chain inspectable, and the beginning of the eval harness that will make improvements measurable rather than assumed.</p>
<p>Full source: <a href="https://github.com/f2015537/clearcode">https://github.com/f2015537/clearcode</a></p>
<p>Part 1 — Architecture before code: <a href="https://blog.divyampatro.dev/clearcode-part-1-reverse-engineering-a-coding-agent-before-writing-a-single-line-of-code">https://blog.divyampatro.dev/clearcode-part-1-reverse-engineering-a-coding-agent-before-writing-a-single-line-of-code</a> Part 2 — Context layer: <a href="https://blog.divyampatro.dev/clearcode-part-2-ast-aware-indexing-vector-stores-and-hybrid-retrieval">https://blog.divyampatro.dev/clearcode-part-2-ast-aware-indexing-vector-stores-and-hybrid-retrieval</a> Part 3 — Memory, agent reasoning, skills, and MCP: <a href="https://blog.divyampatro.dev/clearcode-part-3-memory-agent-reasoning-skills-and-mcp">https://blog.divyampatro.dev/clearcode-part-3-memory-agent-reasoning-skills-and-mcp</a> Part 4 — Tasks layer: <a href="https://blog.divyampatro.dev/clearcode-part-4-autonomous-plan-execution-llm-as-judge-and-human-in-the-loop-approval">https://blog.divyampatro.dev/clearcode-part-4-autonomous-plan-execution-llm-as-judge-and-human-in-the-loop-approval</a></p>
]]></content:encoded></item><item><title><![CDATA[ClearCode Part 4: Autonomous Plan Execution, LLM-as-Judge, and Human-in-the-Loop Approval]]></title><description><![CDATA[Where Part 3 left us
Part 3 built four capabilities: short-term memory with session persistence, multi-step agent reasoning across tool calls, a three-tier progressive skills system, and MCP integrati]]></description><link>https://blog.divyampatro.dev/clearcode-part-4-autonomous-plan-execution-llm-as-judge-and-human-in-the-loop-approval</link><guid isPermaLink="true">https://blog.divyampatro.dev/clearcode-part-4-autonomous-plan-execution-llm-as-judge-and-human-in-the-loop-approval</guid><category><![CDATA[ai agents]]></category><category><![CDATA[Build In Public]]></category><category><![CDATA[langgraph]]></category><category><![CDATA[Python]]></category><category><![CDATA[autonomous agents]]></category><category><![CDATA[Task planning]]></category><dc:creator><![CDATA[Divyam Patro]]></dc:creator><pubDate>Mon, 13 Jul 2026 04:11:37 GMT</pubDate><content:encoded><![CDATA[<h2>Where Part 3 left us</h2>
<p>Part 3 built four capabilities: short-term memory with session persistence, multi-step agent reasoning across tool calls, a three-tier progressive skills system, and MCP integration with GitHub and filesystem servers by default.</p>
<p>The agent could understand a codebase, remember a conversation, apply domain knowledge, and take individual actions via tools. But each action was still in direct response to a user prompt. One question, one answer. One instruction, one file edit.</p>
<p>Part 4 changes the interaction model. The agent can now plan.</p>
<hr />
<h2>The tasks layer</h2>
<p>Six new files, one new command, one qualitative shift in what the agent can do.</p>
<pre><code class="language-plaintext">clearcode/tasks/
├── planner.py      # LLM to structured ExecutionPlan (Pydantic)
├── approval.py     # Human-in-the-loop review: A/M/R loop with Rich table
├── task_store.py   # SQLite WAL store with atomic state transitions
├── executor.py     # Per-task agent with least-privilege tools + LLM-as-judge
├── orchestrator.py # Dependency loop, serial dispatch, retry, re-index after run
└── recovery.py     # Resets IN_PROGRESS to PENDING on restart
</code></pre>
<p>The entry point is <code>/plan</code> . Everything else is invisible to the user until execution begins.</p>
<hr />
<h2>Step 1: Structured plan generation</h2>
<p><code>planner.py</code> calls the LLM with <code>response_format=ExecutionPlan</code>, which is OpenAI's structured output mode. The response is a Pydantic model, not free-form text.</p>
<p><code>ExecutionPlan</code> contains a list of <code>PlannedTask</code> objects. Each task has:</p>
<ul>
<li><p><code>id</code> - a unique string identifier (e.g. task_001)</p>
</li>
<li><p><code>title</code> - a short description of what the task does</p>
</li>
<li><p><code>task_type</code> - one of: design, implement, configure, review, test, integrate</p>
</li>
<li><p><code>dependencies</code> - a list of task IDs that must be complete before this task can start</p>
</li>
<li><p><code>acceptance_criteria</code> - what the LLM judge will verify after execution</p>
</li>
</ul>
<p>The task type is not cosmetic. <code>executor.py</code> uses it to select a least-privilege tool set per task. A design task does not get write access. A test task does not get the ability to create new files. Each task type gets exactly the tools it actually needs.</p>
<p>This is what a real generated plan looks like:</p>
<pre><code class="language-plaintext">&gt; /plan create a portfolio website for a backend engineer with 8 years of
  experience using HTML, CSS, and JavaScript

Planning with LLM...

Plan: backend_engineer_portfolio
Goal: Professional portfolio site with dark theme, hero, skills, timeline,
      projects, and contact section.
Stack: HTML · CSS · JavaScript | Est: 7.5h

  ID        Type        Title                                      Depends on
  task_001  design      Define site content architecture           -
  task_002  implement   Create semantic HTML page structure        task_001
  task_003  configure   Configure design tokens and dark theme     task_002
  task_004  implement   Style hero, navigation, and header         task_003
  task_005  implement   Style content sections                     task_003
  task_006  implement   Smooth scroll and active nav behavior      task_002, task_004
  task_007  implement   Animations and scroll reveal               task_005, task_006
  task_008  implement   Responsive layout refinements              task_004, task_005
  task_009  review      Accessibility and SEO pass                 task_002, task_006
  task_010  test        Validate HTML and CSS quality              task_008, task_009
  task_011  test        Test responsive behavior                   task_010
  task_012  integrate   Final integration and delivery check       task_011
</code></pre>
<p>Twelve tasks. A typed dependency graph. Task types that map to different execution contexts. This is what the LLM returns as a structured Pydantic object before any execution begins.</p>
<hr />
<h2>Step 2: Human-in-the-loop approval</h2>
<p><code>approval.py</code> renders the plan as a Rich table and enters a loop:</p>
<pre><code class="language-plaintext">[A]pprove / [M]odify task / [R]eject and re-plan:
</code></pre>
<img src="https://raw.githubusercontent.com/f2015537/clearcode/main/demo/clearcode-plan-opt.gif" alt="ClearCode /plan demo" style="display:block;margin:0 auto" />

<p><em>Full /plan loop: goal input, plan generation, Rich table approval, serial execution, and re-index confirmation</em></p>
<p>A starts execution. M lets the user edit a specific task's fields before approving. R discards the plan and regenerates from the goal with optional guidance.</p>
<p>Nothing runs until the user approves. This is a design decision, not a missing feature. An autonomous agent that executes before the user has reviewed the plan is not something this architecture is built for.</p>
<p>The approval loop is synchronous by design. It uses <code>rich.Prompt.ask()</code> to block until the user responds, which means it blocks the async event loop during the wait. This is documented in CLAUDE.md as a known flaw. The correct fix is an async input primitive. For now, the blocking is predictable and bounded: it happens once, during approval, before any tool calls run.</p>
<hr />
<h2>Step 3: Persistence before execution</h2>
<p><code>task_store.py</code> writes the approved plan to SQLite before execution begins, not during and not after. WAL mode is enabled for atomic state transitions. Each task has four possible states: PENDING, IN_PROGRESS, COMPLETED, FAILED.</p>
<p>State transitions happen via SQL CASE expressions. The orchestrator never reads a partially updated state. If execution crashes mid-task, the store reflects the last committed state.</p>
<p>Config:</p>
<pre><code class="language-yaml">tasks:
  db_path: .clearcode/tasks/tasks.db
</code></pre>
<p><code>db_path</code> is CWD-relative, which means each project directory gets its own isolated task store.</p>
<hr />
<h2>Step 4: Execution with dependency resolution</h2>
<p><code>orchestrator.py</code> loops over the task graph and dispatches ready tasks: those whose dependencies are all in COMPLETED or SKIPPED state. Tasks execute serially. This is a deliberate tradeoff. Serial execution is predictable, debuggable, and avoids tool contention between concurrent agents. Parallel execution would require explicit locking on the filesystem and the index, neither of which is implemented yet.</p>
<p>For the portfolio plan above:</p>
<pre><code class="language-plaintext">[A]pprove / [M]odify task / [R]eject and re-plan: A

Starting: [task_001] Define site content architecture
Completed: [task_001] Define site content architecture
Starting: [task_002] Create semantic HTML page structure
Completed: [task_002] Create semantic HTML page structure
...
Completed: [task_012] Final integration and delivery check

All 12 tasks completed successfully!

Re-indexing generated files so /ask can query them...
Index updated - you can now use /ask to ask about the generated code.
</code></pre>
<p>After the run, the orchestrator calls the indexer with <code>force=True</code> on any files created during execution. This bypasses the skip-if-exists guard and ensures that <code>/ask</code> queries work against the generated code immediately, without restarting.</p>
<hr />
<h2>Step 5: LLM-as-judge per task</h2>
<p><code>executor.py</code> builds a fresh agent for each task with the tool set appropriate for that task's <code>task_type</code>. After the agent completes the task, a second LLM call evaluates the output against the task's <code>acceptance_criteria</code>.</p>
<p>The judge returns a pass or fail verdict. On pass, the orchestrator marks the task COMPLETED and moves to the next ready task. On fail, <code>fail_task</code> increments the retry count and returns the task to PENDING for another attempt.</p>
<p>Tasks retry up to three times before being marked FAILED. A failed task does not stop the run. The orchestrator marks downstream dependents as SKIPPED and continues with tasks whose dependencies are otherwise satisfied.</p>
<hr />
<h2>Step 6: Crash recovery</h2>
<p><code>recovery.py</code> runs on every startup before any command is processed. It finds all tasks in IN_PROGRESS state and resets them to PENDING. This handles the case where the process crashed mid-execution: the next run picks up from the last completed task rather than leaving the store in an inconsistent state.</p>
<p><code>/task_status</code> shows the current state of all tasks in the active project's store.</p>
<hr />
<h2>The honest flaws</h2>
<p>These are all documented in CLAUDE.md.</p>
<p><strong>Duplicate task IDs from the planner.</strong> The LLM occasionally generates two tasks with the same id (e.g. two tasks both named task_005). <code>task_store.py</code> does a direct INSERT with no deduplication guard. SQLite raises UNIQUE constraint failed after the user has already approved the plan and execution has started. The entire run is lost. The fix is to validate task IDs before the INSERT, not rely on the database constraint to surface the problem after approval.</p>
<p><strong>Off-by-one in fail_task.</strong> <code>fail_task</code> increments <code>retry_count</code> unconditionally before checking whether <code>retry_count &gt;= max_retries</code>. A task that exhausts all three retries ends up with <code>retry_count = 4</code> in the DB, not <code>3</code>. The retry log is misleading but the behavior is correct: the task is still marked FAILED after the third attempt.</p>
<p><strong>get_dep_results omits output_files.</strong> When a task completes, its results (the text summary) are stored alongside a separate <code>output_files</code> field listing files it created. <code>get_dep_results</code> returns only <code>id</code>, <code>title</code>, and <code>result</code>. It omits <code>output_files</code>. A downstream agent that needs to read files written by its dependency has no way to find them. It sees only the text summary, not the actual generated artifacts. This is the most consequential flaw for any plan where one task writes files that another task must read and extend.</p>
<p><strong>Async event loop blocking during approval.</strong> <code>rich.Prompt.ask()</code> is synchronous blocking I/O called inside <code>async def _run_async</code>. The event loop is blocked while waiting for user input at the approval step.</p>
<p><strong>MCP subprocess leak.</strong> <code>MultiServerMCPClient</code> is not used as an async context manager. The MCP stdio subprocesses (npx processes for GitHub and filesystem servers) are started at agent creation time but never explicitly terminated. They run for the lifetime of the REPL process and are cleaned up only when the process exits.</p>
<hr />
<h2>What the demo shows</h2>
<p>The GIF in the README shows the full <code>/plan</code> loop in real time: goal input, plan generation, Rich table display, approval, serial task execution with progress markers, and the re-index confirmation. The full terminal output is reproduced verbatim in the README.</p>
<hr />
<h2>Where Part 4 leaves things</h2>
<p>All seven built layers are now wired together and running: context, agent, memory, MCP, skills, tasks, and the underlying LLM and embedder provider abstraction. The REPL has eight commands (/ask, /plan, /task_status, /show_index, /new_session, /switch, /session, /exit).</p>
<p>Three layers remain: safety, freshness, and eval. The eval layer is the one I am most deliberate about. It is the thing that makes all improvements measurable rather than impressionistic.</p>
<p>Part 5 is not yet decided. As always, drop a preference in the comments.</p>
<p>Full source: <a href="https://github.com/f2015537/clearcode">https://github.com/f2015537/clearcode</a></p>
<p>Part 1 - Architecture before code: <a href="https://blog.divyampatro.dev/clearcode-part-1-reverse-engineering-a-coding-agent-before-writing-a-single-line-of-code">https://blog.divyampatro.dev/clearcode-part-1-reverse-engineering-a-coding-agent-before-writing-a-single-line-of-code</a> Part 2 - Context layer: <a href="https://blog.divyampatro.dev/clearcode-part-2-ast-aware-indexing-vector-stores-and-hybrid-retrieval">https://blog.divyampatro.dev/clearcode-part-2-ast-aware-indexing-vector-stores-and-hybrid-retrieval</a> Part 3 - Memory, MCP, and skills: <a href="https://blog.divyampatro.dev/clearcode-part-3-memory-agent-reasoning-skills-and-mcp">https://blog.divyampatro.dev/clearcode-part-3-memory-agent-reasoning-skills-and-mcp</a></p>
]]></content:encoded></item><item><title><![CDATA[ClearCode Part 3: Memory, Agent Reasoning, Skills, and MCP]]></title><description><![CDATA[Where Part 2 left us
Part 2 built the context layer: AST-aware indexing with tree-sitter across 15 languages, three retrieval backends switchable via a single config field, and honest documented limit]]></description><link>https://blog.divyampatro.dev/clearcode-part-3-memory-agent-reasoning-skills-and-mcp</link><guid isPermaLink="true">https://blog.divyampatro.dev/clearcode-part-3-memory-agent-reasoning-skills-and-mcp</guid><category><![CDATA[ai agents]]></category><category><![CDATA[Build In Public]]></category><category><![CDATA[langgraph]]></category><category><![CDATA[mcp]]></category><category><![CDATA[Python]]></category><category><![CDATA[skills]]></category><category><![CDATA[memory]]></category><dc:creator><![CDATA[Divyam Patro]]></dc:creator><pubDate>Sun, 05 Jul 2026 10:11:25 GMT</pubDate><content:encoded><![CDATA[<h2>Where Part 2 left us</h2>
<p>Part 2 built the context layer: AST-aware indexing with tree-sitter across 15 languages, three retrieval backends switchable via a single config field, and honest documented limitations around module-level constants. ClearCode could understand a codebase.</p>
<p>Part 3 makes it remember, reason, and act.</p>
<p>Four capabilities were built across approximately 12 commits. This post covers each one with the design decisions, the implementation details, and the things that still do not work correctly. One of the flaws documented below was fixed in the final commit before this post was written. That is not accidental — it is part of how this series works.</p>
<hr />
<h2>1. Short-term memory</h2>
<h3>What was built</h3>
<p>Two modules: <code>memory/session.py</code> handles session identity, <code>memory/short_term.py</code> handles persistence and compression.</p>
<p>Sessions are UUID strings, generated on first run and persisted to <code>.memory/current_session</code>. On startup, ClearCode reads this file and resumes the existing session. Three REPL commands surface session management:</p>
<pre><code class="language-plaintext">/new_session        start a fresh conversation
/switch         resume a past session by ID
/session            show the current ID
</code></pre>
<p>Persistence is handled by LangGraph's <code>SqliteSaver</code> checkpointer, which stores the full conversation state in a SQLite database at <code>.memory/memory.db</code>. Every turn is checkpointed. Closing and reopening ClearCode in the same directory resumes exactly where the conversation ended.</p>
<p>Context compression is handled by <code>SummarizationMiddleware</code>, configured via <code>config.yaml</code>:</p>
<pre><code class="language-yaml">memory:
  db_path: .memory/memory.db
  summarize_at_tokens: 4000
  keep_last_messages: 20
</code></pre>
<p>Once the conversation crosses 4000 tokens, the middleware compresses everything before the last 20 messages into a summary. The summary replaces the older messages; the last 20 turns stay verbatim. Token cost is bounded across long sessions while preserving fine-grained detail of recent context.</p>
<h3>What it looks like in practice</h3>
<pre><code class="language-plaintext">&gt; /ask What is the name of the localStorage key this app uses, and what does
  each task object look like — list its exact fields?

The localStorage key is "orbitTasks".

Each task object has three fields:
  - id        unique identifier — crypto.randomUUID() with Date.now() fallback
  - title     the text of the task
  - completed boolean

&gt; /ask Perfect. Now I want to support multiple named lists (Work, Personal).
  Using the storage key you just identified, propose the minimal change.

Instead of the single key "orbitTasks", use a dynamic key based on the list
name — "orbitTasks-Work", "orbitTasks-Personal", and so on.

Two functions need to change:

    function saveTasks(listName) {
        localStorage.setItem(`orbitTasks-${listName}`, JSON.stringify(tasks));
    }

    function addTask(title, listName) {
        tasks.unshift({
            id: crypto.randomUUID ? crypto.randomUUID() : String(Date.now()),
            title,
            completed: false
        });
        saveTasks(listName);
        renderTasks();
    }

Everything else — toggleTask, deleteTask, renderTasks — remains unchanged.
</code></pre>
<p>The second answer picks up "orbitTasks" from the first turn without re-searching. The agent read it from the conversation history that the checkpointer had stored.</p>
<h3>On documenting flaws</h3>
<p>The original implementation of <code>get_checkpointer()</code> opened a new SQLite connection on every call and never closed it. Connections accumulated across the session. It worked — SQLite tolerates concurrent connections to the same file — but the connection count grew without bound.</p>
<p>I documented this in <code>CLAUDE.md</code>. Writing it down made it impossible to publish with it in that state. The fix — a module-level singleton connection — went in as the final commit before this post was written.</p>
<p>This is the part of building in public that people do not always talk about: the act of documenting a flaw clearly enough that it can be explained to a reader is also the act of understanding it clearly enough to fix it. CLAUDE.md has become a forcing function.</p>
<h3>The remaining known flaws</h3>
<p><code>get_session_history()</code> is defined in <code>short_term.py</code> but never called. Dead code. Left in because it documents the interface — the function that would be called if session history needed to be inspected programmatically. It should either be wired up or deleted.</p>
<p><code>memory.db_path</code> is CWD-relative. Running ClearCode from different directories creates separate <code>.memory/</code> folders. This is the right default behaviour for a per-project tool, but it means <code>/switch</code> only works if you launch from the same directory where the session was created.</p>
<p><code>show_index</code> in the Qdrant backends hardcodes <code>limit=1000</code>. For large codebases this silently truncates the displayed index. The correct approach is pagination or a configurable limit.</p>
<hr />
<h2>2. Agent reasoning</h2>
<p>Part 2 built a retrieval system that returned relevant chunks. Part 3 wires the agent to use those chunks as the starting point for multi-step reasoning across tool calls.</p>
<p>The agent chains tools: retrieve context, read a specific file, follow a function reference, read that file, return a complete picture. Retrieval is no longer the end of the pipeline — it is the beginning.</p>
<p>A real example, run against a live codebase:</p>
<pre><code class="language-plaintext">&gt; /ask Trace what happens from the moment a user submits the add-task form to
  when the new task appears on screen. Give me the exact function call chain.

Here's the detailed trace:

1. Event Listener and Form Submission
   The process begins when an event listener captures the form submission event,
   prevented from its default action via event.preventDefault(). Defined in
   todo-app/js/scripts.js, lines 102-115.

2. Task Title Extraction and Verification
   The task title is extracted with taskInput.value.trim(). If the title is
   empty, focus returns to the input field and the function exits.

3. addTask(title) — lines 73-82
   Creates a new task object and unshifts it to the front of the tasks array.
   id is generated with crypto.randomUUID() or String(Date.now()) as a fallback.
   Calls saveTasks() then renderTasks().

4. saveTasks() — lines 14-16
   Serialises the tasks array to localStorage under the key "orbitTasks".

5. renderTasks() — lines 38-71
   Clears the current task list (taskList.innerHTML = ''), re-renders every
   visible task as a 
 with a check button, title span, and delete button,
   then calls updateStats() to refresh the counters.

6. Interface Update
   Input field is cleared and refocused. The empty-state element is toggled
   based on whether any visible tasks remain.
</code></pre>
<p>The agent read <code>scripts.js</code> via the filesystem MCP server, found the exact line ranges, and traced the full call chain — not from memory, but from the actual file.</p>
<p>This is the difference between a retrieval system and an agent. Retrieval returns relevant text. An agent uses that text to decide what to read next.</p>
<hr />
<h2>3. Skills</h2>
<h3>The problem skills solve</h3>
<p>A coding agent pointed at a machine learning repository and asked to add a feature should apply different standards than the same agent pointed at a React component library. The ML repo might have conventions around reproducibility, experiment tracking, and data pipeline structure that the React repo does not. The agent has no way to know this from the code alone.</p>
<p>Skills are the mechanism for injecting that domain knowledge without paying for it on every query.</p>
<h3>Three-tier progressive disclosure</h3>
<p>Skills use a three-tier loading model to keep token costs low while making full expertise available on demand.</p>
<p><strong>Tier 1 — always in the system prompt.</strong> A compact index of skill names, descriptions, and <code>when_to_use</code> trigger keywords. The agent sees this on every query, paying only a few tokens per skill regardless of how detailed the skill body is. Twenty skills cost the same as zero in token terms until one is triggered.</p>
<p><strong>Tier 2 — loaded on match.</strong> When the user's request matches a skill's trigger keywords, the agent calls <code>load_skill(name)</code> to retrieve the full <code>SKILL.md</code> body — complete instructions, decision rules, code templates, and rationale.</p>
<p><strong>Tier 3 — fetched individually.</strong> Skills can ship support files (scripts, templates, reference documentation). After loading Tier 2, the agent sees a listing of available support files and reads only the ones the task actually requires.</p>
<p>Skills live in <code>.clearcode/skills//SKILL.md</code> inside whatever project ClearCode is pointed at. They are project-specific, versioned alongside the code, and visible in the repository.</p>
<h3>What it looks like in practice</h3>
<pre><code class="language-plaintext">&gt; /ask I have a 40-page financial report PDF with embedded tables on every
  page. I need to extract all of them into a single pandas DataFrame.

To extract tables from a multi-page PDF into a single DataFrame, use pdfplumber:

    import pdfplumber
    import pandas as pd

    with pdfplumber.open("your_report.pdf") as pdf:
        all_tables = []
        for page in pdf.pages:
            tables = page.extract_tables()
            for table in tables:
                if table:
                    df = pd.DataFrame(table[1:], columns=table[0])
                    all_tables.append(df)

    combined_df = pd.concat(all_tables, ignore_index=True)

pdfplumber preserves table geometry better than pypdf for dense financial
layouts. If a page has no discernible table structure, extract_tables()
returns an empty list — the guard (if table) handles that safely.
</code></pre>
<p>The <code>pdf</code> skill was loaded from <code>.clearcode/skills/pdf/SKILL.md</code>. The agent did not guess at the library choice — it followed instructions that encode a decision already made: pdfplumber for dense financial layouts, with the reasoning documented in the skill rather than inferred from the query.</p>
<h3>The skill trigger change worth naming</h3>
<p>An early version of the skill trigger instruction was suggestive: "if the request matches, consider loading the skill." Under ambiguous queries, the agent sometimes retrieved a result without loading the relevant skill first.</p>
<p>The trigger was rewritten to be directive: "if the request matches these keywords, you must call load_skill before responding." One word in the system prompt, one word in behaviour: the agent now reliably loads domain knowledge before acting on matched requests.</p>
<hr />
<h2>4. MCP</h2>
<h3>Two servers ship by default</h3>
<p>Two MCP servers are configured in <code>clearcode_mcp_servers.json</code> at launch:</p>
<ul>
<li><p>A GitHub server. Public repos work unauthenticated. Add <code>GITHUB_TOKEN</code> to <code>.env</code> for private repos and write access.</p>
</li>
<li><p>A filesystem server scoped to the current working directory. The agent can read and write files within the project.</p>
</li>
</ul>
<p>Adding a new server — Jira, Linear, Slack, a database — is a single entry in the JSON config. The agent picks it up at next startup with no code changes required.</p>
<pre><code class="language-plaintext">&gt; /ask Go to the GitHub repo f2015537/clearcode and look at the last 5 commits
  on main. Summarise what has been built layer by layer.

Here is a summary of the last 5 commits on the main branch:

- cf98612 — "Add agent, skills, and memory examples to README"
  Documents the system's live capabilities with real output — code navigation,
  skill-driven answers, and session memory continuity.

- 6dd6b3e — "Update CLAUDE.md and README to reflect current project state"
  Marks agent, memory, MCP, and skills layers complete. Removes the local
  filesystem tools now covered by the filesystem MCP server.

- d9db21f — "Add filesystem MCP server; remove local filesystem tools"
  Shifts filesystem operations to a centralised MCP server scoped to CWD —
  same capabilities, no bespoke tool code to maintain.
...
</code></pre>
<p>The agent called GitHub's <code>list_commits</code> tool, retrieved live data from the API, and composed the summary. No local git history involved.</p>
<h3>The architectural change that MCP made possible</h3>
<p>Early in Part 3, I wrote bespoke filesystem tools: <code>read_file</code>, <code>write_file</code>, <code>append_file</code>, <code>delete_file</code>, <code>list_directory</code>, <code>file_exists</code>. Each was a Python function registered with the agent.</p>
<p>Once the MCP filesystem server was added, those tools became redundant. The filesystem server provides the same capabilities through the same protocol the GitHub server uses. The bespoke code was removed.</p>
<p>This is the point of MCP: not just adding capabilities, but changing the cost structure of adding them. A new capability used to mean writing a Python function, handling errors, managing logging, and registering the tool with the agent. Now it means one entry in a JSON file. The agent does not care whether a capability comes from a local function or an MCP server — it calls tools the same way either way.</p>
<hr />
<h2>Where Part 3 leaves things</h2>
<p>The four active capabilities in ClearCode are now: retrieval (dense, sparse, hybrid), agent reasoning (multi-step tool use), skills (three-tier progressive disclosure), and MCP (GitHub and filesystem by default, extensible via config).</p>
<p>The known flaws are documented in <code>CLAUDE.md</code>. The connection leak in <code>get_checkpointer()</code> was fixed before this post was published. The dead code in <code>get_session_history()</code> and the hardcoded <code>limit=1000</code> in <code>show_index</code> are still there — named, not hidden.</p>
<p>Part 4 is not decided yet. The remaining layers are safety, freshness, observability, and eval. If you have a preference on what comes next, drop it in the comments. I read every one.</p>
<p>Full source: <a href="https://github.com/f2015537/clearcode">https://github.com/f2015537/clearcode</a></p>
<p>Part 1 - Architecture before code: <a href="https://blog.divyampatro.dev/clearcode-part-1-reverse-engineering-a-coding-agent-before-writing-a-single-line-of-code">https://blog.divyampatro.dev/clearcode-part-1-reverse-engineering-a-coding-agent-before-writing-a-single-line-of-code</a> Part 2 - Context layer: <a href="https://blog.divyampatro.dev/clearcode-part-2-ast-aware-indexing-vector-stores-and-hybrid-retrieval">https://blog.divyampatro.dev/clearcode-part-2-ast-aware-indexing-vector-stores-and-hybrid-retrieval</a></p>
]]></content:encoded></item><item><title><![CDATA[ClearCode Part 2: AST-Aware Indexing, Vector Stores, and Hybrid Retrieval]]></title><description><![CDATA[Where Part 1 left us
Part 1 covered the architecture before writing any code: the folder structure, the reasoning behind each layer, and the open questions I did not have answers to yet. The context l]]></description><link>https://blog.divyampatro.dev/clearcode-part-2-ast-aware-indexing-vector-stores-and-hybrid-retrieval</link><guid isPermaLink="true">https://blog.divyampatro.dev/clearcode-part-2-ast-aware-indexing-vector-stores-and-hybrid-retrieval</guid><category><![CDATA[RAG ]]></category><category><![CDATA[ai agents]]></category><category><![CDATA[Build In Public]]></category><category><![CDATA[Python]]></category><category><![CDATA[langchain]]></category><category><![CDATA[tree-sitter]]></category><dc:creator><![CDATA[Divyam Patro]]></dc:creator><pubDate>Sun, 28 Jun 2026 07:47:22 GMT</pubDate><content:encoded><![CDATA[<h2>Where Part 1 left us</h2>
<p>Part 1 covered the architecture before writing any code: the folder structure, the reasoning behind each layer, and the open questions I did not have answers to yet. The context layer was the piece I expected to spend the most time on.</p>
<p>That turned out to be correct. This post covers what I built there and what I learned.</p>
<p>The end state: a working RAG-powered code assistant that runs as a local REPL. Point it at any codebase and ask questions in plain English. It indexes the source files, embeds them into a vector store, and answers using retrieved context.</p>
<hr />
<h2>The chunking decision</h2>
<p>The first and most consequential decision in any RAG system for code is how to chunk the source files.</p>
<p>The most common approach in tutorials is RecursiveCharacterTextSplitter: split on a character count, with some overlap. This is the right tool for prose. It is the wrong tool for source code.</p>
<p>Character splits break functions at arbitrary boundaries. They separate docstrings from the function bodies they describe. They mix unrelated code into the same chunk because two functions happened to be close together in the file. The embedding model then has to make sense of a fragment that has no semantic coherence, and retrieval degrades accordingly.</p>
<p>The alternative is structure-aware chunking: use the AST to identify the natural boundaries in the code, and make each named block its own chunk.</p>
<p>I used tree-sitter for this. The <code>code_parser.py</code> module walks the AST of each source file and extracts top-level named blocks - functions, classes, methods - as atomic chunks. For a Python file with 10 functions, you get 10 chunks, each containing the signature, the docstring, and the full body of one function. For a JavaScript file with classes and methods, each method is one chunk.</p>
<p>Two decisions within the chunking strategy are worth explaining:</p>
<p><strong>No nested functions indexed separately.</strong> The <code>_walk</code> function stops recursing the moment it hits a named block node. A nested function is included in its parent's chunk, not indexed as a separate unit. Indexing nested functions separately would duplicate context - the outer function already contains the inner one - and produce misleading chunk boundaries where the inner function appears without its surrounding context.</p>
<p><strong>Fallback for non-code files.</strong> Text files, markdown, config files, and anything without meaningful AST structure fall back to a sliding window chunker. The two strategies are composable: the AST chunker handles source files, the sliding window handles everything else.</p>
<hr />
<h2>The byte offset bug</h2>
<p>This is the detail that cost me the most time.</p>
<p>tree-sitter returns byte offsets when you ask it where a node starts and ends in the source file. Most Python source files are ASCII, so byte offsets and character offsets are identical, and you never notice the difference. The moment a source file contains a multi-byte character - a Unicode string literal, a non-ASCII variable name, a comment with an emoji - byte and character offsets diverge.</p>
<p>If you slice source on character indices using a byte offset, you get the wrong content. The slice is off by however many extra bytes appeared before that point in the file. There is no error. The chunk is silently wrong.</p>
<p>The fix is simple once you understand it: encode the source to bytes first, slice on byte indices, then decode the result. All source slicing in <code>code_parser.py</code> now happens this way.</p>
<pre><code class="language-python">source_bytes = source.encode("utf-8")
chunk_content = source_bytes[start_byte:end_byte].decode("utf-8")
</code></pre>
<p>This is the kind of correctness detail that only surfaces when you run against real-world codebases rather than toy examples. The test suite now includes a file with multi-byte characters to catch regressions.</p>
<hr />
<h2>Three retrieval backends</h2>
<p>The retrieval layer sits behind a factory interface. Switching backends is a single field in <code>config.yaml</code>:</p>
<pre><code class="language-yaml">vector_store:
  retrieval_mode: hybrid  # dense | sparse | hybrid
</code></pre>
<p>Three backends are implemented:</p>
<p><strong>Dense (ChromaDB or Qdrant)</strong> - embeds each chunk with <code>text-embedding-3-small</code> and retrieves by cosine similarity at query time. Best for conceptual and design questions where the question and the answer use different vocabulary.</p>
<p><strong>Sparse (BM25 via fastembed)</strong> - indexes each chunk as a bag of terms and retrieves by keyword overlap. No embedding model required. Best for exact identifier lookups, error string searches, constant name lookups.</p>
<p><strong>Hybrid (Qdrant)</strong> - stores both a dense embedding and a sparse BM25 vector per chunk, and fuses the two scores at query time. Best when a question mixes exact identifiers with conceptual reasoning.</p>
<hr />
<h2>What the difference looks like in practice</h2>
<p>These are real outputs from all three retrieval modes, run against the ClearCode codebase itself.</p>
<p><strong>Question 1: Conceptual design question</strong></p>
<pre><code class="language-plaintext">&gt; /ask what is the philosophy behind not indexing nested functions?

The design philosophy is outlined across code_parser.py and CLAUDE.md. The _walk
function (lines 113–135) stops recursing the moment it hits a named block node,
ensuring that each top-level function or class is indexed as a single coherent
unit. Indexing nested functions separately would duplicate context — the outer
function already contains the inner one — and produce misleading chunk boundaries.

This also simplifies the stable chunk ID scheme (source::name::start_line) used
for idempotent upserts in ChromaDB, where collisions on nested definitions with
the same name would silently overwrite chunks.
</code></pre>
<p>Dense retrieval returned the design rationale from <code>code_parser.py</code> and <code>CLAUDE.md</code> with full context - the <code>_walk</code> function, the reasoning about semantic coherence, and the note about stable chunk IDs.</p>
<p>Sparse retrieval on the same question retrieved auth-related chunks with no connection to the question. "Philosophy", "nested", and "functions" do not co-occur as exact terms in the relevant source locations - the design rationale is written in prose that uses different vocabulary from the question.</p>
<p><strong>Question 2: Exact identifier lookup</strong></p>
<pre><code class="language-plaintext">&gt; /ask what value does CHUNK_OVERLAP have and where is it defined?

CHUNK_OVERLAP is used in the _sliding_window function in
clearcode/context/indexers/code_parser.py at line 154, where it controls the
step size between successive window positions as CHUNK_SIZE - CHUNK_OVERLAP.
</code></pre>
<p>Sparse retrieval found the exact usage site in <code>code_parser.py</code> at line 154, where <code>CHUNK_OVERLAP</code> appears as a literal identifier.</p>
<p>Dense retrieval on the same question found "10-line overlap" in <code>CLAUDE.md</code> where the concept is described in prose - right answer, wrong location. No line reference, no code context. The embedding captured the concept but not the identifier.</p>
<p><strong>Question 3: Mixed identifier and conceptual</strong></p>
<pre><code class="language-plaintext">&gt; /ask how does _extract_name fall back when no identifier node is found,
  and why does that cause a problem with BLOCK_NODE_TYPES like arrow_function?

_extract_name (code_parser.py, lines 138–143) iterates over the children of a
block node looking for a child with type "identifier", "name", or
"property_identifier". If none is found, it returns node.type as a fallback —
so for an arrow_function node with no direct identifier child, the chunk name
becomes "arrow_function".

This is a problem because the identifier for an arrow function (const x = () =&gt; {})
lives on the parent variable_declarator, not on the arrow_function node itself.
The fallback produces a generic, non-unique name that collides across all
anonymous arrow functions in the same file, and makes retrieval results
meaningless for JS codebases heavy in functional patterns.
</code></pre>
<p>Hybrid retrieval retrieved the full picture: the fallback implementation from <code>code_parser.py</code> lines 138-143, and the parent/child AST relationship explanation that makes the arrow_function case a problem.</p>
<p>Dense alone retrieved the conceptual relationship but not the exact implementation. Sparse alone pinned the identifiers but missed the reasoning. Hybrid composed both.</p>
<hr />
<h2>The honest limitation</h2>
<p>Module-level constants defined between functions do not fall inside any named AST node. They are not indexed by the AST chunker.</p>
<p>Ask for the value of <code>CHUNK_OVERLAP</code> defined at module level (lines 44-47 in <code>code_parser.py</code>) and neither dense nor sparse retrieval will find it. Dense found the value in documentation prose. Sparse found the usage site. Neither found the definition.</p>
<p>This is not a bug I missed. It is a tradeoff with a known fix: index module-level statements (constants, imports, top-level assignments) as an additional chunk type per file. That is the next improvement to the context layer.</p>
<p>It is documented explicitly rather than papered over because a system that knows what it does not know is more useful than one that guesses.</p>
<hr />
<h2>Multi-project use</h2>
<p>ChromaDB stores its index in a <code>.chromadb/</code> folder inside whichever directory you run <code>clearcode</code> from. Each project automatically gets its own isolated index.</p>
<p>Qdrant uses a single named collection by default. Running <code>clearcode</code> in a new project reuses the existing collection rather than re-indexing. For multi-project use, ChromaDB is the recommended starting point.</p>
<hr />
<h2>What comes next</h2>
<p>Part 3 is taking shape. The current plan is to focus on memory and tools - giving the agent the ability to remember context across sessions and take real actions on the codebase rather than just answering questions about it.</p>
<p>But I am genuinely curious what you would prioritize. If you were building this, what would you tackle next? Drop a comment - I read every one, and the best suggestions go directly into the roadmap.</p>
<p>Follow along at <a href="https://blog.divyampatro.dev/series/clearcode">https://blog.divyampatro.dev/series/clearcode</a></p>
<p>Full source: <a href="https://github.com/f2015537/clearcode">https://github.com/f2015537/clearcode</a> Part 1: <a href="https://blog.divyampatro.dev/clearcode-part-1-reverse-engineering-a-coding-agent-before-writing-a-single-line-of-code">https://blog.divyampatro.dev/clearcode-part-1-reverse-engineering-a-coding-agent-before-writing-a-single-line-of-code</a></p>
]]></content:encoded></item><item><title><![CDATA[ClearCode Part 1: Reverse Engineering a Coding Agent Before Writing a Single Line of Code]]></title><description><![CDATA[Why I am building this
I use Claude Code every day. For the longest time it felt like a black box.
I type a prompt. Code appears. Files change. Tests run. Pull requests get written. I have no real ide]]></description><link>https://blog.divyampatro.dev/clearcode-part-1-reverse-engineering-a-coding-agent-before-writing-a-single-line-of-code</link><guid isPermaLink="true">https://blog.divyampatro.dev/clearcode-part-1-reverse-engineering-a-coding-agent-before-writing-a-single-line-of-code</guid><category><![CDATA[ai agents]]></category><category><![CDATA[Build In Public]]></category><category><![CDATA[claude-code]]></category><category><![CDATA[Python]]></category><category><![CDATA[llm]]></category><category><![CDATA[architecture]]></category><dc:creator><![CDATA[Divyam Patro]]></dc:creator><pubDate>Thu, 25 Jun 2026 05:35:30 GMT</pubDate><content:encoded><![CDATA[<h2>Why I am building this</h2>
<p>I use Claude Code every day. For the longest time it felt like a black box.</p>
<p>I type a prompt. Code appears. Files change. Tests run. Pull requests get written. I have no real idea what is happening between my input and those outputs.</p>
<p>That is not a complaint. It is a problem I want to solve, for myself, by doing the only thing that has ever actually worked for me when I want to understand something: building it from scratch.</p>
<p>ClearCode is my attempt to reverse engineer a production-grade autonomous coding agent. Not to beat Anthropic. Not to ship a competing product. To understand how these systems actually work under the hood, and to document every decision and dead end publicly so anyone else curious about the same question has a ground-up reference to learn from.</p>
<p>This is part 1: the architecture before the code.</p>
<hr />
<h2>What a coding agent actually needs to do</h2>
<p>Before designing anything, it helps to think clearly about what the problem actually is. A coding agent is not just an LLM with a text editor. It needs to:</p>
<ul>
<li><p>Understand the codebase it is working in, not just the file you are currently looking at</p>
</li>
<li><p>Know which parts of that codebase are relevant to the current task</p>
</li>
<li><p>Keep that understanding current as files change</p>
</li>
<li><p>Take actions: read files, write files, run commands, search, navigate</p>
</li>
<li><p>Reason about multi-step problems where the right next action depends on what previous actions returned</p>
</li>
<li><p>Know when it is done, and when it is stuck</p>
</li>
<li><p>Not do something irreversible without checking first</p>
</li>
</ul>
<p>That list is not exhaustive. But it is enough to start making architectural decisions.</p>
<hr />
<h2>The folder structure</h2>
<p>The first design decision was how to divide the problem into layers. Here is where I landed:</p>
<pre><code class="language-plaintext">clearcode/
│
├── context/                     # Context layer
│   ├── indexers/                # Build indexes over the codebase
│   ├── retrievers/              # Query those indexes
│   └── memory/                  # Short-term and long-term memory
│
├── agent/                       # Agent reasoning layer
│
├── llm/                         # LLM provider abstraction
│
├── tools/                       # Individual tool functions
│
├── mcp/                         # MCP server integrations
│
├── skills/                      # Higher-level composed capabilities
│
├── safety/                      # Safety layer
│
├── freshness/                   # Freshness layer
│
├── observability/               # Observability layer
│
└── eval/                        # Evaluation layer
    ├── datasets/                # Shared golden dataset
    ├── retrieval/               # Recall@k, MRR, NDCG, Hit Rate
    ├── context/                 # Context precision, context recall
    ├── generation/              # Faithfulness, answer relevancy (RAGAS)
    └── agent/                   # Task success rate, step accuracy
</code></pre>
<p>Let me explain the reasoning behind each layer.</p>
<hr />
<h2>The context layer</h2>
<p>This is where I expect to spend the most time and learn the most. A coding agent's understanding of the codebase it is working in is the ceiling for everything else. If the agent retrieves the wrong files, or retrieves the right files but in the wrong form, no amount of reasoning quality will save the output.</p>
<p>The context layer has three parts:</p>
<p><strong>Indexers</strong> build representations of the codebase that can be searched. This is where the chunking strategy decisions from my earlier RAG work become directly relevant. For code, AST-based chunking (preserving functions and classes as atomic units) is almost certainly better than character-based chunking. I expect to spend significant time here.</p>
<p><strong>Retrievers</strong> query those indexes. From my RAG case study, I know that hybrid retrieval (semantic plus BM25) handles the full range of query types better than either alone. An exact function name lookup and a conceptual query about permission logic need different retrieval strategies.</p>
<p><strong>Memory</strong> is the layer I am most excited and most uncertain about. Short-term memory needs to track what has happened in the current session. Long-term memory needs to persist things the agent should remember across sessions. From my LLM memory patterns work, I know the scoping distinction that matters: STM is per-thread, LTM is per-user.</p>
<hr />
<h2>The agent layer</h2>
<p>The agent is the reasoning loop. It decides what to do next, calls a tool, observes the result, and decides what to do after that.</p>
<p>I do not have a firm opinion on the agent architecture yet. The main question is how much structure to impose on the loop: a free-form ReAct-style loop where the LLM decides everything, a more structured plan-then-execute pattern, or something in between. I expect this to be one of the most consequential decisions in the whole project.</p>
<hr />
<h2>The LLM provider abstraction</h2>
<p>This layer exists so that the rest of the system does not know or care which model it is using. From my earlier work with LiteLLM, I know that decoupling the model from the agent logic means you can benchmark different models against the same tasks and switch based on cost, latency, or capability without touching any other layer.</p>
<hr />
<h2>Tools</h2>
<p>Tools are the atomic capabilities the agent can call: read a file, write a file, run a terminal command, search the codebase, look something up. Each tool is a function with a well-defined input and output.</p>
<p>The principle here is the same one I applied in the MCP server project: write the tool logic once, expose it cleanly, and let the agent layer decide when and how to use it.</p>
<hr />
<h2>MCP server integrations</h2>
<p>MCP is the protocol that makes tools portable across agents and frameworks. Rather than binding tools directly to a single agent, wrapping them in an MCP server means they can be used by any MCP-compatible client.</p>
<p>I will wire ClearCode's tools into an MCP server so they are reusable outside the agent loop itself.</p>
<hr />
<h2>Skills</h2>
<p>Skills are composed capabilities built on top of individual tools. "Refactor this function" is a skill that might call read-file, analyse-code, write-file, and run-tests in sequence. "Add a feature" is a skill that might involve planning, searching, writing, and testing.</p>
<p>The distinction between a tool and a skill is the level of composition. Tools are atomic. Skills are workflows.</p>
<hr />
<h2>Safety</h2>
<p>A coding agent that can write and execute code needs guardrails. The safety layer is where I will enforce things like: no running destructive commands without confirmation, no writing outside the project directory, no making network calls without explicit permission.</p>
<p>I do not have a concrete plan for this layer yet. It is one of the genuinely hard problems in autonomous agent design.</p>
<hr />
<h2>Freshness</h2>
<p>The codebase changes as the agent works. A file the agent indexed at the start of a session may be different by the time the agent tries to use that index later in the same session. The freshness layer is responsible for detecting staleness and triggering re-indexing.</p>
<p>This is a problem I have not seen addressed clearly in most agent tutorials. I suspect it is more important in practice than the literature suggests.</p>
<hr />
<h2>Evaluation</h2>
<p>This is the layer I am most deliberate about upfront, because it is the one that gets skipped most often in agent projects. The eval layer has four sub-layers, each measuring a different dimension:</p>
<p><strong>Retrieval:</strong> Recall@k, MRR, NDCG, Hit Rate. Does the context layer return the right files?</p>
<p><strong>Context:</strong> Context precision and context recall. Is the retrieved context relevant, and is it complete?</p>
<p><strong>Generation:</strong> Faithfulness and answer relevancy via RAGAS. Does the agent's output actually reflect what it retrieved, and does it answer the question?</p>
<p><strong>Agent:</strong> Task success rate and step accuracy. Does the agent complete the task correctly, and does it take reasonable steps to get there?</p>
<p>Without this layer, I cannot tell whether the changes I make to the context pipeline or the agent loop are actually improving anything.</p>
<hr />
<h2>What I do not know yet</h2>
<p>Quite a lot. The folder structure above represents the shape of the problem as I understand it today. Some of those layers will turn out to be more complex than I expect. Some will be simpler. Some of the things I think I need will turn out to be unnecessary. Some things I have not thought of yet will turn out to be critical.</p>
<p>That is the point of building this in public. The unknown unknowns are more interesting than the known unknowns, and the only way to find them is to start.</p>
<hr />
<h2>What comes next</h2>
<p>Part 2 will cover the context layer: how to build an index over a codebase, what chunking strategy to use for code specifically, and how to query it. That is where the building actually starts.</p>
<p>Source code (work in progress): <a href="https://github.com/f2015537/clearcode">https://github.com/f2015537/clearcode</a></p>
<p>If you have ever wondered how tools like Claude Code or Cursor work under the hood, follow along. I am going to find out.</p>
]]></content:encoded></item><item><title><![CDATA[Memory in LLM Agents, Explained: From Stateless Calls to Long-Term Memory]]></title><description><![CDATA[Introduction
Most explanations of agent memory start at the wrong altitude. They jump straight to "use a vector database" without explaining what problem that solves, or why a vector database is somet]]></description><link>https://blog.divyampatro.dev/memory-in-llm-agents-explained-from-stateless-calls-to-long-term-memory</link><guid isPermaLink="true">https://blog.divyampatro.dev/memory-in-llm-agents-explained-from-stateless-calls-to-long-term-memory</guid><category><![CDATA[AI]]></category><category><![CDATA[llm]]></category><category><![CDATA[langchain]]></category><category><![CDATA[langgraph]]></category><category><![CDATA[ai agents]]></category><category><![CDATA[Python]]></category><dc:creator><![CDATA[Divyam Patro]]></dc:creator><pubDate>Sun, 21 Jun 2026 07:22:36 GMT</pubDate><content:encoded><![CDATA[<h2>Introduction</h2>
<p>Most explanations of agent memory start at the wrong altitude. They jump straight to "use a vector database" without explaining what problem that solves, or why a vector database is sometimes the wrong tool entirely.</p>
<p>This post walks through memory the way I built it: as a progression of seven small, runnable scripts, starting from a completely stateless LLM call and ending at a two-layer system with separate short-term and long-term memory. Each step introduces exactly one new concept. By the end, the line between short-term and long-term memory stops being fuzzy and becomes a concrete architectural decision: what is scoped to a thread, and what is scoped to a user.</p>
<p>All code referenced here is in the open-source repo at the end of this post. Every script is self-contained and runnable on its own.</p>
<hr />
<h2>1. Stateless: the baseline problem</h2>
<p>The simplest possible setup is a single call to an LLM with no history attached. Tell it your name, ask it your name in the next message, and it has no idea. Every call starts from zero.</p>
<p>This is not a bug. It is the literal absence of memory, and it is the baseline every other pattern in this guide is solving for.</p>
<hr />
<h2>2. Short-term memory in RAM</h2>
<p>The most direct fix: keep the full conversation in a Python list, and send that list with every call to the model.</p>
<pre><code class="language-python">history.append({"role": "user", "content": user_input})
response = llm.invoke(history)
history.append({"role": "assistant", "content": response.content})
</code></pre>
<p>Now the model can recall earlier turns, because it is literally being shown them every time. The catch: this history lives in process memory. Restart the script and it is gone. This pattern is memory in the loosest sense, useful for a single uninterrupted session, useless across sessions.</p>
<hr />
<h2>3. Persistence and threads</h2>
<p>The next step is durability. Write the conversation history to SQLite after every turn, and reload it on startup.</p>
<p>This alone solves the restart problem. The second piece is the thread_id: rather than one global history, each thread_id is its own isolated conversation. Multiple conversations can live in the same store without bleeding into each other, and you can switch between them on demand.</p>
<p>At this point memory has gone from "exists only while the process is running" to "exists independently of the process, scoped to a conversation."</p>
<hr />
<h2>4. Sliding window</h2>
<p>A real conversation grows without bound, and sending the full history on every call gets expensive fast, both in tokens and in latency. The sliding window pattern fixes the cost problem directly: a <code>@before_model</code> middleware hook trims the stored history down to the last N messages right before every call to the model.</p>
<pre><code class="language-python">@before_model
def trim_to_window(messages):
    return messages[-WINDOW:]
</code></pre>
<p>Token cost is now bounded, regardless of how long the conversation runs. The tradeoff is blunt: anything said more than N turns ago is permanently inaccessible to the model. If the user mentioned an important constraint 10 turns ago and the window is 4, that constraint is gone.</p>
<p>This is the right tool when recent context is what matters and the conversation is expected to be short or self-contained.</p>
<hr />
<h2>5. Summarization</h2>
<p>Summarization is the more graceful version of the same idea. Instead of discarding old messages outright, a <code>@before_model</code> middleware hook compresses them into a short summary once the stored message count crosses a threshold, while keeping the most recent few messages verbatim alongside that summary.</p>
<pre><code class="language-python">@before_model
def summarize_if_needed(messages):
    if len(messages) &gt; SUMMARIZE_AFTER:
        old, recent = messages[:-KEEP_RECENT], messages[-KEEP_RECENT:]
        summary = llm.invoke([SUMMARY_PROMPT, *old])
        return [summary_message(summary), *recent]
    return messages
</code></pre>
<p>This retains the gist of everything that happened earlier in the conversation, at the cost of losing fine-grained detail. A sliding window would have kept the literal text of the last N turns and nothing before that; summarization keeps a compressed signal of everything before that, plus the literal text of the most recent few turns.</p>
<p>Neither pattern is strictly better. Sliding window is cheaper and simpler. Summarization preserves more of the conversation's shape, at the cost of an extra LLM call to generate the summary.</p>
<hr />
<h2>6. Long-term memory: the user profile</h2>
<p>Every pattern so far is still short-term memory. It lives within one conversation thread and, even with persistence, none of it is shared if the user starts a different thread.</p>
<p>Long-term memory is a different scope entirely: durable facts about a user that should be available no matter which conversation they are currently having. The pattern here uses a second LLM call after every turn to extract durable facts (name, job, stated preferences) and write them to a key-value store (LangGraph's <code>SqliteStore</code>):</p>
<pre><code class="language-python">facts = extract_facts_llm.invoke([extraction_prompt, *recent_turns])
store.put(namespace=("user", user_id), key="profile", value=facts)
</code></pre>
<p>On the next session, even a completely separate thread, those facts are loaded from the store and injected into the system prompt. This is the first pattern in the series where memory survives across sessions that have no shared conversation history at all.</p>
<hr />
<h2>7. Combining short-term and long-term memory</h2>
<p>The full architecture uses both layers together, each with a different scope:</p>
<ul>
<li><p><strong>Short-term memory</strong>, via <code>SqliteSaver</code>: conversation history, scoped to a <code>thread_id</code>. Restored automatically on reconnect to the same thread.</p>
</li>
<li><p><strong>Long-term memory</strong>, via <code>SqliteStore</code>: durable behavioral rules, scoped to a <code>user_id</code>. The agent saves rules autonomously via a <code>save_rule</code> tool, and those rules apply to every future session for that user, regardless of thread.</p>
</li>
</ul>
<p>The clarifying detail: open a new thread for the same user, and the conversation history resets, but the rules that user taught the agent in a previous thread are already active. Switch to a different user entirely, and that user inherits none of the first user's rules. Each user's long-term memory is fully isolated.</p>
<p>This is the insight that took the fuzziness out of "short-term vs long-term memory" for me. They are not the same kind of memory at different durations. They are scoped along different dimensions: STM resets per thread, LTM persists per user across every thread that user ever opens.</p>
<hr />
<h2>Choosing a pattern</h2>
<table>
<thead>
<tr>
<th>Situation</th>
<th>Pattern</th>
</tr>
</thead>
<tbody><tr>
<td>Single-session prototype, no persistence needed</td>
<td>STM in RAM</td>
</tr>
<tr>
<td>Conversation needs to survive restarts</td>
<td>STM plus SQLite with threads</td>
</tr>
<tr>
<td>Long conversations, recent context matters most, cost-sensitive</td>
<td>Sliding window</td>
</tr>
<tr>
<td>Long conversations, full context shape matters</td>
<td>Summarization</td>
</tr>
<tr>
<td>Facts about the user should persist across sessions</td>
<td>Long-term memory, user profile</td>
</tr>
<tr>
<td>Production agent with both ongoing conversations and persistent user behavior</td>
<td>STM and LTM combined</td>
</tr>
</tbody></table>
<hr />
<h2>Further reading</h2>
<p>This series draws directly on LangChain and LangGraph's memory primitives. For the underlying API reference:</p>
<ul>
<li><p><a href="https://docs.langchain.com/oss/python/langchain/short-term-memory">LangChain short-term memory</a></p>
</li>
<li><p><a href="https://docs.langchain.com/oss/python/langchain/short-term-memory#before-model">Before-model middleware</a></p>
</li>
<li><p><a href="https://docs.langchain.com/oss/python/langchain/middleware/overview">Middleware overview</a></p>
</li>
<li><p><a href="https://docs.langchain.com/oss/python/langchain/short-term-memory#summarize-messages">Message summarization</a></p>
</li>
<li><p><a href="https://docs.langchain.com/oss/python/langchain/long-term-memory">LangChain long-term memory</a></p>
</li>
<li><p><a href="https://docs.langchain.com/oss/python/langgraph/stores">LangGraph stores</a></p>
</li>
</ul>
<hr />
<h2>Conclusion</h2>
<p>Memory in LLM applications is not one feature, it is a set of independent tradeoffs: durability versus simplicity, recall versus token cost, thread-scoped versus user-scoped. Building each pattern in isolation, in order, makes those tradeoffs visible rather than hidden behind a single "add memory" abstraction.</p>
<p>Full source, all 7 scripts, self-contained and runnable: <a href="https://github.com/f2015537/llm-memory-patterns">https://github.com/f2015537/llm-memory-patterns</a></p>
]]></content:encoded></item><item><title><![CDATA[PageIndex: Vectorless, Reasoning-Based RAG Explained]]></title><description><![CDATA[Introduction
PageIndex, an open-source project by VectifyAI, is currently the #1 trending repository on GitHub with over 30,000 stars. Its central claim is direct and worth taking seriously: similarit]]></description><link>https://blog.divyampatro.dev/pageindex-vectorless-reasoning-based-rag-explained</link><guid isPermaLink="true">https://blog.divyampatro.dev/pageindex-vectorless-reasoning-based-rag-explained</guid><category><![CDATA[RAG ]]></category><category><![CDATA[AI]]></category><category><![CDATA[llm]]></category><category><![CDATA[vector database]]></category><category><![CDATA[Open Source]]></category><dc:creator><![CDATA[Divyam Patro]]></dc:creator><pubDate>Thu, 18 Jun 2026 12:01:25 GMT</pubDate><content:encoded><![CDATA[<h2>Introduction</h2>
<p>PageIndex, an open-source project by VectifyAI, is currently the #1 trending repository on GitHub with over 30,000 stars. Its central claim is direct and worth taking seriously: similarity is not relevance, and most RAG systems are optimizing for the wrong thing.</p>
<p>This post is my breakdown of how PageIndex works, why the architecture makes sense for a specific class of documents, and where I think the tradeoffs are understated. All credit for the underlying system goes to VectifyAI. Links to the original repository are at the end.</p>
<hr />
<h2>The problem PageIndex is responding to</h2>
<p>Traditional RAG chunks a document into fixed-size windows, embeds each chunk into a vector space, and retrieves whichever chunks are closest to the query's embedding at inference time. This works reasonably well for short, homogenous documents.</p>
<p>It breaks down for long, structured professional documents: financial reports, regulatory filings, legal contracts, technical manuals. A chunk can be lexically and even semantically similar to a query while still being the wrong section to answer it, because professional documents encode relevance through structure (which section, which subsection, which exception clause) that flat vector similarity cannot see.</p>
<p>This is the same failure mode I found in my own RAG case study on code search: a docstring containing the actual answer got split away from its function body by character-based chunking, and the embedding retriever never found it. PageIndex generalizes that observation into a different default: do not chunk at all.</p>
<hr />
<h2>How PageIndex works</h2>
<p>The system has two stages.</p>
<p><strong>Stage 1: Build a hierarchical tree index.</strong></p>
<p>Instead of slicing a document into arbitrary chunks, PageIndex parses it into a tree structure resembling a table of contents. Each node has a title, a node ID, a start and end page range, and an LLM-generated summary of that section's content. Nested sections become nested tree nodes.</p>
<p>This tree is generated once per document, using an LLM (GPT-4o by default in the open-source version) to identify section boundaries and write summaries.</p>
<p><strong>Stage 2: Reasoning-based retrieval through tree search.</strong></p>
<p>At query time, instead of embedding the query and running a similarity search, an LLM reasons over the tree. It reads node summaries, decides which branches are worth exploring further, and navigates down toward the most relevant leaf nodes. This is explicitly compared to AlphaGo in the project's README: tree search guided by a learned evaluator, applied to documents instead of a game board.</p>
<p>The result is retrieval that is traceable. You can see exactly which path through the document tree the LLM took to arrive at an answer, and which page ranges it drew from, rather than an opaque list of cosine similarity scores.</p>
<hr />
<h2>The evidence</h2>
<p>VectifyAI cites a system called Mafin 2.5, built on PageIndex, that scored 98.7% on FinanceBench, a benchmark for financial document question answering. They claim this outperforms vector-based RAG approaches on the same benchmark.</p>
<p>This is a strong, specific, falsifiable claim, which is more than most RAG tooling marketing offers. It is worth treating as a meaningful signal rather than a definitive conclusion. Benchmark performance on FinanceBench specifically does not automatically generalize to every document type, especially ones with less clean hierarchical structure than financial filings.</p>
<hr />
<h2>Where the architecture makes sense</h2>
<p>PageIndex's design assumptions hold up well for documents that already have strong hierarchical structure: SEC filings, regulatory text, academic textbooks, legal contracts, technical manuals. These documents are written with a table of contents in mind. A tree index captures structure that genuinely exists in the source material rather than imposing artificial structure through chunk boundaries.</p>
<p>For this category of document, removing chunking entirely is not a workaround, it is the more accurate representation of how the information is actually organized.</p>
<hr />
<h2>Where I would want more detail before adopting it in production</h2>
<p><strong>Latency and cost at query time.</strong> Vector similarity search is a single embedding call plus a fast nearest-neighbor lookup. Tree search requires the LLM to reason over multiple nodes, potentially across multiple reasoning steps per query. This trades a cheap, fast retrieval step for a more expensive, slower one. Whether that tradeoff is worth it depends entirely on your latency budget and query volume.</p>
<p><strong>Documents without clean hierarchical structure.</strong> Financial filings and legal documents are well-suited to this approach because their structure is the point. Documents that are unstructured prose, internal Slack threads exported to PDF, meeting transcripts, casual documentation, do not have a meaningful tree to build, and PageIndex's self-hosted version's reliance on standard PDF parsing rather than the enhanced OCR pipeline in the paid tier may struggle to extract a useful structure from these.</p>
<p><strong>Scale beyond a single document.</strong> The open-source repo and its core tree search operate on a single document. VectifyAI's newer "PageIndex File System" extension addresses multi-document reasoning, but that capability sits in the commercial layer, not the open-source core covered in this repository.</p>
<hr />
<h2>The broader pattern worth noticing</h2>
<p>This project is evidence of a shift in how the RAG community is thinking about chunking. My own case study on code RAG found that chunking strategy mattered more than retrieval method when the chunking destroyed semantically important context. PageIndex is a more radical version of the same insight: rather than finding the right chunk size, remove the chunking step and index the structure that already exists in the document.</p>
<p>Whether vectorless reasoning-based retrieval becomes a mainstream pattern or remains well-suited to a specific category of structured professional documents is an open question. The architecture is a serious, well-evidenced answer to a real limitation in standard RAG, and it is worth understanding even if you do not adopt it directly.</p>
<hr />
<h2>Credit and links</h2>
<p>All credit for PageIndex goes to VectifyAI and the project's contributors.</p>
<p>Original repository: <a href="https://github.com/VectifyAI/PageIndex">https://github.com/VectifyAI/PageIndex</a> Project homepage: <a href="https://vectify.ai/pageindex">https://vectify.ai/pageindex</a></p>
<p>If this approach is useful to you, consider starring the original repository.</p>
]]></content:encoded></item><item><title><![CDATA[Chunking vs Retrieval: A RAG Case Study on a Real Codebase]]></title><description><![CDATA[Introduction
Most RAG discussions treat chunking and retrieval as separate concerns. Pick a chunk size, pick a retriever, tune k, done. What is less discussed is how much these decisions interact - sp]]></description><link>https://blog.divyampatro.dev/chunking-vs-retrieval-a-rag-case-study-on-a-real-codebase</link><guid isPermaLink="true">https://blog.divyampatro.dev/chunking-vs-retrieval-a-rag-case-study-on-a-real-codebase</guid><category><![CDATA[RAG ]]></category><category><![CDATA[langchain]]></category><category><![CDATA[Python]]></category><category><![CDATA[AI]]></category><category><![CDATA[llm]]></category><category><![CDATA[codesearch]]></category><category><![CDATA[#Embeddings]]></category><dc:creator><![CDATA[Divyam Patro]]></dc:creator><pubDate>Sun, 14 Jun 2026 07:21:11 GMT</pubDate><content:encoded><![CDATA[<h2>Introduction</h2>
<p>Most RAG discussions treat chunking and retrieval as separate concerns. Pick a chunk size, pick a retriever, tune k, done. What is less discussed is how much these decisions interact - specifically, how a bad chunking strategy can make retrieval irrelevant before it even runs.</p>
<p>This case study runs the same three questions through four different RAG configurations on the same Python codebase. Semantic RAG with character chunking, semantic RAG with AST-based chunking, lexical RAG with BM25, and hybrid RAG with Reciprocal Rank Fusion. Same LLM. Same codebase. Only the chunking strategy or retrieval method changes.</p>
<p>The finding: chunking strategy mattered more than retrieval strategy. AST-based chunking won or tied on every query. The retrieval method only became decisive after bad chunking had already destroyed the relevant context.</p>
<hr />
<h2>The setup</h2>
<p>The target codebase is a small Python task-management application with auth, permissions, billing, notifications, audit logging, and a recurring-task scheduler. Thirteen files. Realistic enough to produce interesting retrieval failures.</p>
<p>Every agent is built the same way: a LangChain agent with a single search_codebase tool, instructed to always search before answering and to cite specific files and functions.</p>
<p>Three questions were chosen to stress different aspects of retrieval:</p>
<p>Q1 tests whether a permission rule documented only in a docstring survives chunking. Q2 tests the lexical gap: the question uses different vocabulary from the code. Q3 tests exact identifier lookup, the easiest case for any retriever.</p>
<hr />
<h2>The four strategies</h2>
<p><strong>Script 1: Semantic RAG, character-based chunking</strong> RecursiveCharacterTextSplitter at 256 chars / 32 overlap. OpenAI embeddings. Chroma vector store. 173 chunks from 13 files. This is the baseline most RAG tutorials implement.</p>
<p><strong>Script 3: Semantic RAG, AST-based chunking</strong> Python's ast module parses each file into a syntax tree. Each top-level class or function becomes one chunk - signature, docstring, and body together. 28 chunks from the same 13 files. Far fewer, far larger, semantically coherent units.</p>
<p><strong>Script 5: Lexical RAG, BM25</strong> Same character-based chunks as Script 1 (173 chunks), retrieved with rank_bm25 instead of embeddings. No vector store. No embedding API calls.</p>
<p><strong>Script 6: Hybrid RAG, Reciprocal Rank Fusion</strong> LangChain EnsembleRetriever fusing semantic and BM25 retrievers at equal weights. Same character-based chunks. RRF re-ranks by combining the ranked lists from both retrievers.</p>
<hr />
<h2>Results</h2>
<img src="https://raw.githubusercontent.com/f2015537/agentic-rag-case-study/main/assets/results-table.png" alt="RAG strategy comparison" style="display:block;margin:0 auto" />

<p><em>Four RAG strategies, same codebase, same questions - results from the case study</em></p>
<hr />
<h2>Q1: The docstring question</h2>
<p>The answer ("admin only") lives entirely in the docstring of TaskService.assign_task:</p>
<pre><code class="language-python">def assign_task(self, token: str, task_id: int, assignee_id: int) -&gt; Task:
    """
    Reassign a task to another user.
    Requires task:assign permission (admin only).
    """
</code></pre>
<p>With 256-char chunking, this docstring and the surrounding permission logic are split across multiple character windows, mixed with fragments from main.py seed data and database.py helpers. The embedding retriever returned chunks that mention assignment but never the docstring itself. The agent hedged: "may be admin-only, but not definitively stated."</p>
<p>BM25 and hybrid fared worse. Their top chunks were dominated by unrelated auth.py text, and both returned "I could not find that in the codebase."</p>
<p>With AST-based chunking, the entire TaskService class including that docstring is one chunk. The retriever returns it directly. The agent quotes the docstring verbatim and answers with full confidence.</p>
<p>This is a chunking effect, not a retrieval effect. The retriever in Script 1 was not wrong - it returned the most semantically similar character windows. The relevant information had simply been destroyed by the chunking step before retrieval ran.</p>
<hr />
<h2>Q2: The lexical gap question</h2>
<p>"If a teammate's work gets handed off to someone else, how do they find out?"</p>
<p>The code says "assignee" and "on_task_assigned". The question says "teammate" and "handed off". These share almost no tokens.</p>
<p>BM25 matched on "task" and "find" and returned irrelevant auth.py and integrations chunks. It has no way to bridge the gap between "handed off" and "on_task_assigned" without semantic understanding. Result: failed.</p>
<p>Both embedding retrievers found the correct chunk because "hand off work to a teammate" and "assign a task to a user" are close in embedding space even without shared vocabulary. The semantic representation captures the conceptual similarity that lexical matching cannot.</p>
<p>Hybrid RRF also succeeded. Its semantic half compensated for its BM25 half, pulling the correct chunk into the top-k results.</p>
<hr />
<h2>Q3: The exact identifier question</h2>
<p>"What exception does check_task_limit raise, and when?"</p>
<p>Every strategy got this right. When the query contains the literal function name from the code, BM25's exact term matching is as effective as embeddings and far cheaper - no embedding API calls required.</p>
<p>This is BM25's strongest use case: exact identifier lookups, error string searches, config key lookups. For these queries, adding a semantic retriever adds cost without adding quality.</p>
<hr />
<h2>Metrics: the precision/recall tradeoff</h2>
<p>Script 7 runs a golden set of 6 queries with known-relevant documents against an InMemoryVectorStore at k values of 1, 3, and 5:</p>
<table>
<thead>
<tr>
<th>k</th>
<th>Recall</th>
<th>Precision</th>
<th>MRR</th>
<th>nDCG</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>0.667</td>
<td>0.833</td>
<td>0.833</td>
<td>0.833</td>
</tr>
<tr>
<td>3</td>
<td>0.917</td>
<td>0.444</td>
<td>0.917</td>
<td>1.000</td>
</tr>
<tr>
<td>5</td>
<td>1.000</td>
<td>0.300</td>
<td>0.917</td>
<td>0.942</td>
</tr>
</tbody></table>
<p>Recall climbs toward 1.0 as k grows. Precision falls. This is the expected tradeoff: retrieving more chunks eventually finds every relevant document, but an increasing share of retrieved chunks are irrelevant noise the LLM has to filter out.</p>
<p>The failure at k=3 flags one query whose answer requires synthesising information from two documents. Only one of the two was retrieved. This is a common failure mode in RAG: questions that need multi-document synthesis are harder than questions with a single-document answer.</p>
<hr />
<h2>The decision framework</h2>
<table>
<thead>
<tr>
<th>Use case</th>
<th>Strategy</th>
<th>Reason</th>
</tr>
</thead>
<tbody><tr>
<td>Conceptual questions about code: permissions, contracts, design intent</td>
<td>AST-based chunking</td>
<td>Keeps function signature, docstring, and body as one retrievable unit</td>
</tr>
<tr>
<td>Exact identifier / error string / config key lookup</td>
<td>BM25 lexical</td>
<td>Precise, fast, no embedding cost</td>
</tr>
<tr>
<td>Mixed workload, unpredictable query types</td>
<td>Hybrid RRF</td>
<td>Semantic compensates for lexical gaps; BM25 handles exact matches</td>
</tr>
<tr>
<td>General prose, documentation, not source code</td>
<td>Semantic, char-chunked</td>
<td>Simple and effective when content lacks syntactic structure worth preserving</td>
</tr>
</tbody></table>
<hr />
<h2>What comes next</h2>
<p>The best configuration this case study points to - AST-based chunking combined with hybrid retrieval - was not wired up in this repo. It is the natural next step, and the most likely setup to handle the full range of query types the three test questions represent.</p>
<p>tree-sitter is worth exploring as an alternative to Python's ast module for multi-language codebases. It supports the same unit-based chunking principle with broader language coverage.</p>
<p>A larger golden set would also strengthen the metrics analysis. Six queries is enough to illustrate the precision/recall tradeoff but not enough to draw statistical conclusions about relative strategy performance.</p>
<hr />
<h2>Conclusion</h2>
<p>Chunking strategy and retrieval strategy are not independent decisions. Chunking runs first and sets the ceiling for what retrieval can possibly find. A retriever cannot return information that chunking has destroyed.</p>
<p>For code Q&amp;A, AST-based chunking raises that ceiling dramatically. The retrieval method matters most in the middle - once chunking has preserved the relevant context, and once the query's vocabulary diverges from the code's.</p>
<p>The practical order of operations: get chunking right first. Then tune retrieval.</p>
<p>Full source: <a href="https://github.com/f2015537/agentic-rag-case-study">https://github.com/f2015537/agentic-rag-case-study</a></p>
]]></content:encoded></item><item><title><![CDATA[Building a RAG Chatbot: Every Design Decision Explained]]></title><description><![CDATA[Introduction
RAG (Retrieval-Augmented Generation) is one of those patterns that looks simple on a diagram and gets complicated fast in practice. The chunking strategy, embedding model choice, vector s]]></description><link>https://blog.divyampatro.dev/building-a-rag-chatbot-every-design-decision-explained</link><guid isPermaLink="true">https://blog.divyampatro.dev/building-a-rag-chatbot-every-design-decision-explained</guid><category><![CDATA[RAG ]]></category><category><![CDATA[langchain]]></category><category><![CDATA[chromadb]]></category><category><![CDATA[Python]]></category><category><![CDATA[AI]]></category><category><![CDATA[llm]]></category><category><![CDATA[google adk]]></category><dc:creator><![CDATA[Divyam Patro]]></dc:creator><pubDate>Fri, 05 Jun 2026 03:35:30 GMT</pubDate><content:encoded><![CDATA[<h2>Introduction</h2>
<p>RAG (Retrieval-Augmented Generation) is one of those patterns that looks simple on a diagram and gets complicated fast in practice. The chunking strategy, embedding model choice, vector store selection, and the split between ingestion and retrieval all have real consequences for answer quality, cost, and maintainability.</p>
<p>This post walks through every design decision I made building a RAG chatbot that answers natural language questions about Washington state hiking trails. The domain is specific. The decisions generalise.</p>
<hr />
<h2>Architecture overview</h2>
<p>The system has two distinct stages that run independently:</p>
<pre><code class="language-plaintext">DATA PIPELINE (data_ingestion.ipynb)

Web Sources          LangChain              ChromaDB
───────────      ──────────────────      ──────────────
NPS.gov      →   WebBaseLoader       →   text-embedding
Wikipedia        BeautifulSoup           -3-small
Recreation.gov   RecursiveCharacter      ./chroma_db/
                 TextSplitter            washington_hikes

AGENT RUNTIME (hiking_agent/)

User Query
    │
    ▼
Google ADK Agent  →  retrieve_info()  →  Chroma similarity search (k=5)
(Gemini 2.5 Flash)   FunctionTool
    │
    ▼
Grounded Response
</code></pre>
<p>The ingestion pipeline runs once and produces a persisted Chroma collection. The agent is stateless and reconnects to that collection on every query.</p>
<hr />
<h2>Decision 1: RAG instead of fine-tuning</h2>
<p>Trail conditions, permit availability, and access windows change seasonally. A fine-tuned model would need a full retraining cycle every time the data changes.</p>
<p>RAG sidesteps this entirely. When the data needs refreshing, re-run the ingestion notebook. The agent does not need to be redeployed. The model does not need to be retrained. The knowledge base updates independently of the running system.</p>
<p>This is the core argument for RAG over fine-tuning whenever the underlying data has a meaningful refresh rate: the separation between knowledge and inference is a feature, not a limitation.</p>
<hr />
<h2>Decision 2: Chunk size - 1000 chars / 150 char overlap</h2>
<p>Chunk size is one of the most consequential RAG decisions and one of the least discussed.</p>
<p>Too small: individual chunks lose context. A chunk that says "permits are required" without the surrounding sentence explaining which trail and which season is useless at retrieval time.</p>
<p>Too large: chunks exceed the token budget for retrieval. With k=5 results passed to Gemini, the combined retrieved context needs to fit within the model's context window while leaving room for the system prompt and response.</p>
<p>1000 chars hits the right balance for NPS and Wikipedia content, which tends to be dense and information-rich. The 150-char overlap is not arbitrary either. It prevents information from being lost at chunk boundaries, which matters most when a key fact (a distance, an elevation, a permit requirement) happens to fall at the end of a chunk.</p>
<hr />
<h2>Decision 3: text-embedding-3-small over text-embedding-3-large</h2>
<p>OpenAI's large embedding model improves retrieval recall on English tasks. It also costs approximately 5x more per token than the small model.</p>
<p>On a 640-chunk corpus, the recall improvement is marginal. The retrieval interface is identical between the two models, so swapping later requires changing a single constructor argument. Starting with the cheaper model and benchmarking before upgrading is the correct order of operations.</p>
<hr />
<h2>Decision 4: Chroma local persistent store over a hosted vector DB</h2>
<p>Pinecone, Weaviate, and Qdrant are all excellent options for production RAG systems. For a single-developer showcase project, they introduce infrastructure cost and setup friction with no meaningful benefit.</p>
<p>Chroma's local persistent mode stores the vector collection on disk and reconnects to it on every session. The retrieval API (as_retriever()) is identical to hosted alternatives. Swapping to a hosted backend if this were to scale requires changing a single constructor call.</p>
<hr />
<h2>Decision 5: Decoupled ingestion and agent</h2>
<p>This is the architectural decision with the most operational impact.</p>
<p>The ingestion pipeline is scraping-dependent. It hits 29 URLs, filters and cleans the content, chunks and embeds it, and writes to disk. It is a one-shot operation that can be triggered manually or scheduled via cron or a workflow orchestrator.</p>
<p>The agent has no scraping dependencies at all. It connects to the persisted Chroma store, runs similarity search, and passes the retrieved chunks to Gemini. It can be deployed to any environment that has access to the chroma_db directory without any of the ingestion tooling.</p>
<p>This separation means:</p>
<ul>
<li><p>Refresh the knowledge base without touching the agent</p>
</li>
<li><p>Deploy the agent without any scraping setup</p>
</li>
<li><p>Run the ingestion pipeline on a schedule without coordinating with the live agent</p>
</li>
</ul>
<hr />
<h2>Data sources and the honest limitation</h2>
<p>The ingestion pipeline scrapes 29 URLs across three authoritative sources: NPS.gov for park-level information and seasonal guidance, Wikipedia for rich articles on individual trails and wilderness areas, and Recreation.gov for permit and quota information.</p>
<p>The honest limitation: Washington Trails Association (WTA) is the most comprehensive per-trail database in the state, with difficulty ratings, distances, and elevation profiles for over 10,000 hikes. It is protected by Cloudflare's JS challenge and cannot be reliably scraped with a standard HTTP client.</p>
<p>This is the single highest-impact data improvement available: a Playwright-based scraper that executes the JS challenge and extracts WTA's per-trail data would transform the chatbot's ability to answer specific trail queries (distance, difficulty, elevation for individual hikes) rather than region-level queries.</p>
<p>I documented this limitation explicitly in the README rather than papering over it. A system that knows what it does not know is more useful than one that guesses.</p>
<hr />
<h2>What I would change at larger scale</h2>
<p><strong>Hybrid search.</strong> Dense vector search alone misses exact-match queries. Combining it with BM25 sparse retrieval improves recall on specific trail names like "Rattlesnake Ledge" or "The Enchantments" where keyword matching is more reliable than semantic similarity.</p>
<p><strong>Metadata filtering.</strong> Storing region, difficulty, and distance as Chroma metadata fields would enable structured pre-filtering before semantic search. "Easy hikes in the North Cascades" becomes a metadata filter + semantic search rather than relying entirely on the embedding to do both.</p>
<p><strong>Evaluation harness.</strong> A golden dataset of Q&amp;A pairs with known correct answers would let me measure retrieval precision and answer quality across data refreshes. Without it, I am eyeballing the demo outputs.</p>
<p><strong>Scheduled re-ingestion.</strong> Trail conditions and permit availability are seasonal. A scheduled pipeline that re-ingests the data sources on a weekly or monthly cadence would keep the knowledge base current without manual intervention.</p>
<hr />
<h2>Conclusion</h2>
<p>The RAG pattern is simple. Getting it right requires deliberate decisions on chunking, embeddings, retrieval depth, and the boundary between ingestion and inference. Every one of those decisions has a tradeoff, and the right choice depends on the data characteristics, the query patterns, and the operational constraints of the system.</p>
<p>The honest limitation section in the README is not an apology. It is a specification of what would make the system better, written for the next person who works on it.</p>
<p>Full source: <a href="https://github.com/f2015537/RAG-Chatbot">https://github.com/f2015537/RAG-Chatbot</a></p>
]]></content:encoded></item><item><title><![CDATA[The Necklace That Failed Five Times: Building a Self-Critiquing Multi-Agent Portrait Pipeline]]></title><description><![CDATA[Introduction
Most AI image generation workflows are open-loop: you write a prompt, you get an image, you decide if it's good. If it's not, you tweak the prompt and try again. The feedback loop runs th]]></description><link>https://blog.divyampatro.dev/the-necklace-that-failed-five-times-building-a-self-critiquing-multi-agent-portrait-pipeline</link><guid isPermaLink="true">https://blog.divyampatro.dev/the-necklace-that-failed-five-times-building-a-self-critiquing-multi-agent-portrait-pipeline</guid><dc:creator><![CDATA[Divyam Patro]]></dc:creator><pubDate>Thu, 28 May 2026 18:22:05 GMT</pubDate><content:encoded><![CDATA[<h2>Introduction</h2>
<p>Most AI image generation workflows are open-loop: you write a prompt, you get an image, you decide if it's good. If it's not, you tweak the prompt and try again. The feedback loop runs through the human.</p>
<p>This project closes that loop. It's a multi-agent pipeline built on Google's Agent Development Kit (ADK) that generates portrait images and self-critiques them — iterating autonomously until every visual attribute passes a strict per-attribute evaluation, or the iteration limit is reached.</p>
<p>The necklace failed all five times. That failure is the most interesting result.</p>
<hr />
<h2>Architecture overview</h2>
<p>The pipeline has four components arranged as a SequentialAgent wrapping a LoopAgent:</p>
<pre><code class="language-plaintext">User message
    │
    ▼
IntakeAgent (LlmAgent)
    │  Parses description → state["person_description"]
    ▼
PortraitRefinementLoop (LoopAgent, max 5 iterations)
    │
    ├── PortraitWriterAgent (LlmAgent)
    │       └── generate_image tool
    │             • gemini-3.1-flash-image-preview
    │             • saves PNG to disk + ADK artifact
    │
    └── PortraitCriticAgent (LlmAgent)
            └── critique_image tool
                  • gemini-3.5-flash (vision)
                  • per-attribute evaluation
            └── exit_loop tool
                  • sets actions.escalate = True
</code></pre>
<p>Each agent has a single, clearly scoped responsibility. The writer generates. The critic evaluates. The loop orchestrates. No agent does more than one thing.</p>
<hr />
<h2>Decision 1: Per-attribute evaluation instead of holistic scoring</h2>
<p>The critic doesn't ask "does this image match the description?" It operates in three steps:</p>
<ol>
<li><p>Parse every distinct visual attribute from the description into an explicit sub-checklist — hair colour, iris detail, clothing texture, lighting direction, necklace position, composition.</p>
</li>
<li><p>Verify each attribute individually. A vague resemblance does not pass. The match must be exact and unambiguous.</p>
</li>
<li><p>Apply general photographic standards regardless of description — face fully visible and sharply rendered, professional lighting, portrait-standard composition.</p>
</li>
</ol>
<p>A single failing attribute triggers another iteration with feedback naming exactly what is wrong and why.</p>
<p>This is more expensive than a holistic score. It's also dramatically more precise. The example run demonstrates why.</p>
<hr />
<h2>The necklace problem</h2>
<p>The test description included: <em>"gold chain necklace visible above the collar."</em></p>
<p>The necklace failed all five iterations. Every generated version rendered it draped on the sweater body below the fold — not above the collar as specified.</p>
<p>This is a physically precise spatial constraint. The necklace is present in every image. It's gold. It's near the collar. A holistic "does this match the description?" check would likely pass it.</p>
<p>But <em>above the collar</em> is an exact positional requirement. The image model consistently defaulted to the more statistically common visual — necklace on chest — regardless of what the prompt said. The per-attribute critic caught this every time. A pass/fail prompt would not have.</p>
<img src="https://github.com/f2015537/iterative-portrait-agent/raw/main/assets/example_run/portrait_v1.png" alt="Iteration 1" style="display:block;margin:0 auto" />

<p><em>Iteration 1 — Fail: Gold chain necklace positioned below the turtleneck collar rather than above it</em></p>
<img src="https://github.com/f2015537/iterative-portrait-agent/raw/main/assets/example_run/portrait_v2.png" alt="Iteration 2" style="display:block;margin:0 auto" />

<p><em>Iteration 2 — Fail: Necklace still below collar; iris shows golden-brown inner ring instead of green</em></p>
<img src="https://github.com/f2015537/iterative-portrait-agent/raw/main/assets/example_run/portrait_v3.png" alt="Iteration 3" style="display:block;margin:0 auto" />

<p><em>Iteration 3 — Fail: All other attributes pass; necklace remains draped onto the sweater body below the fold</em></p>
<img src="https://github.com/f2015537/iterative-portrait-agent/raw/main/assets/example_run/portrait_v4.png" alt="Iteration 4" style="display:block;margin:0 auto" />

<p><em>Iteration 4 — Fail: Necklace below collar; shadow cast on wrong cheek; iris lacks defined green inner ring</em></p>
<img src="https://github.com/f2015537/iterative-portrait-agent/raw/main/assets/example_run/portrait_v5.png" alt="Iteration 5" style="display:block;margin:0 auto" />

<p><em>Iteration 5 — Fail: Necklace below collar; eyes appear fully green rather than hazel with a green inner ring</em></p>
<p>The iris detail showed a similar pattern. The description specified "hazel eyes with a distinct green inner ring." The model repeatedly collapsed this to a uniform colour — fully green or fully brown — unable to render the two-tone structure at portrait scale.</p>
<p>These aren't prompt engineering failures. They're the edges of what current image generation models can reliably produce, surfaced by evaluation that's precise enough to find them.</p>
<hr />
<h2>Decision 2: Tools for image bytes, strings for inter-agent communication</h2>
<p>ADK session state is JSON-serialisable. Image bytes are not. This constraint shapes the tool design.</p>
<p>The <code>generate_image</code> tool handles the <code>google.genai</code> API call, saves the PNG to disk, and saves an ADK artifact for inline UI rendering. It returns a file path string to the agent — not image bytes.</p>
<p>The <code>critique_image</code> tool reads the image from disk, sends it to Gemini's vision model alongside the structured evaluation prompt, and returns a text critique string.</p>
<p>All inter-agent communication flows as plain strings: a file path and a critique. The tools own the binary I/O; the agents never touch it. This keeps the session state clean and the agent logic simple.</p>
<hr />
<h2>Decision 3: Async tools</h2>
<p><code>adk web</code> runs on a FastAPI/uvicorn event loop. Image generation via the Gemini API can take several seconds per call.</p>
<p>Making <code>generate_image</code> and <code>critique_image</code> async — using <code>client.aio.models.generate_content</code> — prevents these calls from blocking the server. <code>tool_context.save_artifact()</code> is also an async ADK method, which requires the tool function itself to be async.</p>
<p>This is a small implementation detail that becomes a significant reliability issue at scale. A synchronous image tool in a web-served agent will stall the event loop under any real load.</p>
<hr />
<h2>Decision 4: IntakeAgent as a state bridge</h2>
<p>When running via <code>adk web</code>, the user's input arrives as a chat message in the conversation history — not pre-loaded into session state. Downstream agents that expect <code>state["person_description"]</code> would fail to find it.</p>
<p>The <code>IntakeAgent</code> solves this by reading the conversation and writing a clean, normalised value into session state. Downstream agents consume it via <code>{person_description}</code> template substitution in their system prompts.</p>
<p>This is a pattern worth noting for any ADK pipeline built for <code>adk web</code>: the first agent in the sequence often needs to be a state initialisation agent, bridging between the chat interface and the structured state that the rest of the pipeline expects.</p>
<hr />
<h2>Decision 5: Early exit with escalate</h2>
<p>The <code>LoopAgent</code> runs up to 5 iterations unconditionally unless told to stop. The critic calls <code>exit_loop</code>, which sets <code>tool_context.actions.escalate = True</code>. ADK treats this as a termination signal and breaks the loop.</p>
<p>The practical effect: if the image passes on iteration 2, you pay for 2 Gemini API calls, not 5. Cost and latency scale with the quality of the generation, not the iteration ceiling. For a pipeline that makes multiple API calls per iteration, this matters.</p>
<hr />
<h2>What I'd change at larger scale</h2>
<p><strong>Parallelise critique and generation where possible.</strong> The current loop is strictly sequential. On a pipeline with more agents, some evaluation steps could run in parallel to reduce wall-clock time per iteration.</p>
<p><strong>Add a confidence threshold to the critic.</strong> Currently it's binary — pass or fail. A confidence score per attribute would allow the writer to focus on low-confidence attributes rather than regenerating from scratch on each iteration.</p>
<p><strong>Persist iteration history for analysis.</strong> The current run saves each portrait to disk but doesn't log the full critique history in a queryable format. A structured log of attribute-level pass/fail per iteration would let you analyse which attributes current models struggle with most — useful data for prompt engineering research.</p>
<p><strong>HTTP SSE transport for remote deployment.</strong> The current setup runs locally. Exposing the pipeline as a network-accessible service requires switching the ADK transport and adding authentication.</p>
<hr />
<h2>Conclusion</h2>
<p>Closing the generation-evaluation loop with a per-attribute critic surfaces failure modes that holistic evaluation misses. The necklace-above-the-collar failure isn't a prompt engineering problem — it's the image model's spatial reasoning hitting its limit, made visible by an evaluator precise enough to find it.</p>
<p>The architecture pattern — sequential agents, loop with early exit, tools owning binary I/O — is reusable for any iterative generation task where quality criteria can be made explicit.</p>
<p>Full source + example run images: <a href="https://github.com/f2015537/iterative-portrait-agent">https://github.com/f2015537/iterative-portrait-agent</a></p>
]]></content:encoded></item><item><title><![CDATA[Bridging Google ADK and MCP: Building Framework-Agnostic AI Tools]]></title><description><![CDATA[Introduction
The AI agent ecosystem is fragmented. Every framework — LangChain, Google ADK, CrewAI, AutoGen — has its own tool format, its own abstractions, and its own way of connecting agents to cap]]></description><link>https://blog.divyampatro.dev/bridging-google-adk-and-mcp-building-framework-agnostic-ai-tools</link><guid isPermaLink="true">https://blog.divyampatro.dev/bridging-google-adk-and-mcp-building-framework-agnostic-ai-tools</guid><dc:creator><![CDATA[Divyam Patro]]></dc:creator><pubDate>Sat, 23 May 2026 01:49:39 GMT</pubDate><content:encoded><![CDATA[<h2>Introduction</h2>
<p>The AI agent ecosystem is fragmented. Every framework — LangChain, Google ADK, CrewAI, AutoGen — has its own tool format, its own abstractions, and its own way of connecting agents to capabilities. If you build tools for one framework, you typically can't use them in another without rewriting them.</p>
<p>The Model Context Protocol (MCP) exists to fix this. MCP is an open standard that defines how agents and tools communicate — independent of the framework on either side. This post walks through a project that demonstrates exactly that: a custom MCP server that wraps Google ADK tools and exposes them to any MCP-compatible client.</p>
<hr />
<h2>The problem with tightly coupled tools</h2>
<p>When you define a tool directly inside an ADK agent, it's coupled to that agent. If you want to use the same tool in a different agent, a different framework, or a different process, you have to rewrite it.</p>
<p>This is the same problem that HTTP solved for web services — before a common protocol, every client and server spoke their own language. MCP is that common protocol for AI tools.</p>
<hr />
<h2>Architecture overview</h2>
<p>The project implements a full ADK ↔ MCP integration loop:</p>
<pre><code class="language-plaintext">User
 └─► ADK LlmAgent (Claude Sonnet 4.6 via LiteLLM)
      └─► McpToolset (MCP client)
           └─► my_adk_mcp_server.py (custom MCP server, subprocess)
                ├─► ADK FunctionTool (create_file)
                └─► ADK FunctionTool (create_file_with_content)
</code></pre>
<p>Every layer in this chain communicates over a defined protocol. The agent doesn't know what the tools do — it knows what tools are available via MCP discovery. The MCP server doesn't know what model is driving the agent — it just handles tool calls.</p>
<hr />
<h2>Decision 1: Wrapping ADK FunctionTools in an MCP server</h2>
<p>The MCP server (<code>my_adk_mcp_server.py</code>) is the architectural centrepiece. It takes ADK <code>FunctionTool</code> instances and exposes them as MCP tools — making them available to any MCP-compatible client, not just ADK agents.</p>
<p>This is the key pattern: <strong>write tools once in ADK, expose them everywhere via MCP.</strong></p>
<p>The two tools exposed in this project are intentionally simple — <code>create_file</code> and <code>create_file_with_content</code>. The simplicity is deliberate: the point is the pattern, not the tools. Swap in any ADK FunctionTool and the architecture is identical.</p>
<hr />
<h2>Decision 2: stdio transport</h2>
<p>MCP supports multiple transport mechanisms. This project uses stdio — the agent spawns the MCP server as a subprocess and communicates over stdin/stdout.</p>
<p>stdio is the right choice for local development and single-machine deployments:</p>
<ul>
<li><p>No network ports to configure</p>
</li>
<li><p>No service discovery</p>
</li>
<li><p>No authentication setup</p>
</li>
<li><p>The server process lifecycle is managed by the agent</p>
</li>
</ul>
<p>The tradeoff is that stdio doesn't work for distributed deployments where the agent and server run on different machines. For production systems with remote tool servers, HTTP SSE or WebSocket transport would be appropriate. For this use case, stdio is the simplest correct option.</p>
<hr />
<h2>Decision 3: LiteLLM for model flexibility</h2>
<p>Google ADK natively targets Gemini models. This project routes through LiteLLM, which means the agent can be pointed at any LLM — Claude Sonnet 4.6, GPT-4o, Mistral, or any other LiteLLM-supported model — without changing the agent logic.</p>
<p>The model becomes a configuration detail:</p>
<pre><code class="language-python"># In agent.py
model="claude-sonnet-4-6"  # swap this for any LiteLLM-supported model
</code></pre>
<p>This is a small change with significant architectural implications. You can now benchmark different models against the same agent and tool setup, or switch models based on cost and latency requirements, without touching the agent or tool code.</p>
<hr />
<h2>Decision 4: McpToolset as the MCP client</h2>
<p>Inside the ADK agent, <code>McpToolset</code> acts as the MCP client. It handles the connection to the MCP server, discovers available tools, and translates MCP tool calls into ADK-compatible invocations.</p>
<p>This means the agent definition is clean:</p>
<pre><code class="language-python"># agent.py — simplified
agent = LlmAgent(
    model="claude-sonnet-4-6",
    tools=[McpToolset(server_params=StdioServerParameters(
        command="python",
        args=["my_adk_mcp_server.py"]
    ))]
)
</code></pre>
<p>The agent doesn't enumerate its tools directly — it discovers them at runtime via MCP. Add a new tool to the MCP server and the agent picks it up automatically.</p>
<hr />
<h2>Running the agent</h2>
<pre><code class="language-bash"># CLI
adk run pilot_agent

# Web UI
adk web
# Open http://localhost:8000, select pilot_agent
</code></pre>
<p>Example interactions:</p>
<pre><code class="language-plaintext">&gt; Create a file called notes.txt
&gt; Create a file called report.md with content "Hello, World!"
</code></pre>
<p>The agent resolves the intent, selects the appropriate MCP tool, and the MCP server executes it.</p>
<hr />
<h2>What I'd extend at larger scale</h2>
<p><strong>Add more ADK FunctionTools to the MCP server.</strong> The current tools are deliberately minimal. The pattern scales linearly — each new ADK tool added to the server becomes available to every MCP client automatically.</p>
<p><strong>Switch to HTTP SSE transport for distributed deployments.</strong> stdio works for local development but isn't suitable for scenarios where the agent and tool server run in different environments. HTTP SSE transport makes the MCP server network-accessible.</p>
<p><strong>Add tool authentication.</strong> The current setup assumes a trusted local environment. Production MCP servers exposed over a network need authentication — OAuth or API key validation at the MCP layer.</p>
<p><strong>Multi-agent coordination.</strong> The MCP server can serve multiple agents simultaneously. This opens up multi-agent architectures where specialised agents share a common tool layer without duplicating tool implementations.</p>
<hr />
<h2>Conclusion</h2>
<p>ADK and MCP are complementary, not competing. Wrapping ADK tools in an MCP server gives you the best of both: ADK's agent framework and tool primitives, with MCP's protocol-level interoperability.</p>
<p>The pattern is simple, the separation is clean, and the architecture scales naturally as you add more tools and more agents.</p>
<p>Full source: <a href="https://github.com/f2015537/building-agents-with-google-adk">https://github.com/f2015537/building-agents-with-google-adk</a></p>
]]></content:encoded></item><item><title><![CDATA[Building a Serverless Full-Stack App on AWS with CDK: Architecture Decisions and Tradeoffs]]></title><description><![CDATA[Introduction
Infrastructure-as-code has a reputation for complexity. But when it's done well — with a typed language, a composable abstraction layer, and clear stack boundaries — it becomes the most r]]></description><link>https://blog.divyampatro.dev/building-a-serverless-full-stack-app-on-aws-with-cdk-architecture-decisions-and-tradeoffs</link><guid isPermaLink="true">https://blog.divyampatro.dev/building-a-serverless-full-stack-app-on-aws-with-cdk-architecture-decisions-and-tradeoffs</guid><dc:creator><![CDATA[Divyam Patro]]></dc:creator><pubDate>Wed, 20 May 2026 05:23:11 GMT</pubDate><content:encoded><![CDATA[<h2>Introduction</h2>
<p>Infrastructure-as-code has a reputation for complexity. But when it's done well — with a typed language, a composable abstraction layer, and clear stack boundaries — it becomes the most reliable way to build and reproduce cloud infrastructure at any scale.</p>
<p>This post walks through the architecture of Space Finder: a full-stack AWS application for listing and discovering venues. Every piece of infrastructure is defined in TypeScript using the AWS CDK, deployed across six stacks. I'll cover the key design decisions, the tradeoffs I made, and what I'd change at larger scale.</p>
<hr />
<h2>Architecture overview</h2>
<p>The application has two main surfaces: a React 19 frontend served via CloudFront, and a serverless API backed by API Gateway, Lambda, and DynamoDB.</p>
<pre><code class="language-plaintext">Browser
  │
  ├── CloudFront → S3 (React SPA)
  │
  ├── API Gateway /spaceFinder ←── Cognito JWT authorizer
  │     └── Lambda (Node 20)
  │           └── DynamoDB
  │
  ├── S3 (photos) ←── direct upload via Cognito Identity Pool credentials
  │
  └── Cognito User Pool ←── SES for verification emails
</code></pre>
<p>Six CDK stacks each own a single concern: auth, database, API, storage, frontend hosting, and monitoring. This makes individual stacks deployable and testable in isolation.</p>
<hr />
<h2>Decision 1: Direct browser-to-S3 uploads</h2>
<p>This is the decision that shaped the most of the architecture.</p>
<p>Lambda has a 6 MB payload limit on API Gateway-proxied requests. Routing photo uploads through the backend would mean either hitting that limit on moderately-sized images, chunking uploads in the client, or running a separate upload service. None of these are good options for a lean stack.</p>
<p>The alternative: after a user signs in, Amplify's <code>fetchAuthSession()</code> returns temporary AWS credentials from the Cognito Identity Pool. These credentials carry an IAM role that grants scoped S3 write access. The browser uploads directly to S3 using the AWS SDK v3 — the backend is never in the data path.</p>
<p>This eliminates unnecessary data transfer costs, removes Lambda from the failure surface for uploads, and sidesteps the payload limit entirely. The tradeoff is that the frontend carries more responsibility: it must handle S3 upload state, retries, and progress tracking.</p>
<hr />
<h2>Decision 2: Per-user data isolation at the DynamoDB layer</h2>
<p>A common pattern for multi-tenant data isolation is to enforce access control in application logic — check the requesting user's ID against the resource owner before returning data. This works, but it's fragile. Logic drifts. Edge cases accumulate.</p>
<p>In Space Finder, isolation is enforced at the query layer. API Gateway validates the Cognito JWT before the request reaches Lambda. The Lambda then reads the <code>sub</code> claim from the authorizer context — a stable, unique identifier per user — and stamps it onto every DynamoDB write. Reads filter by it.</p>
<p>There's no application logic that can accidentally return another user's spaces, because the query itself is scoped. This is a simpler invariant to reason about and audit.</p>
<hr />
<h2>Decision 3: Single Lambda for all CRUD</h2>
<p>The Lambda handler routes on <code>httpMethod</code> internally — GET, POST, PUT, and DELETE are all handled in one function.</p>
<p>The conventional microservices pattern would give each operation its own Lambda. That's the right call at scale: independent deployment, independent scaling, independent cold start profiles. But for a solo project with modest traffic, one handler means one deployment, one log stream to tail, and one cold start to reason about.</p>
<p>This is a deliberate tradeoff, not an oversight. The codebase is structured to make the migration straightforward: each route's logic lives in its own module, so splitting into separate Lambdas later is a refactor, not a rewrite.</p>
<hr />
<h2>Decision 4: Monitoring wired into CDK</h2>
<p>Observability is not an afterthought here. A CloudWatch alarm monitors the API error rate. When it fires, it publishes to an SNS topic, which triggers a dedicated Lambda that POSTs to a Slack webhook.</p>
<p>The entire chain is defined in CDK. There are no manual console configurations to drift from the code. A new deployment recreates the monitoring stack from scratch if needed.</p>
<p>SES is used for transactional email during signup. By default, SES runs in sandbox mode — it only sends to verified addresses. Requesting production access via the AWS console lifts this restriction, but the sandbox is sufficient for development and demo purposes.</p>
<hr />
<h2>Deployment</h2>
<p>The full infrastructure deploys with three commands:</p>
<pre><code class="language-bash">npm install
cdk bootstrap   # first time only
cdk deploy --all
</code></pre>
<p>CDK outputs the API URL, Cognito pool IDs, and CloudFront domain after each deployment. These populate the <code>.env</code> files for both the backend test scripts and the Vite frontend build.</p>
<p>The <code>UIDeploymentStack</code> handles the frontend: it builds the React app, uploads the assets to S3, and automatically invalidates the CloudFront cache. No manual cache invalidation step.</p>
<hr />
<h2>What I'd change at larger scale</h2>
<p><strong>Separate Lambdas per route.</strong> The single-Lambda approach is pragmatic but doesn't scale operationally. As traffic grows, independent scaling and deployment become important.</p>
<p><strong>DynamoDB single-table design.</strong> The current schema partitions spaces per user with a straightforward key structure. A single-table design with composite keys and GSIs would support more access patterns without additional tables.</p>
<p><strong>SES production access from day one.</strong> The sandbox restriction catches developers off-guard. Requesting production access early avoids surprises when real users try to sign up.</p>
<p><strong>End-to-end tests against the deployed API.</strong> The current test directory contains manual HTTP scripts. Automated integration tests running against the live stack would close the confidence gap between CDK deploy and production readiness.</p>
<hr />
<h2>Conclusion</h2>
<p>AWS CDK with TypeScript makes cloud infrastructure feel like application code — typed, composable, and version-controlled. The six-stack structure keeps concerns separate without adding operational overhead. The direct S3 upload pattern and DynamoDB-layer isolation are patterns worth carrying into any multi-tenant AWS application.</p>
<p>The full source is on GitHub: <a href="https://github.com/f2015537/space-finder">github.com/f2015537/space-finder</a></p>
<p>Live demo: <a href="https://d24kqex7dx8ru7.cloudfront.net">https://d24kqex7dx8ru7.cloudfront.net</a></p>
]]></content:encoded></item></channel></rss>