<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Saurabh Singh</title>
    <description>The latest articles on DEV Community by Saurabh Singh (@saurabh_singh_86cdc588e85).</description>
    <link>https://dev.to/saurabh_singh_86cdc588e85</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4025684%2Fedda0cd1-38a6-47a1-9eae-cd423d2a65a7.png</url>
      <title>DEV Community: Saurabh Singh</title>
      <link>https://dev.to/saurabh_singh_86cdc588e85</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/saurabh_singh_86cdc588e85"/>
    <language>en</language>
    <item>
      <title>My search agent reads 20 messages to answer one question</title>
      <dc:creator>Saurabh Singh</dc:creator>
      <pubDate>Fri, 14 Aug 2026 19:04:16 +0000</pubDate>
      <link>https://dev.to/saurabh_singh_86cdc588e85/my-search-agent-reads-20-messages-to-answer-one-question-okd</link>
      <guid>https://dev.to/saurabh_singh_86cdc588e85/my-search-agent-reads-20-messages-to-answer-one-question-okd</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; I am at the very beginning of building agent infrastructure. I built &lt;a href="https://github.com/Saurabhsing21/Lumina" rel="noopener noreferrer"&gt;Lumina&lt;/a&gt;, a search agent that streams cited answers, and gave it memory with one line: &lt;code&gt;messages.slice(-20)&lt;/code&gt;.&lt;br&gt;
 It worked, for a while. Then I noticed it citing sources that did not exist. Chasing that one bug took me through token budgets, a hidden formatting marker, an ordering bug, and eventually a question I hadn't actually answered for myself: does a search agent need "real" memory at all? You do not need a vector database to give an agent memory. You do need to know exactly what you are putting inside the window you send it, and that turned out to be the harder problem.&lt;/p&gt;



&lt;p&gt;I was building Lumina, a search agent that streams cited, sourced answers back to the user (think a small, self-hosted version of the "ask a question, get an answer with &lt;code&gt;[1][2]&lt;/code&gt; next to it" pattern). Solo project, no infra budget, no team to argue with about architecture. When I asked around about how to give an agent memory, almost everyone said some version of the same thing: embeddings, a vector store, retrieval over past turns. That's the default answer now. It's treated less like a design decision and more like a checkbox.&lt;/p&gt;

&lt;p&gt;Which sounded to me like: you need a second piece of infrastructure before your agent is allowed to remember a follow-up question.&lt;/p&gt;

&lt;p&gt;So I ignored that and wrote the simplest thing I could defend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// backend/src/agent/agent-runner.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_HISTORY_MESSAGES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;historyFromDbMessages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;MAX_HISTORY_MESSAGES&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;User&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;assistant&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4jnlx8ghgkovo5404voh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4jnlx8ghgkovo5404voh.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;br&gt;
Last 20 messages, sent straight through. No ranking, no similarity search, no separate memory store. I asked a question, then a follow-up, and it resolved correctly. I asked a second follow-up. Also fine. I assumed memory was, more or less, a solved problem for this project, and I moved on to streaming and citation UI.&lt;/p&gt;

&lt;p&gt;A few days later I was reading through a longer thread and noticed the model citing &lt;code&gt;[1]&lt;/code&gt; and &lt;code&gt;[2]&lt;/code&gt; on a turn where it had not called &lt;code&gt;web_search&lt;/code&gt; at all.&lt;/p&gt;

&lt;p&gt;That stopped me. Not "why is this slow," not even "why is this wrong," specifically: where did that citation come from? The agent hadn't retrieved anything this turn. It had no sources this turn. And yet there they were, formatted exactly like real citations, sitting in the answer.&lt;/p&gt;

&lt;p&gt;That question took over the next few days. This is what I found.&lt;/p&gt;
&lt;h2&gt;
  
  
  Question 1: Why cap the history at all?
&lt;/h2&gt;

&lt;p&gt;The obvious answer is tokens. That's true, but it's not the whole answer, and it wasn't actually the reason I picked 20 specifically.&lt;/p&gt;

&lt;p&gt;Lumina's agent loop is tool first: on every turn, the model looks at the system prompt, the conversation history, and a &lt;code&gt;web_search&lt;/code&gt; tool definition, and decides for itself whether it needs to search or can answer from what's already there. It makes that decision by reading the history directly. There's no separate classifier or router deciding "this needs a search," it's the same LLM call making the judgment inline.&lt;/p&gt;

&lt;p&gt;That means an unbounded history doesn't just cost tokens. It changes the decision surface. A ten turns ago tangent sitting in context can quietly shift whether the model thinks the current question is "already answered" or needs fresh information. Rough math: if an average exchange (user question plus assistant answer) runs somewhere around 150 to 300 tokens once you include the citation formatting, a 20 message window lands around 3,000 to 6,000 tokens of history before the current question is even added. That's a manageable, predictable slice to reason over. Send the full history of a long running thread instead, and you're asking the model to weigh a hundred turn old detail against the actual question in front of it, on every single turn, forever.&lt;/p&gt;

