Feature - Dynamic Tool Selection (Solving the 128 Tool Limit)

Feature - Dynamic Tool Selection (Solving the 128 Tool Limit)

Hey everyone! Today we are going to understand a critical production problem and how we solved it - OpenAI's 128 tool limit!

Our bot has 137 tools (clients, memberships, attendance, salary, classes, leads, and more). But OpenAI says: "Sorry, max 128 tools per request." So the bot was completely broken - every single request failed!

Instead of deleting tools, we built Dynamic Tool Selection - the bot analyzes each message and sends only the relevant tools. Smart, fast, zero extra cost!

What we will cover:

  • The Problem - Why did the bot suddenly break?
  • Why Not Just Remove 9 Tools?
  • The Solution - Dynamic Tool Selection
  • How It Works - The Big Picture
  • Tool Categories (30 categories, 137 tools)
  • Keyword Matching - How the bot picks tools
  • Related Categories - The Smart Expansion
  • The Safety Cap - Never exceed 128
  • Multi-turn Context - Remembering past messages
  • Code Walkthrough
  • Real Test Results
  • Files Changed
  • Interview Questions

The Problem - Why Did the Bot Break?

Our AI chatbot uses LangChain + OpenAI GPT-4o. When you send a message, the bot tells GPT-4o: "Here are all the tools (functions) you can call." GPT-4o reads the tool definitions and decides which one to use.

How LangChain Tool Binding Works:
==================================

llm = ChatOpenAI(model="gpt-4o")

# We tell GPT-4o about ALL available tools
llm = llm.bind_tools(ALL_TOOLS)   # ← sends tool definitions to OpenAI

# Now when we ask a question:
response = await llm.ainvoke(messages)

# GPT-4o looks at the tools and picks the right one:
# "User asked about clients → I'll call get_clients_list"

The problem? Our ALL_TOOLS list grew to 137 tools!

How we got to 137 tools:
=========================

# Started with basics
Clients:        12 tools (get, create, update, delete, bulk...)
Memberships:     6 tools (get, freeze, unfreeze, history...)
Attendance:      6 tools (today, stats, reports, by date...)
Revenue:         2 tools
Trainers:        2 tools
Enquiries:       4 tools

# Then we added more features
Staff:           5 tools
Salary:          6 tools
Plans:           4 tools
Offers:          5 tools

# Then even more...
Leads CRM:      10 tools (full pipeline management)
Goals:           7 tools (milestones, progress tracking)
Documents:       5 tools (templates, signed docs, PDFs)
Referrals:       5 tools
Classes:         4 tools
Appointments:    4 tools

# And engagement features...
Gamification:    4 tools
Loyalty:         4 tools
Engagement:      3 tools
Surveys:         3 tools
Wearables:       3 tools

# Plus utilities
Theme:           1 tool
Navigation:      1 tool
Gym/Branches:    4 tools
Currencies:      2 tools
Equipment:       3 tools
Products:        3 tools
Campaigns:       2 tools
Custom Fields:   2 tools
Facilities:      4 tools
Diets:           4 tools
Photos:          2 tools
Notes:           3 tools
Guests:          2 tools

TOTAL:         137 tools!

And then one day, every request started failing with this error:

The Error:
==========

{
  "error": "Invalid 'tools': array too long. Expected an array
            with maximum length 128, but got an array with length 137"
}

OpenAI's hard limit: 128 tools per API request.
We have: 137 tools.
Overflow: 9 tools too many!
What the user sees:
===================

User: "How many clients do I have?"

Bot: ❌ Error! Something went wrong.

User: "Show me today's attendance"

Bot: ❌ Error! Something went wrong.

EVERY. SINGLE. MESSAGE. FAILS.
The bot is completely unusable!

Why Not Just Remove 9 Tools?

You might think: "Just delete 9 tools and you're back to 128!" But that's a bad idea:

Option 1: Delete 9 tools
==========================

