Back to portfolioLive demo

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.

01

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.

NOT A PIPELINE
Step count is undecidable in advance

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.

GROUNDING BY CODE, NOT BY ASKING NICELY
"Don't hallucinate" was tried twice in the prior project and failed twice

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.

02

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
LLM call, rules + tools
ACT
tool call executed
EVALUATE
enough evidence yet?
GROUND
claims checked vs. sources

THINK → ACT → EVALUATE repeats up to 8 times; GROUND runs once, on the final report only.

TOOLS AVAILABLE

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()).

BUDGET ENFORCEMENT

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.

03

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.

RULE 1 · DECOMPOSE
Search engines return facts, not judgments

"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.

RULE 2 · SAME KIND OF THING
Sharing a category isn't enough to license a comparison

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.

RULE 3 · CITE BY URL, NEVER [1]
A bracket number with nothing to resolve it against is as uncited as nothing at all

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.

RULE 4 · RETRY BEFORE GIVING UP
A dead link isn't a dead trail

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.

RULE 5 · NEVER DO MATH IN YOUR HEAD
A CAGR calculation done in reasoning is error-prone, even when the model is confident

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.

04

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.

05

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.

MODE 1 · ASK A QUESTION
Direct research question in, full cited report out

The plain research loop end to end — for someone who wants the whole write-up, not a distilled answer.

MODE 2 · QUICK GROUNDED ANSWER
Classify the question before deciding how hard to work on it

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.

MODE 3 · RESEARCH AN ARTICLE
Feed it an article; it finds what the article doesn't say

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.

MODE 4 · WRITE AN ARTICLE
Same gap research, one more pass to weave it into a finished piece

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.

ALSO CALLABLE OVER PLAIN HTTP
Not chat-only — a GitHub Actions job or another model can call this directly

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.

06

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.

FREE RETRIEVAL LAYER

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.

SWAPPABLE BACKEND

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.

07

Measured against Tavily, not just claimed

MeasuredThe headline promise — a Tavily-grade answer without the paid search API — was put on the clock rather than left as a claim. The same eight questions (the ask and quick-answer modes) were run through this agent and through Tavily’s own Search API on identical inputs, and the two sets of outputs compared side by side.
TAVILY · SYNTHESIZED ANSWERS
0 / 8

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.

THIS AGENT · ON THE SAME EIGHT
8 / 8

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.

THE TELLING CASE · A MODEL THAT DOESN'T EXIST
Asked for the benchmark scores of a fictional model, the grounding check earned its keep

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)
08

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.

Full comparison, side by side

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.

09

Measured it, then chose not to ship it

ShippedThe agent itself is live and usable on Hugging Face Spaces right now — that is what the "Shipped" badge on this project means.
Not shippedExactly one thing was left out on purpose: wiring this agent into TechDrishti's daily pipeline. That integration was built and measured, then cut — for the reason below.

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.

CURRENT BUILD · DDG-ONLY
~$0.01 / run

9–20 min per run

WITH THIS AGENT WIRED IN
~$0.17 / 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.

10

By the numbers

8-turn
hard iteration cap (MAX_ITERATIONS), code-enforced regardless of what the model wants
5
CORE_RULES shared verbatim across every mode: ask, quick answer, research an article, write an article
4
chat modes on one agent loop: ask, quick grounded answer, research an article, write an article
2
swappable backends (DeepSeek V4 Flash / Sarvam) behind one LLM_PROVIDER env var