Skip to content
Design AI Research Loops That Stop, Recover and Produce Evidence
Article

Design AI Research Loops That Stop, Recover and Produce Evidence

AI & Machine Learning

Learn how to build an AI research loop with clear states, firm stop conditions, safe retries after failed tool calls, and a final report that rejects unsupported conclusions.

By MrScraper Team 14 min read

Most AI research agents run in a loop. The model looks at what it knows, picks a tool, reads the result and decides what to do next. Then it does it again.

That loop is where things go wrong. It can run forever. It can crash on one bad tool call and lose an hour of work. And it can end with a confident answer that none of its sources support.

This guide shows how to design the loop itself. You will define clear states, add stop conditions, recover from failed tool calls and reject any conclusion that lacks evidence.

The example task

For each of three vendors, does the public documentation say the product supports SAML single sign-on? If yes, which plan includes it?

Each vendor is a sub-question. The loop is done when every sub-question has an answer backed by evidence, or when it runs out of budget.

The loop uses two MrScraper endpoints:

  • Scrape Sitemap with the map agent to find pages on each vendor's docs site
  • Scrape Markdown to read a page as clean text Your own model reads each page and pulls out evidence.

What are the loop states?

A loop without named states is hard to stop, hard to resume and hard to debug. Give every step a name.

State What happens Next state
PLAN Pick the next open sub-question DISCOVER or REPORT
DISCOVER Find candidate pages on the vendor's site READ
READ Fetch the next unread candidate page EXTRACT
EXTRACT The model pulls evidence from the page EVALUATE
EVALUATE Check whether the sub-question is answered READ, PLAN or REPORT
REPORT Build the final answer from verified evidence DONE

There are also two end states:

  • DONE means the loop finished normally.
  • STOPPED means a stop condition ended the loop early. The state records which one.

Write the loop as a state machine

Each state is a small function. It does one thing, updates the job, and returns the name of the next state. The main loop just calls the right function until it reaches an end state.

python
from dataclasses import dataclass, field
 
 
@dataclass
class Job:
    job_id: str
    sub_questions: list           # [{"id": "vendor_a", "domain": "...", "start_url": "..."}]
    state: str = "PLAN"
    current: str | None = None    # The sub-question being worked on
    candidates: dict = field(default_factory=dict)   # sub_question_id -> [urls]
    pages: dict = field(default_factory=dict)        # url -> markdown
    evidence: list = field(default_factory=list)
    answered: set = field(default_factory=set)     # Has valid evidence
    exhausted: set = field(default_factory=set)    # No pages left to try
    counters: dict = field(default_factory=lambda: {
        "steps": 0, "discover_calls": 0, "pages": 0, "tokens": 0,
        "failures_in_a_row": 0, "rounds_without_new_evidence": 0,
    })
    stop_reason: str | None = None
    pending_call: dict | None = None
    last_url: str | None = None
 
 
def run(job: Job, handlers: dict) -> Job:
    while job.state not in ("DONE", "STOPPED"):
        reason = check_stop_conditions(job)
        if not reason:
            job.counters["steps"] += 1
            try:
                job.state = handlers[job.state](job)
            except StopLoop as e:
                reason = str(e)
        if reason:
            job.stop_reason = reason
            handlers["REPORT"](job)   # Still report what was verified
            job.state = "STOPPED"
        save_job(job)
    return job

save_job is your own function. It writes the job to a database or file. The call after every step matters. If the process dies, you load the last saved job and call run again. It picks up in the state where it stopped.

Write the small states

Here are four of the states. READ comes later, in the section on failed tool calls. extract_evidence is your own model call. It reads one page and returns a list of evidence items in the format shown later in this guide.

python
from urllib.parse import urlparse
 
 
def plan(job: Job) -> str:
    done = job.answered | job.exhausted
    open_ids = [q["id"] for q in job.sub_questions if q["id"] not in done]
    if not open_ids:
        return "REPORT"
    job.current = open_ids[0]
    return "READ" if job.current in job.candidates else "DISCOVER"
 
 
