Why Your Agent Made 340 Search Calls to Answer One Question
Unbounded tool loops are the default. Budgets, deduplication, and a stopping rule turn an expensive agent into a predictable one.


- Count everything, per request
- Tell the model its remaining budget
- Deduplicate at the tool boundary
- Read fewer pages, more deliberately
- Tier the budget by question
- Log the shape, not just the total
The first production surprise with a research agent is the bill. Not because search is expensive per call, but because nothing in the default agent loop tells the model to stop. Give it a hard question and it will search, read, decide it needs more context, search again, and keep going until it hits your max-iterations guard — which you set to 50 because that seemed safe.
Three mechanisms fix this, and they compose.
Count everything, per request
Not globally, not per day. Per user request, because that’s the unit you’re trying to bound.
import time
from dataclasses import dataclass, field
@dataclass
class Budget:
max_searches: int = 8
max_reads: int = 6
max_seconds: float = 90.0
searches: int = 0
reads: int = 0
started: float = field(default_factory=time.monotonic)
@property
def elapsed(self) -> float:
return time.monotonic() - self.started
def can_search(self) -> bool:
return self.searches < self.max_searches and self.elapsed < self.max_seconds
def can_read(self) -> bool:
return self.reads < self.max_reads and self.elapsed < self.max_seconds
def summary(self) -> str:
return (f"{self.searches}/{self.max_searches} searches, "
f"{self.reads}/{self.max_reads} reads, "
f"{self.elapsed:.0f}s/{self.max_seconds:.0f}s")
The time bound matters as much as the call counts. An agent that makes six searches against a slow set of pages can still blow a 30-second SLA.
Tell the model its remaining budget
This is the part that changes behaviour, and it’s cheap. Put the budget in the tool result, not the system prompt:
def search_tool(query: str, budget: Budget) -> str:
if not budget.can_search():
return (
f"SEARCH BUDGET EXHAUSTED ({budget.summary()}). No further searches are "
"available. Answer using what you have already gathered, and state "
"explicitly which parts of the question you could not verify."
)
budget.searches += 1
results = raw_search(query)
remaining = budget.max_searches - budget.searches
header = f"[{remaining} searches remaining this request]"
if remaining <= 2:
header += (" — begin consolidating toward an answer rather than "
"opening new lines of inquiry.")
return f"{header}\n\n{format_results(results)}"
A system-prompt instruction to “be efficient” does approximately nothing. A number in the tool output that decrements visibly changes the model’s planning, because it’s in the immediate context at the moment of the next decision.
The exhaustion message is equally important. Without instructions on what to do when the budget runs out, an agent that hits the wall will either keep retrying the tool or produce an answer that quietly omits what it couldn’t check.
Deduplicate at the tool boundary
Agents re-issue near-identical queries constantly — a rephrase that returns the same ten URLs. Catch it before it costs anything:
import re
from difflib import SequenceMatcher
def normalize(q: str) -> str:
q = re.sub(r"[^\w\s]", " ", q.lower())
return " ".join(sorted(set(q.split()) - STOPWORDS))
STOPWORDS = {"the", "a", "an", "of", "in", "for", "to", "is", "what", "how", "and"}
class SearchCache:
def __init__(self, threshold: float = 0.85):
self.threshold = threshold
self.entries: list[tuple[str, str]] = []
def lookup(self, query: str) -> str | None:
key = normalize(query)
for prev_key, result in self.entries:
if key == prev_key:
return result
if SequenceMatcher(None, key, prev_key).ratio() >= self.threshold:
return result
return None
def store(self, query: str, result: str) -> None:
self.entries.append((normalize(query), result))
Then in the tool, before spending a budget slot:
def search_tool(query: str, budget: Budget, cache: SearchCache) -> str:
cached = cache.lookup(query)
if cached is not None:
return (
"[Cached — this query is nearly identical to one you already ran. "
"No budget consumed. If these results don't help, try a substantially "
"different angle rather than rephrasing.]\n\n" + cached
)
...
Telling the model why it got a cached result is what stops the loop. A silent cache hit looks like a fresh search that happened to return the same thing, so the agent rephrases again.
Normalising by sorted content words with stopwords removed catches “best vector database open source” and “open source vector databases best” as the same query, which naive string comparison misses.
Read fewer pages, more deliberately
Reading is usually the expensive half. Make the agent commit to which results are worth it:
READ_TOOL = {
"name": "read_pages",
"description": (
"Fetch the full text of up to 3 URLs from your search results. "
"This is the expensive operation — choose the results most likely to "
"contain the specific facts you need, based on their titles and snippets. "
"You have a limited number of reads per request."
),
"input_schema": {
"type": "object",
"properties": {
"urls": {
"type": "array",
"items": {"type": "string"},
"maxItems": 3,
"description": "URLs from prior search results, most promising first.",
},
"reason": {
"type": "string",
"description": "What specific fact you expect these pages to contain.",
},
},
"required": ["urls", "reason"],
},
}
Two design choices doing real work. Batching up to three URLs in one call lets you fetch them concurrently — same budget, a third of the latency. And the required reason field forces the model to articulate what it’s looking for before spending, which measurably reduces speculative reads.
Tier the budget by question
A flat budget is either too tight for hard questions or too loose for easy ones. Classify once, cheaply:
BUDGETS = {
"simple": Budget(max_searches=2, max_reads=1, max_seconds=20),
"standard": Budget(max_searches=6, max_reads=4, max_seconds=60),
"deep": Budget(max_searches=15, max_reads=12, max_seconds=240),
}
def budget_for(question: str) -> Budget:
msg = client.messages.create(
model="claude-haiku-4-5-20251001", max_tokens=10,
system=("Classify the research effort this question needs. "
"Answer with exactly one word: SIMPLE, STANDARD, or DEEP."),
messages=[{"role": "user", "content": question}],
)
tier = msg.content[0].text.strip().lower()
import copy
return copy.deepcopy(BUDGETS.get(tier if tier in BUDGETS else "standard"))
The deepcopy isn’t incidental — these are mutable counters, and handing out the shared instance means your second request starts with the first one’s budget already spent.
Log the shape, not just the total
def record(request_id: str, budget: Budget, question: str, answered: bool) -> None:
metrics.emit({
"request_id": request_id,
"searches": budget.searches,
"reads": budget.reads,
"seconds": round(budget.elapsed, 1),
"hit_search_cap": budget.searches >= budget.max_searches,
"hit_read_cap": budget.reads >= budget.max_reads,
"hit_time_cap": budget.elapsed >= budget.max_seconds,
"answered": answered,
})
The number to watch is the cap-hit rate. If most requests finish under budget, your caps are fine and the occasional expensive question is real work. If a third of requests hit the search cap, either the caps are too tight or — more often — the agent is looping on rephrases, and the cache above will show up as an immediate drop in call volume with no change in answer quality.