Build Your Own 'Deep Search': A Free, Python-Powered Agentic Workflow with OpenAI Agents SDK, Gemini and DuckDuckGo

pouya ghpouya gh
8 min read

This article demonstrates how to build a powerful, multi-agent search system using Python. This framework leverages the agents library, Google's Gemini model, and the free DuckDuckGo search engine to automate in-depth research on any given topic. We will construct a team of AI agents that can plan research, execute web searches, and synthesize the findings into a comprehensive report. To ensure you can run the code yourself, a guide for obtaining the necessary Google API key is included at the end of this tutorial.

In the next article, we'll take this system and build a user-friendly web interface for it using Gradio.


Part 1: Initial Setup and Dependencies

First, we import all the necessary libraries. We use the agents library for the core agent framework, DDGS for free web searches, and pydantic for data validation. Crucially, we use the openai SDK, but we configure it to communicate with Google's API endpoint. This allows us to leverage the structure of the OpenAI client to interact with the Gemini model.

# Essential libraries for building our agentic framework
from agents import Agent, OpenAIChatCompletionsModel, Runner, function_tool
from agents.model_settings import ModelSettings
from ddgs import DDGS  # DuckDuckGo for free, anonymous web searches
from pydantic import BaseModel, Field # For defining structured data models
from dotenv import load_dotenv
import asyncio
import os
from IPython.display import display, Markdown # For displaying rich output in notebooks
from openai import AsyncOpenAI # The OpenAI SDK, adapted for Google's API

# Load environment variables (API keys) from a .env file
load_dotenv(override=True)
base_url = os.getenv("GOOGLE_BASE_URL")
api_key = os.getenv("GOOGLE_API_KEY")

Explanation:

  • agents: This is the core library that provides the Agent and Runner classes to create and manage our AI agents.

  • DDGS: This library provides a simple interface to the DuckDuckGo search engine, allowing our agent to access real-world information for free.

  • pydantic: We use this to define the expected structure of the AI's output, ensuring we get reliable, well-formed data.

  • dotenv: This is a best practice for security. It loads sensitive information like API keys from a local .env file instead of hardcoding them directly in the script.

  • AsyncOpenAI: While named for OpenAI, this client is configured to connect to Google's API endpoint, enabling us to use Gemini models.


Part 2: The Search Tool

At the heart of any research system is the ability to search the web. We define a simple function, web_search, and decorate it with @function_tool. This decorator allows the agents library to recognize it as a tool that our AI agents can use.

@function_tool
def web_search(query: str) -> str:
    """
    Performs a web search for the given query and returns the top results.
    """
    with DDGS() as ddgs:
        # Fetch the top 3 text results for the query
        results = [r["body"] for r in ddgs.text(query, max_results=3)]
        return "\n".join(results) if results else "No results found."

Explanation:

  • This function takes a query string as input.

  • It uses DDGS().text() to perform a search and retrieve the content (body) of the top 3 results.

  • It then joins these results into a single string, separated by newlines, which is returned to the agent that called the tool.


Part 3: Defining the Agent Team

Our research workflow is broken down into three specialized agents: a Planner, a Searcher, and a Writer.

1. The Search Agent

This agent's only job is to perform a web search. It receives a search term and uses the web_search tool to find relevant information, then summarizes it.

# Instructions for the agent that performs the search
INSTRUCTIONS_SEARCH = "You are a research assistant. Given a search term, you search the web for that term and \
produce a concise summary of the results. The summary must be 2-3 paragraphs and less than 300 \
words. Capture the main points. Write succinctly; no need for complete sentences or perfect \
grammar. This will be consumed by someone synthesizing a report, so it's vital you capture the \
essence and ignore any fluff. Do not include any additional commentary other than the summary itself."

# Initialize the OpenAI client to connect to Google's API
client = AsyncOpenAI(base_url=base_url, api_key=api_key)
model = OpenAIChatCompletionsModel(model="gemini-2.0-flash", openai_client=client)

# Create the search agent
search_agent = Agent(
    name="Search agent",
    instructions=INSTRUCTIONS_SEARCH,
    tools=[web_search],
    model=model,
    model_settings=ModelSettings(tool_choice="required"), # Force the agent to use the tool
)

2. The Planner Agent

This agent acts as the strategist. Given a broad research query, its task is to break it down into several specific, actionable search queries. We define a WebSearchPlan structure to ensure its output is a well-organized list of searches.

HOW_MANY_SEARCHES = 3

# Instructions for the agent that plans the research
INSTRUCTIONS_PLAN = f"You are a helpful research assistant. Given a query, come up with a set of web searches \
to perform to best answer the query. Output {HOW_MANY_SEARCHES} terms to query for."

# Pydantic models to structure the planner's output
class WebSearchItem(BaseModel):
    reason: str = Field(description="Your reasoning for why this search is important to the query.")
    query: str = Field(description="The search term to use for the web search.")

class WebSearchPlan(BaseModel):
    searches: list[WebSearchItem] = Field(description="A list of web searches to perform to best answer the query.")

