Home / Articles

Routing Questions Across SQL Tools and Web Search with a Gemini Agent

How a LangChain tool-calling agent on Vertex AI picks between three SQLite text-to-SQL tools and live web search, plus the data, dependency and auth bugs to expect.

Publisher: REACTAPP.TOP13 min read2891 words

This article is published in English.

Sources: Building a Multi-Tool AI Agent for Bangladesh: Teaching Gemini to Pick the Right Tool for the Job

A chatbot built on one data source handles one kind of question well and breaks the moment a user asks about something adjacent: it either invents an answer or gives up. The more useful engineering goal is a system that recognizes the limits of its own knowledge and passes each question to the component that can actually answer it. This walkthrough builds exactly that: a single agent that answers questions about Bangladesh (schools, hospitals, restaurants and general knowledge) by choosing, per question, one of four narrow tools. Along the way you will see how to wrap SQLite databases as safe text-to-SQL tools, why tool descriptions matter more than routing code, and which non-model failures are most likely to stall a project like this.

Why an orchestrator beats one model that knows everything

Most serious AI systems today share a shape: a language model that is good at understanding intent sits in front of several reliable, narrowly scoped tools that are good at retrieving facts. Support assistants switch between a billing backend and a knowledge base; research assistants switch between web search and internal documents. The model does not need to hold the answers. It needs to judge which capability to reach for.

The project here applies that pattern with four specialists and one orchestrator:

┌──────────────────────────┐
   question  ──────────▶ │      AgentExecutor        │
                         │  (Gemini + tool-calling)  │
                         └────────────┬──────────────┘
                                      │ picks 1+ tools based on
                                      │ their description
              ┌──────────────┬───────┴───────┬───────────────┐
              ▼              ▼               ▼               ▼
     InstitutionsDBTool HospitalsDBTool RestaurantsDBTool WebSearchTool
              │              │               │                │
      institutions.db   hospitals.db   restaurants.db     Tavily API
        (SQLite)          (SQLite)       (SQLite)         (live web)
  • InstitutionsDBTool covers schools, colleges and madrashas taken from the official EIIN registry.
  • HospitalsDBTool covers government health facilities registered under DGHS.
  • RestaurantsDBTool covers eateries whose data was collected from Google Places.
  • WebSearchTool runs a live search (through the Tavily API) for anything the databases cannot answer.

Everything runs on LangChain's AgentExecutor, with Gemini on Google Vertex AI acting as the reasoning engine. Each database tool turns a natural-language question into SQL, runs it, and converts the rows back into a readable sentence.

Some sample routing decisions make the intent concrete:

  • "How many colleges are in Rajshahi division?" goes to InstitutionsDBTool, because it is a structured count over the EIIN data.
  • "List government hospitals in Chattogram district" goes to HospitalsDBTool, because it is a structured list over the DGHS facilities.
  • "Find restaurants with biryani in the name near Pirojpur" goes to RestaurantsDBTool, because it is a lookup in the restaurant table.
  • "What is the role of DGHS in Bangladesh?" goes to WebSearchTool, because no database stores that kind of general knowledge.

The agent never inspects the databases themselves. All it sees are four plain-English tool descriptions, and routing quality depends on how well those descriptions are written rather than on any hardcoded branching.

Inspect the datasets before you design anything

Before any agent code existed, the three datasets were downloaded from Hugging Face and examined row by row. That hour paid off more than any other step, because the project brief turned out to be wrong about what the data contained. It expected bed capacity in the hospital data and university degree programs in the institution data. Reality looked different:

  • all-bangladeshi-hospitals is a directory of DGHS/MOPA-registered facilities and offices: name, facility type, agency, division, district and a private-or-government flag. It holds no capacity figures for beds or staff.
  • Institutional-Information-of-Bangladesh covers the schools, colleges, madrashas and technical institutes that hold EIIN registration. It does not include universities or any degree-program information.
  • Bangladeshi-Restaurant-Data comes from Google Places scraping: name, address, coordinates, rating and review count. There is no cuisine column.

This gap between description and content is ordinary. The right move is not to bend the data toward the brief but to design around what is really present and write down the mismatch so nobody later files it as a bug. In this case a "biryani" query became a text match on the restaurant name, and a location filter became a text match on the address, since neither a cuisine nor a district field exists. The compromise is small and clearly documented.

The broader habit is worth adopting early: treat the dataset as the source of truth and any schema diagram or brief as someone's guess. What .head() prints is the real specification.

Loading messy CSVs into typed SQLite tables

Each dataset is cleaned, cast to proper types and written to its own SQLite file. The schema is declared explicitly so that SQLite does not infer column types, and so that the text-to-SQL chain later sees meaningful column names. Note the NOT NULL constraint on name and the integer is_private flag with a default of zero:

HOSPITALS_SCHEMA = """
CREATE TABLE hospitals (
    facility_id       INTEGER PRIMARY KEY,
    name              TEXT NOT NULL,
    name_bangla       TEXT,
    code              INTEGER,
    agency            TEXT,
    facility_type     TEXT,
    division          TEXT,
    district          TEXT,
    city_corporation  TEXT,
    upazila           TEXT,
    is_private        INTEGER NOT NULL DEFAULT 0
);
"""

