Autonomous Research Agent

Verifiable research, not confident prose.

Plans, searches, reads, cross-checks, and writes a cited report ; then a deterministic gate proves every claim against text captured at retrieval time.

/runs/demo ; idle
Question

Compare Tesla, BYD, and Rivian's battery strategy and predict which is best positioned for 2030.

  • Plan ready ; 4 sub-questions freshness horizon 24 months
  • Research round 1/3
  • web_search battery strategy 2030
  • Source [S1] fetched verbatim → EvidenceStore
  • Writing report
  • Verification found issues 1 claim flagged · repair pass
Verification found issues ⚠ single-source ; two URLs, one eTLD+1

First claim [S1][S2] ; two independent domains. Tesla is ahead [S3][S4].

Next.js (App Router) FastAPI (SSE) pure-Python async agent · Anthropic SDK tool runner

The problem

A search tool bolted to one LLM call fails in three specific ways

Not because the model is weak ; because nothing in that loop is positioned to check the output. The prose is confident either way, which is exactly the problem.

  • 01

    Citations that resolve to nothing

    The model cites a page it never opened. Nothing captured the text behind that URL at retrieval time, so no later check can confirm the sentence came from it ; or that the page ever said it.

  • 02

    Five links, one story

    A wire report republished across six outlets looks like six sources. Two subdomains of one company look like two companies. Independence was never tested, so agreement is counted where none exists.

  • 03

    Numbers that expired

    Without a freshness horizon and a publication date per source, a 2021 roadmap reads exactly like a 2026 one. Undated pages are the worst case: they get silently treated as current.

The answer

Four guardrails, each one a mechanism you can read

All four are enforced after generation, by code rather than by another model. Everything retrieved lands in an EvidenceStore at retrieval time; the gate resolves every claim against that store and nothing else.

Every [Sn] marker resolves to retrieved text

verify.py regexes [S(\d+)] out of the finished report and resolves each id against the EvidenceStore. The id must map to a registered source that has at least one verbatim snippet behind it. A source the agent only saw in a search result and never fetched has no snippet, so citing it fails as unsupported ; search results are leads, not evidence.

Two independent sources, with syndication collapsed

Independence is eTLD+1, resolved by tldextract against an offline public-suffix list. ir.tesla.com and www.tesla.com are both tesla.com ; one domain, not two. Titles that match at ≥ 0.92 similarity (difflib.SequenceMatcher) collapse to one, so the same wire copy on two domains cannot count twice.

Stale sources get flagged; unknown dates stay unknown

The planner derives a freshness horizon from the question itself (default 24 months). A claim is fresh if any cited source falls inside it, stale if every dated source falls outside it, and unknown if no cited source carries a publication date at all. Unknown is surfaced as its own verdict ; never rounded up to fresh.

Search is bounded in five independent layers

max_uses caps the server tools at 12 searches and 20 fetches. max_iterations caps the runner at 30. Each round gets a task_budget of 40,000 tokens (120,000 split across 3 rounds). A dedup ledger normalizes queries word-order-insensitively and rejects repeats. And a no-progress detector ends the run the moment a round adds zero new sources.

When verification fails, the agent gets exactly one bounded repair pass with the specific violations quoted back to it ; and the repair is kept only if it did not increase the issue count. If problems survive that, the report ships with those claims marked ⚠ flagged in the UI. It degrades honestly rather than failing closed or quietly asserting.

How a run works

Seven stages, and the one that costs nothing is the one that decides

An ambiguous question pauses the run and asks ; before any research budget is spent. Everything after that is bounded.

  1. clarify

    pauses before budget is spent

  2. plan

    sub-questions + freshness horizon

  3. research

    ≤ 3 rounds, bounded

  4. gap analysis

    what is still thin?

  5. report

    streamed, evidence prompt-cached

  6. verify

    $0.00 · deterministic

  7. repair

    at most one pass

Research stops early on any of three conditions: a round that adds no new sources, a token budget that runs out, or a gap analysis confident enough to stop. Whichever fires is recorded on the run as stopped_because.

What you actually see

The interface shows the evidence, not just the answer

Sources stream in as they are retrieved. The report streams token by token. Every [Sn] marker links to the row it came from, and any claim the gate could not prove carries its reason in words.

/runs/8f2c1a ; researching…

Progress

  1. Plan ready ; 4 sub-questions freshness horizon 24 months
  2. Research round 1/3
  3. web_search battery strategy 2030 comparison
  4. Source [S1] site0.com Tesla ramps 4680 cell output…
  5. Source [S4] tesla.com 4680 production update
  6. Gap analysis ; confidence 72% 2 thin, 1 stale → another round
  7. Writing report
  8. Verification found issues 3 cited claims checked

