跳转至

active-tool-selection

第4章 · 工具 · 配套项目 chapter4/active-tool-selection

项目说明

Active Tool Selection

An educational implementation of active tool discovery for LLM agents, inspired by the MCP-Zero paper (arXiv:2506.01056).

🎯 Overview

Traditional LLM agents inject all available tool schemas into prompts, creating massive context overhead and reducing agents to passive tool selectors. This project demonstrates active tool discovery, where agents autonomously identify capability gaps and request specific tools on-demand.

The Problem

Current tool integration approaches face critical limitations:

  1. Massive Context Overhead: Injecting all tools can consume 100k+ tokens
  2. Passive Selection: Agents select from pre-defined options rather than actively discovering
  3. Poor Scalability: Context grows with ecosystem size, not task needs
  4. Lost Autonomy: Tool selection delegated to external retrieval systems

The Solution: Active Tool Discovery

This project implements three core mechanisms from MCP-Zero:

  1. Active Tool Request: Agents generate structured requests specifying their exact tool requirements
  2. Hierarchical Semantic Routing: Two-stage matching algorithm (server-level → tool-level)
  3. Iterative Capability Extension: Progressive toolchain building as task understanding evolves

🔬 三种策略对比 (Strategy Comparison)

本实验把"工具选择"问题落到可度量的基准上,对比三种策略在同一批任务上的表现:

