357 lines
12 KiB
Python
357 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import inspect
|
|
import re
|
|
from collections.abc import Awaitable
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Protocol
|
|
|
|
from typing_extensions import Self
|
|
|
|
from ...llm.tool_context import (
|
|
FunctionTool,
|
|
ProviderTool,
|
|
RawFunctionTool,
|
|
Tool,
|
|
ToolContext,
|
|
ToolError,
|
|
Toolset,
|
|
function_tool,
|
|
)
|
|
from ...types import NOT_GIVEN, NotGivenOr
|
|
|
|
|
|
@dataclass
|
|
class SearchItem:
|
|
"""A search candidate derived from a single tool at index time."""
|
|
|
|
source: Tool | Toolset
|
|
name: str
|
|
description: str
|
|
parameters: dict[str, str] = field(default_factory=dict) # {name: description}
|
|
index_data: Any = field(default=None, repr=False)
|
|
|
|
|
|
class SearchStrategy(Protocol):
|
|
def build_index(self, items: list[SearchItem]) -> None | Awaitable[None]: ...
|
|
def search(
|
|
self, query: str, items: list[SearchItem], max_results: int
|
|
) -> list[SearchItem] | Awaitable[list[SearchItem]]: ...
|
|
def cleanup(self) -> None | Awaitable[None]: ...
|
|
|
|
|
|
_DEFAULT_SEARCH_DESCRIPTION = (
|
|
"Search for available tools by describing what you need. "
|
|
"The matching tools will become available for use after calling this tool. "
|
|
"Call this before attempting to use a tool that isn't "
|
|
"currently available."
|
|
)
|
|
|
|
_DEFAULT_QUERY_DESCRIPTION = (
|
|
"keywords to search for in the tool names and descriptions, split by spaces"
|
|
)
|
|
|
|
|
|
class ToolSearchToolset(Toolset):
|
|
"""Wraps tools/toolsets and exposes a tool_search function for dynamic loading.
|
|
|
|
Instead of loading all tool definitions into LLM context, this exposes a single
|
|
``tool_search`` function. When the LLM calls it, matching tools are dynamically
|
|
loaded into the context.
|
|
|
|
Each tool (FunctionTool, RawFunctionTool, ProviderTool) is indexed as its own
|
|
SearchItem. If a matched tool belongs to a Toolset, the entire Toolset is loaded
|
|
atomically.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
id: str,
|
|
tools: list[Tool | Toolset] | None = None,
|
|
max_results: int = 5,
|
|
search_strategy: NotGivenOr[SearchStrategy] = NOT_GIVEN,
|
|
search_description: NotGivenOr[str] = NOT_GIVEN,
|
|
query_description: NotGivenOr[str] = NOT_GIVEN,
|
|
) -> None:
|
|
super().__init__(id=id, tools=tools)
|
|
self._strategy = search_strategy or BM25SearchStrategy()
|
|
self._max_results = max_results
|
|
self._loaded_tools: list[Tool | Toolset] = []
|
|
|
|
self._search_items: list[SearchItem] = []
|
|
self._initialized = False
|
|
self._lock = asyncio.Lock()
|
|
|
|
search_description = search_description or _DEFAULT_SEARCH_DESCRIPTION
|
|
query_description = query_description or _DEFAULT_QUERY_DESCRIPTION
|
|
self._search_tool = function_tool(
|
|
self._handle_search,
|
|
raw_schema={
|
|
"name": "tool_search",
|
|
"description": search_description,
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"query": {"type": "string", "description": query_description}},
|
|
"required": ["query"],
|
|
},
|
|
},
|
|
)
|
|
|
|
@property
|
|
def tools(self) -> list[Tool | Toolset]:
|
|
return [self._search_tool, *self._loaded_tools]
|
|
|
|
async def setup(self, *, reload: bool = False) -> Self:
|
|
await super().setup()
|
|
async with self._lock:
|
|
if not reload and self._initialized:
|
|
return self
|
|
|
|
# setup wrapped toolsets
|
|
toolsets = [t for t in self._tools if isinstance(t, Toolset)]
|
|
if toolsets:
|
|
await asyncio.gather(*(ts.setup() for ts in toolsets))
|
|
|
|
self._search_items = []
|
|
|
|
def _index_tool(tool: Tool | Toolset, source: Tool | Toolset) -> None:
|
|
if isinstance(tool, Toolset):
|
|
tool_ctx = ToolContext([tool])
|
|
for tool in tool_ctx.flatten():
|
|
_index_tool(tool, source)
|
|
elif isinstance(tool, (FunctionTool, RawFunctionTool)):
|
|
self._search_items.append(
|
|
SearchItem(
|
|
name=tool.id,
|
|
description=_get_tool_description(tool),
|
|
parameters=_get_tool_params(tool),
|
|
source=source,
|
|
)
|
|
)
|
|
elif isinstance(tool, ProviderTool):
|
|
self._search_items.append(
|
|
SearchItem(name=tool.id, description="", parameters={}, source=source)
|
|
)
|
|
else:
|
|
raise ValueError(f"Unsupported tool type: {type(tool)}")
|
|
|
|
for tool in self._tools:
|
|
_index_tool(tool, tool)
|
|
|
|
result = self._strategy.build_index(self._search_items)
|
|
if inspect.isawaitable(result):
|
|
await result
|
|
self._initialized = True
|
|
return self
|
|
|
|
async def _handle_search(self, raw_arguments: dict[str, object]) -> str:
|
|
query = str(raw_arguments.get("query", ""))
|
|
tools = await self._search_tools(query)
|
|
if not tools:
|
|
raise ToolError(f"No tools found matching '{query}'.")
|
|
|
|
self._loaded_tools = tools
|
|
return "Tools loaded successfully."
|
|
|
|
async def _search_tools(self, query: str) -> list[Tool | Toolset]:
|
|
if not query:
|
|
raise ToolError("query cannot be empty")
|
|
|
|
results = self._strategy.search(query, self._search_items, self._max_results)
|
|
if inspect.isawaitable(results):
|
|
results = await results
|
|
|
|
return list(dict.fromkeys(result.source for result in results))
|
|
|
|
async def aclose(self) -> None:
|
|
await super().aclose()
|
|
self._initialized = False
|
|
self._search_items.clear()
|
|
self._loaded_tools.clear()
|
|
|
|
result = self._strategy.cleanup()
|
|
if inspect.isawaitable(result):
|
|
await result
|
|
|
|
|
|
def _get_tool_description(tool: FunctionTool | RawFunctionTool) -> str:
|
|
if isinstance(tool, FunctionTool):
|
|
return tool.info.description or ""
|
|
return str(tool.info.raw_schema.get("description", ""))
|
|
|
|
|
|
def _get_tool_params(tool: FunctionTool | RawFunctionTool) -> dict[str, str]:
|
|
if isinstance(tool, FunctionTool):
|
|
from ...llm.utils import function_arguments_to_pydantic_model
|
|
|
|
model = function_arguments_to_pydantic_model(tool)
|
|
return {name: field.description or "" for name, field in model.model_fields.items()}
|
|
|
|
props = tool.info.raw_schema.get("parameters", {}).get("properties", {})
|
|
return {
|
|
name: prop.get("description", "") if isinstance(prop, dict) else ""
|
|
for name, prop in props.items()
|
|
}
|
|
|
|
|
|
class KeywordSearchStrategy:
|
|
"""Keyword search using regex matching.
|
|
|
|
Scoring: name match = 3pts, description match = 2pts, parameter name/desc match = 1pt each.
|
|
"""
|
|
|
|
def build_index(self, items: list[SearchItem]) -> None:
|
|
for item in items:
|
|
item.index_data = {
|
|
"name": item.name.lower(),
|
|
"description": item.description.lower(),
|
|
"parameters": " ".join(f"{k} {v}" for k, v in item.parameters.items()).lower(),
|
|
}
|
|
|
|
def search(self, query: str, items: list[SearchItem], max_results: int) -> list[SearchItem]:
|
|
keywords = list(set(query.lower().split()))
|
|
if not keywords:
|
|
return []
|
|
|
|
scored: list[tuple[float, SearchItem]] = []
|
|
for item in items:
|
|
s = self._score(item, keywords)
|
|
if s > 0:
|
|
scored.append((s, item))
|
|
|
|
scored.sort(key=lambda x: x[0], reverse=True)
|
|
return [item for _, item in scored[:max_results]]
|
|
|
|
def cleanup(self) -> None:
|
|
pass
|
|
|
|
def _score(self, item: SearchItem, keywords: list[str]) -> float:
|
|
score = 0.0
|
|
idx = item.index_data
|
|
if idx is None:
|
|
self.build_index([item])
|
|
idx = item.index_data
|
|
|
|
for kw in keywords:
|
|
try:
|
|
pattern = re.compile(kw)
|
|
except re.error:
|
|
pattern = re.compile(re.escape(kw))
|
|
|
|
if pattern.search(idx["name"]):
|
|
score += 3.0
|
|
if pattern.search(idx["description"]):
|
|
score += 2.0
|
|
if pattern.search(idx["parameters"]):
|
|
score += 1.0
|
|
|
|
return score
|
|
|
|
|
|
class BM25SearchStrategy:
|
|
"""BM25-based search strategy.
|
|
|
|
BM25 ranks items by term frequency, inverse document frequency, and document
|
|
length normalization. Better than simple keyword matching for larger tool
|
|
collections because it down-weights common terms and rewards rare, specific matches.
|
|
|
|
Each SearchItem is treated as a document composed of its name (weighted 3x),
|
|
description (weighted 2x), and parameter names/descriptions (weighted 1x).
|
|
|
|
Args:
|
|
k1: Term frequency saturation parameter. Higher values give more weight to
|
|
repeated terms. Default 1.5.
|
|
b: Length normalization parameter (0-1). Higher values penalize longer
|
|
documents more. Default 0.75.
|
|
"""
|
|
|
|
def __init__(self, *, k1: float = 1.5, b: float = 0.75) -> None:
|
|
self._k1 = k1
|
|
self._b = b
|
|
self._avg_dl: float = 0.0
|
|
self._idf: dict[str, float] = {}
|
|
|
|
def build_index(self, items: list[SearchItem]) -> None:
|
|
import math
|
|
|
|
for item in items:
|
|
tokens = self._tokenize(item)
|
|
# build term frequency map
|
|
tf: dict[str, float] = {}
|
|
for token in tokens:
|
|
tf[token] = tf.get(token, 0.0) + 1.0
|
|
item.index_data = {"tokens": tokens, "tf": tf, "dl": len(tokens)}
|
|
|
|
# compute average document length
|
|
total_dl = sum(item.index_data["dl"] for item in items)
|
|
self._avg_dl = total_dl / len(items) if items else 0.0
|
|
|
|
# compute IDF for all terms
|
|
n = len(items)
|
|
df: dict[str, int] = {}
|
|
for item in items:
|
|
seen: set[str] = set()
|
|
for token in item.index_data["tokens"]:
|
|
if token not in seen:
|
|
df[token] = df.get(token, 0) + 1
|
|
seen.add(token)
|
|
|
|
self._idf = {}
|
|
for term, freq in df.items():
|
|
# standard BM25 IDF: log((N - df + 0.5) / (df + 0.5) + 1)
|
|
self._idf[term] = math.log((n - freq + 0.5) / (freq + 0.5) + 1.0)
|
|
|
|
def search(self, query: str, items: list[SearchItem], max_results: int) -> list[SearchItem]:
|
|
query_terms = query.lower().replace("_", " ").split()
|
|
if not query_terms:
|
|
return []
|
|
|
|
scored: list[tuple[float, SearchItem]] = []
|
|
for item in items:
|
|
s = self._score(item, query_terms)
|
|
if s > 0:
|
|
scored.append((s, item))
|
|
|
|
scored.sort(key=lambda x: x[0], reverse=True)
|
|
return [item for _, item in scored[:max_results]]
|
|
|
|
def cleanup(self) -> None:
|
|
self._idf.clear()
|
|
self._avg_dl = 0.0
|
|
|
|
def _tokenize(self, item: SearchItem) -> list[str]:
|
|
"""Tokenize with field weighting: name 3x, description 2x, parameters 1x."""
|
|
name_tokens = item.name.lower().replace("_", " ").split()
|
|
desc_tokens = item.description.lower().split()
|
|
param_tokens = []
|
|
for k, v in item.parameters.items():
|
|
param_tokens.extend(k.lower().replace("_", " ").split())
|
|
param_tokens.extend(v.lower().split())
|
|
|
|
# weight by repeating tokens
|
|
return name_tokens * 3 + desc_tokens * 2 + param_tokens
|
|
|
|
def _score(self, item: SearchItem, query_terms: list[str]) -> float:
|
|
idx = item.index_data
|
|
assert idx is not None, "index data must be built before scoring"
|
|
|
|
tf = idx["tf"]
|
|
dl = idx["dl"]
|
|
score = 0.0
|
|
|
|
for term in query_terms:
|
|
if term not in self._idf:
|
|
continue
|
|
idf = self._idf[term]
|
|
term_freq = tf.get(term, 0.0)
|
|
# BM25 scoring formula
|
|
numerator = term_freq * (self._k1 + 1.0)
|
|
denominator = term_freq + self._k1 * (
|
|
1.0 - self._b + self._b * dl / self._avg_dl if self._avg_dl > 0 else 1.0
|
|
)
|
|
score += idf * numerator / denominator
|
|
|
|
return score
|