Deep research agent architecture needs explicit admission and stop rules. Decide which tool call can start, how much capacity the job reserves, and what happens when a source fails or time expires. Preserve completed evidence rather than restart the entire run. Collected documents are not automatically a claim-ready answer.
Separate the research workflow from its controller
A packaged workflow such as the SearchCans research guide defines questions, search and source review. The controller here owns a narrower engineering job: admitting calls under a shared budget and exposing why a run stopped. It does not replace source judgment, model orchestration or a full research product.
| State | Controller action | Evidence retained |
|---|---|---|
| Call proposed | Check deadline, source scope and reserved capacity | Operation identity and estimated charge |
| Call admitted | Use a separately bounded client operation | Actual outcome and source identity |
| Read failed | Keep a failure record without source text | Stage, URL and error type |
| Budget exhausted | Do not admit further calls | Completed sources and remaining estimate |
| Collection ended | Hand material to claim review | Unreviewed source ledger, not a fabricated report |
Anthropic’s engineering account describes coordination, evaluation and recovery challenges in a research system. Its internal results are not measurements of this implementation. This example makes the application’s resource boundary inspectable without borrowing another company’s performance figures.
Use a conservative reservation ledger
The runnable Python controller reserves one estimated credit for standard search and two for standard Reader, matching the current first-party charging units before add-ons. Reservation is conservative admission control, not an invoice calculation: failures do not automatically refund the local estimate, and proxy or other operations need their own documented costs.
import hashlib
from time import monotonic
from urllib.parse import urlparse
def run_research(queries, search, read, allowed_hosts, *, credit_budget=7,
source_cap=3, deadline_seconds=60, clock=monotonic):
if credit_budget < 0 or source_cap < 1 or deadline_seconds <= 0:
raise ValueError("Invalid job budget")
started = clock()
remaining, seen, sources, failures = credit_budget, set(), [], []
def reserve(estimate):
nonlocal remaining
if clock() - started >= deadline_seconds or remaining < estimate:
return False
remaining -= estimate
return True
for query in queries:
if len(sources) >= source_cap or not reserve(1):
break
try:
urls = search(query)
if not isinstance(urls, list) or any(not isinstance(url, str) for url in urls):
raise ValueError("Search adapter must return URL list")
except Exception as error:
failures.append({"stage": "search", "error_type": type(error).__name__})
continue
for url in urls:
parsed = urlparse(url)
if parsed.scheme != "https" or parsed.hostname not in allowed_hosts or url in seen:
continue
if len(sources) >= source_cap or not reserve(2):
break
seen.add(url)
try:
text = read(url)
if not isinstance(text, str) or not text.strip():
raise ValueError("Empty source")
sources.append({"url": url, "text": text,
"body_sha256": hashlib.sha256(text.encode()).hexdigest(),
"claim_review": "not_reviewed"})
except Exception as error:
failures.append({"stage": "read", "url": url,
"error_type": type(error).__name__})
return {"sources": sources, "failures": failures,
"reserved_estimate": credit_budget - remaining,
"remaining_estimate": remaining,
"elapsed_seconds": clock() - started,
"deadline_reached": clock() - started >= deadline_seconds,
"answer_ready": False}
if __name__ == "__main__":
import json
result = run_research(["fixture question"],
lambda q: ["https://example.test/a", "https://example.test/b"],
lambda url: "Synthetic source body", {"example.test"})
print(json.dumps({"fixture_only": True, "result": result}, indent=2))
Understand what the deadline does
The controller checks its deadline before admitting another operation. It does not interrupt a blocking callback already running. Search and read adapters need finite network timeouts, and an application needing cancellation should use a bounded async scheduler. A client timeout also does not prove that a remote job was cancelled.
The source cap counts completed useful sources; failed reads can still consume the finite reservation budget. Duplicate and out-of-scope URLs are skipped before a read is admitted. Preserve failures so the next stage cannot mistake an incomplete collection for a complete evidence set.
Persist an honest checkpoint
Save the returned JSON with the input queries, declared budget and provider settings. It contains completed text hashes, failed reads and the remaining estimate. It is a collection checkpoint, not a durable automatic resume implementation. A resume feature needs an explicit remaining-task queue and charge reconciliation before it can safely continue.
Use the agent tool contract for the consumer-facing interface and the evidence selection budget for limited model context. This architecture page owns resource admission and stop behavior. It does not repeat pricing rankings or claim that a bounded loop produces a correct answer by itself.