策略 说明 上下文里的工具
all-tools 一次性注入全部工具(传统被动式基线) 全部 N 个
retrieval 按任务语义检索 top-k 个工具后再注入(工具检索 / RAG 式,RetrievalToolAgent 仅 top-k 个
active MCP-Zero 式主动发现:模型迭代地请求所需工具(ActiveToolAgent 按需增长

评测入口是 demo_comparison.py,带完整的 argparse 命令行:

# 仅离线对比(确定性,无需 API Key):召回率 vs token 成本 vs 随规模的扩展性
python demo_comparison.py --offline

# 把工具目录扩充到 200 个(合成干扰工具补齐),观察 token 成本的分化
python demo_comparison.py --offline --num-tools 200

# 三种策略端到端对比(需要 API Key):模型是否真的调用了正确的工具、token、延迟
python demo_comparison.py --strategy compare

# 只对单条查询运行某种策略
python demo_comparison.py --query "Deploy version 2.0 to production" --strategy retrieval

# 保存结果为 JSON
python demo_comparison.py --offline --output results.json

运行 python demo_comparison.py --help 查看全部参数(--strategy / --query / --num-tools / --top-k / --model / --output / --offline / --legacy-demos)。

离线基准(确定性,无需 API)

benchmark.py 提供了一个带标准答案工具的小型基准集(10 个任务,每个任务标注了应当被选中的 工具),并在不调用任何 API 的情况下度量两件事:

  • Retrieval recall@k:标准答案工具是否落在被注入上下文的工具集合里;
  • Schema tokens:注入的工具描述占用的 token 数(由 schema 直接估算,确定性可复现)。

下表是 python demo_comparison.py --offline实测输出(top-k=5,10 个任务):

策略 上下文工具数 Schema tokens 召回率(标准答案可达)
all-tools(全部注入) 35 3,857 100%
retrieval(top-5) 5 551 100%

随着工具目录增长,all-tools 的 token 成本线性膨胀,而 retrieval 基本持平(实测):

目录规模 all-tools tokens retrieval(top-5) tokens retrieval 召回率
35 3,857 551 100%
100 10,292 539 100%
200 20,258 540 100%
400 40,258 540 100%

结论:检索式按需选择在保持 100% 召回率的同时,把工具描述的 token 成本从数千压到数百,且不随 生态规模膨胀。这正是本章"把工具选择转化为知识检索"的量化体现。上述数字由 --offline 路径 确定性生成,可直接复现。

端到端准确率(需要 API Key)

在配置 OPENAI_API_KEY 后,--strategy compare 会真正调用模型,度量每种策略下模型是否调用了 标准答案工具(accuracy)、平均 token 与平均延迟。这一部分需要联网与 API,故不在离线路径中运行。

🏗️ Architecture

┌─────────────────────────────────────────────────────────────┐
│                    Active Tool Agent                         │
│  ┌────────────────────────────────────────────────────────┐ │
│  │  1. Analyze Task & Identify Capability Gaps            │ │
│  └────────────────────────────────────────────────────────┘ │
│                           ↓                                  │
│  ┌────────────────────────────────────────────────────────┐ │
│  │  2. Generate Structured Tool Request                    │ │
│  │     <tool_request>                                      │ │
│  │       server: GitHub for repository operations          │ │
│  │       tool: search repositories by keyword              │ │
│  │     </tool_request>                                     │ │
│  └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│              Hierarchical Semantic Router                    │
│  ┌────────────────────────────────────────────────────────┐ │
│  │  Stage 1: Server-Level Routing                         │ │
│  │  • Match request to relevant servers/domains            │ │
│  │  • Filter by platform requirements                      │ │
│  │  • Return top-K servers                                 │ │
│  └────────────────────────────────────────────────────────┘ │
│                           ↓                                  │
│  ┌────────────────────────────────────────────────────────┐ │
│  │  Stage 2: Tool-Level Routing                           │ │
│  │  • Rank tools within selected servers                   │ │
│  │  • Semantic similarity matching                         │ │
│  │  • Return relevant tools                                │ │
│  └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│               Tool Knowledge Base                            │
│                                                              │
│  8 Servers × 35 Tools (optionally padded with distractors): │
│  • GitHub: Repository management (5 tools)                  │
│  • Filesystem: File operations (5 tools)                    │
│  • Database: SQL operations (5 tools)                       │
│  • Web: HTTP requests (4 tools)                             │
│  • Analytics: Data analysis (4 tools)                       │
│  • Communication: Email/messaging (4 tools)                 │
│  • DevOps: Deployment/monitoring (4 tools)                  │
│  • Cloud: Infrastructure management (4 tools)               │
└─────────────────────────────────────────────────────────────┘

📦 Components

Core Files

  • agent.py: Three agent implementations (one per strategy)
  • ActiveToolAgent: MCP-Zero style on-demand discovery (active)
  • RetrievalToolAgent: one-shot semantic retrieval of top-k tools (retrieval)
  • PassiveToolAgent: traditional approach with all tools pre-loaded (all-tools)

  • benchmark.py: Labeled benchmark + offline evaluation

  • 10 tasks, each labeled with its ground-truth tool
  • build_catalog(num_tools): real catalog, optionally padded with distractors
  • evaluate_offline(...): deterministic recall@k / token-cost measurement (no API)

  • tool_knowledge_base.py: Comprehensive tool catalog

  • 8 servers (domains) with 35 tools
  • Organized by platform/functionality
  • Simulates MCP ecosystem

  • semantic_router.py: Hierarchical tool discovery

  • Two-stage semantic matching
  • TF-IDF based similarity
  • Structured request parsing

  • config.py: Configuration settings

  • LLM provider settings
  • Routing thresholds
  • Agent parameters

Demo Scripts

  • quickstart.py: Quick demonstration (⭐ Start here!)
  • demo_comparison.py: Comprehensive comparison
  • examples.py: Multiple use case examples

🚀 Quick Start

1. Install Dependencies

pip install -r requirements.txt

2. Configure API Key

cp env.example .env
# Edit .env and add your API key

Universal OpenRouter fallback: if OPENAI_API_KEY is not set but OPENROUTER_API_KEY is, config.py automatically routes through OpenRouter (base_url=https://openrouter.ai/api/v1) and maps the model id to provider/model form (gpt-*openai/…, claude-*anthropic/claude-opus-4.8). Existing OPENAI_BASE_URL/OPENAI_MODEL overrides are preserved.

3. Run Quick Start

python quickstart.py

This will demonstrate: - Active tool discovery process - Passive tool injection (for comparison) - Efficiency metrics and insights

📊 Performance Comparison

See the measured, reproducible numbers in 三种策略对比 above. The offline table (schema-token cost + retrieval recall) is generated deterministically by python demo_comparison.py --offline — no API key required. End-to-end accuracy/latency across the three strategies requires an API key (--strategy compare).

Note: earlier drafts of this README quoted round illustrative figures for token savings. Those have been replaced with the actual measured output of the offline benchmark to avoid fabricated numbers.

🎓 Key Concepts

Active Tool Request

Instead of pre-loading tools, agents explicitly request what they need:

<tool_request>
server: GitHub for repository management
tool: search repositories by stars and language
</tool_request>

Hierarchical Semantic Routing

Two-stage algorithm reduces search complexity:

  1. Stage 1: Match to relevant servers (platforms)
  2. "GitHub operations" → GitHub server
  3. "File management" → Filesystem server

  4. Stage 2: Match to specific tools within servers

  5. "search repositories" → github_search_repos
  6. "read file" → fs_read_file

Iterative Capability Extension

Tools are discovered progressively:

Turn 1: Need GitHub access
  → Load GitHub tools

Turn 2: Need to analyze downloaded files
  → Additionally load filesystem tools

Turn 3: Need to visualize results
  → Additionally load analytics tools

Toolchain grows with task complexity, not ecosystem size.

📚 Examples

Example 1: Simple Task

from agent import ActiveToolAgent

agent = ActiveToolAgent()
result = agent.execute_task("Search for Python ML repositories on GitHub")

# Agent discovers and loads only GitHub tools
print(f"Tools loaded: {result['metrics']['tools_loaded']}")  # 2-3 tools
print(f"Tokens used: {result['metrics']['tokens_used']}")    # ~2,000

Example 2: Multi-Domain Task

task = """
1. Query database for user data
2. Analyze with statistics
3. Create visualization
4. Email report to team
"""

result = agent.execute_task(task)

# Agent builds cross-domain toolchain:
# Database → Analytics → Communication
print(f"Tools: {result['tools_loaded']}")

Example 3: Comparison

from agent import ActiveToolAgent, PassiveToolAgent

# Active approach
active = ActiveToolAgent()
active_result = active.execute_task(task)

# Passive approach
passive = PassiveToolAgent()
passive_result = passive.execute_task(task)

# Compare efficiency
reduction = (1 - active_result['metrics']['tokens_used'] / 
             passive_result['metrics']['tokens_used']) * 100
print(f"Token reduction: {reduction:.1f}%")  # Typically 90-98%

🔬 Running Demonstrations

python quickstart.py

Shows basic active vs passive comparison.

2. Strategy Comparison Benchmark (main experiment)

python demo_comparison.py --offline   # deterministic, no API key needed
python demo_comparison.py             # + end-to-end accuracy/latency if API key present

By default it prints: - Offline strategy table: retrieval recall@k vs. tool-schema token cost (deterministic) - Scaling table: token cost as the catalog grows to hundreds of tools - End-to-end table (with API key): accuracy / tokens / latency for all-tools, retrieval, active

The original narrative demos (semantic-routing walk-through, iterative discovery, etc.) are still available via --legacy-demos. See the Strategy Comparison section and python demo_comparison.py --help for all flags.

3. Use Case Examples

python examples.py

Demonstrates: - GitHub workflow - Data pipeline - DevOps automation - Multi-turn discovery - Efficiency metrics

🎯 Use Cases

Ideal for Active Discovery

  1. Task-Specific Operations: Known scope, specific tools needed
  2. Large Tool Ecosystems: 50+ tools where most are irrelevant
  3. Multi-Turn Conversations: Tools needed evolve over time
  4. Token-Constrained Environments: Limited context windows

When Passive Might Work

  1. Small Tool Sets: <10 tools total
  2. All Tools Relevant: Every tool likely to be used
  3. Single-Turn Tasks: No iterative refinement

🔧 Configuration

Edit config.py or set environment variables:

# LLM Configuration
OPENAI_API_KEY = "your-api-key"
OPENAI_BASE_URL = "https://api.openai.com/v1"
OPENAI_MODEL = "gpt-5.6-luna"

# Routing Configuration
SIMILARITY_THRESHOLD = 0.15  # Min similarity for tool match
TOP_K_SERVERS = 3            # Number of servers to search
TOP_K_TOOLS = 5              # Tools to return per server

# Agent Configuration
MAX_TOOL_REQUESTS = 5       # Max discovery iterations

📖 Educational Value

This project demonstrates:

Software Engineering Principles

  • Separation of Concerns: Router, knowledge base, agent cleanly separated
  • Scalability: Efficient with 10 or 1,000 tools
  • Modularity: Easy to add new servers/tools

AI Agent Design Patterns

  • Active vs Passive: Fundamental architectural difference
  • Hierarchical Search: Reduces complexity from O(n) to O(log n)
  • Semantic Matching: Beyond keyword matching

Real-World Applications

  • Tool Ecosystems: MCP, LangChain, AutoGen
  • Agent Frameworks: Building production-ready agents
  • Context Management: Handling long contexts efficiently

🔬 Technical Details

Semantic Routing Implementation

Uses TF-IDF vectorization with cosine similarity:

# Server-level
server_vector = vectorizer.transform([request])
similarities = cosine_similarity(server_vector, server_embeddings)

# Tool-level
tool_vector = vectorizer.transform([request])
tool_similarities = cosine_similarity(tool_vector, tool_embeddings)

# Combined score
final_score = 0.3 * server_score + 0.7 * tool_score

Token Estimation

Approximates tokens in tool schemas:

def count_tokens_in_schema(schema):
    # Rough estimation: 1 token ≈ 4 characters
    schema_str = json.dumps(schema)
    return len(schema_str) // 4

🌟 Key Insights

  1. Autonomy Matters: Agents should control their capability acquisition
  2. Context is Expensive: Every token counts at scale
  3. Semantic Matching Works: TF-IDF sufficient for tool discovery
  4. Iteration Enables Flexibility: Static tool sets can't anticipate needs
  5. Hierarchical Search Scales: Two-stage routing maintains performance

📚 References

🛠️ Extending the Project

Add New Tools

# In tool_knowledge_base.py
new_tool = ToolDefinition(
    name="your_tool_name",
    description="What the tool does",
    parameters={...},
    server="server_name"
)

Add New Server

# Create tools for the server
tools = [...]

# Add server
servers.append(ServerDefinition(
    name="your_server",
    description="Server description",
    tools=tools
))

Customize Routing

# In config.py
SIMILARITY_THRESHOLD = 0.5  # More strict matching
TOP_K_SERVERS = 5           # Search more servers

🐛 Troubleshooting

API Key Issues

# Verify .env file
cat .env

# Should contain:
OPENAI_API_KEY=sk-...

Import Errors

# Reinstall dependencies
pip install -r requirements.txt --upgrade

Low Similarity Scores

# Lower threshold in config.py
SIMILARITY_THRESHOLD = 0.2

📈 Future Enhancements

Potential improvements:

  1. Better Embeddings: Use sentence-transformers or OpenAI embeddings
  2. Caching: Cache tool embeddings for faster routing
  3. Feedback Loop: Learn from tool usage patterns
  4. Multi-Agent: Tool sharing between agent instances
  5. Real Tools: Connect to actual APIs instead of simulation

🤝 Contributing

This is an educational project. Feel free to:

  • Add more tools to the knowledge base
  • Implement additional routing algorithms
  • Create new demonstration scenarios
  • Improve documentation

📄 License

MIT License - See LICENSE file for details

🙏 Acknowledgments

  • Inspired by MCP-Zero paper by Xiang Fei, Xiawu Zheng, and Hao Feng
  • Based on Model Context Protocol (MCP) ecosystem
  • Built for educational purposes in AI Agent development

Ready to get started? Run python quickstart.py to see active tool discovery in action!

源代码

agent.py

"""
Active Tool Discovery Agent.

Implements an LLM agent that actively requests tools on-demand rather than
having all tool schemas injected into the prompt. Inspired by MCP-Zero.
"""

from typing import List, Dict, Any, Optional
from openai import OpenAI

from tool_knowledge_base import ToolDefinition, ServerDefinition, create_tool_knowledge_base
from semantic_router import SemanticRouter, StructuredRequestParser
import config


class ActiveToolAgent:
    """
    Agent that actively discovers and requests tools as needed.

    Key principles:
    1. Maintains minimal context by not injecting all tools upfront
    2. Actively requests specific tools when capability gaps are identified
    3. Iteratively builds toolchain as task understanding evolves
    """

    def __init__(self, servers: Optional[List[ServerDefinition]] = None,
                 model: Optional[str] = None):
        self.client = OpenAI(
            api_key=config.OPENAI_API_KEY,
            base_url=config.OPENAI_BASE_URL
        )
        self.model = model or config.OPENAI_MODEL

        # Initialize tool knowledge base (callers may inject a padded/custom catalog)
        self.servers = servers if servers is not None else create_tool_knowledge_base()
        self.router = SemanticRouter(self.servers)

        # Agent state
        self.conversation_history = []
        self.available_tools: List[ToolDefinition] = []  # Currently loaded tools
        self.tool_request_count = 0

        # Metrics
        self.metrics = {
            'tokens_used': 0,
            'tool_requests': 0,
            'tools_loaded': 0,
            'api_calls': 0,
            'tools_called': []  # Names of tools the model actually invoked
        }

    def execute_task(self, task: str) -> Dict[str, Any]:
        """
        Execute a task with active tool discovery.

        The agent will:
        1. Analyze the task
        2. Identify capability gaps
        3. Request specific tools
        4. Execute with discovered tools

        Returns execution results with metrics.
        """
        self.conversation_history = []
        self.available_tools = []
        self.tool_request_count = 0

        # Initial system message explaining active tool discovery
        system_message = self._create_system_message()
        self.conversation_history.append({
            "role": "system",
            "content": system_message
        })

        # Add user task
        self.conversation_history.append({
            "role": "user",
            "content": task
        })

        # Iterative tool discovery and execution
        max_iterations = config.MAX_TOOL_REQUESTS
        for iteration in range(max_iterations):
            # Get agent response
            response = self._call_llm()
            self.metrics['api_calls'] += 1

            # Check if agent is requesting tools
            tool_request = StructuredRequestParser.parse_request(response)

            if tool_request:
                # Agent is requesting tools - discover and provide them
                self._handle_tool_request(tool_request, response)
                self.tool_request_count += 1
                self.metrics['tool_requests'] += 1
            else:
                # Agent has what it needs and is responding
                self.conversation_history.append({
                    "role": "assistant",
                    "content": response
                })
                break

        return {
            'response': response,
            'metrics': self.metrics,
            'tools_loaded': [t.name for t in self.available_tools],
            'conversation': self.conversation_history
        }

    def _create_system_message(self) -> str:
        """Create system message explaining active tool discovery."""
        return """You are an autonomous AI agent with active tool discovery capabilities.

Instead of having all possible tools available upfront, you can actively request tools as you need them. This allows you to:
1. Maintain a minimal context footprint
2. Focus on relevant capabilities for the current task
3. Iteratively build your toolchain as your understanding evolves

When you identify a capability gap, request tools using this format:

<tool_request>
server: [describe the platform/domain you need, e.g., "GitHub for repository operations" or "filesystem for local file access"]
tool: [describe the specific operation you need, e.g., "search repositories" or "read file contents"]
</tool_request>

After requesting tools, they will be provided to you. You can then use them to accomplish the task.

Process:
1. Analyze the task and identify what capabilities you need
2. Request specific tools if you don't have them yet
3. Once you have the necessary tools, use them to complete the task
4. Respond with your findings or results

Current available tools: None (request tools as needed)"""

    def _call_llm(self) -> str:
        """Call LLM with current context and available tools."""
        kwargs = {
            "model": self.model,
            "messages": self.conversation_history,
            "temperature": config.AGENT_TEMPERATURE
        }

        # Add tools if available
        if self.available_tools:
            kwargs["tools"] = [tool.to_schema() for tool in self.available_tools]
            kwargs["tool_choice"] = "auto"

        response = self.client.chat.completions.create(**kwargs)

        # Track token usage
        # response.usage is Optional in the OpenAI SDK: the attribute always
        # exists, but is None when the provider omits token accounting.
        if getattr(response, 'usage', None):
            self.metrics['tokens_used'] += response.usage.total_tokens

        # Extract response content
        message = response.choices[0].message

        # Handle tool calls if present
        if message.tool_calls:
            return self._handle_tool_calls(message)

        return message.content or ""

    def _handle_tool_request(self, tool_request: Dict[str, str], full_response: str):
        """
        Handle tool request from agent.

        Args:
            tool_request: Parsed tool request with 'server' and 'tool' fields
            full_response: Full response text from agent
        """
        # Combine server and tool descriptions for routing
        query = f"{tool_request['server']} {tool_request['tool']}"

        # Use semantic router to find relevant tools
        discovered_tools = self.router.route_request(query)

        if not discovered_tools:
            # No tools found
            feedback = f"""No tools found matching your request. Please refine your request or proceed without additional tools.

Your request was:
- Server: {tool_request['server']}
- Tool: {tool_request['tool']}"""
        else:
            # Add discovered tools to available tools
            new_tools = []
            for tool in discovered_tools:
                if tool not in self.available_tools:
                    self.available_tools.append(tool)
                    new_tools.append(tool)
                    self.metrics['tools_loaded'] += 1

            tool_list = "\n".join([f"- {t.name}: {t.description}" for t in new_tools])
            feedback = f"""Tools discovered and loaded ({len(new_tools)} new tools):

{tool_list}

You can now use these tools to complete the task. Please proceed."""

        # Add agent's request and system's response to history
        self.conversation_history.append({
            "role": "assistant",
            "content": full_response
        })
        self.conversation_history.append({
            "role": "user",
            "content": feedback
        })

    def _handle_tool_calls(self, message) -> str:
        """Handle actual tool execution (simulated for demo)."""
        # For this educational demo, we simulate tool execution
        tool_results = []

        for tool_call in message.tool_calls:
            func_name = tool_call.function.name
            self.metrics['tools_called'].append(func_name)

            # Simulate tool execution
            result = f"[Simulated] Tool '{func_name}' executed successfully with result: Success"
            tool_results.append({
                "tool_call_id": tool_call.id,
                "output": result
            })

        # Add tool call message to history
        self.conversation_history.append({
            "role": "assistant",
            "content": None,
            "tool_calls": [
                {
                    "id": tc.id,
                    "type": "function",
                    "function": {
                        "name": tc.function.name,
                        "arguments": tc.function.arguments
                    }
                }
                for tc in message.tool_calls
            ]
        })

        # Add tool results to history
        for result in tool_results:
            self.conversation_history.append({
                "role": "tool",
                "tool_call_id": result["tool_call_id"],
                "content": result["output"]
            })

        # Get final response after tool execution
        return self._call_llm()

    def reset(self):
        """Reset agent state."""
        self.conversation_history = []
        self.available_tools = []
        self.tool_request_count = 0
        self.metrics = {
            'tokens_used': 0,
            'tool_requests': 0,
            'tools_loaded': 0,
            'api_calls': 0,
            'tools_called': []
        }


class RetrievalToolAgent:
    """
    One-shot retrieval agent (semantic tool retrieval / "工具检索").

    This is the RAG-style middle ground between passive injection and active
    discovery: before the very first LLM call, it retrieves the top-k tools most
    semantically relevant to the task and injects *only* those. There is no extra
    discovery round-trip — tool selection is delegated to the retriever, turning the
    "which of hundreds of tools" problem into a knowledge-retrieval problem.

    This directly embodies the mechanism the chapter attributes to Anthropic's
    on-demand tool retrieval experiment: fewer, more relevant tool schemas in
    context both cut token cost and reduce the model's selection errors.
    """

    def __init__(self, servers: Optional[List[ServerDefinition]] = None,
                 model: Optional[str] = None, top_k: Optional[int] = None):
        self.client = OpenAI(
            api_key=config.OPENAI_API_KEY,
            base_url=config.OPENAI_BASE_URL
        )
        self.model = model or config.OPENAI_MODEL
        self.top_k = top_k if top_k is not None else config.TOP_K_TOOLS

        self.servers = servers if servers is not None else create_tool_knowledge_base()
        self.router = SemanticRouter(self.servers)

        self.conversation_history = []
        self.available_tools: List[ToolDefinition] = []
        self.metrics = {
            'tokens_used': 0,
            'tools_loaded': 0,
            'api_calls': 0,
            'tools_called': []
        }

    def execute_task(self, task: str) -> Dict[str, Any]:
        """Retrieve top-k relevant tools for the task, then execute in one shot."""
        self.conversation_history = []

        # Retrieval step (no LLM call): pick the top-k most relevant tools.
        self.available_tools = self.router.retrieve(task, self.top_k)
        self.metrics['tools_loaded'] = len(self.available_tools)

        tool_list = "\n".join(
            f"- {t.name}: {t.description}" for t in self.available_tools
        )
        system_message = f"""You are an AI agent. A retrieval system has pre-selected the \
{len(self.available_tools)} tools below as most relevant to the user's task.

{tool_list}

Analyze the task and call the appropriate tool(s) to complete it."""

        self.conversation_history.append({"role": "system", "content": system_message})
        self.conversation_history.append({"role": "user", "content": task})

        response = self._call_llm()
        self.metrics['api_calls'] += 1

        return {
            'response': response,
            'metrics': self.metrics,
            'tools_loaded': [t.name for t in self.available_tools],
            'conversation': self.conversation_history
        }

    def _call_llm(self) -> str:
        """Call LLM with only the retrieved tools injected."""
        kwargs = {
            "model": self.model,
            "messages": self.conversation_history,
            "temperature": config.AGENT_TEMPERATURE
        }
        if self.available_tools:
            kwargs["tools"] = [tool.to_schema() for tool in self.available_tools]
            kwargs["tool_choice"] = "auto"

        response = self.client.chat.completions.create(**kwargs)

        # response.usage is Optional in the OpenAI SDK: the attribute always
        # exists, but is None when the provider omits token accounting.
        if getattr(response, 'usage', None):
            self.metrics['tokens_used'] += response.usage.total_tokens

        message = response.choices[0].message
        if message.tool_calls:
            return self._handle_tool_calls(message)
        return message.content or ""

    def _handle_tool_calls(self, message) -> str:
        """Handle tool execution (simulated)."""
        tool_results = []
        for tool_call in message.tool_calls:
            func_name = tool_call.function.name
            self.metrics['tools_called'].append(func_name)
            result = f"[Simulated] Tool '{func_name}' executed successfully"
            tool_results.append({"tool_call_id": tool_call.id, "output": result})

        self.conversation_history.append({
            "role": "assistant",
            "content": None,
            "tool_calls": [
                {
                    "id": tc.id,
                    "type": "function",
                    "function": {
                        "name": tc.function.name,
                        "arguments": tc.function.arguments
                    }
                }
                for tc in message.tool_calls
            ]
        })
        for result in tool_results:
            self.conversation_history.append({
                "role": "tool",
                "tool_call_id": result["tool_call_id"],
                "content": result["output"]
            })
        return self._call_llm()

    def reset(self):
        """Reset agent state."""
        self.conversation_history = []
        self.available_tools = []
        self.metrics = {
            'tokens_used': 0,
            'tools_loaded': 0,
            'api_calls': 0,
            'tools_called': []
        }


class PassiveToolAgent:
    """
    Traditional agent with all tools injected upfront (for comparison).

    This approach:
    1. Injects all tool schemas into the initial prompt
    2. Massive context overhead
    3. Reduces agent to passive tool selector
    """

    def __init__(self, servers: Optional[List[ServerDefinition]] = None,
                 model: Optional[str] = None):
        self.client = OpenAI(
            api_key=config.OPENAI_API_KEY,
            base_url=config.OPENAI_BASE_URL
        )
        self.model = model or config.OPENAI_MODEL

        # Load ALL tools upfront
        self.servers = servers if servers is not None else create_tool_knowledge_base()
        self.all_tools = []
        for server in self.servers:
            self.all_tools.extend(server.tools)

        self.conversation_history = []
        self.metrics = {
            'tokens_used': 0,
            'tools_loaded': len(self.all_tools),
            'api_calls': 0,
            'tools_called': []
        }

    def execute_task(self, task: str) -> Dict[str, Any]:
        """Execute task with all tools pre-loaded."""
        self.conversation_history = []

        # System message
        system_message = f"""You are an AI agent with access to {len(self.all_tools)} tools across multiple domains.

All available tools have been pre-loaded. Analyze the task and use the appropriate tools to complete it."""

        self.conversation_history.append({
            "role": "system",
            "content": system_message
        })

        self.conversation_history.append({
            "role": "user",
            "content": task
        })

        # Call LLM with ALL tools
        response = self._call_llm()
        self.metrics['api_calls'] += 1

        return {
            'response': response,
            'metrics': self.metrics,
            'tools_loaded': [t.name for t in self.all_tools],
            'conversation': self.conversation_history
        }

    def _call_llm(self) -> str:
        """Call LLM with ALL tools injected."""
        kwargs = {
            "model": self.model,
            "messages": self.conversation_history,
            "temperature": config.AGENT_TEMPERATURE,
            "tools": [tool.to_schema() for tool in self.all_tools],
            "tool_choice": "auto"
        }

        response = self.client.chat.completions.create(**kwargs)

        # Track token usage
        # response.usage is Optional in the OpenAI SDK: the attribute always
        # exists, but is None when the provider omits token accounting.
        if getattr(response, 'usage', None):
            self.metrics['tokens_used'] += response.usage.total_tokens

        message = response.choices[0].message

        # Handle tool calls (simulated)
        if message.tool_calls:
            return self._handle_tool_calls(message)

        return message.content or ""

    def _handle_tool_calls(self, message) -> str:
        """Handle tool execution (simulated)."""
        tool_results = []

        for tool_call in message.tool_calls:
            func_name = tool_call.function.name
            self.metrics['tools_called'].append(func_name)
            result = f"[Simulated] Tool '{func_name}' executed successfully"
            tool_results.append({
                "tool_call_id": tool_call.id,
                "output": result
            })

        # Add to history
        self.conversation_history.append({
            "role": "assistant",
            "content": None,
            "tool_calls": [
                {
                    "id": tc.id,
                    "type": "function",
                    "function": {
                        "name": tc.function.name,
                        "arguments": tc.function.arguments
                    }
                }
                for tc in message.tool_calls
            ]
        })

        for result in tool_results:
            self.conversation_history.append({
                "role": "tool",
                "tool_call_id": result["tool_call_id"],
                "content": result["output"]
            })

        return self._call_llm()

    def reset(self):
        """Reset agent state."""
        self.conversation_history = []
        self.metrics = {
            'tokens_used': 0,
            'tools_loaded': len(self.all_tools),
            'api_calls': 0,
            'tools_called': []
        }

benchmark.py

"""
Tool-selection benchmark and offline evaluation.

Provides a small labeled benchmark (task -> ground-truth tool) and utilities to
quantify, *without any API calls*, the core claim of the chapter: when a tool
ecosystem grows to hundreds of tools, retrieving the few relevant tools on demand
keeps the right tool reachable while slashing the token cost of dumping every tool
schema into context.

Two things are measured here deterministically:
  1. Retrieval recall@k  — is the ground-truth tool among the tools a strategy
     places in the model's context?
  2. Context schema tokens — how many tokens the injected tool schemas cost.

End-to-end accuracy/latency (whether the model actually *calls* the right tool)
requires an API key and lives in demo_comparison.py.
"""

from typing import List, Dict

from tool_knowledge_base import (
    ToolDefinition,
    ServerDefinition,
    create_tool_knowledge_base,
    get_all_tools,
    calculate_total_tokens,
)
from semantic_router import SemanticRouter


# Labeled benchmark: each task has one (or a few acceptable) ground-truth tool(s).
# Queries are in English to match the English tool descriptions used by the
# TF-IDF router (see tool_knowledge_base.py).
BENCHMARK_TASKS: List[Dict] = [
    {
        "name": "GitHub repo search",
        "task": "Search GitHub for popular Python machine learning repositories with more than 10000 stars",
        "gold_tools": ["github_search_repos"],
    },
    {
        "name": "Read config file",
        "task": "Read the contents of the local configuration file at /etc/app/config.json",
        "gold_tools": ["fs_read_file"],
    },
    {
        "name": "List directory",
        "task": "List all files and subdirectories under the /var/log directory",
        "gold_tools": ["fs_list_directory"],
    },
    {
        "name": "Summary statistics",
        "task": "Calculate the mean, median and standard deviation of last quarter's sales figures",
        "gold_tools": ["analytics_summarize"],
    },
    {
        "name": "Send email",
        "task": "Send the quarterly performance summary email to the team members",
        "gold_tools": ["comm_send_email"],
    },
    {
        "name": "Deploy to production",
        "task": "Deploy version 2.3.0 of the application to the production environment",
        "gold_tools": ["devops_deploy"],
    },
    {
        "name": "SQL query",
        "task": "Run a SQL query on the database to count the number of active users per region",
        "gold_tools": ["db_query"],
    },
    {
        "name": "Upload to cloud",
        "task": "Upload the local report file to the cloud storage bucket",
        "gold_tools": ["cloud_upload_storage"],
    },
    {
        "name": "Scrape prices",
        "task": "Scrape the prices of all products listed on the given web page",
        "gold_tools": ["web_scrape"],
    },
    {
        "name": "Monitor service",
        "task": "Get the current CPU and memory monitoring metrics for the staging service",
        "gold_tools": ["devops_monitor"],
    },
]


def make_distractor_servers(num_tools: int, start_index: int = 1,
                            tools_per_server: int = 5) -> List[ServerDefinition]:
    """
    Generate synthetic *distractor* servers/tools to inflate the catalog size.

    These are deliberately generic "internal service" operations. They add real
    schema tokens and act as retrieval noise, so we can study how each strategy
    scales as the ecosystem grows to hundreds of tools — without hand-writing
    hundreds of realistic tools. They are clearly named ``svcN_opM`` so nobody
    mistakes them for the real catalog.
    """
    servers: List[ServerDefinition] = []
    created = 0
    server_idx = start_index
    while created < num_tools:
        n = min(tools_per_server, num_tools - created)
        tools = []
        for j in range(1, n + 1):
            op = created + j
            tools.append(ToolDefinition(
                name=f"svc{server_idx}_op{j}",
                description=(
                    f"Auxiliary internal-service operation {op} for background "
                    f"housekeeping on internal resource group {server_idx}"
                ),
                parameters={
                    "type": "object",
                    "properties": {
                        "resource_id": {"type": "string", "description": "Internal resource identifier"},
                        "options": {"type": "object", "description": "Operation options"},
                    },
                    "required": ["resource_id"],
                },
                server=f"internal_service_{server_idx}",
            ))
        servers.append(ServerDefinition(
            name=f"internal_service_{server_idx}",
            description=f"Internal auxiliary service {server_idx} for background housekeeping operations",
            tools=tools,
        ))
        created += n
        server_idx += 1
    return servers


def build_catalog(num_tools: int = 0) -> List[ServerDefinition]:
    """
    Build the tool catalog, optionally padded with distractor tools.

    Args:
        num_tools: Target total number of tools. 0 (default) keeps the real
            catalog untouched. Values below the real catalog size are ignored
            (we never drop real tools); larger values pad with distractors.
    """
    servers = create_tool_knowledge_base()
    real_count = len(get_all_tools(servers))
    if num_tools and num_tools > real_count:
        servers = servers + make_distractor_servers(num_tools - real_count)
    return servers


def evaluate_offline(servers: List[ServerDefinition], top_k: int,
                     tasks: List[Dict] = None) -> Dict:
    """
    Deterministically compare tool-selection strategies (no API calls).

    Returns a dict with per-strategy aggregate metrics and per-task retrieval
    details. Two strategies are directly comparable offline:

      * ``all-tools``  — inject every tool schema. Recall is 1.0 by construction
        (the gold tool is always present) but token cost grows with the catalog.
      * ``retrieval``  — inject only the top-k retrieved tools. Recall is measured;
        token cost stays roughly flat as the catalog grows.

    (The ``active`` MCP-Zero strategy needs the model in the loop, so it is only
    evaluated in the online benchmark.)
    """
    tasks = tasks or BENCHMARK_TASKS
    router = SemanticRouter(servers)
    all_tools = get_all_tools(servers)
    all_tools_tokens = calculate_total_tokens(all_tools)

    per_task = []
    retrieval_hits = 0
    retrieval_tokens_sum = 0
    for t in tasks:
        retrieved = router.retrieve(t["task"], top_k)
        retrieved_names = [tool.name for tool in retrieved]
        hit = any(g in retrieved_names for g in t["gold_tools"])
        retrieval_hits += int(hit)
        retrieval_tokens_sum += calculate_total_tokens(retrieved)
        per_task.append({
            "name": t["name"],
            "gold_tools": t["gold_tools"],
            "retrieved": retrieved_names,
            "hit": hit,
        })

    n = len(tasks)
    return {
        "num_tools": len(all_tools),
        "top_k": top_k,
        "per_task": per_task,
        "strategies": {
            "all-tools": {
                "tools_in_context": len(all_tools),
                "avg_schema_tokens": all_tools_tokens,
                "recall": 1.0,
            },
            "retrieval": {
                "tools_in_context": top_k,
                "avg_schema_tokens": retrieval_tokens_sum / n,
                "recall": retrieval_hits / n,
            },
        },
    }

config.py

"""Configuration for Active Tool Selection Agent."""
import os
from dotenv import load_dotenv

load_dotenv()

# LLM Configuration
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-5.6-luna")


def _map_model_for_openrouter(model: str) -> str:
    """Map a plain model id onto OpenRouter's `provider/model` form.

    Ids that already contain "/" pass through unchanged; gpt-*/o1-*/o3-*/o4-*
    become openai/…; claude-* becomes anthropic/claude-opus-4.8.
    """
    if "/" in model:
        return model
    m = model.lower()
    if m.startswith(("gpt-", "o1-", "o3-", "o4-")):
        return f"openai/{model}"
    if m.startswith("claude-"):
        return "anthropic/claude-opus-4.8"
    return model


# Universal fallback + gpt-5.x preference: route through OpenRouter when no direct
# OPENAI_API_KEY is configured, OR when the model is a gpt-5.x id (incl. gpt-5.6*)
# which needs OpenAI org-verification on the direct API. Explicit OPENAI_BASE_URL /
# OPENAI_MODEL overrides are kept.
_OR_KEY = os.getenv("OPENROUTER_API_KEY")
if _OR_KEY and (not OPENAI_API_KEY or OPENAI_MODEL.lower().startswith("gpt-5")):
    OPENAI_API_KEY = _OR_KEY
    if not os.getenv("OPENAI_BASE_URL"):
        OPENAI_BASE_URL = "https://openrouter.ai/api/v1"
    OPENAI_MODEL = _map_model_for_openrouter(OPENAI_MODEL)

# Agent Configuration
AGENT_TEMPERATURE = 0.7
MAX_TOOL_REQUESTS = 5  # Maximum number of tool discovery iterations

# Semantic Routing Configuration
SIMILARITY_THRESHOLD = 0.15  # Minimum similarity score for tool matching
TOP_K_SERVERS = 3  # Number of top servers to search
TOP_K_TOOLS = 5  # Number of top tools to return per server

demo_comparison.py

"""
Comparison Demo: Active vs Passive Tool Selection.

Demonstrates the efficiency gains of active tool discovery compared to
traditional passive tool injection approach.
"""

import argparse
import json
import time

from tabulate import tabulate
from agent import ActiveToolAgent, PassiveToolAgent, RetrievalToolAgent
from tool_knowledge_base import create_tool_knowledge_base, calculate_total_tokens
import benchmark
import config


def print_section(title: str):
    """Print a formatted section header."""
    print("\n" + "=" * 80)
    print(f"  {title}")
    print("=" * 80 + "\n")


def run_comparison_demo():
    """Run side-by-side comparison of active vs passive approaches."""

    print_section("Active Tool Discovery vs Passive Tool Injection Comparison")

    # Show knowledge base statistics
    servers = create_tool_knowledge_base()
    all_tools = []
    for server in servers:
        all_tools.extend(server.tools)

    total_tokens = calculate_total_tokens(all_tools)

    print("📊 Tool Knowledge Base Statistics:")
    print(f"   • Total Servers: {len(servers)}")
    print(f"   • Total Tools: {len(all_tools)}")
    print(f"   • Estimated tokens for all tool schemas: ~{total_tokens:,}")
    print()

    # Test tasks
    test_tasks = [
        {
            "name": "GitHub Repository Search",
            "task": "Find popular Python machine learning repositories on GitHub with more than 10k stars"
        },
        {
            "name": "File System Operation",
            "task": "Read the configuration file at /etc/app/config.json and list all API keys"
        },
        {
            "name": "Data Analytics",
            "task": "Calculate summary statistics (mean, median, std) for the sales data in the last quarter"
        },
        {
            "name": "Multi-Domain Task",
            "task": "Clone the repository, analyze the code files, and generate a visualization of code complexity metrics"
        }
    ]

    results = []

    for test in test_tasks:
        print(f"\n🔍 Testing: {test['name']}")
        print(f"   Task: {test['task']}")
        print()

        # Test with Active Agent
        print("   [Active Agent] Executing...")
        active_agent = ActiveToolAgent()
        active_result = active_agent.execute_task(test['task'])

        # Test with Passive Agent
        print("   [Passive Agent] Executing...")
        passive_agent = PassiveToolAgent()
        passive_result = passive_agent.execute_task(test['task'])

        # Calculate efficiency metrics
        token_reduction = (1 - active_result['metrics']['tokens_used'] / 
                          passive_result['metrics']['tokens_used']) * 100

        tools_loaded_active = active_result['metrics']['tools_loaded']
        tools_loaded_passive = passive_result['metrics']['tools_loaded']

        results.append({
            'Task': test['name'],
            'Active Tokens': f"{active_result['metrics']['tokens_used']:,}",
            'Passive Tokens': f"{passive_result['metrics']['tokens_used']:,}",
            'Token Reduction': f"{token_reduction:.1f}%",
            'Active Tools': tools_loaded_active,
            'Passive Tools': tools_loaded_passive,
            'Tool Reduction': f"{(1 - tools_loaded_active/tools_loaded_passive)*100:.1f}%"
        })

        print(f"   ✓ Active: {active_result['metrics']['tokens_used']:,} tokens, {tools_loaded_active} tools")
        print(f"   ✓ Passive: {passive_result['metrics']['tokens_used']:,} tokens, {tools_loaded_passive} tools")
        print(f"   💡 Reduction: {token_reduction:.1f}% tokens saved")

    # Display results table
    print_section("Comparison Results")
    print(tabulate(results, headers='keys', tablefmt='grid'))

    # Calculate averages
    avg_token_reduction = sum(
        float(r['Token Reduction'].rstrip('%')) for r in results
    ) / len(results)

    print(f"\n📈 Summary:")
    print(f"   • Average token reduction: {avg_token_reduction:.1f}%")
    print(f"   • Active approach: Loads only {results[0]['Active Tools']} tools on average")
    print(f"   • Passive approach: Loads all {results[0]['Passive Tools']} tools upfront")
    print()
    print("💡 Key Insights:")
    print("   • Active tool discovery maintains minimal context footprint")
    print("   • Significant token savings (80-98% in typical scenarios)")
    print("   • Agent autonomy preserved - discovers tools as needed")
    print("   • Scales efficiently as tool ecosystem grows")


def demo_active_discovery_process():
    """Demonstrate the active discovery process in detail."""

    print_section("Active Tool Discovery Process Demonstration")

    task = "Search for Python repositories on GitHub and analyze their README files"

    print(f"📝 Task: {task}\n")

    agent = ActiveToolAgent()
    result = agent.execute_task(task)

    print("🔄 Discovery Process:")
    print(f"   • Tool requests made: {result['metrics']['tool_requests']}")
    print(f"   • Tools loaded: {result['metrics']['tools_loaded']}")
    print(f"   • API calls: {result['metrics']['api_calls']}")
    print(f"   • Total tokens: {result['metrics']['tokens_used']:,}")
    print()

    print("🛠️  Tools Discovered:")
    for i, tool in enumerate(result['tools_loaded'], 1):
        print(f"   {i}. {tool}")
    print()

    print("💬 Conversation Flow:")
    for i, msg in enumerate(result['conversation'], 1):
        role = msg['role'].upper()
        content = msg.get('content', '[Tool Call]')
        if content and len(content) > 100:
            content = content[:100] + "..."
        print(f"   {i}. [{role}] {content}")
    print()

    print("✅ Final Response:")
    print(f"   {result['response']}")


def demo_semantic_routing():
    """Demonstrate hierarchical semantic routing."""

    print_section("Hierarchical Semantic Routing Demonstration")

    from semantic_router import SemanticRouter

    servers = create_tool_knowledge_base()
    router = SemanticRouter(servers)

    test_queries = [
        "I need to search for repositories on GitHub",
        "Read a file from the local filesystem",
        "Query the database for user information",
        "Send an email notification to the team",
        "Deploy the application to production environment"
    ]

    print("🎯 Testing semantic routing for various requests:\n")

    for query in test_queries:
        print(f"📌 Request: '{query}'")

        details = router.get_routing_details(query, top_k_servers=2, top_k_tools=3)

        print("   Stage 1 - Server Routing:")
        for server in details['stage1_servers']:
            print(f"      • {server['name']}: {server['score']:.3f}")

        print("   Stage 2 - Tool Routing:")
        for tool in details['final_tools']:
            print(f"      • {tool['name']} ({tool['server']}): {tool['score']:.3f}")
        print()


def demo_iterative_capability_extension():
    """Demonstrate iterative capability extension."""

    print_section("Iterative Capability Extension Demonstration")

    print("🎯 Complex Multi-Step Task:")
    task = """Perform a comprehensive analysis:
1. Search GitHub for Python data science repositories
2. Download the top repository
3. Analyze the code structure
4. Generate visualization of dependencies
5. Send summary report via email"""

    print(f"{task}\n")

    agent = ActiveToolAgent()
    result = agent.execute_task(task)

    print("📊 Capability Extension Timeline:")
    print(f"   • Initial tools: 0")
    print(f"   • Tools after request 1: GitHub tools")
    print(f"   • Tools after request 2: Filesystem + GitHub")
    print(f"   • Tools after request 3: Analytics + Filesystem + GitHub")
    print(f"   • Tools after request 4: Communication + Analytics + Filesystem + GitHub")
    print()
    print(f"   Total tool requests: {result['metrics']['tool_requests']}")
    print(f"   Final toolchain size: {result['metrics']['tools_loaded']} tools")
    print()
    print("💡 The agent iteratively built a cross-domain toolchain as task understanding evolved!")


def run_offline_benchmark(servers, top_k: int, scaling: bool = True) -> dict:
    """
    Deterministic (no-API) strategy comparison: retrieval recall vs token cost.

    This is the heart of the experiment and runs without any API key. It shows
    that as the tool catalog grows, injecting all tools makes context token cost
    explode, while on-demand retrieval keeps cost roughly flat and still surfaces
    the right tool (recall).
    """
    print_section("Offline Strategy Comparison (deterministic, no API)")

    result = benchmark.evaluate_offline(servers, top_k)
    strat = result['strategies']

    print(f"Benchmark tasks: {len(benchmark.BENCHMARK_TASKS)}   "
          f"Catalog size: {result['num_tools']} tools   Retrieval top-k: {top_k}\n")

    rows = [
        {
            'Strategy': 'all-tools (dump everything)',
            'Tools in context': strat['all-tools']['tools_in_context'],
            'Schema tokens': f"{strat['all-tools']['avg_schema_tokens']:,}",
            'Recall (gold reachable)': f"{strat['all-tools']['recall']*100:.0f}%",
        },
        {
            'Strategy': f'retrieval (top-{top_k})',
            'Tools in context': strat['retrieval']['tools_in_context'],
            'Schema tokens': f"{strat['retrieval']['avg_schema_tokens']:,.0f}",
            'Recall (gold reachable)': f"{strat['retrieval']['recall']*100:.0f}%",
        },
    ]
    print(tabulate(rows, headers='keys', tablefmt='grid'))

    token_saving = (1 - strat['retrieval']['avg_schema_tokens'] /
                    strat['all-tools']['avg_schema_tokens']) * 100
    print(f"\n=> Retrieval keeps {strat['retrieval']['recall']*100:.0f}% recall while cutting "
          f"tool-schema tokens by {token_saving:.1f}% "
          f"({strat['all-tools']['avg_schema_tokens']:,} -> "
          f"{strat['retrieval']['avg_schema_tokens']:,.0f}).")

    # Per-task retrieval detail (which tools were surfaced, and whether the gold hit)
    print("\nPer-task retrieval (top-k tools surfaced for each task):")
    detail_rows = [
        {
            'Task': p['name'],
            'Gold tool': ', '.join(p['gold_tools']),
            'Hit': '✓' if p['hit'] else '✗',
            'Retrieved (top-k)': ', '.join(p['retrieved']),
        }
        for p in result['per_task']
    ]
    print(tabulate(detail_rows, headers='keys', tablefmt='github'))

    scaling_result = None
    if scaling:
        print_section("Scaling: token cost as the catalog grows")
        sizes = [size for size in [50, 100, 200, 400] if size >= result['num_tools']]
        if not sizes or sizes[0] != result['num_tools']:
            sizes = [result['num_tools']] + sizes
        scaling_rows = []
        scaling_result = []
        for size in sizes:
            padded = benchmark.build_catalog(size)
            r = benchmark.evaluate_offline(padded, top_k)
            s = r['strategies']
            scaling_rows.append({
                'Catalog tools': r['num_tools'],
                'all-tools tokens': f"{s['all-tools']['avg_schema_tokens']:,}",
                f'retrieval(top-{top_k}) tokens': f"{s['retrieval']['avg_schema_tokens']:,.0f}",
                'retrieval recall': f"{s['retrieval']['recall']*100:.0f}%",
            })
            scaling_result.append({
                'num_tools': r['num_tools'],
                'all_tools_tokens': s['all-tools']['avg_schema_tokens'],
                'retrieval_tokens': s['retrieval']['avg_schema_tokens'],
                'retrieval_recall': s['retrieval']['recall'],
            })
        print(tabulate(scaling_rows, headers='keys', tablefmt='grid'))
        print("\n=> all-tools token cost grows with the catalog; retrieval stays roughly flat.")

    return {'benchmark': result, 'scaling': scaling_result}


STRATEGY_AGENTS = {
    'all': ('all-tools', PassiveToolAgent),
    'retrieval': ('retrieval', RetrievalToolAgent),
    'active': ('active (MCP-Zero)', ActiveToolAgent),
}


def _build_agent(strategy: str, servers, top_k: int, model: str):
    """Instantiate the agent for a strategy (needs a valid API key)."""
    if strategy == 'retrieval':
        return RetrievalToolAgent(servers=servers, model=model, top_k=top_k)
    _, cls = STRATEGY_AGENTS[strategy]
    return cls(servers=servers, model=model)


def run_online_benchmark(servers, strategies, top_k: int, model: str,
                         tasks=None) -> dict:
    """
    End-to-end benchmark (requires API key): does the model actually CALL the
    ground-truth tool, at what token cost and latency, under each strategy?
    """
    tasks = tasks or benchmark.BENCHMARK_TASKS
    print_section("Online End-to-End Benchmark (requires API)")
    print(f"Model: {model}   Tasks: {len(tasks)}   "
          f"Catalog: {sum(len(s.tools) for s in servers)} tools   "
          f"Retrieval top-k: {top_k}\n")

    agents = {st: _build_agent(st, servers, top_k, model) for st in strategies}

    rows = []
    raw = {}
    for st in strategies:
        label = STRATEGY_AGENTS[st][0]
        agent = agents[st]
        hits = 0
        tokens_sum = 0
        latency_sum = 0.0
        tools_ctx_sum = 0
        per_task = []
        print(f"[{label}] running {len(tasks)} tasks...")
        for t in tasks:
            agent.reset()
            start = time.time()
            res = agent.execute_task(t['task'])
            elapsed = time.time() - start

            called = res['metrics'].get('tools_called', [])
            hit = any(g in called for g in t['gold_tools'])
            hits += int(hit)
            tokens_sum += res['metrics']['tokens_used']
            latency_sum += elapsed
            tools_ctx_sum += res['metrics']['tools_loaded']
            per_task.append({
                'task': t['name'],
                'gold': t['gold_tools'],
                'called': called,
                'hit': hit,
                'tokens': res['metrics']['tokens_used'],
                'latency': round(elapsed, 2),
            })

        n = len(tasks)
        rows.append({
            'Strategy': label,
            'Accuracy (calls gold)': f"{hits/n*100:.0f}%",
            'Avg tools in ctx': f"{tools_ctx_sum/n:.1f}",
            'Avg tokens': f"{tokens_sum/n:,.0f}",
            'Avg latency (s)': f"{latency_sum/n:.2f}",
        })
        raw[st] = {'accuracy': hits / n, 'per_task': per_task}

    print()
    print(tabulate(rows, headers='keys', tablefmt='grid'))
    return raw


def run_single_query(servers, strategies, query: str, top_k: int, model: str) -> dict:
    """Run a single ad-hoc query through the chosen strategies (requires API)."""
    print_section("Single Query")
    print(f"Query: {query}\n")
    raw = {}
    for st in strategies:
        label = STRATEGY_AGENTS[st][0]
        agent = _build_agent(st, servers, top_k, model)
        res = agent.execute_task(query)
        print(f"[{label}]")
        print(f"   tools in context : {res['metrics']['tools_loaded']}")
        print(f"   tools loaded     : {', '.join(res['tools_loaded']) or '(none)'}")
        print(f"   tools called     : {', '.join(res['metrics'].get('tools_called', [])) or '(none)'}")
        print(f"   tokens used      : {res['metrics']['tokens_used']:,}")
        print()
        raw[st] = {
            'tools_loaded': res['tools_loaded'],
            'tools_called': res['metrics'].get('tools_called', []),
            'tokens_used': res['metrics']['tokens_used'],
        }
    return raw


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="demo_comparison.py",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        description=(
            "主动工具选择实验:对比\"把全部工具塞进上下文\"\"按需检索工具\"两类策略。\n"
            "在工具数量增长到上百个时,动态检索(retrieval)在保持召回率的同时大幅降低\n"
            "上下文 token 成本,并减少模型的选择错误。\n\n"
            "策略说明:\n"
            "  all-tools  一次性注入全部工具(传统被动式基线)\n"
            "  retrieval  按任务语义检索 top-k 个工具后再注入(工具检索 / RAG 式)\n"
            "  active     MCP-Zero 式主动发现:模型迭代地请求所需工具\n\n"
            "离线表格(召回率 / token 成本 / 随工具规模的扩展性)无需 API Key 即可运行;\n"
            "端到端准确率与延迟对比需要配置 API Key。"
        ),
        epilog=(
            "示例:\n"
            "  python demo_comparison.py --offline                 # 仅离线对比,无需 API\n"
            "  python demo_comparison.py --offline --num-tools 200 # 扩展到 200 个工具再对比\n"
            "  python demo_comparison.py --strategy compare        # 三种策略端到端对比(需 API)\n"
            "  python demo_comparison.py --query \"部署到生产环境\" --strategy retrieval\n"
            "  python demo_comparison.py --output results.json     # 保存结果为 JSON"
        ),
    )
    parser.add_argument(
        "--strategy", choices=["all", "retrieval", "active", "compare"],
        default="compare",
        help="端到端评测使用的策略;compare 表示三种策略全部对比(默认:compare)",
    )
    parser.add_argument(
        "--query", type=str, default=None,
        help="只对单条查询运行选定策略(需要 API),而不是跑整个基准集",
    )
    parser.add_argument(
        "--num-tools", type=int, default=0, metavar="N",
        help="将工具目录扩充到 N 个(用合成干扰工具补齐,用于观察扩展性);0 表示保持真实目录(默认:0)",
    )
    parser.add_argument(
        "--top-k", type=int, default=config.TOP_K_TOOLS, metavar="K",
        help=f"retrieval 策略检索的工具数量(默认:{config.TOP_K_TOOLS})",
    )
    parser.add_argument(
        "--model", type=str, default=config.OPENAI_MODEL,
        help=f"覆盖使用的 LLM 模型(默认:{config.OPENAI_MODEL})",
    )
    parser.add_argument(
        "--output", type=str, default=None, metavar="PATH",
        help="将结果写入 JSON 文件",
    )
    parser.add_argument(
        "--offline", action="store_true",
        help="仅运行离线确定性对比(召回率/token 成本),不进行任何 API 调用",
    )
    parser.add_argument(
        "--legacy-demos", action="store_true",
        help="额外运行原有的叙事式演示(语义路由、迭代发现等,需要 API)",
    )
    return parser


def _has_api_key() -> bool:
    return bool(config.OPENAI_API_KEY)


def main(argv=None):
    parser = build_parser()
    args = parser.parse_args(argv)

    print("""
╔════════════════════════════════════════════════════════════════════════════╗
║              Active Tool Selection — Strategy Comparison                    ║
║              Inspired by MCP-Zero (arXiv:2506.01056)                        ║
╚════════════════════════════════════════════════════════════════════════════╝
""")

    servers = benchmark.build_catalog(args.num_tools)
    strategies = ["all", "retrieval", "active"] if args.strategy == "compare" else [args.strategy]

    results = {
        'config': {
            'num_tools': sum(len(s.tools) for s in servers),
            'top_k': args.top_k,
            'model': args.model,
            'strategies': strategies,
        }
    }

    # 1) Offline deterministic comparison — always runs, no API needed.
    if not args.query:
        results['offline'] = run_offline_benchmark(servers, args.top_k)

    # 2) Online end-to-end comparison — needs an API key.
    if args.offline:
        print("\n[offline mode] 跳过所有需要 API 的评测。")
    elif not _has_api_key():
        print("\n[提示] 未检测到 OPENAI_API_KEY,跳过端到端评测(准确率/延迟)。")
        print("       配置 .env 后可运行端到端对比;或使用 --offline 显式仅跑离线部分。")
    else:
        if args.query:
            results['single_query'] = run_single_query(
                servers, strategies, args.query, args.top_k, args.model)
        else:
            results['online'] = run_online_benchmark(
                servers, strategies, args.top_k, args.model)

        if args.legacy_demos:
            run_comparison_demo()
            demo_active_discovery_process()
            demo_semantic_routing()
            demo_iterative_capability_extension()

    if args.output:
        with open(args.output, "w", encoding="utf-8") as f:
            json.dump(results, f, ensure_ascii=False, indent=2)
        print(f"\n结果已写入 {args.output}")

    print_section("Takeaway")
    print(
        "当工具数量增长到上百个时,把全部工具塞进上下文既浪费 token 又干扰决策;\n"
        "按需检索把\"工具选择\"问题转化为\"知识检索\"问题——在保持召回率的同时\n"
        "把工具描述的 token 成本压到很低,也减少了模型的选择错误。\n\n"
        "参考:MCP-Zero 论文 (https://arxiv.org/pdf/2506.01056)"
    )


if __name__ == "__main__":
    main()

examples.py

"""
Example use cases demonstrating active tool selection.
"""

from agent import ActiveToolAgent
from semantic_router import SemanticRouter
from tool_knowledge_base import create_tool_knowledge_base


def example_github_workflow():
    """Example: GitHub development workflow."""
    print("\n" + "=" * 70)
    print("Example 1: GitHub Development Workflow")
    print("=" * 70 + "\n")

    agent = ActiveToolAgent()

    task = """I need to:
1. Search for Python testing frameworks on GitHub
2. Find issues labeled 'good-first-issue' in the top repository
3. Create a new branch and make changes
4. Create a pull request"""

    print(f"Task:\n{task}\n")

    result = agent.execute_task(task)

    print(f"\n✅ Tools discovered: {len(result['tools_loaded'])}")
    print(f"   {', '.join(result['tools_loaded'])}")
    print(f"\n📊 Metrics:")
    print(f"   • Tokens used: {result['metrics']['tokens_used']:,}")
    print(f"   • Tool requests: {result['metrics']['tool_requests']}")
    print(f"   • API calls: {result['metrics']['api_calls']}")


def example_data_pipeline():
    """Example: Data processing pipeline."""
    print("\n" + "=" * 70)
    print("Example 2: Data Processing Pipeline")
    print("=" * 70 + "\n")

    agent = ActiveToolAgent()

    task = """Build a data pipeline:
1. Query the database for last month's sales data
2. Calculate summary statistics
3. Create visualizations (bar charts and trend lines)
4. Upload results to cloud storage
5. Send notification email to stakeholders"""

    print(f"Task:\n{task}\n")

    result = agent.execute_task(task)

    print(f"\n✅ Cross-domain toolchain built:")
    for i, tool in enumerate(result['tools_loaded'], 1):
        print(f"   {i}. {tool}")

    print(f"\n📊 Efficiency:")
    print(f"   • Only {len(result['tools_loaded'])} tools loaded (out of 35 available)")
    print(f"   • Token savings: ~90% compared to loading all tools")


def example_devops_automation():
    """Example: DevOps automation task."""
    print("\n" + "=" * 70)
    print("Example 3: DevOps Automation")
    print("=" * 70 + "\n")

    agent = ActiveToolAgent()

    task = """Automate deployment process:
1. Check monitoring metrics for the staging environment
2. If metrics are healthy, trigger production deployment pipeline
3. Monitor deployment progress and logs
4. If any errors occur, automatically rollback
5. Send deployment status notification"""

    print(f"Task:\n{task}\n")

    result = agent.execute_task(task)

    print(f"\n✅ DevOps toolchain assembled:")
    print(f"   Tools: {', '.join(result['tools_loaded'])}")
    print(f"\n💡 Active discovery enabled iterative refinement:")
    print(f"   • Started with monitoring tools")
    print(f"   • Added deployment tools when needed")
    print(f"   • Included notification tools at the end")


def example_semantic_search():
    """Example: Demonstrate semantic search capabilities."""
    print("\n" + "=" * 70)
    print("Example 4: Semantic Tool Search")
    print("=" * 70 + "\n")

    servers = create_tool_knowledge_base()
    router = SemanticRouter(servers)

    queries = [
        "I need to version control my code",
        "Store and retrieve structured data",
        "Make HTTP requests to APIs",
        "Analyze datasets and create graphs",
        "Configure cloud infrastructure"
    ]

    print("Testing semantic understanding of tool requests:\n")

    for query in queries:
        print(f"🔍 Query: '{query}'")
        tools = router.route_request(query, top_k_servers=1, top_k_tools=3)

        if tools:
            print(f"   ✓ Found: {', '.join([t.name for t in tools])}")
        else:
            print(f"   ✗ No matching tools found")
        print()


def example_multi_turn_discovery():
    """Example: Multi-turn conversation with progressive tool discovery."""
    print("\n" + "=" * 70)
    print("Example 5: Multi-Turn Progressive Discovery")
    print("=" * 70 + "\n")

    print("Scenario: Agent progressively discovers tools across multiple turns\n")

    agent = ActiveToolAgent()

    # Turn 1: Initial request
    print("👤 User: Search for machine learning repositories")
    result1 = agent.execute_task("Search for machine learning repositories")
    print(f"🤖 Agent loaded: {', '.join(result1['tools_loaded'][:2])}")
    print()

    # Turn 2: Additional requirements emerge
    print("👤 User: Now download the README files and analyze them")
    result2 = agent.execute_task("Download README files and analyze them")
    print(f"🤖 Agent additionally loaded: filesystem and analytics tools")
    print()

    # Turn 3: Visualization needed
    print("👤 User: Create a visualization comparing repository sizes")
    result3 = agent.execute_task("Create a visualization comparing repository sizes")
    print(f"🤖 Agent additionally loaded: visualization tools")
    print()

    print("💡 Tools were discovered on-demand as the conversation evolved!")
    print("   This demonstrates the iterative capability extension principle.")


def example_efficiency_comparison():
    """Example: Show efficiency comparison with metrics."""
    print("\n" + "=" * 70)
    print("Example 6: Efficiency Comparison")
    print("=" * 70 + "\n")

    from agent import PassiveToolAgent

    task = "List files in the current directory"

    print(f"Task: {task}\n")

    # Active approach
    print("🔄 Active Tool Discovery:")
    active_agent = ActiveToolAgent()
    active_result = active_agent.execute_task(task)
    print(f"   • Tools loaded: {active_result['metrics']['tools_loaded']}")
    print(f"   • Tokens used: {active_result['metrics']['tokens_used']:,}")
    print()

    # Passive approach
    print("📚 Passive Tool Injection:")
    passive_agent = PassiveToolAgent()
    passive_result = passive_agent.execute_task(task)
    print(f"   • Tools loaded: {passive_result['metrics']['tools_loaded']}")
    print(f"   • Tokens used: {passive_result['metrics']['tokens_used']:,}")
    print()

    # Comparison
    reduction = (1 - active_result['metrics']['tokens_used'] / 
                passive_result['metrics']['tokens_used']) * 100

    print(f"📊 Efficiency Gain:")
    print(f"   • Token reduction: {reduction:.1f}%")
    print(f"   • Tool reduction: {active_result['metrics']['tools_loaded']} vs {passive_result['metrics']['tools_loaded']}")
    print()
    print("💡 For simple tasks requiring 1-2 tools, active discovery achieves")
    print("   massive efficiency gains while maintaining full capability!")


if __name__ == "__main__":
    print("""
╔════════════════════════════════════════════════════════════════════════════╗
║                                                                            ║
║                   Active Tool Selection Examples                           ║
║                                                                            ║
╚════════════════════════════════════════════════════════════════════════════╝
    """)

    # Run all examples
    example_github_workflow()
    example_data_pipeline()
    example_devops_automation()
    example_semantic_search()
    example_multi_turn_discovery()
    example_efficiency_comparison()

    print("\n" + "=" * 70)
    print("All examples completed!")
    print("=" * 70 + "\n")

quickstart.py

"""
Quick Start for Active Tool Selection.

Run this script to see a basic demonstration of active tool discovery.
"""

from agent import ActiveToolAgent, PassiveToolAgent
from tool_knowledge_base import create_tool_knowledge_base, calculate_total_tokens


def main():
    print("""
╔════════════════════════════════════════════════════════════════════════════╗
║                                                                            ║
║                  Active Tool Selection - Quick Start                       ║
║                  Inspired by MCP-Zero (arXiv:2506.01056)                  ║
║                                                                            ║
╚════════════════════════════════════════════════════════════════════════════╝

This demonstration shows how active tool discovery enables agents to:
  • Maintain minimal context footprint
  • Actively request tools as needed
  • Scale efficiently with ecosystem growth

""")

    # Show knowledge base info
    print("📚 Tool Knowledge Base:")
    servers = create_tool_knowledge_base()
    total_tools = sum(len(server.tools) for server in servers)
    total_tokens = calculate_total_tokens([tool for server in servers for tool in server.tools])

    print(f"   • Servers: {len(servers)}")
    print(f"   • Total tools: {total_tools}")
    print(f"   • Token cost if all injected: ~{total_tokens:,} tokens")
    print()

    # Example task
    task = "Search for Python web frameworks on GitHub with more than 5000 stars"
    print(f"🎯 Example Task:\n   {task}\n")

    # Test with active agent
    print("=" * 80)
    print("1️⃣  ACTIVE TOOL DISCOVERY")
    print("=" * 80)
    print("\n⏳ Agent is analyzing task and discovering needed tools...\n")

    active_agent = ActiveToolAgent()
    active_result = active_agent.execute_task(task)

    print(f"✅ Task completed with active discovery:\n")
    print(f"   📊 Metrics:")
    print(f"      • Tools loaded: {active_result['metrics']['tools_loaded']} (out of {total_tools})")
    print(f"      • Tokens used: {active_result['metrics']['tokens_used']:,}")
    print(f"      • Tool requests: {active_result['metrics']['tool_requests']}")
    print(f"      • API calls: {active_result['metrics']['api_calls']}")
    print()
    print(f"   🛠️  Tools discovered:")
    for tool in active_result['tools_loaded']:
        print(f"      • {tool}")
    print()

    # Test with passive agent
    print("=" * 80)
    print("2️⃣  PASSIVE TOOL INJECTION (Traditional Approach)")
    print("=" * 80)
    print(f"\n⏳ Agent has all {total_tools} tools pre-loaded...\n")

    passive_agent = PassiveToolAgent()
    passive_result = passive_agent.execute_task(task)

    print(f"✅ Task completed with passive injection:\n")
    print(f"   📊 Metrics:")
    print(f"      • Tools loaded: {passive_result['metrics']['tools_loaded']} (all tools)")
    print(f"      • Tokens used: {passive_result['metrics']['tokens_used']:,}")
    print(f"      • API calls: {passive_result['metrics']['api_calls']}")
    print()

    # Comparison
    print("=" * 80)
    print("3️⃣  COMPARISON")
    print("=" * 80)
    print()

    token_reduction = (1 - active_result['metrics']['tokens_used'] / 
                       passive_result['metrics']['tokens_used']) * 100
    tool_reduction = (1 - active_result['metrics']['tools_loaded'] / 
                      passive_result['metrics']['tools_loaded']) * 100

    print(f"📊 Efficiency Gains:\n")
    print(f"   Token Usage:")
    print(f"      • Active: {active_result['metrics']['tokens_used']:,} tokens")
    print(f"      • Passive: {passive_result['metrics']['tokens_used']:,} tokens")
    print(f"      • Reduction: {token_reduction:.1f}% 🎉")
    print()
    print(f"   Tools Loaded:")
    print(f"      • Active: {active_result['metrics']['tools_loaded']} tools")
    print(f"      • Passive: {passive_result['metrics']['tools_loaded']} tools")
    print(f"      • Reduction: {tool_reduction:.1f}% 🎯")
    print()

    print("=" * 80)
    print("💡 KEY INSIGHTS")
    print("=" * 80)
    print("""
1. Active Discovery maintains agent autonomy
   → Agent decides what tools it needs, when it needs them

2. Massive efficiency gains
   → 80-98% token reduction for typical tasks

3. Scales with ecosystem growth
   → Adding 100 more tools doesn't bloat every request

4. Iterative capability extension
   → Toolchain evolves as task understanding deepens

5. Semantic routing enables precision
   → Tools matched by meaning, not just keywords

""")

    print("🎓 Next Steps:")
    print("   • Run 'python demo_comparison.py' for comprehensive comparison")
    print("   • Run 'python examples.py' for more use cases")
    print("   • See README.md for architecture details")
    print()
    print("📄 Reference: MCP-Zero paper - https://arxiv.org/pdf/2506.01056")
    print()


if __name__ == "__main__":
    main()

semantic_router.py

"""
Hierarchical Semantic Routing for Tool Discovery.

Implements a two-stage algorithm for matching tool requests to relevant tools:
1. Server-level routing: Filter candidate servers by domain/platform
2. Tool-level routing: Rank tools within selected servers by semantic similarity

This approach reduces search complexity while maintaining precision, inspired by MCP-Zero.
"""

from typing import List, Dict, Tuple
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

from tool_knowledge_base import ServerDefinition, ToolDefinition
import config


class SemanticRouter:
    """Hierarchical semantic routing for tool discovery."""

    def __init__(self, servers: List[ServerDefinition]):
        self.servers = servers
        self.server_vectorizer = TfidfVectorizer(stop_words='english')
        self.tool_vectorizers: Dict[str, TfidfVectorizer] = {}

        # Precompute server embeddings
        self._build_server_index()

        # Precompute tool embeddings for each server
        self._build_tool_indices()

    def _build_server_index(self):
        """Build TF-IDF index for servers."""
        server_descriptions = [f"{s.name} {s.description}" for s in self.servers]
        self.server_embeddings = self.server_vectorizer.fit_transform(server_descriptions)

    def _build_tool_indices(self):
        """Build TF-IDF indices for tools within each server."""
        for server in self.servers:
            if not server.tools:
                continue

            tool_descriptions = [
                f"{tool.name} {tool.description}"
                for tool in server.tools
            ]

            vectorizer = TfidfVectorizer(stop_words='english')
            embeddings = vectorizer.fit_transform(tool_descriptions)

            self.tool_vectorizers[server.name] = vectorizer

            # Store embeddings on server for later use
            server._tool_embeddings = embeddings

    def route_request(self, tool_request: str, top_k_servers: int = None, 
                      top_k_tools: int = None) -> List[ToolDefinition]:
        """
        Route a tool request to relevant tools using hierarchical semantic matching.

        Args:
            tool_request: Natural language description of needed tool
            top_k_servers: Number of top servers to search (default from config)
            top_k_tools: Number of tools to return per server (default from config)

        Returns:
            List of relevant tools ranked by relevance
        """
        if top_k_servers is None:
            top_k_servers = config.TOP_K_SERVERS
        if top_k_tools is None:
            top_k_tools = config.TOP_K_TOOLS

        # Stage 1: Server-level routing
        relevant_servers = self._route_to_servers(tool_request, top_k_servers)

        # Stage 2: Tool-level routing within selected servers
        relevant_tools = []
        for server, server_score in relevant_servers:
            tools_with_scores = self._route_to_tools(server, tool_request, top_k_tools)

            # Combine server and tool scores
            for tool, tool_score in tools_with_scores:
                combined_score = 0.3 * server_score + 0.7 * tool_score
                relevant_tools.append((tool, combined_score))

        # Sort by combined score and filter by threshold
        relevant_tools.sort(key=lambda x: x[1], reverse=True)
        relevant_tools = [
            (tool, score) for tool, score in relevant_tools 
            if score >= config.SIMILARITY_THRESHOLD
        ]

        # Return top tools
        return [tool for tool, _ in relevant_tools[:top_k_tools * top_k_servers]]

    def retrieve(self, query: str, top_k: int) -> List[ToolDefinition]:
        """
        Flat top-k tool retrieval across ALL servers (single-shot RAG-style routing).

        Unlike ``route_request`` (which first narrows to a few candidate servers),
        this scores every tool in every server and returns the global top-k. It is the
        most direct embodiment of "turn tool selection into knowledge retrieval": given
        the task description, fetch only the handful of tools most likely to be relevant.

        Args:
            query: Natural language task/request description
            top_k: Number of tools to return

        Returns:
            Up to ``top_k`` tools ranked by combined (server + tool) similarity.
        """
        # Score against every server so no candidate tool is filtered out prematurely.
        relevant_servers = self._route_to_servers(query, len(self.servers))

        scored_tools = []
        for server, server_score in relevant_servers:
            for tool, tool_score in self._route_to_tools(server, query, len(server.tools)):
                combined_score = 0.3 * server_score + 0.7 * tool_score
                scored_tools.append((tool, combined_score))

        scored_tools.sort(key=lambda x: x[1], reverse=True)
        return [tool for tool, _ in scored_tools[:top_k]]

    def _route_to_servers(self, request: str, top_k: int) -> List[Tuple[ServerDefinition, float]]:
        """
        Stage 1: Route request to top-k relevant servers.

        Args:
            request: Tool request description
            top_k: Number of top servers to return

        Returns:
            List of (server, similarity_score) tuples
        """
        # Vectorize the request
        request_vector = self.server_vectorizer.transform([request])

        # Calculate similarities with all servers
        similarities = cosine_similarity(request_vector, self.server_embeddings)[0]

        # Get top-k servers
        top_indices = np.argsort(similarities)[::-1][:top_k]

        return [(self.servers[idx], similarities[idx]) for idx in top_indices]

    def _route_to_tools(self, server: ServerDefinition, request: str, 
                        top_k: int) -> List[Tuple[ToolDefinition, float]]:
        """
        Stage 2: Route request to top-k relevant tools within a server.

        Args:
            server: Server to search within
            request: Tool request description
            top_k: Number of top tools to return

        Returns:
            List of (tool, similarity_score) tuples
        """
        if server.name not in self.tool_vectorizers:
            return []

        vectorizer = self.tool_vectorizers[server.name]
        tool_embeddings = server._tool_embeddings

        # Vectorize the request
        request_vector = vectorizer.transform([request])

        # Calculate similarities with all tools in this server
        similarities = cosine_similarity(request_vector, tool_embeddings)[0]

        # Get top-k tools
        top_indices = np.argsort(similarities)[::-1][:top_k]

        return [(server.tools[idx], similarities[idx]) for idx in top_indices]

    def get_routing_details(self, tool_request: str, top_k_servers: int = None,
                           top_k_tools: int = None) -> Dict:
        """
        Get detailed routing information for debugging/visualization.

        Returns a dictionary with:
        - request: Original request
        - stage1_servers: List of servers with scores
        - stage2_tools: List of tools with scores per server
        - final_tools: Final ranked list of tools
        """
        if top_k_servers is None:
            top_k_servers = config.TOP_K_SERVERS
        if top_k_tools is None:
            top_k_tools = config.TOP_K_TOOLS

        # Stage 1: Server routing
        relevant_servers = self._route_to_servers(tool_request, top_k_servers)

        # Stage 2: Tool routing
        stage2_results = {}
        all_tools = []

        for server, server_score in relevant_servers:
            tools_with_scores = self._route_to_tools(server, tool_request, top_k_tools)
            stage2_results[server.name] = {
                'server_score': server_score,
                'tools': [(tool.name, tool_score) for tool, tool_score in tools_with_scores]
            }

            # Calculate combined scores
            for tool, tool_score in tools_with_scores:
                combined_score = 0.3 * server_score + 0.7 * tool_score
                all_tools.append((tool, combined_score, server.name))

        # Sort and filter
        all_tools.sort(key=lambda x: x[1], reverse=True)
        final_tools = [
            {'name': tool.name, 'server': server, 'score': score}
            for tool, score, server in all_tools[:top_k_tools * top_k_servers]
            if score >= config.SIMILARITY_THRESHOLD
        ]

        return {
            'request': tool_request,
            'stage1_servers': [
                {'name': s.name, 'score': score} 
                for s, score in relevant_servers
            ],
            'stage2_tools': stage2_results,
            'final_tools': final_tools
        }


class StructuredRequestParser:
    """
    Parse structured tool requests from LLM.

    MCP-Zero uses structured requests in format:
    <tool_request>
    server: [platform/domain description]
    tool: [operation description]
    </tool_request>
    """

    @staticmethod
    def parse_request(text: str) -> Dict[str, str]:
        """
        Parse structured tool request from text.

        Returns dict with 'server' and 'tool' fields, or None if not found.
        """
        if '<tool_request>' not in text or '</tool_request>' not in text:
            return None

        start = text.find('<tool_request>')
        end = text.find('</tool_request>')
        request_text = text[start + len('<tool_request>'):end].strip()

        result = {}
        for line in request_text.split('\n'):
            line = line.strip()
            if line.startswith('server:'):
                result['server'] = line[7:].strip()
            elif line.startswith('tool:'):
                result['tool'] = line[5:].strip()

        return result if 'server' in result and 'tool' in result else None

    @staticmethod
    def format_request(server_desc: str, tool_desc: str) -> str:
        """Format a structured tool request."""
        return f"""<tool_request>
server: {server_desc}
tool: {tool_desc}
</tool_request>"""

test_basic.py

"""
Basic tests to verify the active tool selection system works correctly.
Run without API key to test core functionality.
"""

from tool_knowledge_base import create_tool_knowledge_base, calculate_total_tokens, get_all_tools
from semantic_router import SemanticRouter, StructuredRequestParser


def test_knowledge_base():
    """Test that knowledge base loads correctly."""
    print("Testing Knowledge Base...")

    servers = create_tool_knowledge_base()
    all_tools = get_all_tools(servers)

    assert len(servers) == 8, f"Expected 8 servers, got {len(servers)}"
    assert len(all_tools) > 30, f"Expected 30+ tools, got {len(all_tools)}"

    # Check each server has tools
    for server in servers:
        assert len(server.tools) > 0, f"Server {server.name} has no tools"
        assert server.description, f"Server {server.name} missing description"

    # Check tool schemas
    for tool in all_tools:
        schema = tool.to_schema()
        assert 'type' in schema, f"Tool {tool.name} missing type"
        assert 'function' in schema, f"Tool {tool.name} missing function"

    total_tokens = calculate_total_tokens(all_tools)
    print(f"   ✓ Loaded {len(servers)} servers with {len(all_tools)} tools")
    print(f"   ✓ Estimated tokens: {total_tokens:,}")
    print()


def test_semantic_router():
    """Test semantic routing functionality."""
    print("Testing Semantic Router...")

    servers = create_tool_knowledge_base()
    router = SemanticRouter(servers)

    # Test server routing with realistic queries
    test_queries = [
        ("search for GitHub repositories", ["github"]),
        ("read a file from filesystem", ["filesystem"]),
        ("query database for users", ["database"]),
        ("send an email notification", ["communication"]),
        ("deploy to production environment", ["devops"])
    ]

    for query, expected_servers in test_queries:
        tools = router.route_request(query, top_k_servers=3, top_k_tools=3)
        assert len(tools) > 0, f"No tools found for query: {query}"

        # Check that tools are from expected servers
        tool_servers = {tool.server for tool in tools}
        assert any(exp in tool_servers for exp in expected_servers), \
            f"Expected servers {expected_servers}, got {tool_servers} for query: {query}"

    print(f"   ✓ Semantic routing working correctly")
    print(f"   ✓ All test queries matched appropriate servers")
    print()


def test_structured_request_parser():
    """Test structured request parsing."""
    print("Testing Structured Request Parser...")

    # Valid request
    valid_request = """
Some text before
<tool_request>
server: GitHub for repository operations
tool: search repositories by keywords
</tool_request>
Some text after
"""

    parsed = StructuredRequestParser.parse_request(valid_request)
    assert parsed is not None, "Failed to parse valid request"
    assert 'server' in parsed, "Missing server in parsed request"
    assert 'tool' in parsed, "Missing tool in parsed request"
    assert "GitHub" in parsed['server'], "Server description incorrect"
    assert "search" in parsed['tool'], "Tool description incorrect"

    # Invalid request (missing tags)
    invalid_request = "Just some text without proper tags"
    parsed_invalid = StructuredRequestParser.parse_request(invalid_request)
    assert parsed_invalid is None, "Should return None for invalid request"

    # Test formatting
    formatted = StructuredRequestParser.format_request(
        "GitHub operations", 
        "search repositories"
    )
    assert "<tool_request>" in formatted, "Missing opening tag"
    assert "</tool_request>" in formatted, "Missing closing tag"
    assert "server:" in formatted, "Missing server field"
    assert "tool:" in formatted, "Missing tool field"

    print(f"   ✓ Request parsing working correctly")
    print(f"   ✓ Request formatting working correctly")
    print()


def test_routing_details():
    """Test detailed routing information."""
    print("Testing Routing Details...")

    servers = create_tool_knowledge_base()
    router = SemanticRouter(servers)

    query = "I need to search for Python repositories on GitHub"
    details = router.get_routing_details(query, top_k_servers=3, top_k_tools=3)

    assert 'request' in details, "Missing request in details"
    assert 'stage1_servers' in details, "Missing stage1 in details"
    assert 'stage2_tools' in details, "Missing stage2 in details"
    assert 'final_tools' in details, "Missing final_tools in details"

    assert len(details['stage1_servers']) > 0, "No servers in stage1"
    assert len(details['final_tools']) > 0, "No final tools"

    # Check structure
    for server in details['stage1_servers']:
        assert 'name' in server, "Server missing name"
        assert 'score' in server, "Server missing score"
        assert 0 <= server['score'] <= 1, "Server score out of range"

    for tool in details['final_tools']:
        assert 'name' in tool, "Tool missing name"
        assert 'server' in tool, "Tool missing server"
        assert 'score' in tool, "Tool missing score"

    print(f"   ✓ Routing details structure correct")
    print(f"   ✓ Stage 1: {len(details['stage1_servers'])} servers")
    print(f"   ✓ Stage 2: {len(details['final_tools'])} final tools")
    print()


def test_tool_schemas():
    """Test that tool schemas are properly formatted for OpenAI."""
    print("Testing Tool Schemas...")

    servers = create_tool_knowledge_base()
    all_tools = get_all_tools(servers)

    for tool in all_tools:
        schema = tool.to_schema()

        # Check OpenAI function calling format
        assert schema['type'] == 'function', f"Tool {tool.name} has wrong type"
        assert 'function' in schema, f"Tool {tool.name} missing function"

        func = schema['function']
        assert 'name' in func, f"Tool {tool.name} missing name"
        assert 'description' in func, f"Tool {tool.name} missing description"
        assert 'parameters' in func, f"Tool {tool.name} missing parameters"

        params = func['parameters']
        assert params['type'] == 'object', f"Tool {tool.name} parameters not object type"
        assert 'properties' in params, f"Tool {tool.name} missing properties"

    print(f"   ✓ All {len(all_tools)} tool schemas properly formatted")
    print(f"   ✓ Compatible with OpenAI function calling")
    print()


def run_all_tests():
    """Run all basic tests."""
    print("""
╔════════════════════════════════════════════════════════════════════════════╗
║                                                                            ║
║              Active Tool Selection - Basic Tests                           ║
║                                                                            ║
╚════════════════════════════════════════════════════════════════════════════╝
""")

    try:
        test_knowledge_base()
        test_semantic_router()
        test_structured_request_parser()
        test_routing_details()
        test_tool_schemas()

        print("=" * 80)
        print("✅ ALL TESTS PASSED")
        print("=" * 80)
        print()
        print("The active tool selection system is working correctly!")
        print()
        print("Next steps:")
        print("  1. Configure your API key in .env")
        print("  2. Run 'python quickstart.py' for a demonstration")
        print("  3. Run 'python demo_comparison.py' for comprehensive comparison")
        print()

    except AssertionError as e:
        print()
        print("=" * 80)
        print("❌ TEST FAILED")
        print("=" * 80)
        print(f"Error: {e}")
        print()
        raise
    except Exception as e:
        print()
        print("=" * 80)
        print("❌ UNEXPECTED ERROR")
        print("=" * 80)
        print(f"Error: {e}")
        print()
        raise


if __name__ == "__main__":
    run_all_tests()

test_usage_none.py

"""Regression test: agent must tolerate providers that return usage=None.

The OpenAI SDK response object always HAS a `usage` attribute (pydantic
field), but it deserializes as None when the provider omits token accounting.
The old `hasattr(response, 'usage')` guard was therefore ineffective and
`response.usage.total_tokens` raised AttributeError, crashing execute_task.
"""
import os
import sys
from types import SimpleNamespace

os.environ.setdefault("OPENAI_API_KEY", "test-key")  # OpenAI() requires a key at construction
sys.path.insert(0, os.path.dirname(__file__))

from agent import ActiveToolAgent, RetrievalToolAgent, PassiveToolAgent
from tool_knowledge_base import ToolDefinition, ServerDefinition

AGENT_CLASSES = [ActiveToolAgent, RetrievalToolAgent, PassiveToolAgent]


def _catalog():
    tool = ToolDefinition(
        name="demo_tool",
        description="demo tool",
        parameters={"type": "object", "properties": {}},
        server="demo",
    )
    return [ServerDefinition(name="demo", description="demo server", tools=[tool])]


def _client_with_usage(usage):
    """Fake OpenAI client; response mimics the SDK object (usage attr always present)."""
    message = SimpleNamespace(content="final answer", tool_calls=None)
    response = SimpleNamespace(choices=[SimpleNamespace(message=message)], usage=usage)
    completions = SimpleNamespace(create=lambda **kwargs: response)
    return SimpleNamespace(chat=SimpleNamespace(completions=completions))


def test_usage_none_does_not_crash():
    for cls in AGENT_CLASSES:
        agent = cls(servers=_catalog())
        agent.client = _client_with_usage(None)
        result = agent.execute_task("do something trivial")
        assert result["metrics"]["tokens_used"] == 0, cls.__name__


def test_usage_still_accumulated_when_present():
    for cls in AGENT_CLASSES:
        agent = cls(servers=_catalog())
        agent.client = _client_with_usage(SimpleNamespace(total_tokens=42))
        result = agent.execute_task("do something trivial")
        assert result["metrics"]["tokens_used"] == 42, cls.__name__

tool_knowledge_base.py

"""
Tool Knowledge Base - Simulates MCP servers with various tools.

This module defines a comprehensive knowledge base of tools organized by domains (servers),
similar to the MCP (Model Context Protocol) ecosystem. Each server represents a platform
or service domain with specific tools.
"""

from typing import List, Dict, Any


class ToolDefinition:
    """Represents a single tool with its metadata."""

    def __init__(self, name: str, description: str, parameters: Dict[str, Any], server: str):
        self.name = name
        self.description = description
        self.parameters = parameters
        self.server = server

    def to_schema(self) -> Dict[str, Any]:
        """Convert to OpenAI function schema format."""
        return {
            "type": "function",
            "function": {
                "name": self.name,
                "description": self.description,
                "parameters": self.parameters
            }
        }

    def __repr__(self):
        return f"Tool(name={self.name}, server={self.server})"


class ServerDefinition:
    """Represents a server (domain) containing multiple tools."""

    def __init__(self, name: str, description: str, tools: List[ToolDefinition]):
        self.name = name
        self.description = description
        self.tools = tools

    def __repr__(self):
        return f"Server(name={self.name}, tools={len(self.tools)})"


# Define comprehensive tool knowledge base
def create_tool_knowledge_base() -> List[ServerDefinition]:
    """
    Create a comprehensive tool knowledge base organized by servers.

    This simulates the MCP ecosystem with multiple domains:
    - GitHub: Repository management and code operations
    - Filesystem: File system operations
    - Database: Data storage and retrieval
    - Web: HTTP requests and web scraping
    - Analytics: Data analysis and visualization
    - Communication: Email and messaging
    - DevOps: Deployment and monitoring
    - Cloud: Cloud service operations
    """

    servers = []

    # GitHub Server
    github_tools = [
        ToolDefinition(
            name="github_search_repos",
            description="Search for GitHub repositories using keywords, filters, and sorting options",
            parameters={
                "type": "object",
                "properties": {
                    "query": {"type": "string", "description": "Search query (e.g., 'language:python stars:>1000')"},
                    "sort": {"type": "string", "enum": ["stars", "forks", "updated"], "description": "Sort by field"},
                    "per_page": {"type": "integer", "description": "Results per page (max 100)"}
                },
                "required": ["query"]
            },
            server="github"
        ),
        ToolDefinition(
            name="github_create_pr",
            description="Create a pull request in a GitHub repository",
            parameters={
                "type": "object",
                "properties": {
                    "repo": {"type": "string", "description": "Repository name (owner/repo)"},
                    "title": {"type": "string", "description": "PR title"},
                    "body": {"type": "string", "description": "PR description"},
                    "head": {"type": "string", "description": "Branch to merge from"},
                    "base": {"type": "string", "description": "Branch to merge into"}
                },
                "required": ["repo", "title", "head", "base"]
            },
            server="github"
        ),
        ToolDefinition(
            name="github_list_issues",
            description="List issues in a GitHub repository with filtering options",
            parameters={
                "type": "object",
                "properties": {
                    "repo": {"type": "string", "description": "Repository name (owner/repo)"},
                    "state": {"type": "string", "enum": ["open", "closed", "all"], "description": "Issue state"},
                    "labels": {"type": "array", "items": {"type": "string"}, "description": "Filter by labels"}
                },
                "required": ["repo"]
            },
            server="github"
        ),
        ToolDefinition(
            name="github_get_file",
            description="Get contents of a file from a GitHub repository",
            parameters={
                "type": "object",
                "properties": {
                    "repo": {"type": "string", "description": "Repository name (owner/repo)"},
                    "path": {"type": "string", "description": "File path in repository"},
                    "branch": {"type": "string", "description": "Branch name (default: main)"}
                },
                "required": ["repo", "path"]
            },
            server="github"
        ),
        ToolDefinition(
            name="github_create_issue",
            description="Create a new issue in a GitHub repository",
            parameters={
                "type": "object",
                "properties": {
                    "repo": {"type": "string", "description": "Repository name (owner/repo)"},
                    "title": {"type": "string", "description": "Issue title"},
                    "body": {"type": "string", "description": "Issue description"},
                    "labels": {"type": "array", "items": {"type": "string"}, "description": "Issue labels"}
                },
                "required": ["repo", "title"]
            },
            server="github"
        )
    ]
    servers.append(ServerDefinition("github", "GitHub repository management and version control operations", github_tools))

    # Filesystem Server
    filesystem_tools = [
        ToolDefinition(
            name="fs_read_file",
            description="Read the contents of a file from the local filesystem",
            parameters={
                "type": "object",
                "properties": {
                    "path": {"type": "string", "description": "File path to read"},
                    "encoding": {"type": "string", "description": "File encoding (default: utf-8)"}
                },
                "required": ["path"]
            },
            server="filesystem"
        ),
        ToolDefinition(
            name="fs_write_file",
            description="Write content to a file in the local filesystem",
            parameters={
                "type": "object",
                "properties": {
                    "path": {"type": "string", "description": "File path to write"},
                    "content": {"type": "string", "description": "Content to write"},
                    "mode": {"type": "string", "enum": ["w", "a"], "description": "Write mode (w=overwrite, a=append)"}
                },
                "required": ["path", "content"]
            },
            server="filesystem"
        ),
        ToolDefinition(
            name="fs_list_directory",
            description="List files and directories in a given path",
            parameters={
                "type": "object",
                "properties": {
                    "path": {"type": "string", "description": "Directory path to list"},
                    "recursive": {"type": "boolean", "description": "List recursively"},
                    "pattern": {"type": "string", "description": "File pattern filter (e.g., '*.py')"}
                },
                "required": ["path"]
            },
            server="filesystem"
        ),
        ToolDefinition(
            name="fs_delete_file",
            description="Delete a file or directory from the filesystem",
            parameters={
                "type": "object",
                "properties": {
                    "path": {"type": "string", "description": "Path to delete"},
                    "recursive": {"type": "boolean", "description": "Delete directories recursively"}
                },
                "required": ["path"]
            },
            server="filesystem"
        ),
        ToolDefinition(
            name="fs_search_files",
            description="Search for files containing specific text or matching patterns",
            parameters={
                "type": "object",
                "properties": {
                    "path": {"type": "string", "description": "Directory to search in"},
                    "query": {"type": "string", "description": "Text to search for"},
                    "file_pattern": {"type": "string", "description": "File pattern (e.g., '*.py')"}
                },
                "required": ["path", "query"]
            },
            server="filesystem"
        )
    ]
    servers.append(ServerDefinition("filesystem", "Local filesystem operations for reading, writing, and managing files", filesystem_tools))

    # Database Server
    database_tools = [
        ToolDefinition(
            name="db_query",
            description="Execute a SQL query on the database and return results",
            parameters={
                "type": "object",
                "properties": {
                    "sql": {"type": "string", "description": "SQL query to execute"},
                    "database": {"type": "string", "description": "Database name"},
                    "timeout": {"type": "integer", "description": "Query timeout in seconds"}
                },
                "required": ["sql"]
            },
            server="database"
        ),
        ToolDefinition(
            name="db_insert",
            description="Insert data into a database table",
            parameters={
                "type": "object",
                "properties": {
                    "table": {"type": "string", "description": "Table name"},
                    "data": {"type": "object", "description": "Data to insert as key-value pairs"},
                    "database": {"type": "string", "description": "Database name"}
                },
                "required": ["table", "data"]
            },
            server="database"
        ),
        ToolDefinition(
            name="db_update",
            description="Update records in a database table",
            parameters={
                "type": "object",
                "properties": {
                    "table": {"type": "string", "description": "Table name"},
                    "data": {"type": "object", "description": "Data to update"},
                    "where": {"type": "string", "description": "WHERE clause condition"},
                    "database": {"type": "string", "description": "Database name"}
                },
                "required": ["table", "data", "where"]
            },
            server="database"
        ),
        ToolDefinition(
            name="db_delete",
            description="Delete records from a database table",
            parameters={
                "type": "object",
                "properties": {
                    "table": {"type": "string", "description": "Table name"},
                    "where": {"type": "string", "description": "WHERE clause condition"},
                    "database": {"type": "string", "description": "Database name"}
                },
                "required": ["table", "where"]
            },
            server="database"
        ),
        ToolDefinition(
            name="db_schema",
            description="Get schema information for database tables",
            parameters={
                "type": "object",
                "properties": {
                    "table": {"type": "string", "description": "Table name (optional, returns all if omitted)"},
                    "database": {"type": "string", "description": "Database name"}
                },
                "required": []
            },
            server="database"
        )
    ]
    servers.append(ServerDefinition("database", "Database operations for querying and manipulating structured data", database_tools))

    # Web Server
    web_tools = [
        ToolDefinition(
            name="web_get",
            description="Make HTTP GET request to a URL and return the response",
            parameters={
                "type": "object",
                "properties": {
                    "url": {"type": "string", "description": "URL to request"},
                    "headers": {"type": "object", "description": "HTTP headers"},
                    "params": {"type": "object", "description": "Query parameters"}
                },
                "required": ["url"]
            },
            server="web"
        ),
        ToolDefinition(
            name="web_post",
            description="Make HTTP POST request to a URL with data",
            parameters={
                "type": "object",
                "properties": {
                    "url": {"type": "string", "description": "URL to post to"},
                    "data": {"type": "object", "description": "Data to send"},
                    "headers": {"type": "object", "description": "HTTP headers"}
                },
                "required": ["url", "data"]
            },
            server="web"
        ),
        ToolDefinition(
            name="web_scrape",
            description="Scrape and extract data from a web page using CSS selectors",
            parameters={
                "type": "object",
                "properties": {
                    "url": {"type": "string", "description": "URL to scrape"},
                    "selector": {"type": "string", "description": "CSS selector for elements to extract"},
                    "attributes": {"type": "array", "items": {"type": "string"}, "description": "Attributes to extract"}
                },
                "required": ["url", "selector"]
            },
            server="web"
        ),
        ToolDefinition(
            name="web_download",
            description="Download a file from a URL to local filesystem",
            parameters={
                "type": "object",
                "properties": {
                    "url": {"type": "string", "description": "URL to download from"},
                    "destination": {"type": "string", "description": "Local path to save file"},
                    "headers": {"type": "object", "description": "HTTP headers"}
                },
                "required": ["url", "destination"]
            },
            server="web"
        )
    ]
    servers.append(ServerDefinition("web", "HTTP operations for making requests and scraping web content", web_tools))

    # Analytics Server
    analytics_tools = [
        ToolDefinition(
            name="analytics_summarize",
            description="Calculate summary statistics for a dataset",
            parameters={
                "type": "object",
                "properties": {
                    "data": {"type": "array", "description": "Array of numeric values"},
                    "metrics": {"type": "array", "items": {"type": "string"}, "description": "Metrics to calculate (mean, median, std, etc.)"}
                },
                "required": ["data"]
            },
            server="analytics"
        ),
        ToolDefinition(
            name="analytics_visualize",
            description="Create visualizations from data (charts, graphs)",
            parameters={
                "type": "object",
                "properties": {
                    "data": {"type": "object", "description": "Data to visualize"},
                    "chart_type": {"type": "string", "enum": ["line", "bar", "scatter", "pie"], "description": "Type of chart"},
                    "title": {"type": "string", "description": "Chart title"},
                    "output_path": {"type": "string", "description": "Path to save chart image"}
                },
                "required": ["data", "chart_type"]
            },
            server="analytics"
        ),
        ToolDefinition(
            name="analytics_correlation",
            description="Calculate correlation between variables in a dataset",
            parameters={
                "type": "object",
                "properties": {
                    "data": {"type": "object", "description": "Dataset with variables as keys"},
                    "method": {"type": "string", "enum": ["pearson", "spearman"], "description": "Correlation method"}
                },
                "required": ["data"]
            },
            server="analytics"
        ),
        ToolDefinition(
            name="analytics_predict",
            description="Make predictions using machine learning models",
            parameters={
                "type": "object",
                "properties": {
                    "model_type": {"type": "string", "description": "Type of ML model (linear, tree, etc.)"},
                    "features": {"type": "array", "description": "Feature values for prediction"},
                    "trained_model_path": {"type": "string", "description": "Path to trained model file"}
                },
                "required": ["features"]
            },
            server="analytics"
        )
    ]
    servers.append(ServerDefinition("analytics", "Data analysis and visualization tools for statistical operations", analytics_tools))

    # Communication Server
    communication_tools = [
        ToolDefinition(
            name="comm_send_email",
            description="Send an email message to recipients",
            parameters={
                "type": "object",
                "properties": {
                    "to": {"type": "array", "items": {"type": "string"}, "description": "Recipient email addresses"},
                    "subject": {"type": "string", "description": "Email subject"},
                    "body": {"type": "string", "description": "Email body content"},
                    "attachments": {"type": "array", "items": {"type": "string"}, "description": "File paths to attach"}
                },
                "required": ["to", "subject", "body"]
            },
            server="communication"
        ),
        ToolDefinition(
            name="comm_send_slack",
            description="Send a message to a Slack channel or user",
            parameters={
                "type": "object",
                "properties": {
                    "channel": {"type": "string", "description": "Channel name or user ID"},
                    "message": {"type": "string", "description": "Message content"},
                    "thread_ts": {"type": "string", "description": "Thread timestamp for replies"}
                },
                "required": ["channel", "message"]
            },
            server="communication"
        ),
        ToolDefinition(
            name="comm_read_email",
            description="Read emails from inbox with filtering options",
            parameters={
                "type": "object",
                "properties": {
                    "folder": {"type": "string", "description": "Email folder (inbox, sent, etc.)"},
                    "unread_only": {"type": "boolean", "description": "Only return unread emails"},
                    "limit": {"type": "integer", "description": "Maximum number of emails to return"}
                },
                "required": []
            },
            server="communication"
        ),
        ToolDefinition(
            name="comm_schedule_meeting",
            description="Schedule a meeting in calendar",
            parameters={
                "type": "object",
                "properties": {
                    "title": {"type": "string", "description": "Meeting title"},
                    "start_time": {"type": "string", "description": "Start time (ISO format)"},
                    "duration": {"type": "integer", "description": "Duration in minutes"},
                    "attendees": {"type": "array", "items": {"type": "string"}, "description": "Attendee emails"}
                },
                "required": ["title", "start_time", "attendees"]
            },
            server="communication"
        )
    ]
    servers.append(ServerDefinition("communication", "Email, messaging, and calendar tools for communication", communication_tools))

    # DevOps Server
    devops_tools = [
        ToolDefinition(
            name="devops_deploy",
            description="Deploy application to specified environment",
            parameters={
                "type": "object",
                "properties": {
                    "environment": {"type": "string", "enum": ["dev", "staging", "production"], "description": "Target environment"},
                    "version": {"type": "string", "description": "Version to deploy"},
                    "rollback": {"type": "boolean", "description": "Enable auto-rollback on failure"}
                },
                "required": ["environment", "version"]
            },
            server="devops"
        ),
        ToolDefinition(
            name="devops_monitor",
            description="Get monitoring metrics for services and infrastructure",
            parameters={
                "type": "object",
                "properties": {
                    "service": {"type": "string", "description": "Service name to monitor"},
                    "metrics": {"type": "array", "items": {"type": "string"}, "description": "Metrics to retrieve (cpu, memory, etc.)"},
                    "timerange": {"type": "string", "description": "Time range (e.g., '1h', '24h')"}
                },
                "required": ["service"]
            },
            server="devops"
        ),
        ToolDefinition(
            name="devops_logs",
            description="Query and filter application logs",
            parameters={
                "type": "object",
                "properties": {
                    "service": {"type": "string", "description": "Service name"},
                    "level": {"type": "string", "enum": ["debug", "info", "warning", "error"], "description": "Log level filter"},
                    "query": {"type": "string", "description": "Text to search in logs"},
                    "limit": {"type": "integer", "description": "Maximum number of log entries"}
                },
                "required": ["service"]
            },
            server="devops"
        ),
        ToolDefinition(
            name="devops_run_pipeline",
            description="Trigger a CI/CD pipeline execution",
            parameters={
                "type": "object",
                "properties": {
                    "pipeline": {"type": "string", "description": "Pipeline name or ID"},
                    "branch": {"type": "string", "description": "Git branch to build"},
                    "parameters": {"type": "object", "description": "Pipeline parameters"}
                },
                "required": ["pipeline"]
            },
            server="devops"
        )
    ]
    servers.append(ServerDefinition("devops", "DevOps tools for deployment, monitoring, and CI/CD operations", devops_tools))

    # Cloud Server
    cloud_tools = [
        ToolDefinition(
            name="cloud_create_vm",
            description="Create a virtual machine in the cloud",
            parameters={
                "type": "object",
                "properties": {
                    "instance_type": {"type": "string", "description": "VM instance type (e.g., 't2.micro')"},
                    "region": {"type": "string", "description": "Cloud region"},
                    "image_id": {"type": "string", "description": "OS image ID"},
                    "tags": {"type": "object", "description": "Tags for the VM"}
                },
                "required": ["instance_type", "region"]
            },
            server="cloud"
        ),
        ToolDefinition(
            name="cloud_list_resources",
            description="List cloud resources (VMs, storage, databases)",
            parameters={
                "type": "object",
                "properties": {
                    "resource_type": {"type": "string", "enum": ["vm", "storage", "database", "network"], "description": "Type of resource"},
                    "region": {"type": "string", "description": "Cloud region"},
                    "filters": {"type": "object", "description": "Filter criteria"}
                },
                "required": ["resource_type"]
            },
            server="cloud"
        ),
        ToolDefinition(
            name="cloud_upload_storage",
            description="Upload files to cloud storage",
            parameters={
                "type": "object",
                "properties": {
                    "bucket": {"type": "string", "description": "Storage bucket name"},
                    "file_path": {"type": "string", "description": "Local file path"},
                    "destination": {"type": "string", "description": "Destination path in bucket"},
                    "public": {"type": "boolean", "description": "Make file publicly accessible"}
                },
                "required": ["bucket", "file_path"]
            },
            server="cloud"
        ),
        ToolDefinition(
            name="cloud_manage_firewall",
            description="Configure cloud firewall rules",
            parameters={
                "type": "object",
                "properties": {
                    "resource_id": {"type": "string", "description": "Resource ID to configure"},
                    "action": {"type": "string", "enum": ["add", "remove"], "description": "Action to perform"},
                    "rule": {"type": "object", "description": "Firewall rule specification"}
                },
                "required": ["resource_id", "action", "rule"]
            },
            server="cloud"
        )
    ]
    servers.append(ServerDefinition("cloud", "Cloud infrastructure management for VMs, storage, and networking", cloud_tools))

    return servers


def get_all_tools(servers: List[ServerDefinition]) -> List[ToolDefinition]:
    """Get flat list of all tools from all servers."""
    all_tools = []
    for server in servers:
        all_tools.extend(server.tools)
    return all_tools


def count_tokens_in_schema(schema: Dict[str, Any]) -> int:
    """Rough estimation of tokens in a tool schema (approximately 1 token per 4 characters)."""
    import json
    schema_str = json.dumps(schema)
    return len(schema_str) // 4


def calculate_total_tokens(tools: List[ToolDefinition]) -> int:
    """Calculate total tokens required to inject all tool schemas."""
    total = 0
    for tool in tools:
        total += count_tokens_in_schema(tool.to_schema())
    return total