&lt;p&gt;So the cap isn't really a memory size decision. It's a decision about how much of the past gets a vote in what the agent does right now. Twenty was a guess, enough turns to resolve a follow-up cleanly, not so many that old context starts leaking into a decision it has no business influencing.&lt;/p&gt;

&lt;p&gt;That part turned out to be fine. It wasn't where the bug was. But it's worth stating plainly, because it's the assumption everything else in this post sits on top of: the window itself was never the problem. What was inside it, was.&lt;/p&gt;
&lt;h2&gt;
  
  
  Question 2: Where were the fake citations actually coming from?
&lt;/h2&gt;

&lt;p&gt;Here's where I lost real time.&lt;/p&gt;

&lt;p&gt;Every assistant message Lumina saves to Postgres gets a hidden marker appended to the end of it. The reason is mundane: the frontend needs to re render citation cards when you reload a conversation, and rather than add a second table and join it back to messages on every read, I just stuffed the source data into the message itself, wrapped in an HTML comment so it wouldn't render:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;${answer}\n\n&amp;lt;!--SOURCES:${JSON.stringify(sources)}--&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A real saved message looks something like this on disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ice is less dense than water[1][2], which is why it floats instead of sinking.

&amp;lt;!--SOURCES:[{"index":1,"title":"Why Ice Floats","url":"https://...","domain":"..."},{"index":2,"title":"Density of Water","url":"https://...","domain":"..."}]--&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Invisible in the UI. Just a comment. I didn't think twice about it when I wrote it, because comments are, by definition, meant to be skipped.&lt;/p&gt;

&lt;p&gt;That's true for a browser parsing HTML. It is not true for a language model reading a plain text message history. The model has no concept of "this part is a comment, ignore it." There's no DOM, no parser, just tokens. And those particular tokens happen to contain a JSON array with fields called &lt;code&gt;index&lt;/code&gt;, &lt;code&gt;title&lt;/code&gt;, and &lt;code&gt;url&lt;/code&gt;, which is structurally identical to the citation data the model is supposed to be producing on its own.&lt;/p&gt;

&lt;p&gt;So a few turns later, mid follow-up, the model had, sitting right there in its own context, indistinguishable from anything else, a fully formed, entirely plausible citation list from three turns earlier. It used it. The &lt;code&gt;[1][2]&lt;/code&gt; pattern matched what it had produced before, syntactically. It just didn't correspond to anything registered in the current turn's actual source list, because no search had happened this turn.&lt;/p&gt;

&lt;p&gt;It wasn't forgetting. If anything it remembered too accurately. It remembered formatting that was only ever meant for my frontend to parse, and treated it as legitimate conversational content because, from where it was sitting, there was no reason not to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that convinced me
&lt;/h2&gt;

&lt;p&gt;I didn't fully trust this explanation until I could reproduce it on demand, so I ran the same three turn conversation twice: once with the marker left in the history as is, once with it stripped out before the messages were sent to the model.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Marker left in history&lt;/th&gt;
&lt;th&gt;Marker stripped from history&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Turn 1, search + answer&lt;/td&gt;
&lt;td&gt;citations &lt;code&gt;[1][2]&lt;/code&gt; correct, match this turn's sources&lt;/td&gt;
&lt;td&gt;citations &lt;code&gt;[1][2]&lt;/code&gt; correct, match this turn's sources&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Turn 2, follow-up with new search triggered&lt;/td&gt;
&lt;td&gt;citations &lt;code&gt;[1][2]&lt;/code&gt; correct, match this turn's sources&lt;/td&gt;
&lt;td&gt;citations &lt;code&gt;[1][2]&lt;/code&gt; correct, match this turn's sources&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Turn 3, follow-up with no new search&lt;/td&gt;
&lt;td&gt;cites &lt;code&gt;[1][2]&lt;/code&gt; again, but they resolve to turn 1's sources, which no longer exist in this turn's registry&lt;/td&gt;
&lt;td&gt;no stale citations. Model either answers without citing or asks a clarifying question&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same model, same three prompts, in the same order. The only variable was whether that one hidden comment made it into the message list the LLM actually received. That's the point where I stopped calling this a hallucination and started calling it what it actually was: correct model behavior on input I hadn't bothered to clean.&lt;/p&gt;

&lt;p&gt;I ran it a second time with a longer, five turn conversation to make sure it wasn't a fluke specific to a three turn setup, and got the same pattern: any turn that didn't trigger a fresh search would occasionally reuse whatever source indices happened to be sitting in the most recent marker still inside the 20 message window.&lt;/p&gt;

