ENGINEERING CASE STUDY
Deep Research Agent
A Tavily-grade research answer, for the cost of one LLM call and a free search stack
AUTONOMOUS ReAct LOOP · TOOL USE · CODE-ENFORCED GROUNDING · DEEPSEEK V4 / SARVAM
Given a research question, this autonomous ReAct agent decides for itself how many searches to run, what to search next based on what it already found, and when it has enough grounded evidence to stop — then hands back a cited report with every comparison claim checked in code against what it actually retrieved, not what the model merely says it retrieved.
It began as an extension of TechDrishti — one job: give the daily Hindi newsroom a free search layer that could actually reason, instead of firing one query and taking whatever came back. It has since grown well past that into a standalone system with four modes, including an automated article writer and a full deep-research loop. Building it end to end is the clearest evidence I have of AI engineering applied to real autonomous LLM workflows and agentic tooling, not a notebook demo.
Why an agent, not another pipeline
TechDrishti is deliberately a cost-controlled, deterministic workflow — it has to run unattended every day on a fixed budget, so predictable behavior beats research depth. This project inverts that trade-off on purpose: research depth is inherently unbounded in advance, so a fixed step count would just be a workflow wearing an agent's clothes.
TechDrishti (a prior project of mine) always runs the same ~6 calls in the same order for every article — the number of steps is decided by code, not by the model. This project's whole point is the opposite: the model decides how many searches to run, what to search next based on what it already found, and when it's confident enough to stop.
Two rounds of increasingly explicit anti-hallucination prompt wording both failed, reproducing the identical wrong comparison 2-3 times out of 3. What actually worked was a hard, code-level check on the output — so that's the default here, not an afterthought.
The research loop
One orchestration loop underneath all three modes. The model owns the reasoning — deciding what to search, whether a result is enough, when to stop. Everything the model cannot itself do — actually making the HTTP request, enforcing a hard budget regardless of what it wants, checking its own final claims — is owned by code.
THINK → ACT → EVALUATE repeats up to 8 times; GROUND runs once, on the final report only.
web_search (DuckDuckGo via ddgs), news_search (Google News RSS, recency-sensitive), fetch_page (full body text of a URL, HTML or PDF), get_current_date (the model's training cutoff isn't "now"), calculate (AST-evaluated arithmetic only, no eval()).
At iteration 8, web_search/news_search/fetch_page are forcibly removed from the request — calculate stays available so the model can still finish computing something it already retrieved, rather than being cut off mid-writeup.
The rules that came from getting it wrong once already
CORE_RULES is shared verbatim across every mode. Each one exists because a specific failure was reproduced and fixed in TechDrishti first — this is what carrying that lesson forward into a system built to do real research actually looks like.
"What is the strategic significance of Z.ai's open-source release given the US ban on Anthropic?" is not a search query — nothing anyone published "answers" it. The agent has to decompose it into fact sub-questions (pricing, dates, deal terms), retrieve those, and do the synthesis itself in its final report.
A bathroom tap and a sink are both plumbing fixtures in the same field, but a tap is a valve mechanism and a sink is a basin — comparing them head-to-head is a category error even though "they're both bathroom fixtures" sounds like a match. The agent has to verify what kind of thing each side actually is (with a search, not a guess from training data) before treating a comparison as valid.
The final report is often consumed programmatically — by another model, or an API caller — with no separate numbered source list attached. So every claim gets its actual source URL written inline, next to the claim, not a footnote marker.
web_search and news_search return several results per query for free — if fetch_page fails on one URL, try another from the same result set before concluding the information isn't available, and rephrase the query itself before spending a whole extra search on it.
calculate() evaluates arithmetic through Python's ast module, not eval() — it can't execute anything beyond + - * / ** % and parentheses, so it's safe to expose directly as a tool with no sandboxing risk.
Prompt quirks that make it hold up
Click a card for how it actually works. Every one of these exists because of a live, reproduced failure — not a hypothetical edge case.
Left unhandled, the agent loop sees an empty message.tool_calls and treats that raw XML-ish text as the model's finished answer — it leaks straight into the final report instead of the search ever happening. The fix is a regex recovery pass (_parse_fallback_tool_calls) that extracts the tag, maps invented tool/arg names onto the real ones through a small alias table (search → web_search, numResults → max_results), drops anything it can't map, and re-injects it as a synthetic tool call so the loop executes it normally. A second, simpler regex is applied right before any text is accepted as a final answer, to strip a dangling, never-closed <tool_call> tag left over from a response that got cut off mid-generation — belt and suspenders, so a leaked tag can never reach the user regardless of which code path produced the text.
Confirmed directly there, not assumed: the exact same prompt with the exact same /no_think instruction in the system message still burned 3000+ tokens on hidden reasoning before writing a single word of output, because /no_think is plain text the model may or may not follow — not an API control. Only an actual parameter (reasoning_effort=None on Sarvam, thinking: {"type": "disabled"} sent via extra_body on DeepSeek V4) reliably turns it off. agent.py reads reasoning_content defensively with getattr(..., None) rather than assuming it exists, since it's not part of the official OpenAI spec either provider otherwise mirrors.
Now a failure returns a structured {"error": ...} dict instead, and extract_sources() checks for that key before ever listing a URL as a real source. The same distinction matters for the grounding check: only web_search, news_search, and fetch_page's successful output count as "evidence" a claim can be checked against — calculate's output never does, since a fabricated number run through calculate would otherwise make itself look grounded just by being present in the conversation.
MAX_ITERATIONS = 8 is enforced in the loop itself, not left to the model's judgment. Once it's hit, web_search/news_search/fetch_page are forcibly removed from the next call — the model gets one final turn with only calculate available, so it can still finish computing a stat it already retrieved without being able to search further. If that final report is itself cut off (finish_reason: length), the agent asks for one more, shorter attempt rather than silently publishing a sentence fragment.
Four modes, one loop
Every mode is the same ResearchAgent with a different system prompt and a different final-answer contract — not four separate codepaths to keep in sync. "Research an article" and "Write an article" are even the same function under the hood, one boolean flag apart.
The plain research loop end to end — for someone who wants the whole write-up, not a distilled answer.
The model tags its own final answer SIMPLE_ANSWER, AMBIGUOUS_ANSWER, or COMPLEX_REPORT on its first line — a fixed, code-checked tag rather than trusting the model to "just keep it short" from memory. Simple questions get a fast verified answer; ambiguous ones get every plausible meaning plus which one fits the given context; genuinely complex questions are still fully researched — a second LLM pass just turns that research into a distilled, grounded conclusion instead of handing back the raw write-up.
One continuous session, not a dossier of independently-researched questions — gap-finding happens inside the model's own first-turn thinking, sharing full article context and one retrieved-evidence pool. Returns the raw findings, not a rewritten article.
Reuses mode 3's exact research session — same code path, one flag flipped (write_hindi=true) — then hands the findings to a separate, non-agentic writing pass (no tools, no search budget) that weaves them together with the original article into one complete Hindi-language piece. Kept as its own LLM call rather than folded into the research loop, since turning findings into prose is a writing job, not a research-decision job.
Three POST routes (/api/concise, /api/research, /api/article) mirror the quick-answer, ask, and article modes for a caller that can't speak Chainlit's websocket chat protocol — deliberately not a second server: Hugging Face Spaces exposes exactly one port, so these routes mount directly onto Chainlit's own FastAPI instance instead of standing up a process with nowhere to listen. Every request accepts an optional provider field, so a caller can A/B the same question against DeepSeek and Sarvam without touching an env var. Guarded by a shared-secret X-API-Key header that fails closed — if the server-side secret is ever unset, the route refuses every request rather than silently reopening itself to anyone on the internet with a spare API quota to spend.
Why this is the cheap alternative to a paid search API
A service like Tavily charges per search call — the metered cost scales with how many queries an agent fires. This project's retrieval layer (DuckDuckGo via ddgs, Google News RSS, direct page/PDF fetches) is free and keyless, inherited directly from the same free-tier search stack TechDrishti already hardened in production. That leaves exactly one metered cost: the LLM calls themselves — and the whole loop is designed to spend that budget on searches deciding what to search next, not on paying per query for the privilege of searching at all.
web_search and news_search cost nothing per call — no API key, no per-query metering. The only real cost is the reasoning wrapped around them, which is exactly where a research agent's value actually lives.
llm_client.py is a thin, provider-generic wrapper — one LLM_PROVIDER env var switches the entire agent between DeepSeek V4 Flash (default, cheap and fast) and Sarvam, with no other code change, so the model behind the loop can chase whichever provider is cheapest without touching agent.py at all.
The budget cap (MAX_ITERATIONS = 8) is also a cost control, not just a runaway-loop guard: it bounds the maximum number of paid LLM calls any single question can ever trigger, regardless of how open-ended the question is.
Measured against Tavily, not just claimed
Every response came back "answer": null — five raw result links and nothing more. As called, Tavily retrieves; it does not reason over what it retrieved.
A grounded, cited answer every time — on the open-ended questions, the synthesis Tavily leaves the reader to assemble by hand from a list of links.
One question asked for the benchmark scores of “Zephyr-Q9” — a model that was never released. Tavily returned five real benchmark leaderboards for other models, with nothing signalling that the model in the question does not exist; a reader skimming them could easily misattribute stray numbers to it. This agent searched, found no such model, and said exactly that instead of inventing scores — the same code-level grounding discipline from section 03, holding up on a live adversarial input rather than a rehearsed one.
The honest boundary: on plain factual lookups — a model’s context window, a single number with an obvious right answer — the two effectively tie, because that is exactly what single-shot search is already good at, and the agent has no business being slower to reach the same fact. The gap only opens on the questions that need decomposition, cross-source synthesis, or a refusal to hallucinate — and the whole run stays bounded by the 8-call cap, so matching Tavily never costs an unbounded number of LLM calls.
Full comparison, case by case (vs Tavily & Claude)Sarvam vs DeepSeek, measured head-to-head
Since this agent runs on either backend behind one LLM_PROVIDER switch, the two were put through the same fixed question set and compared on identical metrics rather than picked by feel. Three kinds of questions were run: basic factual questions, open-ended research queries, and disambiguation cases (an ambiguous entity with more than one plausible answer). Article writing was also tried manually against both models but left out of the scored comparison, since writing isn't this agent's job — the research loop's job is finding and grounding facts, not prose. Qualitative answers were judged by feeding both models' output to ChatGPT and asking which held up better and why; numerical claims were checked by actually running the calculation, not by asking a model whether a number looked plausible.
A later end-to-end pass re-ran all four modes on both backends — not just the research questions — and reconfirmed the same split: DeepSeek goes deeper and grounds better (on one gap-research case Sarvam returned a list of angles to research rather than the researched facts themselves), while Sarvam is roughly 3× faster and far less prone to over-searching. For this tool, where grounding is the whole point, DeepSeek stays the default; Sarvam is the fast, cheap option better suited to the high-frequency loop steps than to the final synthesis.
Measured it, then chose not to ship it
The obvious next move was to wire this agent back into TechDrishti as its research layer, so every ambiguous entity and comparison question got the full reasoning loop instead of a flat search. I built that integration and measured it against the production pipeline rather than guessing whether it was worth it. The numbers are why it is not in production.
9–20 min per run
+~81 min onto the same run
Roughly 17× the cost and over an hour of extra runtime, for a marginal gain in research depth on a small share of a daily article's questions. TechDrishti has to finish unattended every morning on a fixed budget, so predictability wins there. The routing code is left in the repo, disabled, not deleted, because the tradeoff could flip for a use case that is not a daily deadline.
The figures above are my own measurements, stated as approximate. The honest framing is the point: knowing when not to ship the more impressive thing is the same discipline as the code-level grounding check above, applied to a build decision instead of a claim.