| """ |
| Model-agnostic tool system with semantic search and programmatic calling. |
| |
| Supports Claude, OpenAI, Gemini with: |
| - Dynamic tool loading via semantic search |
| - Embedding-based tool retrieval |
| - Programmatic tool calling (tools called by generated code, not injected) |
| """ |
|
|
| import json |
| import numpy as np |
| from typing import Any, Dict, List, Optional, Callable |
| from dataclasses import dataclass, field |
|
|
|
|
| @dataclass |
| class Tool: |
| """Generic tool representation compatible with multiple LLM providers.""" |
|
|
| name: str |
| description: str |
| function: Callable |
| input_schema: Dict[str, Any] |
| examples: List[Dict[str, Any]] = field(default_factory=list) |
|
|
| def to_dict(self) -> Dict[str, Any]: |
| """Convert to dictionary format.""" |
| return { |
| "name": self.name, |
| "description": self.description, |
| "input_schema": self.input_schema, |
| "examples": self.examples, |
| } |
|
|
| def to_text(self) -> str: |
| """Convert to searchable text for embedding.""" |
| parts = [ |
| f"Tool: {self.name}", |
| f"Description: {self.description}", |
| ] |
|
|
| |
| if "properties" in self.input_schema: |
| parts.append("Parameters:") |
| for param_name, param_info in self.input_schema["properties"].items(): |
| param_desc = param_info.get("description", "") |
| param_type = param_info.get("type", "") |
| parts.append(f" - {param_name} ({param_type}): {param_desc}") |
|
|
| return "\n".join(parts) |
|
|
| def to_claude_format(self) -> Dict[str, Any]: |
| """Convert to Claude tool format.""" |
| return { |
| "name": self.name, |
| "description": self.description, |
| "input_schema": self.input_schema, |
| } |
|
|
| def to_openai_format(self) -> Dict[str, Any]: |
| """Convert to OpenAI function calling format.""" |
| return { |
| "type": "function", |
| "function": { |
| "name": self.name, |
| "description": self.description, |
| "parameters": self.input_schema, |
| } |
| } |
|
|
| def to_gemini_format(self) -> Dict[str, Any]: |
| """Convert to Gemini function calling format.""" |
| return { |
| "name": self.name, |
| "description": self.description, |
| "parameters": self.input_schema, |
| } |
|
|
|
|
| class ToolRegistry: |
| """ |
| Central registry for all tools with embedding support. |
| |
| Features: |
| - Store tools with metadata |
| - Generate embeddings for semantic search (using Qwen3-Embedding-0.6B by default) |
| """ |
|
|
| def __init__(self, embedding_model_name: str = "qwen3-0.6b"): |
| """ |
| Initialize tool registry. |
| |
| Args: |
| embedding_model_name: Name of embedding model (default: qwen3-0.6b) |
| See make_llm.LOCAL_EMBEDDING_MODELS for options. |
| """ |
| self.tools: Dict[str, Tool] = {} |
| self.embeddings: Optional[np.ndarray] = None |
| self.tool_names_ordered: List[str] = [] |
|
|
| |
| self._embedding_model = None |
| self._embedding_model_name = embedding_model_name |
|
|
| @property |
| def embedding_model(self): |
| """Lazy load embedding model on first use.""" |
| if self._embedding_model is None: |
| from .make_llm import make_llm_emb_local |
| self._embedding_model = make_llm_emb_local(self._embedding_model_name) |
| return self._embedding_model |
|
|
| def register_tool(self, tool: Tool) -> None: |
| """Register a tool in the registry.""" |
| self.tools[tool.name] = tool |
| |
| self.embeddings = None |
|
|
| def register_langchain_tool(self, langchain_tool: Any) -> None: |
| """ |
| Register a LangChain tool by converting it to our Tool format. |
| |
| Args: |
| langchain_tool: LangChain tool object with name, description, and func |
| """ |
| |
| input_schema = { |
| "type": "object", |
| "properties": {}, |
| "required": [] |
| } |
|
|
| |
| if hasattr(langchain_tool, 'args_schema') and langchain_tool.args_schema: |
| |
| try: |
| input_schema = langchain_tool.args_schema.model_json_schema() |
| except: |
| pass |
|
|
| |
| tool = Tool( |
| name=getattr(langchain_tool, 'name', langchain_tool.__class__.__name__), |
| description=getattr(langchain_tool, 'description', 'No description available'), |
| function=langchain_tool.func if hasattr(langchain_tool, 'func') else langchain_tool, |
| input_schema=input_schema, |
| ) |
|
|
| self.register_tool(tool) |
|
|
| def build_embeddings(self) -> None: |
| """Generate embeddings for all registered tools.""" |
| if not self.tools: |
| return |
|
|
| |
| self.tool_names_ordered = list(self.tools.keys()) |
| tool_texts = [self.tools[name].to_text() for name in self.tool_names_ordered] |
|
|
| |
| embeddings_list = self.embedding_model.embed_documents(tool_texts) |
| self.embeddings = np.array(embeddings_list) |
|
|
| |
| norms = np.linalg.norm(self.embeddings, axis=1, keepdims=True) |
| self.embeddings = self.embeddings / norms |
|
|
| def get_tool(self, name: str) -> Optional[Tool]: |
| """Get a tool by name.""" |
| return self.tools.get(name) |
|
|
| def list_tools(self) -> List[str]: |
| """List all registered tool names.""" |
| return list(self.tools.keys()) |
|
|
|
|
| class EmbedToolRetriever: |
| """ |
| Embedding-based tool retrieval using semantic similarity. |
| |
| Uses Qwen3-Embedding-0.6B (via ToolRegistry) to embed tool descriptions |
| and find relevant tools via cosine similarity. |
| |
| Note: Core tools (ALWAYS_LOADED_TOOLS) are always included and don't count |
| towards the min_tools/max_tools quota. |
| """ |
|
|
| def __init__( |
| self, |
| registry: ToolRegistry, |
| min_tools: int = 5, |
| max_tools: int = 20, |
| always_loaded_tools: List[str] = None, |
| ): |
| """ |
| Initialize embedding-based tool retriever. |
| |
| Args: |
| registry: ToolRegistry instance |
| min_tools: Minimum number of tools to retrieve (default: 5) |
| max_tools: Maximum number of tools to retrieve (default: 20) |
| always_loaded_tools: Tools to always include (default: ALWAYS_LOADED_TOOLS) |
| """ |
| self.registry = registry |
| self.min_tools = min_tools |
| self.max_tools = max_tools |
| self.always_loaded_tools = always_loaded_tools if always_loaded_tools is not None else ALWAYS_LOADED_TOOLS |
|
|
| def select(self, query: str, skill_tools: Optional[List[str]] = None) -> List[str]: |
| """ |
| Select relevant tools using semantic similarity. |
| |
| Core tools (ALWAYS_LOADED_TOOLS) are always included and don't count |
| towards the min_tools/max_tools quota. |
| |
| Args: |
| query: Natural language query describing needed tools |
| skill_tools: Optional list of tools from matched skill (always included) |
| |
| Returns: |
| List of tool names |
| """ |
| if not self.registry.tools: |
| return [] |
|
|
| |
| if self.registry.embeddings is None: |
| self.registry.build_embeddings() |
|
|
| |
| core_tools = set() |
| for tool_name in self.always_loaded_tools: |
| if tool_name in self.registry.tools: |
| core_tools.add(tool_name) |
|
|
| |
| if skill_tools: |
| for tool_name in skill_tools: |
| if tool_name in self.registry.tools: |
| core_tools.add(tool_name) |
|
|
| |
| query_embedding = np.array(self.registry.embedding_model.embed_query(query)) |
| query_embedding = query_embedding / np.linalg.norm(query_embedding) |
|
|
| |
| similarities = np.dot(self.registry.embeddings, query_embedding) |
|
|
| |
| sorted_indices = np.argsort(similarities)[::-1] |
|
|
| |
| selected = [] |
| for idx in sorted_indices: |
| if len(selected) >= self.max_tools: |
| break |
| tool_name = self.registry.tool_names_ordered[idx] |
| if tool_name not in core_tools: |
| selected.append(tool_name) |
|
|
| |
| if len(selected) < self.min_tools: |
| |
| pass |
|
|
| |
| final_tools = list(core_tools) + selected |
| return final_tools |
|
|
|
|
| |
| |
| ALWAYS_LOADED_TOOLS = [ |
| |
| "execute_python", |
| "execute_bash", |
| |
| "inspect_tool_code", |
| |
| "query_pubmed", |
| "web_search", |
| ] |
|
|
|
|
| class LLMToolSelector: |
| """ |
| LLM-based tool selection using the main agent's model. |
| |
| More accurate than embedding-based retrieval for domain-specific queries. |
| |
| Note: Core tools are always loaded and don't count towards min_tools/max_tools quota. |
| See ALWAYS_LOADED_TOOLS for the list (code execution, inspection, interpretation, |
| preprocessing, literature search, web research). |
| """ |
|
|
| def __init__( |
| self, |
| registry: ToolRegistry, |
| min_tools: int = 5, |
| max_tools: int = 20, |
| always_loaded_tools: List[str] = None, |
| ): |
| """ |
| Initialize LLM tool selector. |
| |
| Args: |
| registry: ToolRegistry instance |
| min_tools: Minimum number of tools to select (default: 5) |
| max_tools: Maximum number of tools to select (default: 20) |
| always_loaded_tools: Tools to always include (default: ALWAYS_LOADED_TOOLS) |
| """ |
| self.registry = registry |
| self.min_tools = min_tools |
| self.max_tools = max_tools |
| self.always_loaded_tools = always_loaded_tools if always_loaded_tools is not None else ALWAYS_LOADED_TOOLS |
| self._llm = None |
|
|
| @property |
| def model(self): |
| """Get the model name from main agent's config.""" |
| try: |
| from . import get_agent_model |
| return get_agent_model() |
| except ImportError: |
| return "unknown" |
|
|
| @property |
| def llm(self): |
| """Lazy load LLM on first use (uses main agent's model).""" |
| if self._llm is None: |
| from . import get_agent_llm |
| self._llm = get_agent_llm() |
| return self._llm |
|
|
| def _build_tool_catalog(self, exclude_tools: Optional[set] = None) -> str: |
| """Build a concise catalog of tools for the LLM. |
| |
| Args: |
| exclude_tools: Set of tool names to exclude from catalog (e.g., core tools) |
| """ |
| exclude = exclude_tools or set() |
| lines = ["Available tools:\n"] |
| for name, tool in self.registry.tools.items(): |
| if name in exclude: |
| continue |
| |
| desc = tool.description.split('.')[0] + '.' |
| lines.append(f"- {name}: {desc}") |
| return "\n".join(lines) |
|
|
| def select(self, query: str, skill_tools: Optional[List[str]] = None) -> List[str]: |
| """ |
| Select relevant tools for a query using LLM. |
| |
| Core tools (see ALWAYS_LOADED_TOOLS) are always included and don't count |
| towards the min_tools/max_tools quota. |
| |
| Args: |
| query: User query describing the task |
| skill_tools: Optional list of tools from matched skill (always included) |
| |
| Returns: |
| List of selected tool names (core tools + skill tools + LLM-selected tools) |
| """ |
| |
| core_tools = set() |
| for tool_name in self.always_loaded_tools: |
| if tool_name in self.registry.tools: |
| core_tools.add(tool_name) |
|
|
| |
| if skill_tools: |
| for tool_name in skill_tools: |
| if tool_name in self.registry.tools: |
| core_tools.add(tool_name) |
|
|
| |
| selected = set() |
|
|
| |
| catalog = self._build_tool_catalog(exclude_tools=core_tools) |
|
|
| |
| prompt = f"""Given a user query, select the most relevant tools from the catalog. |
| |
| USER QUERY: {query} |
| |
| {catalog} |
| |
| INSTRUCTIONS: |
| 1. Select {self.min_tools}-{self.max_tools} tools that would be needed to complete this task |
| 2. Include database/search tools if information gathering is needed |
| 3. Include analysis tools if data processing is needed |
| 4. Include visualization tools if plots are requested |
| 5. Include utility tools that may be helpful (file I/O, validation, etc.) |
| 6. Return ONLY a JSON list of tool names, nothing else |
| |
| Note: Core tools (execute_python, execute_bash, inspect_tool_code, query_pubmed, search_google) are already loaded. |
| |
| SELECTED TOOLS (JSON list of {self.min_tools}-{self.max_tools} tools):""" |
|
|
| try: |
| |
| response = self.llm.invoke(prompt) |
| content = response.content if hasattr(response, 'content') else str(response) |
|
|
| |
| import re |
| |
| match = re.search(r'\[.*?\]', content, re.DOTALL) |
| if match: |
| tool_names = json.loads(match.group()) |
| |
| for name in tool_names: |
| if name in self.registry.tools and name not in core_tools: |
| selected.add(name) |
|
|
| except Exception as e: |
| |
| print(f"LLM tool selection failed: {e}") |
|
|
| |
| selected_list = list(selected)[:self.max_tools] |
|
|
| |
| final_tools = list(core_tools) + selected_list |
| return final_tools |
|
|
| def select_with_reasoning(self, query: str) -> Dict[str, Any]: |
| """ |
| Select tools with explanation (for debugging/transparency). |
| |
| Returns dict with 'tools' and 'reasoning' keys. |
| """ |
| catalog = self._build_tool_catalog() |
|
|
| prompt = f"""Given a user query, select the most relevant tools and explain why. |
| |
| USER QUERY: {query} |
| |
| {catalog} |
| |
| Respond in JSON format: |
| {{ |
| "tools": ["tool1", "tool2", ...], |
| "reasoning": "Brief explanation of why these tools were selected" |
| }}""" |
|
|
| try: |
| response = self.llm.invoke(prompt) |
| content = response.content if hasattr(response, 'content') else str(response) |
|
|
| |
| import re |
| match = re.search(r'\{.*\}', content, re.DOTALL) |
| if match: |
| result = json.loads(match.group()) |
| |
| result['tools'] = [t for t in result.get('tools', []) if t in self.registry.tools] |
| return result |
|
|
| except Exception as e: |
| return {"tools": [], "reasoning": f"Selection failed: {e}"} |
|
|
| return {"tools": [], "reasoning": "Could not parse response"} |
|
|
|
|
| class ToolExecutor: |
| """ |
| Generic tool executor supporting multiple LLM providers. |
| |
| Handles programmatic tool calling where tools are invoked by |
| generated code rather than injected into namespace. |
| """ |
|
|
| def __init__(self, registry: ToolRegistry): |
| """ |
| Initialize tool executor. |
| |
| Args: |
| registry: ToolRegistry instance |
| """ |
| self.registry = registry |
| self.execution_context = {} |
|
|
| def execute_tool(self, tool_name: str, **kwargs) -> Any: |
| """ |
| Execute a tool by name with given arguments. |
| |
| Args: |
| tool_name: Name of tool to execute |
| **kwargs: Tool arguments |
| |
| Returns: |
| Tool execution result |
| """ |
| tool = self.registry.get_tool(tool_name) |
| if tool is None: |
| raise ValueError(f"Tool '{tool_name}' not found in registry") |
|
|
| try: |
| result = tool.function(**kwargs) |
| return result |
| except Exception as e: |
| return {"error": str(e), "tool": tool_name} |
|
|
| def get_tool_function(self, tool_name: str) -> Optional[Callable]: |
| """ |
| Get the executable function for a tool. |
| |
| Used for making tools available in code execution context. |
| """ |
| tool = self.registry.get_tool(tool_name) |
| return tool.function if tool else None |
|
|
| def create_tool_context(self, tool_names: List[str]) -> Dict[str, Callable]: |
| """ |
| Create a dict of tool functions for code execution. |
| |
| Args: |
| tool_names: List of tool names to make available |
| |
| Returns: |
| Dict mapping tool names to their functions |
| """ |
| context = {} |
| for name in tool_names: |
| func = self.get_tool_function(name) |
| if func: |
| context[name] = func |
| return context |
|
|