def discover(job: Job) -> str:
    q = next(q for q in job.sub_questions if q["id"] == job.current)
    job.counters["discover_calls"] += 1
    try:
        result = call_with_retries(
            {"url": q["start_url"], "agent": "map", "maxDepth": 2, "maxPages": 3,
             "limit": 50, "includePatterns": [".*(sso|saml|security|pricing).*"]},
            {},
        )
    except SkipCall:
        job.counters["failures_in_a_row"] += 1
        job.exhausted.add(job.current)
        return "PLAN"
    job.counters["tokens"] += result.get("token_usage") or 0
    site = urlparse(q["start_url"]).netloc
    urls = (result.get("data") or {}).get("urls", [])
    job.candidates[job.current] = [u for u in urls if urlparse(u).netloc == site][:5]
    return "READ"
 
 
def extract(job: Job) -> str:
    page = job.pages.get(job.last_url, "")
    found = extract_evidence(page, job.current, job.last_url) if page else []
    for item in found:
        item["id"] = f"E{len(job.evidence) + 1}"
        job.evidence.append(item)
    job.counters["rounds_without_new_evidence"] = (
        0 if found else job.counters["rounds_without_new_evidence"] + 1
    )
    return "EVALUATE"
 
 
def evaluate(job: Job) -> str:
    has_valid = any(
        e["sub_question"] == job.current and evidence_is_valid(job, e)[0]
        for e in job.evidence
    )
    if has_valid:
        job.answered.add(job.current)
        job.counters["rounds_without_new_evidence"] = 0
        return "PLAN"
    return "READ"
 
 
HANDLERS = {"PLAN": plan, "DISCOVER": discover, "READ": read_page,
            "EXTRACT": extract, "EVALUATE": evaluate, "REPORT": build_report}

build_report collects the supported and rejected conclusions and returns "DONE". The last section of this guide covers what goes into it.

Which stop conditions prevent runaway work?

A loop needs more than one way to stop. Each rule below catches a different kind of runaway.

Hard limits

These are simple counters. Pick numbers that fit the task.

python
LIMITS = {
    "max_steps": 60,
    "max_discover_calls": 6,      # Two per vendor
    "max_pages": 15,
    "max_tokens": 300,            # MrScraper tokens for this job
    "max_failures_in_a_row": 3,
    "max_rounds_without_new_evidence": 3,
}

Signals that work has stalled

Hard limits stop a loop eventually. These rules stop it sooner, when more work is unlikely to help:

  • No new evidence. If several pages in a row add nothing, the loop is probably reading the wrong pages.
  • Repeated actions. If the model asks for a page it already read, skip it and count it as a round with no new evidence.
  • Too many failures. If several tool calls fail in a row, something is wrong outside the loop. Stop and report it.

Check every condition before each step

python
def check_stop_conditions(job: Job) -> str | None:
    c = job.counters
    if job.state == "REPORT":
        return None  # Always let the loop write its report
    if c["steps"] >= LIMITS["max_steps"]:
        return "step limit reached"
    if c["pages"] >= LIMITS["max_pages"] and job.state == "READ":
        return "page limit reached"
    if c["discover_calls"] >= LIMITS["max_discover_calls"] and job.state == "DISCOVER":
        return "discovery limit reached"
    if c["tokens"] >= LIMITS["max_tokens"]:
        return "token budget used"
    if c["failures_in_a_row"] >= LIMITS["max_failures_in_a_row"]:
        return "too many failed tool calls in a row"
    if c["rounds_without_new_evidence"] >= LIMITS["max_rounds_without_new_evidence"]:
        return "no new evidence"
    return None

A STOPPED loop is not a failure. It still produces a report from whatever evidence it verified. The report says which stop condition fired and which sub-questions are still open.

Respect your account limits

Your job budget should never be larger than what your MrScraper account has left. The Platform API has an endpoint that reports your token limit and usage. Check it before the loop starts.

python
import os
import requests
 
PLATFORM_URL = "https://api.app.mrscraper.com/api/v1"
TOKEN = os.environ["MRSCRAPER_API_TOKEN"]
 
 
def tokens_remaining() -> int:
    response = requests.get(
        f"{PLATFORM_URL}/subscription-accounts",
        headers={"x-api-token": TOKEN},
        timeout=30,
    )
    response.raise_for_status()
    data = response.json()["data"]
    return data["tokenLimit"] - data["tokenUsage"]
 
 