Sources (6)

ID Domain Published Access
[S1] site0.com 2026-03-01 live
[S2] site1.com 2026-03-01 live
[S3] tesla.com 2026-04-01 live
[S4] tesla.com 2026-05-01 cache
[S5] site2.com 2026-03-01 live
[S6] a0.com 2021-01-01 ⚠ stale live

Report

Verification found issues (3 claims checked)

⚠ single-source ; fewer than 2 independent domains support this claim.

First claim [S1][S2]. Two independent registered domains stand behind this sentence, both inside the 24-month horizon, both with verbatim text captured at retrieval.

Tesla is ahead [S3][S4]. Flagged ; single-source: ir.tesla.com and www.tesla.com both resolve to tesla.com. Two citations, one independent domain.

Rivian signs long-term supply deal with LG [S5]

Illustration of the product UI. It is populated with the verifier’s own test fixtures from tests/test_verify.py ; including the ir.tesla.com / www.tesla.com pair from TestGuardrail2Independence, which is a real, passing test of the single-source rule. This repository ships no committed example report, so no research findings are shown here.

Where the tokens go

Every ceiling is a constant you can read, and the gate is free

Cost is governed by two things: which model each role gets, and the hard ceilings around the research loop. Both live in researcher/config.py, and both are overridable per run from the request body ; or by env vars on Settings.role_config.

Per-role model routing

Role Model Effort Thinking Load
Planner / gap analysis claude-sonnet-5 medium adaptive
Research loop claude-sonnet-5 high adaptive · summarized
Final report claude-opus-4-8 xhigh adaptive · summarized
Query rewrite claude-haiku-4-5 ; off

The research rounds dominate token volume, so Sonnet 5 carries the bulk and Opus 4.8 is spent only on the report ; a small share of total tokens. There is no cheaper option for the research role: it drives tool use across many turns, and dropping it below Sonnet 5 costs more in wasted rounds than it saves per token. thinking is set explicitly for every role because the API default differs by model.

Hard ceilings per run

Token budget per run
120,000
task_budget_tokens
Research rounds
3
max_rounds
Runner iterations
30
max_runner_iterations
web_search calls
12
web_search_max_uses
web_fetch calls
20
web_fetch_max_uses
Report output cap
64,000
report_max_tokens

verify_report() ; $0.00

The gate is regex and set arithmetic. No model call, no tokens, no latency worth measuring ; it runs on every report, and it runs again after the repair pass. The most consequential step in the pipeline is the one with no marginal cost.

What is deliberately not on this page

A dollar figure per run. Token usage is tracked per role at runtime ; UsageTracker aggregates input, output and both cache counters into the run result ; but this repository contains no committed measurement from a real run, so there is no honest number to publish. Quoting one here would be the exact failure the rest of this page is built to prevent. Run it with your own key and read result.usage.

Tradeoffs, stated

The limits are chosen, documented, and tested

Each of these is a place where the easy answer was rejected for a reason worth writing down.

One worker, on purpose

Run state and SSE fan-out live in process, so the server runs --workers 1. That is a deliberate ceiling, not an oversight: multi-worker needs Redis pub/sub or a task queue, and neither earns its complexity until there is load to justify it.

The stream can drop; the history cannot

The SSE endpoint keeps a replay buffer, so a reconnecting browser receives the full event history rather than joining mid-run. GET /api/runs/{id}/events.json returns the same buffer as plain JSON when EventSource is unavailable.

A cached read is labelled a cached read

Fetches are disk-cached under .research/cache/. When a URL later dies, the agent serves its cached copy and marks that source access="cache" in the UI and in the evidence record. It does not pretend the page was read live.

Python runs client-side, in a subprocess

Analysis code executes in an isolated subprocess ; temp cwd, hard 15s timeout, sockets blocked ; deliberately not the server code_execution tool, which collides with the web tools’ own internal use of it.

Frontend types are generated, not hand-written

lib/types.ts comes from the backend’s OpenAPI schema via npm run gen:types. The event union cannot drift from the Pydantic models without the TypeScript build noticing.

74 tests, no API key, no spend

The deterministic core ; evidence store, verifier, budget ledger, cache, transport ; is covered by 74 tests that run with no ANTHROPIC_API_KEY and cost nothing. Even the pause_turn restart path is exercised through the real SDK runner against a mocked transport.