Problem 1: WHICH 9 tools do you remove?
  - Remove salary tools? → Staff can't check payroll
  - Remove survey tools? → Can't check NPS scores
  - Remove referral tools? → Can't manage referral program

  Every tool exists because someone NEEDS it!

Problem 2: What happens when you add feature #31?
  - You'll hit 129 tools again
  - Delete another tool?
  - This is a losing game!

Problem 3: What about feature #32, #33, #34...?
  - The app keeps growing
  - You'll keep removing tools
  - Eventually the bot loses useful capabilities

              ┌──────────────────────────────────────┐
              │  Deleting tools = treating SYMPTOMS   │
              │  Dynamic selection = fixing the CAUSE │
              └──────────────────────────────────────┘

The Solution - Dynamic Tool Selection

Instead of sending ALL 137 tools every time, we analyze the user's message and send only the relevant tools!

The Idea:
=========

User: "Show me all clients"

OLD (broken):
  Send ALL 137 tools → ❌ OpenAI rejects (> 128)

NEW (smart):
  Analyze: "clients" → needs client tools + membership + attendance
  Send 32 relevant tools → ✅ OpenAI accepts!
  GPT-4o picks: get_clients_list → works perfectly!


User: "What is pending salary?"

OLD (broken):
  Send ALL 137 tools → ❌ OpenAI rejects (> 128)

NEW (smart):
  Analyze: "salary" → needs salary tools + staff tools
  Send 19 relevant tools → ✅ OpenAI accepts!
  GPT-4o picks: get_pending_salaries → works perfectly!
Why is this better than deleting tools?
========================================

1. ALL 137 tools still exist - nothing removed
2. Each message gets ONLY what it needs
3. Can add 100 more tools - still works!
4. Faster API calls (fewer tool definitions to send)
5. Zero extra cost (no extra LLM call)
6. The selection is pure keyword matching - instant!

                    ┌─────────────────────────┐
                    │   137 tools available    │
                    └────────────┬────────────┘
                                 │
                     User sends message
                                 │
                    ┌────────────▼────────────┐
                    │   Keyword Matching       │
                    │   (instant, no LLM)      │
                    │                          │
                    │   "salary" → salary +    │
                    │   staff categories       │
                    └────────────┬────────────┘
                                 │
                    ┌────────────▼────────────┐
                    │   19 tools selected      │
                    │   (well under 128!)      │
                    └────────────┬────────────┘
                                 │
                    ┌────────────▼────────────┐
                    │   Sent to OpenAI GPT-4o  │
                    │   ✅ Works perfectly!     │
                    └─────────────────────────┘

How It Works - The Big Picture

Step-by-step flow:
===================

User: "Show me today's attendance"
        │
        ▼
┌───────────────────────────────────────────────────┐
│  STEP 1: Message arrives at agent.py               │
│                                                    │
│  _setup_chat() is called with the message          │
└────────────────────┬──────────────────────────────┘
                     │
                     ▼
┌───────────────────────────────────────────────────┐
│  STEP 2: select_tools() analyzes the message       │
│                                                    │
│  message = "show me today's attendance"            │
│                                                    │
│  Scan keywords:                                    │
│  ✗ "client" → no                                   │
│  ✗ "salary" → no                                   │
│  ✓ "attendance" → YES! Match "attendance" category │
│  ✓ "today" → YES! Also matches "attendance"        │
│                                                    │
│  Matched categories: {attendance}                  │
│                                                    │
│  Expand related: attendance → + clients,           │
│                                + memberships       │
│                                                    │
│  Final categories: {attendance, clients,           │
│                     memberships}                   │
└────────────────────┬──────────────────────────────┘
                     │
                     ▼
┌───────────────────────────────────────────────────┐
│  STEP 3: Collect tools from matched categories     │
│                                                    │
│  CORE tools (always):           8 tools            │
│  + clients category:           12 tools            │
│  + memberships category:        6 tools            │
│  + attendance category:         6 tools            │
│  ─────────────────────────────────────             │
│  TOTAL:                        32 tools ✅          │
│                                                    │
│  (well under 128 limit!)                           │
└────────────────────┬──────────────────────────────┘
                     │
                     ▼
