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.

Profile picture of Serply
Serply
A budget meter tracking an agent's tool calls

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.