&lt;p&gt;The fix was one line, applied before anything goes back into history:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\n?&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;!--SOURCES:&lt;/span&gt;&lt;span class="se"&gt;[\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;--&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trimEnd&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Strip the marker, keep the visible answer text, then hand it to the model. No new tools, no new infrastructure. Just don't let the model see something it was never meant to read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 3: Why did truncating before stripping still cause problems?
&lt;/h2&gt;

&lt;p&gt;I got the regex right almost immediately. I got the order of operations wrong for longer than I'd like to admit.&lt;/p&gt;

&lt;p&gt;My first pass truncated to the last 20 messages first, then stripped markers from whatever remained:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// what I had, roughly&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;recent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;MAX_HISTORY_MESSAGES&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cleaned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;recent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;stripMarker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That looks harmless. On short test threads, it was. On a couple of longer, real threads, a broken comment fragment started leaking through into what the model received. Not because &lt;code&gt;.slice()&lt;/code&gt; cuts a message in half, it doesn't, message boundaries stay intact, but because of how I'd chained the two transformations, a message sitting right at the 20 message boundary occasionally had its marker only partially matched by the regex, depending on what else was on either side of it after truncation.&lt;/p&gt;

&lt;p&gt;The practical effect: on certain threads, the model would receive a message ending in something like &lt;code&gt;...ice is less dense than water[1][2].&lt;/code&gt; followed by a stray, unclosed &lt;code&gt;&amp;lt;!--SOURCES:&lt;/code&gt; fragment with no closing &lt;code&gt;--&amp;gt;&lt;/code&gt;. That's not just noise. It's a dangling, structurally broken tag sitting in the model's context, which is arguably worse than the clean, well formed version of the same bug, because now the model is trying to make sense of malformed markup instead of just ignoring it.&lt;/p&gt;

&lt;p&gt;Reordering, strip the marker first, from the full message set, then truncate to the last 20, removed the ambiguity entirely, because truncation now happens after every message is already clean. Small fix, one line moved above another. But it's exactly the kind of bug that hides from every quick manual test you'll run while building the feature, and only shows up once a real conversation is long enough to actually reach the boundary you capped it at.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 4: Does a search agent need "real" memory at all?
&lt;/h2&gt;

&lt;p&gt;This is the question that made me stop feeling behind on architecture, and it took the citation bug to get me to actually ask it properly instead of assuming the answer.&lt;/p&gt;

&lt;p&gt;A vector store retrieves by semantic similarity. It ranks past messages by how related they seem to the current query, and pulls back the top few. For a follow-up like "what about the second one," the message that actually matters isn't the most topically similar one across the whole conversation. It's specifically the literal previous turn, in order, regardless of how "similar" it scores. Semantic retrieval can rank an earlier, more keyword heavy message above the one that actually matters, which would make a follow-up agent worse, not better. A sliding window doesn't have this failure mode, because it doesn't rank anything, it just preserves order.&lt;/p&gt;

&lt;p&gt;The other half of what people usually mean by "agent memory," not re searching something you've already answered, turned out to be a prompt level decision, not a retrieval level one. Lumina's system prompt instructs the model not to call &lt;code&gt;web_search&lt;/code&gt; again for follow-ups that are answerable from prior turns in the visible history. That decision lives entirely in &lt;code&gt;prompts/prompt.md&lt;/code&gt; and the instructions appended in &lt;code&gt;prompt-loader.ts&lt;/code&gt;, not in any retrieval layer. No embeddings involved, no similarity threshold to tune.&lt;/p&gt;

&lt;p&gt;Where I do think retrieval based memory would actually matter is a fundamentally different shape of product: something that has to remember a specific user across sessions, days apart, where the fact you need genuinely isn't in the last 20 messages because it isn't even in this conversation. That's a real, well studied problem. It's what tools like mem0 and long term memory layers in agent frameworks are built for. Lumina isn't that product right now. It's a single thread search agent. Reaching for that architecture before I had that specific problem would have meant building and maintaining infrastructure to solve something I don't currently have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things I got wrong, collected in one place so you can skip them
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"Agent memory means a vector database, that's the default now."&lt;/strong&gt; Not for a single thread, tool first search agent. A sliding window solved the actual failure mode I had, which was resolving the previous turn correctly, not long horizon recall.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"What I store for my own UI doesn't matter to the model."&lt;/strong&gt; It does. Anything that ends up back inside the message array is language, as far as the model is concerned, formatting conventions, HTML comments, and all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Truncate first, clean up second, it's just an ordering detail."&lt;/strong&gt; It isn't just a detail. Cleaning up after truncating leaves you exposed to whatever happens to sit exactly at the cutoff, and that's the one case a quick manual test will never happen to hit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"This is the model hallucinating, models do that."&lt;/strong&gt; It looked exactly like that from the outside. It wasn't. The model was doing correct, expected inference on input that I had failed to sanitize.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Twenty is an arbitrary number I should tune later."&lt;/strong&gt; It's a real design decision about how much of the past gets to influence the agent's next action, not just a token budget knob. I hadn't actually thought about it that way until I had to defend it to myself while writing this.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually helped
&lt;/h2&gt;

&lt;p&gt;A short list, in case you're building something similar and want to skip some of the detours I took.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read the loop itself first&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/Saurabhsing21/Lumina/blob/main/docs/AGENT_LOOP.md" rel="noopener noreferrer"&gt;&lt;code&gt;docs/AGENT_LOOP.md&lt;/code&gt;&lt;/a&gt;: the internal architecture document I ended up writing while debugging this, including the full sequence diagram from HTTP request to streamed, cited answer, and the state machine the loop runs on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Read when you're deciding how much memory you actually need&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Anthropic's writing on context and retrieval, for thinking clearly about what actually belongs inside a model's context window versus what should just live in your own storage layer.&lt;/li&gt;
&lt;li&gt;LangChain's memory documentation, mainly for the general window versus retrieval framing, useful even if you don't end up using their implementation, which I didn't.&lt;/li&gt;
&lt;li&gt;mem0's docs, as a reference point for what a genuine cross session memory layer looks like when you actually need one, so you can tell the difference between that problem and the one I had.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Read as code&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;backend/src/agent/agent-runner.ts&lt;/code&gt; in the Lumina repo: the entire "memory system" discussed in this post is about fifteen lines inside that one file.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;backend/src/agent/agent-loop.ts&lt;/code&gt;: for how the tool first turn cycle actually decides, per turn, whether to search or answer, which is the mechanism the history window feeds into.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where I'm taking this next
&lt;/h2&gt;

&lt;p&gt;Right now, truncation is a hard cutoff. Message 21 stops existing to the model, silently, with no summary and no warning. The next thing I want to try is summarizing whatever falls out of the window instead of dropping it outright, so a very long thread degrades gracefully instead of the agent abruptly forgetting how the conversation started.&lt;/p&gt;

&lt;p&gt;I also want to write a test that pushes a conversation past the 20 message boundary automatically as part of CI, so an ordering bug like the one in this post shows up in a failing test instead of in a production screenshot three weeks later, after I've forgotten exactly why I wrote the truncation logic the way I did.&lt;/p&gt;

&lt;p&gt;Longer term, if Lumina ever needs to remember something about a specific user across separate sessions, not within a thread, but genuinely days apart, that's the point where a retrieval layer stops being premature and starts being the right tool. I don't think I'm there yet. But now I at least have a clearer test for when "yet" arrives: the question isn't "do agents typically have memory," it's "is the fact I need actually inside the window I'm already sending."&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I am a builder, not an infra expert, and I've been wrong at least five documented times in this post alone. If something here is off, I'd rather be corrected than confident, tell me.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>I Gave My Search Agent Memory and a Team. Then I Checked If That Was Actually True</title>
      <dc:creator>Saurabh Singh</dc:creator>
      <pubDate>Sat, 08 Aug 2026 06:08:33 +0000</pubDate>
      <link>https://dev.to/saurabh_singh_86cdc588e85/i-gave-my-search-agent-memory-and-a-team-then-i-checked-if-that-was-actually-true-2f8d</link>
      <guid>https://dev.to/saurabh_singh_86cdc588e85/i-gave-my-search-agent-memory-and-a-team-then-i-checked-if-that-was-actually-true-2f8d</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 2 of my agent build series&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Let me set the scene again.&lt;/p&gt;

&lt;p&gt;Part 1 shipped. One tool, one loop, no memory — and I was honest about all three limits in the post. A few days later I ended up reading through Google's writeup on how they built &lt;strong&gt;Dev Signal&lt;/strong&gt;, an internal multi-agent system that goes from "scan Reddit for trending questions" to "draft a technical blog post" to "remember your writing style for next time." Root orchestrator, three specialist agents, and a long-term memory layer sitting underneath all of it.&lt;/p&gt;

&lt;p&gt;I closed the tab and immediately started drafting a title in my head: &lt;em&gt;"I gave Lumina memory and a team of agents."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Same instinct as last time. Same problem as last time — I hadn't written a line of it yet.&lt;/p&gt;

&lt;p&gt;So instead of the announcement post, here's the actual plan — architecture, code, and the parts of Google's pattern I'm not stealing as-is because their own comment section already poked holes in them.&lt;/p&gt;

&lt;p&gt;![Lumina multi-agent architecture: a supervisor node routes to search, verify, and synth agents sharing short-term state, with the synth agent reading and writing long-term memory in a vector store]&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffmy99puykq2jtgews3oc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffmy99puykq2jtgews3oc.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Infrastructure and Model Setup
&lt;/h2&gt;

&lt;p&gt;Lumina today is Bun + TypeScript. The multi-agent version is a separate Python service sitting next to it — LangGraph for the graph, LangChain for the memory tools, same OpenRouter model underneath so I'm not paying for two providers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lumina_agents/graph.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langgraph.graph&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;StateGraph&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;END&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain_openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ChatOpenAI&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TypedDict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Annotated&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;operator&lt;/span&gt;

&lt;span class="n"&gt;llm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ChatOpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anthropic/claude-sonnet-4-6&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://openrouter.ai/api/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AgentState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TypedDict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Annotated&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;operator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;add&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;
    &lt;span class="n"&gt;verified_claims&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;
    &lt;span class="n"&gt;final_answer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AgentState&lt;/code&gt; is the short-term working memory every node reads and writes. It's the thing that replaces the ad-hoc object I was passing through &lt;code&gt;agent-runner.ts&lt;/code&gt; by hand in the TS version — LangGraph threads it through the graph for me instead.&lt;/p&gt;




&lt;h2&gt;
  
  
  Memory Ingestion Logic
&lt;/h2&gt;

&lt;p&gt;The goal isn't "store everything Lumina ever sees." It's: capture the handful of things a user actually corrects or repeats — preferred source recency, terse vs. detailed answers — and make those available on the next session without re-asking.&lt;/p&gt;

&lt;h3&gt;
  
  
  Long-term Memory
&lt;/h3&gt;

&lt;p&gt;A vector store, written to explicitly, never automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lumina_agents/memory.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain_chroma&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Chroma&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain_openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OpenAIEmbeddings&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain_core.tools&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;

&lt;span class="n"&gt;embeddings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAIEmbeddings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text-embedding-3-small&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;memory_store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Chroma&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;collection_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lumina_user_memory&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;embedding_function&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;persist_directory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./memory_db&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@tool&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;save_preference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;preference&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Save an explicit user preference to long-term memory.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;memory_store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_texts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;preference&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;metadatas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Saved: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;preference&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="nd"&gt;@tool&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;load_preferences&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Retrieve the most relevant stored preferences for this user.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;memory_store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;similarity_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;page_content&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two tools, same split Google's pattern uses — one for writing, one for reading. The difference is &lt;code&gt;save_preference&lt;/code&gt; only fires when a node explicitly calls it after an unambiguous signal ("always sort by recency"), not automatically at the end of every turn. Given the retrieval-precision problem below, I'd rather have less memory that's trustworthy than more memory I have to second-guess.&lt;/p&gt;

&lt;h3&gt;
  
  
  Short-term Memory
&lt;/h3&gt;

&lt;p&gt;This is just &lt;code&gt;AgentState&lt;/code&gt;. No separate service, no persistence — it resets when the graph run ends:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AgentState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;AgentState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# reads what search_node already put in state, no DB round-trip
&lt;/span&gt;    &lt;span class="n"&gt;sources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sources&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;overlap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;check_source_overlap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;query&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verified_claims&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;overlap&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The boundary matters for the same reason it did in Google's writeup: state answers "what happened this run," memory answers "what do I know about this user across every run." Conflating them is how you end up either re-asking the same question every session or leaking one session's context into a completely different conversation.&lt;/p&gt;




&lt;h2&gt;
  
  
  Specialist 1: Search Agent
&lt;/h2&gt;

&lt;p&gt;This one's not new — it's Lumina's existing &lt;code&gt;web_search&lt;/code&gt; tool, ported over as a node instead of the whole loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lumina_agents/agents/search_agent.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain_core.tools&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;lumina_agents.tools.tavily&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tavily_search&lt;/span&gt;

&lt;span class="nd"&gt;@tool&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;web_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;basic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Search the web and return numbered, deduped results.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tavily_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AgentState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;AgentState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind_tools&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;web_search&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_calls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;web_search&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_calls&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;args&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sources&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same citation-numbering rule I learned the hard way in Part 1 applies here — the index gets assigned the moment a result comes back, not whenever the model gets around to referencing it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Specialist 2: Verify Agent
&lt;/h2&gt;

&lt;p&gt;New. Lumina v0 trusted whatever came back first. This node checks whether the sources actually agree before anything gets synthesized into an answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lumina_agents/agents/verify_agent.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AgentState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;AgentState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;sources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sources&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Given these sources: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;
Identify which claims are supported by 2+ independent sources.
Flag any claim supported by only one source as UNVERIFIED.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verified_claims&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deliberately dumb prompt, on purpose — the point of this node existing is that "did I check" becomes a visible step in the graph instead of an implicit assumption baked into the synthesis prompt.&lt;/p&gt;




&lt;h2&gt;
  
  
  Specialist 3: Synth Agent
&lt;/h2&gt;

&lt;p&gt;Writes the final answer, and is the only node that reads from long-term memory — because tone and format preferences only matter at the point where you're producing output, not while you're gathering it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lumina_agents/agents/synth_agent.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;synth_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AgentState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;AgentState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;prefs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;load_preferences&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;query&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;query&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]})&lt;/span&gt;
    &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Verified findings: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;verified_claims&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;