┌───────────────────────────────────────────────────┐
│  STEP 4: Bind selected tools to LLM               │
│                                                    │
│  llm = ChatOpenAI("gpt-4o").bind_tools(32 tools)  │
│                                                    │
│  GPT-4o receives only 32 tool definitions          │
│  instead of 137!                                   │
└────────────────────┬──────────────────────────────┘
                     │
                     ▼
┌───────────────────────────────────────────────────┐
│  STEP 5: GPT-4o picks the right tool               │
│                                                    │
│  "User wants today's attendance..."                │
│  → calls get_attendance_today()                    │
│  → returns attendance data                         │
│  → bot responds with the info!                     │
│                                                    │
│  ✅ Everything works! No error!                     │
└───────────────────────────────────────────────────┘

Tool Categories (30 Categories, 137 Tools)

We organized all 137 tools into 30 logical categories:

TOOL_CATEGORIES = {
    "clients":      12 tools  │  get_clients_list, create_client, update_client...
    "memberships":   6 tools  │  get_membership_stats, freeze_membership...
    "attendance":    6 tools  │  get_attendance_today, get_attendance_stats...
    "revenue":       2 tools  │  get_revenue_stats, get_membership_sales
    "trainers":      2 tools  │  get_trainers_list, get_trainers_stats
    "enquiries":     4 tools  │  get_enquiries_list, create_enquiry...
    "staff":         5 tools  │  get_staff_list, create_staff...
    "salary":        6 tools  │  get_salary_stats, get_pending_salaries...
    "facilities":    4 tools  │  get_amenities_list, create_facility...
    "diets":         4 tools  │  get_diet_plans, create_diet...
    "plans":         4 tools  │  get_membership_plans, create_plan...
    "offers":        5 tools  │  get_offers_list, validate_offer_code...
    "leads":        10 tools  │  get_leads_list, update_lead_stage...
    "referrals":     5 tools  │  get_referrals_list, create_referral...
    "documents":     5 tools  │  get_document_templates, get_signed_document_pdf...
    "goals":         7 tools  │  get_member_goals, create_goal_milestone...
    "photos":        2 tools  │  get_member_photos, get_photo_details
    "notes":         3 tools  │  get_member_notes, create_member_note...
    "classes":       4 tools  │  get_class_types, get_session_bookings...
    "appointments":  4 tools  │  get_services, get_available_slots...
    "guests":        2 tools  │  get_guest_visits, get_guest_visit_stats
    "products":      3 tools  │  get_products, get_low_stock_products...
    "campaigns":     2 tools  │  get_campaigns, get_campaign_details
    "equipment":     3 tools  │  get_equipment, get_upcoming_maintenance...
    "custom_fields": 2 tools  │  get_custom_fields, get_entity_custom_values
    "engagement":    3 tools  │  get_engagement_dashboard, get_churn_alerts...
    "gamification":  4 tools  │  get_challenges, get_challenge_leaderboard...
    "loyalty":       4 tools  │  get_loyalty_dashboard, get_available_rewards...
    "wearables":     3 tools  │  get_wearable_connections, get_user_wearable_data...
    "surveys":       3 tools  │  get_surveys, get_survey_analytics...
}
                   ───
                   129 tools in categories
                   + 8 CORE tools (always included)
                   = 137 total ✅

Plus 8 CORE tools that are ALWAYS included (every message needs these):

CORE_TOOLS (always included):
==============================

1. change_theme          → User can always ask to change theme
2. navigate_to_page      → User can always ask to navigate
3. get_gym_info          → Bot always needs gym context
4. get_branches_info     → Bot needs to know branches
5. get_current_branch    → Bot needs current branch
6. create_branch         → Admin might create a branch anytime
7. get_currencies        → For formatting money values
8. convert_currency      → For currency conversion

Keyword Matching - How the Bot Picks Tools

Each category has a list of trigger keywords. If ANY keyword appears in the user's message, that category is selected.

