A deep-research agent: one lead agent plans and writes; several subagents research disjoint sub-questions in parallel, each in its own isolated context window, and return only a short summary.
When Fan-Out Is Correct
Justified only when all three hold; the checklist for the next problem:
- Independent sub-questions: workers never need each other mid-run (else message passing, billed twice).
- Read-only work: parallel reads cannot corrupt state (writes need per-worker copies or locks).
- Composable by concatenation: a synthesizer merges summaries end to end (else it is a sequential workflow you wrote).
Core reason it pays: a thorough search reads far more than it keeps (360k read, 4.8k kept), and context isolation discards the 60k a worker read. Not speed.
Pipeline
Question
-> Scout: 1-2 broad searches, learn shape, DO NOT answer
-> Decompose: split into 3-6 disjoint, jointly-exhaustive sub-questions
-> Fan out: N subagents, each own ~60k window -> ~800-tok summary back
-> Synthesize draft; gaps/contradictions? loop to decompose (cap 2 rounds)
-> Citation check: fetch every cited URL, verify entailment
-> Report
- Scout partitions the observed space; skipping it partitions the model’s stale training prior (measured 43% duplicated fetches + a missing subtopic). Highest return per dollar (~$0.06). Rules: forbid answering; cap searches with
max_uses. - Decompose = partition into disjoint (no overlap) and jointly exhaustive (full coverage) pieces; each scope must name what NOT to cover.
Subagent Brief
Entire contents of a worker’s window at turn 1; no mention of the original question or other workers (that absence is the isolation). Forces four things:
| Element | Failure prevented |
|---|---|
| Boundary (what NOT to touch) | Overlap; 3 workers, same 6 URLs, paid 3x |
| Budget (max searches) | Runaway: one worker 31 searches, 60% of cost |
| Output contract (declared shape) | Non-composable prose the synthesizer averages |
| Explicit unknowns | Silent coverage gaps read as complete |
- Scope must state exclusions: “Focus on X” is a preference; “Do NOT cover Y, Z” is a boundary.
- Unknowns field converts an unobservable (did it have gaps?) into an observable that feeds the gap-check round.
Security: The Tool Surface Is the Boundary
Threat is prompt injection: instruction-like text inside fetched page data. Exposure needs all three legs of the lethal trifecta; removing one is enough:
- Private context (scope line + question)
- Attacker-controlled content (fetched page)
- Egress channel (outbound GET)
- Control 1 (chroot):
write_noteconfined tonotes/, rejected afterresolve()(not..string match). Does NOT close egress. - Control 2 (allowlist) — load-bearing:
web_fetchhost checked against scout’s hosts + short static list. A GET is an outbound channel (model picks host + query string), so it closes leg 3. Data-framing pages does not remove leg 2; chroot does not remove leg 3. - Host check must be suffix, not substring:
host == d or host.endswith("." + d)(elseec.europa.eu.attacker.examplepasses).
Caps Must Bind to a Constant the Model Cannot See
A cap whose bound comes from the model’s own structured output enforces nothing, and the defect is invisible to any test using a cooperating model.
| Guard | Model controls | Harness enforces |
|---|---|---|
turn_cap() | sq.max_searches | min(sq.max_searches, MAX_SEARCHES_PER_WORKER) + 4 |
admit() | # of sub-questions | sub_questions[:MAX_WORKERS] |
preflight() | nothing | raises if worst-case $ > MAX_RUN_COST |
Constants (harness, unseen): MAX_SEARCHES_PER_WORKER=12, MAX_WORKERS=6, WORKER_TIMEOUT_S=300, MAX_RUN_COST=$8.00. preflight prices the cap, not the expected run, before any worker starts. Thread pool max_workers caps concurrency only, not turns or spend.
Model Tiering & Concurrency
- Opus ($5/$25): lead — decompose + synthesize (judgment). Sonnet ($3/$15): workers — search + summarize (execution). Largest single cost lever; Opus workers = 1.6x, no measured accuracy gain.
- Threads not processes (I/O-bound, GIL irrelevant); elapsed = max(worker), not sum.
- One shared deadline, not per-future: a dead worker becomes a missing
complete=Falsefinding, not a stalled run.completeflag crosses the boundary so the synthesizer treats silence as unknown, not absence.
Memory & Compression
| Layer | Contents | Lifetime |
|---|---|---|
| Lead working | Question, plan, N summaries (~5k) | One run |
| Subagent working | Own searches/pages (~60k, discarded) | One subtask |
| Filesystem | notes/*.md, read on demand | The run |
| Episodic | Good sources, dead-end queries | Across runs |
- 75x = capability ratio (resident): 6x60k read = 360k; 6x800 = 4.8k held; 360k/4.8k = 75. Not a discount.
- Billed is different: stateless API resends history each turn, so a worker bills ~
obs x turns²/2(~300k), ~1.8M per run. Both true, measure different things. - Fan-out is cheaper per token read: one context bills
a·n²/2; w workers billa·n²/(2w). Quadratic divides by worker count. Multi-agent still costs 4-15x overall because it reads more.
Citation Verification
Runs before release; fabricated sources end pilots. Three gates, cheapest first, each reject strips the claim (not the report):
- Gate 1 DEAD: URL 4xx/5xx/timeout.
- Gate 2 UNSUPPORTED: 200 but claim literals absent (substring scan).
- Gate 3 CONTRADICTED / VERIFIED: literals present, so pay for NLI (natural language inference) via Haiku ($1/$5) — entails vs contradicts. Catches the dangerous class where every literal is on the page but it means the opposite (“7% … whichever is HIGHER” = floor, not cap).
Verify the verifier: Entailment declares quoted_span before verdict (constrained decoding emits in order), then Python confirms the span is really in the page AND len(span) >= MIN_SPAN_CHARS (40). Checked on contradicts too (an invented-span judge can strip true claims). "" is a substring of every page; "7%" is the exact case showing substring checks fail. Publish fabrication rate (fabricated/contradicted per 100 claims); bar = zero false verified.
Cost (4 workers, 8 turns each)
| Phase | Model | Cost |
|---|---|---|
| Scout (2) | Opus | $0.06 |
| Decompose (1) | Opus | $0.03 |
| Subagents (32) | Sonnet | $2.37 |
| Synthesis (1) | Opus | $0.12 |
| Citation (20) | Haiku | $0.10 |
| Total (56 calls) | ≈ $2.68 |
Workers = 88% of the bill. Biggest lever: prompt cache the worker prefix (reads 0.1x, writes 1.25x) saves 46%. Caveats: caching starts turn 2 (0.6k brief below Sonnet’s 1,024-token minimum); 5-min TTL, so a slow tool call between turns makes caching strictly worse. Vs single agent: 25-search run costs $5.78 and fits 145.6k in a 200k window but buries citations mid-context; 360k of source has no single-agent config at all.
Key Gotchas
- Skipping scout partitions the stale prior, not the topic.
- A cap that reads its bound from the plan is not a cap.
- Framing pages as data does not close egress; the allowlist does.
- A verifier you do not verify is a second opinion.
- Write the adversarial test first: every broken guard failed on the second case, not an exotic one.