User preferences on record: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;prefs&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;
Write the final answer, following those preferences where they apply.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;final_answer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Root Orchestrator
&lt;/h2&gt;

&lt;p&gt;The supervisor is the piece I'm most cautious about. An LLM deciding "route this to search vs. verify vs. synth" is classification with a probabilistic model attached — it will misroute sometimes, and that's not a bug to patch, it's a property to design around. So the routing function checks explicit signal first and only falls back to the model for genuinely ambiguous cases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lumina_agents/graph.py (continued)
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AgentState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sources&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verified_claims&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verify&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verified_claims&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;final_answer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;synth&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;END&lt;/span&gt;

&lt;span class="n"&gt;graph&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;StateGraph&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AgentState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;search_node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verify&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verify_node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;synth&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;synth_node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_entry_point&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_conditional_edges&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_conditional_edges&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verify&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_conditional_edges&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;synth&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No LLM call in &lt;code&gt;route()&lt;/code&gt; at all, for now. State shape decides the next node deterministically. I'll only reach for LLM-based routing if a real case shows up that state shape can't disambiguate — not by default, because it's less code to write upfront.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'm Not Copying As-Is
&lt;/h2&gt;

&lt;p&gt;Google's own comment section did the work of stress-testing this pattern before I had to, and two points are worth taking seriously instead of shipping the architecture and hoping:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Embedding similarity isn't a precision instrument.&lt;/strong&gt; One commenter had actually measured it — semantic search struggled to cleanly separate stylistic preferences that were near-opposite of each other. A &lt;code&gt;load_preferences&lt;/code&gt; call can hand back the wrong preference with a confident-looking score. Nothing pulled from memory changes the final answer's substance without at least a recency check behind it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unstructured content flowing into long-term memory is a prompt-injection surface.&lt;/strong&gt; If &lt;code&gt;save_preference&lt;/code&gt; ever gets called on text the model read off the open web instead of something the user explicitly typed, that's an open door. For now, only user-authored turns can trigger a memory write — search results never do.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Three specialists — search, verify, synth — routed by explicit state instead of LLM judgment, with a short-term &lt;code&gt;AgentState&lt;/code&gt; for in-run handoffs and a long-term vector store for anything a user explicitly wants remembered across sessions. Not the full Dev Signal pattern, and not meant to be — the parts I kept are the ones that held up under scrutiny; the parts I changed are the ones that didn't.&lt;/p&gt;