CATEGORY_KEYWORDS = {

    "clients": ["client", "member", "customer", "person",
                "user", "people", "who", "profile"],

    "salary":  ["salary", "pay", "wage", "payroll",
                "compensation", "pending salary"],

    "attendance": ["attendance", "check-in", "present",
                   "absent", "came today", "who came", "today"],

    "classes": ["class", "session", "group class", "yoga",
                "zumba", "pilates", "booking"],

    "leads":   ["lead", "crm", "pipeline", "follow-up",
                "stage", "convert lead"],

    ...30 categories total
}
Example: How keyword matching works
=====================================

Message: "show me pending salary"
          │        │
          │        └── matches "salary" category ✓
          │            matches "pending salary" keyword ✓
          └── no match for other categories

Result: salary category selected!


Message: "who came today?"
          │    │    │
          │    │    └── matches "today" → attendance ✓
          │    └── matches "came today" → attendance ✓
          └── matches "who" → clients ✓

Result: attendance + clients categories selected!


Message: "show me all yoga classes this week"
                    │    │
                    │    └── matches "classes" category ✓
                    └── matches "yoga" → classes ✓

Result: classes category selected!

Related Categories - The Smart Expansion

Here's the clever part. When you ask about "clients", you probably also need membership and attendance tools. We define these relationships:

RELATED_CATEGORIES = {
    "clients":      → also include "memberships", "attendance"
    "salary":       → also include "staff"
    "trainers":     → also include "staff"
    "appointments": → also include "trainers", "clients"
    "classes":      → also include "clients"
    "leads":        → also include "clients", "enquiries"
    "plans":        → also include "offers"
    "memberships":  → also include "clients"
    "goals":        → also include "clients"
    "photos":       → also include "clients"
    "notes":        → also include "clients"
    "documents":    → also include "clients"
}
Why related categories matter:
===============================

WITHOUT related categories:
  User: "freeze John's membership"
  Keyword match: "freeze" → memberships category (6 tools)

  Problem: Bot has freeze_membership tool but NO way to
  find John! It doesn't have get_clients_list!

  Bot: "I can freeze a membership but I can't search
       for John because I don't have client tools" ❌

WITH related categories:
  User: "freeze John's membership"
  Keyword match: "freeze" → memberships category (6 tools)
  Related: memberships → also include clients (12 tools)

  Bot: First calls get_clients_list(name="John")
       Gets John's ID
       Then calls freeze_membership(clientId=5)
       "Done! John's membership is now frozen." ✅
Another example:
================

User: "book an appointment for a client with a trainer"

Keyword match:
  "appointment" → appointments (4 tools)
  "client" → clients (12 tools)
  "trainer" → trainers (2 tools)

Related expansion:
  appointments → + trainers, + clients (already matched)
  trainers → + staff (5 tools)
  clients → + memberships (6 tools), + attendance (6 tools)

Final tools: 8 core + 4 + 12 + 2 + 5 + 6 + 6 = 43 tools
Still well under 128! ✅

Default Categories - When Nothing Matches

What if the user sends a generic message like "hello" or "what can you do?" No keywords match any category. We include a default set:

DEFAULT_CATEGORIES = [
    "clients",       12 tools  ← most common
    "memberships",    6 tools  ← frequently needed
    "attendance",     6 tools  ← daily use
    "revenue",        2 tools  ← business overview
    "plans",          4 tools  ← pricing queries
    "trainers",       2 tools  ← staff queries
    "enquiries",      4 tools  ← new prospects
]

Default total: 8 core + 36 category = 44 tools
Plenty of tools for general questions!

The Safety Cap - Never Exceed 128

What if a message somehow triggers ALL 30 categories? We have a safety cap:

MAX_TOOLS = 128

def select_tools(message, conversation_history):
    # ... keyword matching ...
    # ... related expansion ...
    # ... collect tools ...

    # Safety cap - if somehow over 128, trim!
    if len(selected) > MAX_TOOLS:
        selected = selected[:MAX_TOOLS]
        logger.warning("Tool selection trimmed to 128!")

    return selected