LIMITS["max_tokens"] = min(LIMITS["max_tokens"], tokens_remaining())

Budgets exist to keep work inside your plan and inside each source's limits. If a loop needs more, raise the budget on purpose or choose a plan that fits. Do not spread work across extra tokens or accounts to go past a limit.

How do you resume after a failed tool call?

Tool calls fail. A page times out. A server returns an error. The question is what the loop does next.

Sort errors into two groups

Error Retry? What the loop does
Timeout Yes, with a wait Retry up to a small limit, then move on
HTTP 5xx Yes, with a wait Retry up to a small limit, then move on
HTTP 429 Yes, with a longer wait Slow down. Never switch tokens to keep going
HTTP 401 No Stop the loop. The token is missing, invalid or expired
Other HTTP 4xx No Skip this call and record why

MrScraper also retries on its side. The maxRetries request field caps those retries, and tokenCap caps the tokens one request and its retries can use. Your loop's retries sit on top of that, so keep both numbers small.

Retry with a limit and a growing wait

python
import random
import time
 
MRSCRAPER_URL = "https://api.mrscraper.com/"
 
 
class StopLoop(Exception):
    pass
 
 
class SkipCall(Exception):
    pass
 
 
def call_with_retries(body: dict, params: dict, attempts: int = 3) -> dict:
    for attempt in range(1, attempts + 1):
        try:
            response = requests.post(
                MRSCRAPER_URL,
                params={"token": TOKEN, **params},
                headers={"x-api-token": TOKEN, "Content-Type": "application/json"},
                json={**body, "maxRetries": 1, "tokenCap": 20},
                timeout=330,
            )
            if response.status_code == 401:
                raise StopLoop("MrScraper rejected the API token")
            if response.status_code == 429 or response.status_code >= 500:
                raise requests.HTTPError(f"retryable status {response.status_code}")
            if response.status_code >= 400:
                raise SkipCall(f"status {response.status_code}")
            return response.json()
        except (requests.Timeout, requests.ConnectionError, requests.HTTPError):
            if attempt == attempts:
                raise SkipCall("failed after retries")
            time.sleep(min(2 ** attempt, 30) + random.random())

The wait grows after each try: about 2 seconds, then 4. The small random extra keeps many jobs from retrying at the exact same moment.

Write down the call before you make it

A crash in the middle of a tool call is the tricky case. Did the call finish? Did you pay for it? To answer that, save a note about the call before you send it.

python
import hashlib
import json
 
 
def call_key(tool: str, args: dict) -> str:
    return hashlib.sha256(json.dumps([tool, args], sort_keys=True).encode()).hexdigest()[:16]
 
 
def read_page(job: Job) -> str:
    queue = job.candidates.get(job.current, [])
    url = next((u for u in queue if u not in job.pages), None)
    if url is None:
        job.exhausted.add(job.current)  # Nothing left to read for this one
        return "PLAN"
 
    key = call_key("read_page", {"url": url})
    job.pending_call = {"key": key, "tool": "read_page", "url": url}
    save_job(job)                       # Saved before the call goes out
 
    try:
        result = call_with_retries({"url": url}, {"markdown": "true"})
        job.pages[url] = result.get("markdown", "")
        job.counters["pages"] += 1
        job.counters["tokens"] += result.get("token_usage") or 0
        job.counters["failures_in_a_row"] = 0
        job.last_url = url
        next_state = "EXTRACT"
    except SkipCall:
        job.pages[url] = ""             # Mark as tried so the loop moves on
        job.counters["failures_in_a_row"] += 1
        next_state = "READ"
 
    job.pending_call = None
    return next_state

When the job loads after a crash, look at pending_call:

  • If the URL is already in job.pages, the call finished. Clear the note and keep going.
  • If it is not, the call never finished. The loop is still in READ, so it simply tries that page again. Because reading a page has no side effects, trying it again is safe. For tools that do have side effects, like sending a message, use the call key to check whether the action already happened before you repeat it.

How do you reject unsupported conclusions?