&lt;p&gt;Code's still at &lt;strong&gt;&lt;a href="https://github.com/Saurabhsing21/Lumina" rel="noopener noreferrer"&gt;github.com/Saurabhsing21/Lumina&lt;/a&gt;&lt;/strong&gt; — the memory branch goes up once this is running end-to-end, not before.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you've run embedding-based memory retrieval in production and it held up better than I'm expecting, or you've got a cleaner way to handle explicit-vs-LLM routing — tell me in the comments. I check daily.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;#ai&lt;/code&gt; &lt;code&gt;#agents&lt;/code&gt; &lt;code&gt;#opensource&lt;/code&gt; &lt;code&gt;#llm&lt;/code&gt; &lt;code&gt;#langgraph&lt;/code&gt; &lt;code&gt;#buildinpublic&lt;/code&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>I Built a Web Search Agent Harness. Then I Checked If It Actually Deserved the Name.</title>
      <dc:creator>Saurabh Singh</dc:creator>
      <pubDate>Fri, 31 Jul 2026 10:53:04 +0000</pubDate>
      <link>https://dev.to/saurabh_singh_86cdc588e85/i-built-a-web-search-agent-harness-then-i-checked-if-it-actually-deserved-the-name-4o0g</link>
      <guid>https://dev.to/saurabh_singh_86cdc588e85/i-built-a-web-search-agent-harness-then-i-checked-if-it-actually-deserved-the-name-4o0g</guid>
      <description>&lt;h1&gt;
  
  
  I Built a Web Search Agent Harness. Then I Checked If It Actually Deserved the Name.
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Part 1 of my agent build series&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Let me set the scene.&lt;/p&gt;