Worst case test:
================

Message with keywords from ALL 30 categories:
"show clients attendance revenue salary staff trainers
 plans offers leads referrals documents goals photos
 notes classes appointments guests products campaigns
 equipment engagement gamification loyalty wearables
 surveys diet facilities"

Result: 128 tools (trimmed from 137)
Core tools always come first, so they're never trimmed!

Multi-turn Context - Remembering Past Messages

Conversations are multi-turn. The user might say "show me clients" and then follow up with "freeze the first one's membership". The word "freeze" alone wouldn't match without context!

The problem with single-message matching:
==========================================

Turn 1: "show me all active clients"
  → Keywords: "client", "active"
  → Categories: clients, memberships ✅

Turn 2: "freeze the first one"
  → Keywords: "freeze"
  → Categories: memberships
  → But "the first one" refers to a client from Turn 1!
  → Without client tools, bot can't look up the client!

Solution: We also scan the last 4 human messages for keywords:

def select_tools(message, conversation_history):
    # Current message
    text = message.lower()

    # Add context from recent conversation
    if conversation_history:
        recent_human = [
            msg.content.lower()
            for msg in conversation_history[-8:]
            if isinstance(msg, HumanMessage)
        ][-4:]  # last 4 human messages
        text = text + " " + " ".join(recent_human)

    # Now keyword matching runs on current + recent messages!
With multi-turn context:
=========================

Turn 1: "show me all active clients"
  text = "show me all active clients"
  → clients, memberships, attendance

Turn 2: "freeze the first one"
  text = "freeze the first one" + "show me all active clients"
                                  ↑ from conversation history!
  → "freeze" matches memberships
  → "clients" (from Turn 1) matches clients
  → Both categories included! ✅
  → Bot can look up the client AND freeze their membership!

Code Walkthrough - tool_selector.py

File: strakly-bot/tool_selector.py (NEW file)

This is the complete select_tools() function:

def select_tools(message, conversation_history=None):
    """Pick the relevant tool subset for a user message."""

    # ── Step 1: Build search text ──────────────────────
    text = message.lower()
    if conversation_history:
        recent_human = [
            msg.content.lower()
            for msg in conversation_history[-8:]
            if isinstance(msg, HumanMessage)
        ][-4:]
        text = text + " " + " ".join(recent_human)

    # ── Step 2: Find matching categories ───────────────
    matched = set()
    for category, keywords in CATEGORY_KEYWORDS.items():
        for kw in keywords:
            if kw in text:
                matched.add(category)
                break  # one match is enough per category

    # ── Step 3: Expand with related categories ─────────
    expanded = set(matched)
    for cat in matched:
        for related in RELATED_CATEGORIES.get(cat, []):
            expanded.add(related)

    # ── Step 4: Use defaults if nothing matched ────────
    if not expanded:
        expanded = set(DEFAULT_CATEGORIES)

    # ── Step 5: Collect tools ──────────────────────────
    seen_ids = set()
    selected = []

    def _add_tools(tools):
        for tool in tools:
            tid = id(tool)
            if tid not in seen_ids:
                seen_ids.add(tid)
                selected.append(tool)

    _add_tools(CORE_TOOLS)  # always first!
    for cat in expanded:
        _add_tools(TOOL_CATEGORIES[cat])

    # ── Step 6: Safety cap ─────────────────────────────
    if len(selected) > 128:
        selected = selected[:128]

    return selected

Key design decisions:

  • CORE_TOOLS added first - they never get trimmed by the safety cap
  • set() for deduplication - if "clients" category is matched directly AND pulled in as a related category, tools aren't added twice
  • id(tool) for uniqueness - uses Python object identity, not name comparison
  • break after first keyword match - one match per category is enough, saves time

Code Walkthrough - agent.py Changes

File: strakly-bot/agent.py (2 lines changed)

BEFORE:
=======

from tools import ALL_TOOLS, TOOL_MAP, set_api_client, cleanup_api_client

