Why Your Agent Isn't Calling the Search Tool

The problem is almost never the system prompt. It's the twelve words in your tool description.

Profile picture of Serply
Serply
Two tool schemas side by side, one clearly better described

You wire up a search tool, write a system prompt telling the model to use it, and then watch it confidently answer a question about last month’s pricing changes from training data. The instinct is to make the system prompt louder. That rarely works.

The model’s decision to call a tool is made against the tool’s own description, in the moment, next to the schema. That text is doing more work than the system prompt, and it’s usually the part that got twelve seconds of thought.

The baseline failure

{
    "name": "search",
    "description": "Searches the web.",
    "input_schema": {
        "type": "object",
        "properties": {"query": {"type": "string"}},
        "required": ["query"],
    },
}

Everything here is technically accurate and nearly useless. “Searches the web” describes the mechanism. The model isn’t wondering how the tool works — it’s wondering whether this question is one where the tool applies.

Describe situations, not mechanisms

{
    "name": "web_search",
    "description": (
        "Search the live web for current information. Use this whenever the answer "
        "depends on facts that may have changed: prices, product availability, "
        "company news, software versions, people's current roles, recent events, "
        "or anything after your training cutoff. Also use it when you are not "
        "confident in a specific factual detail. Prefer searching over guessing."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "query": {
                "type": "string",
                "description": (
                    "The search query, phrased the way you would type it into "
                    "Google. Use keywords, not a full sentence. Include specific "
                    "product names, versions, or dates when relevant."
                ),
            },
            "num": {
                "type": "integer",
                "description": "Number of results, 1-50. Use 5 for a quick check, 20+ for research.",
                "default": 10,
            },
        },
        "required": ["query"],
    },
}

The enumerated list of situations is what changes behavior. Models pattern-match the current question against those categories, and “software versions” catches a question that “searches the web” doesn’t.

“Prefer searching over guessing” is worth its own sentence. Left unsaid, a model’s default is to answer when it feels able to, and it often feels able when it shouldn’t.

The query parameter description matters nearly as much. Without guidance, models write queries like “What were the pricing changes announced by major SERP API providers in the second quarter of this year?” — a sentence that performs badly as a Google query. Telling them to use keywords produces SERP API pricing changes Q2 2026, which actually retrieves something.

Distinguishing tools from each other

When you expose several endpoints, the descriptions have to do disambiguation work:

TOOLS = [
    {
        "name": "web_search",
        "description": (
            "General Google web search. Use for reference material, comparisons, "
            "documentation, and broad questions. Returns titles, URLs, and snippets."
        ),
    },
    {
        "name": "news_search",
        "description": (
            "Search recent news articles. Use when the question is about events, "
            "announcements, or press coverage rather than reference material — "
            "'what happened with X' rather than 'how does X work'. Returns articles "
            "with publication dates and sources."
        ),
    },
    {
        "name": "product_search",
        "description": (
            "Search product listings with prices, ratings, and review counts. Use "
            "when the user wants to buy something or compare specific products. "
            "Do not use for general research about a product category."
        ),
    },
    {
        "name": "read_page",
        "description": (
            "Fetch the full text of a specific URL as markdown. Use after a search "
            "when the snippet doesn't contain enough detail. Requires a URL you "
            "already have — this cannot find pages, only read them."
        ),
    },
]

Three techniques in there. Contrastive phrasing — “‘what happened with X’ rather than ‘how does X work’” — teaches the boundary better than either description alone. Explicit negatives (“do not use for general research”) cut the most common misfire. And stating a precondition (“requires a URL you already have”) stops the model from calling read_page with a search query in the URL field, which is a genuinely common failure.

Describe the return shape

Models plan better when they know what’s coming back:

    "description": (
        "Search the live web. Returns up to `num` results, each with a title, URL, "
        "snippet, and rank position. Snippets are short — roughly one or two "
        "sentences — so use read_page when you need detail."
    ),

That last clause preempts a specific failure: the model calls search, gets thin snippets, and answers from them anyway rather than reading the page. Telling it in advance that snippets are short makes the follow-up call much more likely.

Put the constraints in the tool output

Some things belong in the result rather than the description, because they’re only true sometimes:

def web_search(query: str, num: int = 10) -> str:
    resp = requests.get(
        f"https://api.serply.io/v1/search/q={quote_plus(query)}&num={num}",
        headers={"X-Api-Key": API_KEY},
        timeout=30,
    )
    if resp.status_code == 429:
        return ("SEARCH UNAVAILABLE: rate limit reached. Do not retry. "
                "Answer with what you have and note that search was unavailable.")
    resp.raise_for_status()

    results = resp.json().get("results", [])
    if not results:
        return (f"No results for '{query}'. Try broader keywords or different "
                "phrasing before concluding the information doesn't exist.")

    return "\n\n".join(
        f"[{r.get('position')}] {r.get('title')}\n{r.get('link')}\n{r.get('description', '')}"
        for r in results
    )

An empty result set is the moment a model is most likely to fall back on memory. A bare “no results” reads as “this doesn’t exist”; the version above reads as “try again differently,” which is usually the right next move.

Test the description, not just the tool

The thing worth measuring is call rate on questions that should trigger it:

SHOULD_SEARCH = [
    "What's the latest version of Python?",
    "How much does the Pro plan cost?",
    "Who is the current CEO of that company?",
    "Did they announce anything at the conference last week?",
]

SHOULD_NOT_SEARCH = [
    "Write a haiku about autumn.",
    "What's 15% of 240?",
    "Rewrite this paragraph more concisely: ...",
]

Run both sets, count tool calls, and iterate on the description. A ten-line change to a description routinely moves call rate on the first set from 40% to over 90% — a result no amount of system prompt tuning will match, because the system prompt isn’t where the decision is being made.