&lt;p&gt;I wanted a Perplexity-style search assistant — type a question, watch it decide to go search the web mid-answer, get back a real answer with numbered citations you can click. Not a chatbot wearing a search icon. Something that actually reasons about &lt;em&gt;whether&lt;/em&gt; it needs to look something up before it does.&lt;/p&gt;

&lt;p&gt;So I built it. Bun backend, React 19 frontend, Tavily for search, OpenRouter for the model, Postgres underneath. A few weeks in, it worked. Streaming answers, clickable sources, follow-up questions, the whole thing.&lt;/p&gt;

&lt;p&gt;Then I went to write this post, typed the words "agent harness" into the title, and stopped.&lt;/p&gt;

&lt;p&gt;Was that actually true? Or was I about to publish a buzzword on top of a fetch call with a nice system prompt?&lt;/p&gt;




&lt;h2&gt;
  
  
  The Claim I Almost Made Without Checking
&lt;/h2&gt;

&lt;p&gt;Here's the ambitious version of this post I almost wrote: &lt;em&gt;"I built a multi-tool agentic harness with full observability and provider failover."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;None of that is true. There's one tool. One model provider path. No retries if a tool call fails mid-turn.&lt;/p&gt;

&lt;p&gt;So before publishing, I went back through my own &lt;code&gt;agent-loop.ts&lt;/code&gt; and &lt;code&gt;agent-runner.ts&lt;/code&gt; like I was reviewing someone else's PR, and asked the boring question: what actually makes something a "harness" instead of a script that calls an API?&lt;/p&gt;