# In _setup_chat():
llm = ChatOpenAI(
    model=config.OPENAI_MODEL,
    temperature=0,
    api_key=config.OPENAI_API_KEY,
    request_timeout=60,
).bind_tools(ALL_TOOLS)    ← sends ALL 137 tools every time!


AFTER:
======

from tools import TOOL_MAP, set_api_client, cleanup_api_client
from tool_selector import select_tools     ← NEW import

# In _setup_chat():
selected_tools = select_tools(message, conv_messages)    ← NEW!
llm = ChatOpenAI(
    model=config.OPENAI_MODEL,
    temperature=0,
    api_key=config.OPENAI_API_KEY,
    request_timeout=60,
).bind_tools(selected_tools)    ← sends only relevant tools!

Notice: TOOL_MAP is still imported from tools! Why?

Why TOOL_MAP stays unchanged:
==============================

TOOL_MAP = {tool.name: tool for tool in ALL_TOOLS}
# Maps tool name → tool function
# Example: "get_clients_list" → get_clients_list function

This is used in _execute_tool_calls() to RUN the tool:

    result = await TOOL_MAP[tool_name].ainvoke(tool_args)

We only change WHICH tools are SENT to OpenAI.
The execution map stays complete (all 137 tools).

Why? Because the conversation history might contain
a tool_call from a previous turn when different tools
were selected. We need to be able to execute ANY tool,
not just the currently selected ones.

┌────────────────────────────┐
│  select_tools (bind_tools) │ → WHICH tools OpenAI sees
│  Changes per message       │   (subset of 137)
└────────────────────────────┘

┌────────────────────────────┐
│  TOOL_MAP (ainvoke)        │ → HOW tools are executed
│  Always complete (137)     │   (never changes)
└────────────────────────────┘

Real Test Results

Here are the actual results from testing with different messages:

Test Results:
=============

Message                                  Tools Selected
──────────────────────────────────────  ──────────────
"show me all clients"                    32 tools ✅
"what is pending salary?"                19 tools ✅
"hello, how are you?"                    44 tools ✅ (defaults)
"show me revenue and attendance stats"   16 tools ✅
"book appointment for client"            43 tools ✅
"show me dashboard"                      44 tools ✅ (defaults)
ALL keywords (worst case)               128 tools ✅ (capped)


Before Dynamic Selection:
  Every message → 137 tools → ❌ REJECTED by OpenAI

After Dynamic Selection:
  Every message → 16-44 tools → ✅ ACCEPTED by OpenAI
  Worst case → 128 tools → ✅ STILL under limit!
Performance benefit:
=====================

BEFORE: 137 tool definitions sent per request
  → ~40KB of JSON per API call
  → OpenAI needs to read all 137 definitions
  → Slower responses

AFTER: ~30 tool definitions sent on average
  → ~9KB of JSON per API call (78% smaller!)
  → OpenAI reads fewer definitions
  → Potentially faster tool selection

AND it costs the same! No extra LLM call needed.
The keyword matching is pure Python string matching.
Runs in microseconds!

Files Changed - Summary

File Action What Changed
strakly-bot/tool_selector.py NEW Tool categories, keyword mapping, select_tools() function
strakly-bot/agent.py Modified Import select_tools, use it instead of ALL_TOOLS in bind_tools()

That's it! Just 1 new file and 2 lines changed in agent.py!

The Architecture

Complete Architecture:
=======================

strakly-bot/
├── tools/
│   ├── __init__.py          ← ALL_TOOLS (137) + TOOL_MAP
│   ├── clients.py           ← 12 client tools
│   ├── memberships.py       ← 6 membership tools
│   ├── attendance.py        ← 6 attendance tools
│   ├── salary.py            ← 6 salary tools
│   ├── leads.py             ← 10 lead tools
│   ├── ... (30 more files)
│   └── theme.py             ← 1 theme tool
│
├── tool_selector.py    ← NEW! Dynamic selection logic
│   ├── TOOL_CATEGORIES     (30 categories)
│   ├── CORE_TOOLS          (8 always-included tools)
│   ├── CATEGORY_KEYWORDS   (keyword → category mapping)
│   ├── RELATED_CATEGORIES  (category → related categories)
│   ├── DEFAULT_CATEGORIES  (fallback when no match)
│   └── select_tools()      (the main function)
│
├── agent.py            ← Uses select_tools() instead of ALL_TOOLS
│   ├── _setup_chat()        → calls select_tools(message)
│   ├── process_chat()       → uses selected tools
│   └── process_chat_stream()→ uses selected tools
│
└── main.py             ← No changes needed