The cleaning function maps the raw CSV headers to those schema names, coerces the identifier and the private flag to numbers (invalid values become nulls instead of crashing), drops rows missing an id or a name, and removes duplicate facilities:

def clean_hospitals(df: pd.DataFrame) -> pd.DataFrame:
    rename_map = {
        "Id": "facility_id", "Name": "name", "Name (Bangla)": "name_bangla",
        "Code": "code", "Agency": "agency", "Type": "facility_type",
        "Division": "division", "District": "district",
        "City Corporation": "city_corporation", "Upazila": "upazila",
        "Private": "is_private",
    }
    out = _select_and_rename(df, rename_map, required=["facility_id", "name"]).copy()    out["facility_id"] = pd.to_numeric(out["facility_id"], errors="coerce").astype("Int64")
    out["is_private"] = pd.to_numeric(out["is_private"], errors="coerce").fillna(0).astype(int)    out = out.dropna(subset=["facility_id", "name"]).drop_duplicates(subset=["facility_id"])
    return out.reset_index(drop=True)

Pay attention to the call to _select_and_rename where you might expect a plain df.rename(). That helper was added only after a real failure, described in the bugs section below. It is a good example of defensive code whose purpose is invisible until you know what broke.

One reusable text-to-SQL tool factory

All three database tools follow an identical pipeline, so it is written once as a factory and called three times. The factory opens the database through SQLDatabase.from_uri, builds a chain that writes SQL with create_sql_query_chain, prepares a tool that executes queries with QuerySQLDatabaseTool, and composes an answer-formatting chain from a prompt, the model and a string parser:

def build_db_qa_tool(*, tool_name: str, description: str, db_path: Path, llm) -> Tool:
    db = SQLDatabase.from_uri(f"sqlite:///{db_path}")
    write_query = create_sql_query_chain(llm, db)
    execute_query = QuerySQLDatabaseTool(db=db)
    format_answer = _ANSWER_PROMPT | llm | StrOutputParser()

The inner function does the actual work for each question. It asks the model for SQL, extracts the first SELECT statement, enforces read-only access and a row limit, executes it, and hands the question, the query and the raw result to the formatting chain. Any exception becomes a readable message instead of a stack trace, which also gives the agent something sensible to reason about:

    def _answer(question: str) -> str:
        try:
            raw_sql = write_query.invoke({"question": question})
            sql = _enforce_read_only_and_limit(_first_select_statement(raw_sql), MAX_SQL_RESULT_ROWS)
            result = execute_query.invoke(sql)
        except Exception as exc:
            return f"I could not query the {tool_name} database for that question (reason: {exc})."        return format_answer.invoke({"question": question, "query": sql, "result": result})    return Tool(name=tool_name, description=description, func=_answer)

Three design choices in this snippet are easy to skip in a first version:

  1. The model generates the SQL through create_sql_query_chain. There are no hand-maintained query templates to keep in sync with the schema.
  2. Generated SQL is treated as untrusted input. _enforce_read_only_and_limit refuses anything that is not a SELECT and caps the number of rows, so a confused or manipulated model cannot modify data or dump an entire table into the context window.
  3. Users never see raw result tuples. The formatting step turns something like "3 rows, district='Dhaka'" into a proper sentence.

A useful extra safeguard, if you adapt this pattern, is to open the SQLite file in read-only mode as well. String checks on SQL can be bypassed in subtle ways, and a database connection that physically cannot write is a second line of defense.

Letting tool descriptions do the routing

This is where a script becomes an agent. The system prompt starts by framing the assistant's role:

SYSTEM_PROMPT = """You are a multi-tool research assistant for Bangladesh.

It then lists the four tools and states three routing rules: use the matching database tool for counts, lists or specific records and never estimate numbers; use web search for general knowledge; and when a question spans datasets, call each relevant tool and merge the results. Below the prompt, create_tool_calling_agent binds the model, tools and prompt together, and AgentExecutor runs the loop with a cap of six iterations and tolerant handling of malformed tool calls:

You have four tools:
  - InstitutionsDBTool : Bangladeshi schools, colleges, madrashas (EIIN registry)
  - HospitalsDBTool    : Bangladeshi government health facilities and offices
  - RestaurantsDBTool  : Bangladeshi restaurants and eateries (Google Places data)
  - WebSearchTool      : live web search, for anything the three databases don't coverRouting rules:
1. If the question asks for a count, list, or specific record from one of the
   three datasets, call the matching DB tool. Never guess numbers yourself.
2. If the question is general knowledge or not covered by any DB tool, call
   WebSearchTool.
3. If a question spans more than one dataset, call each relevant tool
   separately and combine the results.
"""agent = create_tool_calling_agent(llm=llm, tools=tools, prompt=prompt)
executor = AgentExecutor(agent=agent, tools=tools, max_iterations=6, handle_parsing_errors=True)