&lt;p&gt;A harness is the code &lt;em&gt;around&lt;/em&gt; the model — the part that decides when the model gets to call a tool, manages the back-and-forth, keeps context sane, and turns the mess into something a frontend can render. Not the model itself. The scaffolding.&lt;/p&gt;

&lt;p&gt;Checked against that definition, line by line:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The model decides, not my code.&lt;/strong&gt; There's no &lt;code&gt;if (needsSearch)&lt;/code&gt; gate before the LLM sees the query. It gets handed a &lt;code&gt;web_search&lt;/code&gt; tool in its schema and chooses whether to use it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The loop actually loops.&lt;/strong&gt; &lt;code&gt;agentLoop()&lt;/code&gt; keeps alternating — ask the LLM, execute whatever tool it called, feed the result back, ask again — until the model returns a stop reason instead of a tool-use reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context is trimmed, not just piled on.&lt;/strong&gt; Only the last 20 messages go to the model. Citation markers get stripped out of history before the next turn, or every follow-up question would silently re-send every source block forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Output is a structured event stream&lt;/strong&gt;, not a blob dropped on the frontend when it's finally done.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So — yes, it's a harness. A real one. Just a small one.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The honest caveat:&lt;/strong&gt; it's a single-tool harness. &lt;code&gt;web_search&lt;/code&gt;, full stop. The multi-tool version — file access, code execution, retries, provider fallback — is a different tier of engineering, and I'm not there yet. I'd rather say that plainly than have someone read the repo and feel oversold by the title.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Loop That Makes It an Agent
&lt;/h2&gt;

&lt;p&gt;Before looking at the implementation, here's the high-level architecture of how the agent executes a request. The core idea is that the application &lt;strong&gt;doesn't decide when to search&lt;/strong&gt;—the model does.&lt;/p&gt;

&lt;p&gt;The harness loads the system prompt, conversation history, and available tools, then hands control to the LLM. If the model decides it needs external information, it calls the &lt;code&gt;web_search&lt;/code&gt; tool. The tool executes, registers and deduplicates sources, and returns the results back to the model. This &lt;strong&gt;LLM → Tool → LLM&lt;/strong&gt; loop continues until the model has enough information to produce a final answer.&lt;/p&gt;

&lt;p&gt;Once the response is complete, the harness extracts follow-up questions, persists the conversation, citations, and state to the database, and streams the final response back to the frontend.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The important part isn't the search itself—it's the orchestration loop.&lt;/strong&gt; The harness manages context, tool execution, streaming, citations, and persistence, while the LLM decides &lt;strong&gt;when&lt;/strong&gt; external information is actually needed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4qyfzxwoa8wi2vormgza.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4qyfzxwoa8wi2vormgza.png" alt="AI Agent Execution Architecture" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;*Figure 1: High-level execution flow of the AI agent harness. The LLM repeatedly reason&lt;/p&gt;

&lt;p&gt;This is the part that turns "an LLM with instructions" into something that behaves like an agent instead of a script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// backend/src/agent/agent-runner.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AgentContext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;systemPrompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;getSystemPrompt&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;history&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])],&lt;/span&gt;
  &lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;webSearchTool&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;agentLoop&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;userMessage&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;getDefaultStreamFn&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tool_execution_end&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;toolName&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;web_search&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onEvent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sources&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message_update&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;assistantMessageEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text_delta&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onEvent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;delta&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;assistantMessageEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My code never decides &lt;em&gt;whether&lt;/em&gt; to search — it just watches the event stream coming out of the loop and forwards the right pieces downstream. Model decides, harness routes. That split is the entire reason I'm comfortable calling this a harness instead of a script with a search button bolted on.&lt;/p&gt;




&lt;h2&gt;
  
  
  The One Tool It Has (And Why the Numbering Almost Broke Me)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// backend/src/tools/web-search.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createWebSearchTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;defaultDepth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;registerResult&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;web_search&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;searchDepth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Union&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="nx"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;basic&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nx"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;advanced&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;])),&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;webSearch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchDepth&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;defaultDepth&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;registerResult&lt;/span&gt;&lt;span class="p"&gt;?.(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`[&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;] &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\nURL: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt; &lt;span class="na"&gt;details&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Tavily call itself is the boring part. The part that actually bit me is &lt;code&gt;registerResult&lt;/code&gt; — every result gets numbered and deduped through a shared registry the instant it comes back, so when the model writes &lt;code&gt;[1]&lt;/code&gt; in its answer, that index has to map to a source the frontend already has sitting in its sidebar. Get the timing wrong — number it after the model already referenced it, or dedupe against the wrong run — and citations silently point at nothing. No error, no crash. Just a &lt;code&gt;[3]&lt;/code&gt; in the answer with nothing behind it. That one took a full evening to notice, because it doesn't fail loud.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Frontend &lt;code&gt;searchMode&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Tavily depth&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;"search"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;basic&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;"research"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;advanced&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Why Not Just Force a Search Every Turn?
&lt;/h2&gt;

&lt;p&gt;I want to address this directly because it's the obvious simpler design, and I genuinely started building it that way first.&lt;/p&gt;