Interview Questions

Q: Why did the bot break and how did you fix it?

"Our LangChain bot had 137 tools, but OpenAI's API enforces a hard limit of 128 tools per request. Every request was failing. Instead of removing tools, we implemented dynamic tool selection - each user message is analyzed with keyword matching to determine which tool categories are relevant, and only those categories (plus always-needed core tools) are sent to OpenAI. This keeps every request under 128 tools while maintaining full functionality."

Q: Why keyword matching instead of using another LLM call to classify the message?

"An extra LLM call would add latency (500ms-2s), cost money per request, and introduce another failure point. Keyword matching is instant (microseconds), free, deterministic, and runs in pure Python. It doesn't need to be perfect - just good enough to include relevant categories. Being slightly generous (including related categories) is fine because 40-50 tools is still well under 128."

Q: What happens if the keyword matching picks the wrong categories?

"There are two cases. Over-selection (too many categories): This is fine - extra tools don't hurt, and we have a 128 cap. Under-selection (missing a needed category): This is handled by related categories (e.g., 'salary' auto-includes 'staff') and multi-turn context (scanning last 4 messages). For truly ambiguous messages with no keyword matches, we fall back to default categories covering the most common operations."

Q: Why are CORE_TOOLS always included?

"Core tools are utilities the bot might need regardless of topic - theme change, page navigation, gym info, and currency formatting. The user can always say 'switch to dark mode' mid-conversation about salary. These 8 tools ensure basic functionality is always available."

Q: Why does TOOL_MAP stay unchanged even though you only send a subset of tools?

"TOOL_MAP is used to execute tool calls, not to tell OpenAI what's available. The full map (all 137 tools) is needed because conversation history might reference tools from previous turns when different categories were selected. select_tools() controls what OpenAI sees; TOOL_MAP controls what can run."

Q: What if you keep adding tools and even individual categories exceed 128?

"The largest category today is 'clients' with 12 tools. Even matching 10 categories with 12 tools each = 120 + 8 core = 128, exactly at the limit. If individual categories grow too large, we could split them into sub-categories (e.g., 'clients_read' and 'clients_write'). But at current growth rates, this won't be needed for a long time."

Key Points to Remember

  • OpenAI hard limit: 128 tools per request - this is an API constraint, not configurable
  • Dynamic selection = send only what's needed - 30-50 tools per message on average instead of 137
  • 30 categories + 8 core tools organize all 137 tools logically
  • Keyword matching is instant - pure string matching, no LLM call, zero cost
  • Related categories prevent missing tools - "salary" auto-includes "staff" tools
  • Multi-turn context - scans last 4 human messages so follow-up questions work
  • Safety cap at 128 - even if all categories match, never exceeds the limit
  • TOOL_MAP stays complete (137) - execution map never changes, only the API binding does
  • Scalable - can add hundreds more tools without breaking, just add new categories
  • Only 2 files changed - 1 new file (tool_selector.py), 2 lines changed in agent.py

What's Next?

This pattern opens up possibilities:

  • Analytics - Track which categories are used most often to optimize defaults
  • Per-role selection - Different users (admin vs trainer vs client) could get different tool subsets
  • Weighted scoring - Instead of binary keyword match, score relevance and pick top categories
  • Embedding-based matching - Use semantic similarity instead of keywords for smarter matching

But the simple keyword approach works perfectly for now. Don't over-engineer when the simple solution gets the job done!

Keep coding, keep learning! See you in the next one!