Give an AutoGen Team a Shared Search Tool

Register Serply's search and scraper endpoints once, then let every agent in an AutoGen group chat call them without duplicating tool code.

Profile picture of Serply
Serply
Three chat bubbles sharing a single search tool icon

Multi-agent conversations have a specific failure mode: three agents confidently discussing something none of them actually looked up. AutoGen makes it easy to spin up a researcher, a critic, and a writer — and equally easy to end up with a very articulate exchange built entirely on training data from two years ago.

The fix is a shared tool surface. Define the search functions once, register them with whichever agents should have them, and let the group chat route calls naturally.

Two tools, defined once

import os
import requests
from urllib.parse import quote_plus
from typing import Annotated

API_KEY = os.environ["SERPLY_API_KEY"]
BASE = "https://api.serply.io/v1"


def web_search(
    query: Annotated[str, "The search query, phrased as you'd type it into Google"],
    num: Annotated[int, "How many results to return, 1-100"] = 10,
) -> str:
    """Search the live web and return titles, links, and snippets."""
    resp = requests.get(
        f"{BASE}/search/q={quote_plus(query)}&num={num}",
        headers={"X-Api-Key": API_KEY},
        timeout=30,
    )
    resp.raise_for_status()
    results = resp.json().get("results", [])
    if not results:
        return "No results found."
    return "\n\n".join(
        f"[{r.get('position')}] {r.get('title')}\n{r.get('link')}\n{r.get('description', '')}"
        for r in results
    )


def read_page(
    url: Annotated[str, "The full URL of a page to read"],
) -> str:
    """Fetch a web page and return its text as markdown."""
    resp = requests.post(
        f"{BASE}/request",
        headers={"X-Api-Key": API_KEY, "Content-Type": "application/json"},
        json={"url": url, "response_type": "markdown"},
        timeout=60,
    )
    resp.raise_for_status()
    return resp.text[:15000]

The Annotated type hints are not decoration — AutoGen reads them to build the JSON schema it hands the model. A vague description here produces an agent that calls the tool with garbage arguments.

Two details worth internalizing about the Serply calls. The search endpoint takes a Google-style query string embedded in the path (/v1/search/q=...), and the scraper’s markdown mode returns the converted text as the response body rather than as JSON, so you read .text. Requesting "response_type": "full" instead gives you a JSON object with the page’s raw HTML under data.

Registering with a group chat

from autogen import AssistantAgent, UserProxyAgent, GroupChat, GroupChatManager

config_list = [{"model": "gpt-4o", "api_key": os.environ["OPENAI_API_KEY"]}]
llm_config = {"config_list": config_list, "temperature": 0}

researcher = AssistantAgent(
    name="Researcher",
    system_message=(
        "You find facts. Always call web_search before making a factual claim. "
        "Use read_page when a snippet isn't enough. Report findings with URLs."
    ),
    llm_config=llm_config,
)

critic = AssistantAgent(
    name="Critic",
    system_message=(
        "You check the Researcher's claims. If a claim has no URL behind it, "
        "call web_search yourself and verify. Say plainly when something is unsupported."
    ),
    llm_config=llm_config,
)

writer = AssistantAgent(
    name="Writer",
    system_message=(
        "You write the final summary using only verified claims. "
        "Cite every factual statement with its URL. Reply TERMINATE when done."
    ),
    llm_config=llm_config,
)

executor = UserProxyAgent(
    name="Executor",
    human_input_mode="NEVER",
    code_execution_config=False,
    is_termination_msg=lambda m: "TERMINATE" in (m.get("content") or ""),
)

Now register the tools. The pattern is a pair per agent: the caller declares the signature, the executor actually runs it.

from autogen import register_function

for agent in (researcher, critic):
    register_function(
        web_search,
        caller=agent,
        executor=executor,
        name="web_search",
        description="Search the live web for current information.",
    )
    register_function(
        read_page,
        caller=agent,
        executor=executor,
        name="read_page",
        description="Read the full text of a web page as markdown.",
    )

Giving the tools to the critic as well as the researcher is the whole point. A critic that can only object in the abstract gets talked out of its objections; a critic that can run its own search wins arguments with evidence.

Running it

chat = GroupChat(
    agents=[executor, researcher, critic, writer],
    messages=[],
    max_round=20,
)
manager = GroupChatManager(groupchat=chat, llm_config=llm_config)

executor.initiate_chat(
    manager,
    message="What are the current pricing tiers for the major SERP API providers?",
)

Keeping the call volume sane

A four-agent chat with two tool-holders can easily fire thirty searches on a single question. Two guardrails are worth adding before you let this run unattended.

First, cache within a session. Identical queries from the researcher and the critic are common and completely wasteful:

from functools import lru_cache

@lru_cache(maxsize=256)
def _cached_search(query: str, num: int) -> str:
    ...

Second, watch the rate limit headers. Serply returns x-ratelimit-requests-limit and x-ratelimit-requests-remaining on responses, and a 429 when you’ve exhausted the window. In a group chat that 429 surfaces as a tool error the model will try to talk its way around, so catch it and return an explicit message instead:

except requests.HTTPError as e:
    if e.response is not None and e.response.status_code == 429:
        return "Rate limit reached. Do not retry this call; work with what you have."
    raise

Telling the model not to retry, in the tool output itself, is more reliable than any system prompt instruction about rate limits.