&lt;p&gt;Force a search before every LLM call. No decision logic, no tool schema, no risk of the model "forgetting" to search when it should. Easier to reason about, easier to test.&lt;/p&gt;

&lt;p&gt;It's wrong for a chat agent, and here's the concrete reason: half of what people type into a thread is a follow-up — &lt;em&gt;"what about for React 19 instead?"&lt;/em&gt; — that needs the model to reread its own last answer, not burn a fresh Tavily call and 2+ seconds of latency for a question it can already answer from context.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Let the model decide when it actually needs fresh information.&lt;/strong&gt; That's the whole reason this is tool-first instead of pipeline-first. It's also the design choice that makes the word "harness" earn its keep here — the intelligence about &lt;em&gt;when&lt;/em&gt; to act lives inside the loop, not hardcoded in front of it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Stack, Complete
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client (React 19 + assistant-ui)
        │
        ▼  POST /ask  +  Bearer JWT
┌─────────────────────────────────────────────┐
│           Express 5 · /ask route             │
│    auth middleware → credit check → runner   │
└───────┬───────────────────────┬──────────────┘
        ▼                       ▼
   agent-runner.ts         Postgres (Prisma 7)
   systemPrompt + tools     history, credits
        │
        ▼
   agent-loop.ts  ── tool-first loop ──┐
        │                              │
        ▼                              ▼
   OpenRouter (LLM)               Tavily (web_search)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Technology&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Runtime&lt;/td&gt;
&lt;td&gt;Bun, Express 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@earendil-works/pi-ai&lt;/code&gt; + OpenRouter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Search&lt;/td&gt;
&lt;td&gt;Tavily API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;PostgreSQL + Prisma 7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth&lt;/td&gt;
&lt;td&gt;Supabase (JWT)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frontend&lt;/td&gt;
&lt;td&gt;React 19, Vite 8, assistant-ui, Tailwind CSS 4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two independent apps, no Docker, no shared infra to babysit. The backend has to know about exactly one thing outside itself at request time: which model to call and which tool to run.&lt;/p&gt;




&lt;h2&gt;
  
  
  What v0 Actually Buys You
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Plain LLM chat&lt;/th&gt;
&lt;th&gt;v0 (this harness)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Freshness&lt;/td&gt;
&lt;td&gt;Frozen at training time&lt;/td&gt;
&lt;td&gt;Live web results, only when the model asks for them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Citations&lt;/td&gt;
&lt;td&gt;None, or hallucinated&lt;/td&gt;
&lt;td&gt;Numbered, deduped, clickable, persisted to the DB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Follow-ups&lt;/td&gt;
&lt;td&gt;Re-explains from scratch&lt;/td&gt;
&lt;td&gt;Reuses trimmed history, no repeat search&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost control&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Per-user credit gate (&lt;code&gt;creditLimit&lt;/code&gt; default 10), search depth toggle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delivery&lt;/td&gt;
&lt;td&gt;Wait for the full response&lt;/td&gt;
&lt;td&gt;NDJSON stream, renders as it's generated&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of that is exotic engineering. That's kind of the point — a harness doesn't need to be clever, it needs to be &lt;em&gt;correct about the loop&lt;/em&gt;. The clever part is knowing which one line-item on that table you're actually solving before you write any code.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;This is v0 on purpose. A few things actually on deck for v1, not just aspirational bullet points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A second tool&lt;/strong&gt; — a "fetch and read this page" tool, so the model can go from a search snippet to the real page instead of reasoning off three lines of preview text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool-call resilience&lt;/strong&gt; — right now a failed Tavily call just fails the turn. A harness that holds up needs retry/backoff at the tool layer, not a bare try/catch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proving the provider seam actually holds&lt;/strong&gt; — &lt;code&gt;search.ts&lt;/code&gt;, &lt;code&gt;models.ts&lt;/code&gt;, and &lt;code&gt;stream-fn.ts&lt;/code&gt; are already written as swap points, but I've only ever run this against Tavily + OpenRouter. An abstraction nobody's swapped is just a hope.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Code's here if you want to see the whole thing: &lt;strong&gt;&lt;a href="https://github.com/Saurabhsing21/Lumina" rel="noopener noreferrer"&gt;github.com/Saurabhsing21/Lumina&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Loop internals specifically: &lt;a href="https://github.com/Saurabhsing21/lumina/blob/main/docs/AGENT_LOOP.md" rel="noopener noreferrer"&gt;&lt;code&gt;docs/AGENT_LOOP.md&lt;/code&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you think one tool doesn't earn the word "harness," or you've hit the same silent-citation bug I did — tell me in the comments. I check daily.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;#ai&lt;/code&gt; &lt;code&gt;#agents&lt;/code&gt; &lt;code&gt;#opensource&lt;/code&gt; &lt;code&gt;#llm&lt;/code&gt; &lt;code&gt;#webdev&lt;/code&gt; &lt;code&gt;#buildinpublic&lt;/code&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>llm</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