# Create the planner agent
planner_agent = Agent(
    name="PlannerAgent",
    instructions=INSTRUCTIONS_PLAN,
    model=model,
    output_type=WebSearchPlan, # Ensure the output conforms to our Pydantic model
)

3. The Writer Agent

The final agent in our pipeline is the writer. It takes the original query and the summarized search results from the search_agent and synthesizes them into a detailed, well-structured report.

# Instructions for the agent that writes the final report
INSTRUCTIONS_WRITE = (
    "You are a senior researcher tasked with writing a cohesive report for a research query. "
    "You will be provided with the original query, and some initial research done by a research assistant.\n"
    "You should first come up with an outline for the report that describes the structure and "
    "flow of the report. Then, generate the report and return that as your final output.\n"
    "The final output should be in markdown format, and it should be lengthy and detailed. Aim "
    "for 5-10 pages of content, at least 1000 words."
)

# Pydantic model to structure the writer's output
class ReportData(BaseModel):
    short_summary: str = Field(description="A short 2-3 sentence summary of the findings.")
    markdown_report: str = Field(description="The final report in Markdown format.")
    follow_up_questions: list[str] = Field(description="Suggested topics to research further.")

# Create the writer agent
writer_agent = Agent(
    name="WriterAgent",
    instructions=INSTRUCTIONS_WRITE,
    model=model,
    output_type=ReportData,
)

Part 4: Orchestrating the Workflow

With our agents defined, we need to create the logic that makes them work together. We use asyncio to run our searches concurrently, making the process faster and more efficient.

async def plan_searches(query: str):
    """ Use the planner_agent to plan which searches to run for the query """
    print("Planning searches...")
    result = await Runner.run(planner_agent, f"Query: {query}")
    print(f"Will perform {len(result.final_output.searches)} searches")
    return result.final_output

async def perform_searches(search_plan: WebSearchPlan):
    """ Call search() for each item in the search plan concurrently """
    print("Searching...")
    tasks = [asyncio.create_task(search(item)) for item in search_plan.searches]
    results = await asyncio.gather(*tasks)
    print("Finished searching")
    return results

async def search(item: WebSearchItem):
    """ Use the search agent to run a web search for each item in the search plan """
    input_prompt = f"Search term: {item.query}\nReason for searching: {item.reason}"
    result = await Runner.run(search_agent, input_prompt)
    return result.final_output

async def write_report(query: str, search_results: list[str]):
    """ Use the writer agent to write a report based on the search results"""
    print("Thinking about report...")
    input_prompt = f"Original query: {query}\nSummarized search results: {search_results}"
    result = await Runner.run(writer_agent, input_prompt)
    print("Finished writing report")
    return result.final_output

Explanation:

  • plan_searches: Kicks off the process by calling the planner_agent.

  • perform_searches: Takes the plan and creates a list of asynchronous tasks—one for each search query. asyncio.gather runs all these searches at the same time.

  • search: A helper function that executes a single search with the search_agent.

  • write_report: Gathers all the research and passes it to the writer_agent to generate the final output.


Part 5: Running the Research

Finally, we define our main query and execute the entire workflow from start to finish. The final report is then displayed using IPython.display.Markdown for a clean, formatted view.

# The main research query
query = "Latest AI Agent frameworks in 2025"

# The main execution block to run the asynchronous workflow
async def main():
    print("Starting research...")
    search_plan = await plan_searches(query)
    search_results = await perform_searches(search_plan)
    report = await write_report(query, search_results)

    # Display the final report in a clean, readable format
    display(Markdown(report.markdown_report))

# In a Jupyter Notebook or IPython environment, you can run the main function
# await main()
# If running as a standard Python script, you would use:
# if __name__ == "__main__":
#     asyncio.run(main())

How to Get a Google API Key for Gemini

To run this code, you need a Google API key. The process is straightforward and free for getting started.

  1. Go to Google AI Studio: Navigate to aistudio.google.com.

  2. Sign In: Sign in with your Google account.

  3. Get API Key: Click on the "Get API key" button, which is usually located in the top-left menu or on the main dashboard.

  4. Create API Key: In the following screen, click on "Create API key in new project".

  5. Copy and Store: Your new API key will be generated and displayed. Copy this key immediately and store it in a safe place. For this project, you should create a file named .env in the same directory as your script and add the following line:

     GOOGLE_API_KEY="YOUR_API_KEY_HERE"
    

    Also, add the base URL to your .env file:

     GOOGLE_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"
    

Now you are ready to execute the code and let your AI research team do the work!

Next Steps: Building a Web Interface with Gradio 🚀

Now that we have a fully functional, powerful research agent running in the backend, the next logical step is to make it accessible to users without needing to run code directly.

In the next article , we will build a user-friendly web interface for our research system using Gradio. This will allow anyone to simply type in a query, press a button, and receive a complete, well-formatted report right in their browser. Stay tuned!

0
Subscribe to my newsletter

Read articles from pouya gh directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

pouya gh
pouya gh