A model can write a conclusion that sounds right but goes beyond its sources. The loop should treat every conclusion as a draft until code checks it.

Store evidence as quotes, not summaries

In the EXTRACT state, ask your model to return evidence in a fixed shape. Each item must quote the page word for word.

json
{
  "sub_question": "vendor_a",
  "claim": "SAML SSO is available on the Business plan.",
  "value": "Business",
  "quote": "SAML single sign-on is included in the Business and Enterprise plans.",
  "url": "https://docs.vendor-a.example/security/sso"
}

Check each piece of evidence

python
def normalize(text: str) -> str:
    return " ".join(text.split()).lower()
 
 
def evidence_is_valid(job: Job, item: dict) -> tuple[bool, str]:
    page = job.pages.get(item.get("url", ""))
    if not page:
        return False, "cited page was not read in this job"
    if normalize(item.get("quote", "")) not in normalize(page):
        return False, "quote does not appear on the page"
    if normalize(str(item.get("value", ""))) not in normalize(item["quote"]):
        return False, "value does not appear in the quote"
    return True, "ok"

Then check each conclusion

A conclusion is a sentence in the final answer. It must point to evidence IDs, and the evidence must back it up:

  1. It cites at least one valid piece of evidence. No citation, no conclusion.
  2. The cited evidence is about the same sub-question. Evidence about Vendor B cannot support a claim about Vendor A.
  3. Broad claims need more than one source. A sentence like "all three vendors include SSO on business plans" covers several sub-questions. Each one must have its own valid evidence.
  4. Conflicts are reported, not hidden. If two valid quotes disagree, the conclusion is marked as disputed, and both sources are shown.
python
def check_conclusion(job: Job, conclusion: dict, valid_ids: set) -> tuple[bool, str]:
    cited = [e for e in job.evidence if e["id"] in conclusion.get("evidence_ids", [])]
    if not cited:
        return False, "no evidence cited"
    if any(e["id"] not in valid_ids for e in cited):
        return False, "cites evidence that failed verification"
    covered = {e["sub_question"] for e in cited}
    missing = set(conclusion["sub_questions"]) - covered
    if missing:
        return False, f"no evidence for {sorted(missing)}"
    values = {normalize(e["value"]) for e in cited if e["sub_question"] in conclusion["sub_questions"]}
    if len(conclusion["sub_questions"]) == 1 and len(values) > 1:
        return False, "sources disagree; report as disputed"
    return True, "supported"

Build the report from what passed

The final report has three parts:

  • Supported conclusions, each with its quotes and source URLs
  • Rejected conclusions, each with the reason it failed
  • Open questions, plus the stop reason if the loop ended early Keep the rejected list in the report, at least for internal readers. It shows where the model went further than its sources. That is the most useful signal you have when you tune the loop.

Hand off to your application

When the loop ends, your app has a saved job with every page read, every piece of evidence and every check result. Store the supported conclusions with their URLs and quotes. Send disputed and open items to a person for review if the answer matters.

FAQs

What are the loop states?

A simple research loop uses six working states: plan, discover, read, extract, evaluate and report. It also has two end states: done and stopped. Each state does one job and names the next state, so the loop is easy to stop, resume and debug.

How do you reject unsupported conclusions?

Store evidence as exact quotes with source URLs. Check that each quote appears on a page the loop actually read and contains the value it claims. Then check that each conclusion cites valid evidence for every sub-question it covers. Mark conflicts as disputed and list rejected conclusions in the report.

Which parts does MrScraper provide, and which parts must my application implement?

MrScraper is a developer-focused web data API. It discovers URLs from a starting page, fetches pages as clean Markdown or HTML, and lets you cap retries and tokens per request. Each response reports token usage, and the Platform API reports your account's token limit and usage.

Your application builds the loop. That includes the states, stop conditions, retry rules, saved job state, the model calls, evidence checks and the final report. Your team also decides which sources it has permission to collect from.

Summarize this post

Open it in your assistant of choice with the prompt ready to send.

Take a Taste of Easy Scraping!

Your choices

Cookie preferences

Necessary cookies keep your selection. Optional categories are disabled until you switch them on.

Strictly necessary

Remembers your privacy selection and keeps the site working.

Always on