Nothing here matches keywords, applies regexes or maps phrases to tools. The model compares the question with each description and chooses. That makes the descriptions the most consequential strings in the codebase: if they are vague or overlap, the agent will start picking the wrong tool, and no amount of prompt tweaking elsewhere will fully compensate. The max_iterations=6 limit is also worth keeping, since it bounds cost and prevents a looping agent from calling tools indefinitely.

Why the classic AgentExecutor instead of LangGraph

LangChain has been moving its recommended agent architecture toward LangGraph, which gives finer control over multi-step, stateful workflows. For this use case, create_tool_calling_agent with AgentExecutor fits better: each request is one question, one or two tool calls and one answer. A graph framework would add structure the problem does not need. Choosing the smallest architecture that solves the problem is a skill in its own right, distinct from knowing that a more powerful option exists. If your routing later needs branching, retries or persistent state, that is the point at which a graph becomes worth its weight; the article on designing resilient agent graphs covers that direction.

Three failures that had nothing to do with the model

The most instructive part of the build was not what worked on the first try but what broke. None of the three problems involved prompts or model quality.

Two LangChain generations installed side by side

LangChain 1.0 reorganized its agents module and moved the classic AgentExecutor pattern into a separate package. The Colab environment ended up with both generations installed, so an import that succeeded in one cell raised ImportError: cannot import name 'AgentExecutor' in the next.

The fix was procedural rather than a code change: uninstall the conflicting packages explicitly, pin exact versions instead of ranges, and restart the runtime before continuing. Dependency hygiene is real engineering work. Check the current LangChain documentation for where AgentExecutor lives in the version you install, because this has changed across releases.

A column name with a space instead of an underscore

The Hugging Face dataset viewer showed a column called INSTITUTE_NAME, but the downloaded file actually used INSTITUTE NAME. That single character produced a KeyError several function calls away from the real cause.

The response was the _select_and_rename helper seen earlier. It strips all non-alphanumeric characters before comparing column names, so INSTITUTE_NAME, INSTITUTE NAME and institute-name all match. When a required column still cannot be found, it prints the actual list of columns rather than failing quietly. A good error message is a debugging tool in its own right.

An API key format the Gemini API rejected

Google has been introducing a newer API key format that starts with AQ. instead of the familiar AIzaSy. At the time this project was built, keys in the new format were rejected by the direct Gemini API with an opaque 401 ACCESS_TOKEN_TYPE_UNSUPPORTED error. Retry logic cannot fix a credential the endpoint does not accept.

The solution was to change the authentication path entirely: the agent now authenticates to Google Vertex AI with OAuth, just like any other Google Cloud service. That avoids the key-format issue and is arguably the more production-appropriate setup anyway. Because this behavior may have changed since, confirm it against the current Google Cloud documentation before you rely on it.

The pattern behind all three: in current AI engineering, the plumbing around the model (package versions, data schemas and authentication flows) breaks far more often than the model does.

The agent in action

Once the databases are built and the agent is wired up, a count question about Rajshahi colleges is routed straight to the institutions tool, which answers with a figure taken from the database:

You: How many colleges are registered in Rajshahi division?
> Entering new AgentExecutor chain...
[Tool: InstitutionsDBTool] → "There are 812 colleges registered in Rajshahi division."

In the same session, a general-knowledge question about DGHS is sent to web search, while a request for government hospitals in Chattogram goes to the hospitals database:

You: What is the role of DGHS in Bangladesh?
> Entering new AgentExecutor chain...
[Tool: WebSearchTool] → "DGHS (Directorate General of Health Services) is the
government body responsible for planning, managing, and delivering public
healthcare services across Bangladesh..."You: List a few government hospitals in Chattogram district.
> Entering new AgentExecutor chain...
[Tool: HospitalsDBTool] → "Chattogram district includes several government
health facilities such as Chattogram Medical College Hospital and Chattogram
General Hospital, both operating under DGHS..."

Three different data sources are used within one conversation, and nowhere is there code that tells the agent which one to pick. The model makes each decision from the tool descriptions alone.

Lessons to carry into the next build

  • Validate the data before writing the schema. Discovering the dataset gaps after building the tools would have meant rewriting all three.
  • Pin dependencies more strictly than seems necessary. In an ecosystem that ships major rewrites every few months, a version range that resolves safely today may resolve very differently in a fresh environment a few weeks later.
  • Read external error messages in full. A Vertex AI 403 error in this project included the exact URL needed to fix it, which saved far more time than guessing.
  • Design for failure as well as the happy path. Assume generated SQL may be wrong, APIs may be down and incoming data may not match the expected schema. This defensive code never appears in a demo, but it is what keeps the system usable the first time something goes wrong in front of a real user.

Wrapping up

Gemini's tool calling routed correctly as soon as it was given clean, clearly described tools. The model was not the hard part. What consumed the time was the surrounding infrastructure: conflicting library versions, a stray space in a column header and a credential format the API would not accept. If you build a multi-tool agent, invest early in three things: honest tool descriptions, strict guardrails on anything the model generates for execution, and a reproducible environment. Those decide whether the agent works as well in production as it does in a demo.