langchain-dynamic-tools-middleware 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ """LangChain middleware that picks the right tools for each step with local hybrid vector search."""
2
+
3
+ from langchain_dynamic_tools._embeddings import (
4
+ DefaultDenseEmbedder,
5
+ DefaultSparseEmbedder,
6
+ DenseEmbedder,
7
+ LangChainDenseEmbedder,
8
+ SparseEmbedder,
9
+ render_tool_text,
10
+ )
11
+ from langchain_dynamic_tools._index import ToolVectorIndex
12
+ from langchain_dynamic_tools._middleware import DynamicToolSelectorMiddleware
13
+
14
+ __version__ = "0.2.0"
15
+
16
+ __all__ = [
17
+ "DefaultDenseEmbedder",
18
+ "DefaultSparseEmbedder",
19
+ "DenseEmbedder",
20
+ "DynamicToolSelectorMiddleware",
21
+ "LangChainDenseEmbedder",
22
+ "SparseEmbedder",
23
+ "ToolVectorIndex",
24
+ "render_tool_text",
25
+ "__version__",
26
+ ]
@@ -0,0 +1,306 @@
1
+ """Embedding protocols, default local embedders, and tool text rendering.
2
+
3
+ The middleware treats embedders as simple protocols so you can plug in any
4
+ embedding backend. The defaults wrap the local models that ship with zvec's
5
+ extension module: all-MiniLM-L6-v2 for dense vectors and SPLADE for sparse
6
+ vectors. Both run on your machine, no API keys, no network calls after the
7
+ first model download.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from typing import Any, Literal, Protocol, runtime_checkable
14
+
15
+ from langchain_core.embeddings import Embeddings
16
+ from langchain_core.tools import BaseTool
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ SparseEncoding = Literal["query", "document"]
21
+
22
+ _INSTALL_HINT = (
23
+ "The default embedders need the sentence-transformers package. Install it "
24
+ "with: pip install 'langchain-dynamic-tools-middleware[local]'"
25
+ )
26
+
27
+
28
+ @runtime_checkable
29
+ class DenseEmbedder(Protocol):
30
+ """Anything that maps text to a fixed-size float vector."""
31
+
32
+ @property
33
+ def dimension(self) -> int:
34
+ """The dimension of the vectors returned by embed()."""
35
+ ...
36
+
37
+ def embed(self, text: str) -> list[float]:
38
+ """Return the dense embedding for a piece of text."""
39
+ ...
40
+
41
+
42
+ @runtime_checkable
43
+ class SparseEmbedder(Protocol):
44
+ """Anything that maps text to a sparse {dimension index: weight} mapping."""
45
+
46
+ def embed_document(self, text: str) -> dict[int, float]:
47
+ """Return the sparse embedding used when indexing a document."""
48
+ ...
49
+
50
+ def embed_query(self, text: str) -> dict[int, float]:
51
+ """Return the sparse embedding used when searching with a query."""
52
+ ...
53
+
54
+
55
+ class DefaultDenseEmbedder:
56
+ """Dense embedder backed by zvec's all-MiniLM-L6-v2 wrapper (384 dims).
57
+
58
+ The model loads lazily on the first embed call so that constructing the
59
+ middleware stays cheap. Works with any keyword arguments accepted by
60
+ ``zvec.extension.DefaultLocalDenseEmbedding``.
61
+
62
+ Example:
63
+ ```python
64
+ from langchain_dynamic_tools import DefaultDenseEmbedder
65
+
66
+ embedder = DefaultDenseEmbedder()
67
+ vector = embedder.embed("Send an email to the team")
68
+ len(vector) # 384
69
+ ```
70
+ """
71
+
72
+ def __init__(self, **model_kwargs: Any) -> None:
73
+ """Store model kwargs; the model itself loads on first use."""
74
+ self._model_kwargs = model_kwargs
75
+ self._model: Any | None = None
76
+
77
+ @property
78
+ def dimension(self) -> int:
79
+ """Always 384, matching the all-MiniLM-L6-v2 model."""
80
+ return 384
81
+
82
+ def _load_model(self) -> Any:
83
+ if self._model is None:
84
+ try:
85
+ from zvec.extension import DefaultLocalDenseEmbedding
86
+ except ImportError as exc: # pragma: no cover - depends on zvec build
87
+ raise ImportError(_INSTALL_HINT) from exc
88
+ try:
89
+ self._model = DefaultLocalDenseEmbedding(**self._model_kwargs)
90
+ except ImportError as exc:
91
+ raise ImportError(_INSTALL_HINT) from exc
92
+ return self._model
93
+
94
+ def embed(self, text: str) -> list[float]:
95
+ """Embed one piece of text into a 384-dimensional float vector."""
96
+ if not isinstance(text, str):
97
+ msg = f"Expected text to be str, got {type(text).__name__}"
98
+ raise TypeError(msg)
99
+ if not text.strip():
100
+ msg = "Text cannot be empty or whitespace only"
101
+ raise ValueError(msg)
102
+ return [float(value) for value in self._load_model().embed(text)]
103
+
104
+
105
+ class DefaultSparseEmbedder:
106
+ """Sparse embedder backed by zvec's SPLADE wrapper.
107
+
108
+ SPLADE produces lexical sparse vectors that complement the dense model:
109
+ dense catches meaning, sparse catches exact terms like function names.
110
+ Query and document sides use different encodings, as SPLADE expects.
111
+ Both share one underlying model through zvec's class-level cache.
112
+
113
+ Example:
114
+ ```python
115
+ from langchain_dynamic_tools import DefaultSparseEmbedder
116
+
117
+ embedder = DefaultSparseEmbedder()
118
+ vector = embedder.embed_query("query the sql database")
119
+ # {10412: 0.83, 8871: 1.02, ...}
120
+ ```
121
+ """
122
+
123
+ def __init__(self, **model_kwargs: Any) -> None:
124
+ """Store model kwargs; models load lazily on first use."""
125
+ self._model_kwargs = model_kwargs
126
+ self._document_model: Any | None = None
127
+ self._query_model: Any | None = None
128
+
129
+ def _build_model(self, encoding_type: SparseEncoding) -> Any:
130
+ try:
131
+ from zvec.extension import DefaultLocalSparseEmbedding
132
+ except ImportError as exc: # pragma: no cover - depends on zvec build
133
+ raise ImportError(_INSTALL_HINT) from exc
134
+ try:
135
+ return DefaultLocalSparseEmbedding(encoding_type=encoding_type, **self._model_kwargs)
136
+ except ImportError as exc:
137
+ raise ImportError(_INSTALL_HINT) from exc
138
+
139
+ def _load_document_model(self) -> Any:
140
+ if self._document_model is None:
141
+ self._document_model = self._build_model("document")
142
+ return self._document_model
143
+
144
+ def _load_query_model(self) -> Any:
145
+ if self._query_model is None:
146
+ self._query_model = self._build_model("query")
147
+ return self._query_model
148
+
149
+ def embed_document(self, text: str) -> dict[int, float]:
150
+ """Embed text with the document-side encoding, used when indexing."""
151
+ if not isinstance(text, str):
152
+ msg = f"Expected text to be str, got {type(text).__name__}"
153
+ raise TypeError(msg)
154
+ if not text.strip():
155
+ msg = "Text cannot be empty or whitespace only"
156
+ raise ValueError(msg)
157
+ raw = self._load_document_model().embed(text)
158
+ return {int(index): float(weight) for index, weight in raw.items()}
159
+
160
+ def embed_query(self, text: str) -> dict[int, float]:
161
+ """Embed text with the query-side encoding, used when searching."""
162
+ if not isinstance(text, str):
163
+ msg = f"Expected text to be str, got {type(text).__name__}"
164
+ raise TypeError(msg)
165
+ if not text.strip():
166
+ msg = "Text cannot be empty or whitespace only"
167
+ raise ValueError(msg)
168
+ raw = self._load_query_model().embed(text)
169
+ return {int(index): float(weight) for index, weight in raw.items()}
170
+
171
+
172
+ class LangChainDenseEmbedder:
173
+ """Dense embedder backed by any standard LangChain Embeddings object.
174
+
175
+ LangChain's Embeddings interface (``embed_query``/``embed_documents``)
176
+ does not match the DenseEmbedder protocol, so this adapter bridges the
177
+ two: ``embed()`` delegates to ``embed_query()`` (tool texts are short,
178
+ so the query/document distinction does not matter here), and
179
+ ``dimension`` is the explicit value when given, otherwise probed once
180
+ from the first embedding and cached. The probe costs no extra API call
181
+ when the first use is ``embed()`` rather than ``dimension``.
182
+
183
+ ``ToolVectorIndex`` applies this adapter automatically, so you can pass
184
+ e.g. ``OpenAIEmbeddings`` directly as ``dense_embedder``:
185
+
186
+ Example:
187
+ ```python
188
+ from langchain_openai import OpenAIEmbeddings
189
+ from langchain_dynamic_tools import DynamicToolSelectorMiddleware
190
+
191
+ tool_router = DynamicToolSelectorMiddleware(
192
+ tools=all_tools,
193
+ top_k=4,
194
+ dense_embedder=OpenAIEmbeddings(model="text-embedding-3-small"),
195
+ dense_dim=1536,
196
+ )
197
+ ```
198
+ """
199
+
200
+ def __init__(self, embeddings: Embeddings, dimension: int | None = None) -> None:
201
+ """Wrap a LangChain Embeddings object, optionally fixing its dimension."""
202
+ if dimension is not None and dimension < 1:
203
+ msg = f"dimension must be >= 1, got {dimension}"
204
+ raise ValueError(msg)
205
+ self._embeddings = embeddings
206
+ self._explicit_dimension = dimension
207
+ self._probed_dimension: int | None = None
208
+
209
+ @property
210
+ def dimension(self) -> int:
211
+ """Explicit dimension, or the probed embedding length (cached)."""
212
+ if self._explicit_dimension is not None:
213
+ return self._explicit_dimension
214
+ if self._probed_dimension is None:
215
+ self._probed_dimension = len(self._embeddings.embed_query("dimension probe"))
216
+ return self._probed_dimension
217
+
218
+ def embed(self, text: str) -> list[float]:
219
+ """Embed one piece of text via the wrapped model's embed_query()."""
220
+ if not isinstance(text, str):
221
+ msg = f"Expected text to be str, got {type(text).__name__}"
222
+ raise TypeError(msg)
223
+ if not text.strip():
224
+ msg = "Text cannot be empty or whitespace only"
225
+ raise ValueError(msg)
226
+ vector = [float(value) for value in self._embeddings.embed_query(text)]
227
+ if self._explicit_dimension is not None:
228
+ if len(vector) != self._explicit_dimension:
229
+ msg = (
230
+ f"Embedding dimension mismatch: expected {self._explicit_dimension}, "
231
+ f"got {len(vector)}"
232
+ )
233
+ raise ValueError(msg)
234
+ elif self._probed_dimension is None:
235
+ self._probed_dimension = len(vector)
236
+ return vector
237
+
238
+
239
+ def render_tool_text(tool: BaseTool) -> str:
240
+ """Render a tool as one compact string for embedding and storage.
241
+
242
+ The string carries the tool name, its description, and its parameters with
243
+ types, required flags, and per-parameter descriptions. This is the exact
244
+ lexical material a retrieval step needs to tell tools apart.
245
+
246
+ Example:
247
+ ```python
248
+ from langchain_core.tools import tool
249
+ from langchain_dynamic_tools import render_tool_text
250
+
251
+ @tool
252
+ def get_weather(city: str) -> str:
253
+ "Get the current weather for a city."
254
+ ...
255
+
256
+ print(render_tool_text(get_weather))
257
+ # get_weather: Get the current weather for a city.
258
+ # Parameters: city (string, required)
259
+ ```
260
+ """
261
+ description = (tool.description or "No description provided.").strip()
262
+ parts = [f"{tool.name}: {description}"]
263
+ parameters = _render_parameters(tool)
264
+ if parameters:
265
+ parts.append(f"Parameters: {parameters}")
266
+ return " ".join(parts)
267
+
268
+
269
+ def _render_parameters(tool: BaseTool) -> str:
270
+ """Format a tool's arguments as a short readable list.
271
+
272
+ Handles both shapes ``tool.args`` produces across langchain-core
273
+ versions: the flat ``{param: {type, description, default}}`` mapping of
274
+ current versions, and the nested JSON schema with a "properties" key.
275
+ """
276
+ try:
277
+ args = tool.args
278
+ except Exception: # noqa: BLE001 - a broken args schema must not break indexing
279
+ logger.debug("Could not read args schema for tool %r", tool.name)
280
+ return ""
281
+ if not isinstance(args, dict) or not args:
282
+ return ""
283
+
284
+ if isinstance(args.get("properties"), dict):
285
+ properties = args["properties"]
286
+ required_names = set(args.get("required") or [])
287
+ nested = True
288
+ else:
289
+ properties = args
290
+ required_names = set()
291
+ nested = False
292
+
293
+ rendered = []
294
+ for name, spec in properties.items():
295
+ if not isinstance(spec, dict):
296
+ continue
297
+ type_name = str(spec.get("type", "any"))
298
+ # Flat mapping (current langchain-core): optional parameters carry a default.
299
+ is_required = name in required_names if nested else "default" not in spec
300
+ flag = "required" if is_required else "optional"
301
+ piece = f"{name} ({type_name}, {flag})"
302
+ param_description = str(spec.get("description", "")).strip()
303
+ if param_description:
304
+ piece = f"{piece}: {param_description}"
305
+ rendered.append(piece)
306
+ return "; ".join(rendered)
@@ -0,0 +1,365 @@
1
+ """The zvec-backed index that stores tool embeddings and answers hybrid searches.
2
+
3
+ Every tool becomes one zvec document with a dense vector, a sparse vector, and
4
+ the rendered tool text. A search embeds the query both ways, runs both vectors
5
+ through a single zvec query, and fuses the two ranked lists with reciprocal
6
+ rank fusion. zvec is an in-process engine, so the whole index lives on local
7
+ disk and searches take milliseconds with no server to run.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import os
14
+ import threading
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ import zvec
18
+
19
+ from langchain_dynamic_tools._embeddings import (
20
+ DefaultDenseEmbedder,
21
+ DefaultSparseEmbedder,
22
+ DenseEmbedder,
23
+ LangChainDenseEmbedder,
24
+ SparseEmbedder,
25
+ render_tool_text,
26
+ )
27
+
28
+ if TYPE_CHECKING:
29
+ from collections.abc import Sequence
30
+
31
+ from langchain_core.embeddings import Embeddings
32
+ from langchain_core.tools import BaseTool
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ DENSE_FIELD = "denseEmbedding"
37
+ SPARSE_FIELD = "sparseEmbedding"
38
+ TEXT_FIELD = "text"
39
+ DEFAULT_PATH = ".dynamicToolsMiddleware"
40
+ DEFAULT_COLLECTION_NAME = "VectorSearch"
41
+
42
+
43
+ def _default_reranker() -> Any:
44
+ """Build the default reciprocal rank fusion reranker from zvec."""
45
+ from zvec.extension import RrfReRanker
46
+
47
+ return RrfReRanker(rank_constant=60)
48
+
49
+
50
+ def _schema_vectors(schema: Any) -> list[Any]:
51
+ """Return the vector schemas of a collection schema as a list."""
52
+ vectors = schema.vectors
53
+ if isinstance(vectors, (list, tuple)):
54
+ return list(vectors)
55
+ return [vectors]
56
+
57
+
58
+ def _stored_dense_dimension(collection: Any) -> int | None:
59
+ """Read the dense vector dimension from an opened collection, if present."""
60
+ for vector in _schema_vectors(collection.schema):
61
+ if vector.name == DENSE_FIELD:
62
+ dimension = getattr(vector, "dimension", None)
63
+ if dimension:
64
+ return int(dimension)
65
+ return None
66
+
67
+
68
+ class ToolVectorIndex:
69
+ """A local zvec collection holding hybrid embeddings of tool definitions.
70
+
71
+ The index is the storage and retrieval half of the middleware. Give it
72
+ tools, it embeds and upserts them once; give it a query, it returns the
73
+ most similar tool names. Syncs are incremental: unchanged tools are never
74
+ re-embedded, so restarting an application with the same tool set costs
75
+ nothing beyond opening the collection.
76
+
77
+ Example:
78
+ ```python
79
+ from langchain_dynamic_tools import ToolVectorIndex
80
+
81
+ index = ToolVectorIndex(tools=[get_weather, send_email])
82
+ index.search("what is the weather in tokyo", top_k=2)
83
+ # [("get_weather", 0.93), ("convert_currency", 0.12)]
84
+ index.close()
85
+ ```
86
+ """
87
+
88
+ def __init__(
89
+ self,
90
+ tools: Sequence[BaseTool],
91
+ *,
92
+ path: str = DEFAULT_PATH,
93
+ collection_name: str = DEFAULT_COLLECTION_NAME,
94
+ dense_embedder: DenseEmbedder | Embeddings | None = None,
95
+ sparse_embedder: SparseEmbedder | None = None,
96
+ dense_dim: int | None = None,
97
+ reranker: Any | None = None,
98
+ ) -> None:
99
+ """Open (or create) the collection and index the given tools.
100
+
101
+ Args:
102
+ tools: Tools to index right away. May be empty for an index you
103
+ fill in later with sync().
104
+ path: Filesystem path of the zvec collection.
105
+ collection_name: Name recorded in the collection schema.
106
+ dense_embedder: Maps text to dense vectors. Defaults to the local
107
+ all-MiniLM-L6-v2 model (384 dims). A standard LangChain
108
+ Embeddings object (e.g. OpenAIEmbeddings) is adapted
109
+ automatically.
110
+ sparse_embedder: Maps text to sparse vectors. Defaults to the
111
+ local SPLADE model.
112
+ dense_dim: Dense vector dimension. Defaults to the embedder's
113
+ dimension. If an existing collection was built with a
114
+ different dimension, it is rebuilt.
115
+ reranker: zvec reranker that fuses the dense and sparse result
116
+ lists. Defaults to RrfReRanker(rank_constant=60).
117
+
118
+ Raises:
119
+ ValueError: If path is empty or dense_dim is less than 1.
120
+ ValueError: If tools contain duplicate names or empty renders.
121
+ """
122
+ if not isinstance(path, str) or not path.strip():
123
+ msg = "path must be a non-empty string"
124
+ raise ValueError(msg)
125
+ dense: DenseEmbedder
126
+ if dense_embedder is None:
127
+ dense = DefaultDenseEmbedder()
128
+ elif isinstance(dense_embedder, DenseEmbedder):
129
+ dense = dense_embedder
130
+ else:
131
+ from langchain_core.embeddings import Embeddings
132
+
133
+ if not isinstance(dense_embedder, Embeddings):
134
+ msg = (
135
+ "dense_embedder must expose .dimension and .embed(text), or be a "
136
+ f"standard LangChain Embeddings object; got {type(dense_embedder).__name__}"
137
+ )
138
+ raise TypeError(msg)
139
+ # Accept standard LangChain embeddings (e.g. OpenAIEmbeddings)
140
+ # directly; an explicit dense_dim doubles as the adapter's
141
+ # dimension so no probing API call is needed.
142
+ dense = LangChainDenseEmbedder(dense_embedder, dimension=dense_dim)
143
+ self._dense = dense
144
+ self._sparse = sparse_embedder if sparse_embedder is not None else DefaultSparseEmbedder()
145
+ self._dense_dim = dense_dim if dense_dim is not None else self._dense.dimension
146
+ if self._dense_dim < 1:
147
+ msg = f"dense_dim must be >= 1, got {self._dense_dim}"
148
+ raise ValueError(msg)
149
+ self._path = path
150
+ self._collection_name = collection_name
151
+ self._reranker = reranker if reranker is not None else _default_reranker()
152
+ self._lock = threading.Lock()
153
+ self._indexed: dict[str, str] = {}
154
+ self._collection = self._open_or_create()
155
+ if tools:
156
+ self.sync(tools)
157
+
158
+ @property
159
+ def path(self) -> str:
160
+ """Filesystem path of the underlying zvec collection."""
161
+ return self._path
162
+
163
+ @property
164
+ def indexed_names(self) -> set[str]:
165
+ """Names of the tools this instance has synced into the collection."""
166
+ return set(self._indexed)
167
+
168
+ def _build_schema(self) -> Any:
169
+ """Build the collection schema: one dense and one sparse vector field."""
170
+ return zvec.CollectionSchema(
171
+ name=self._collection_name,
172
+ vectors=[
173
+ zvec.VectorSchema(
174
+ name=DENSE_FIELD,
175
+ data_type=zvec.DataType.VECTOR_FP32,
176
+ dimension=self._dense_dim,
177
+ index_param=zvec.HnswIndexParam(metric_type=zvec.MetricType.IP),
178
+ ),
179
+ zvec.VectorSchema(
180
+ name=SPARSE_FIELD,
181
+ data_type=zvec.DataType.SPARSE_VECTOR_FP32,
182
+ index_param=zvec.HnswIndexParam(metric_type=zvec.MetricType.IP),
183
+ ),
184
+ ],
185
+ fields=[zvec.FieldSchema(name=TEXT_FIELD, data_type=zvec.DataType.STRING)],
186
+ )
187
+
188
+ def _open_or_create(self) -> Any:
189
+ """Open the collection at path, creating it if needed.
190
+
191
+ An existing collection built with a different dense dimension is
192
+ destroyed and rebuilt, since its vectors cannot be reused. A path
193
+ that exists but holds no usable collection raises a clear error
194
+ rather than deleting anything, except for one case: an empty
195
+ leftover directory holds no data, so the stub is removed and the
196
+ collection created fresh.
197
+ """
198
+ schema = self._build_schema()
199
+ collection: Any | None = None
200
+ if os.path.exists(self._path):
201
+ try:
202
+ collection = zvec.open(self._path)
203
+ except Exception as open_error:
204
+ if os.path.isdir(self._path) and not os.listdir(self._path):
205
+ os.rmdir(self._path)
206
+ return zvec.create_and_open(path=self._path, schema=schema)
207
+ try:
208
+ collection = zvec.create_and_open(path=self._path, schema=schema)
209
+ except Exception:
210
+ msg = (
211
+ f"Path {self._path!r} exists but is not a usable zvec "
212
+ f"collection (open failed with: {open_error}). Remove the "
213
+ f"path or choose a different one."
214
+ )
215
+ raise RuntimeError(msg) from open_error
216
+ if collection is None:
217
+ return zvec.create_and_open(path=self._path, schema=schema)
218
+ stored_dimension = _stored_dense_dimension(collection)
219
+ if stored_dimension is not None and stored_dimension != self._dense_dim:
220
+ logger.info(
221
+ "Collection at %r has dense dimension %d but the embedder produces %d; "
222
+ "rebuilding the collection",
223
+ self._path,
224
+ stored_dimension,
225
+ self._dense_dim,
226
+ )
227
+ collection.destroy()
228
+ return zvec.create_and_open(path=self._path, schema=schema)
229
+ return collection
230
+
231
+ def _stored_texts(self) -> dict[str, str]:
232
+ """Read the id and text of every document currently stored."""
233
+ texts: dict[str, str] = {}
234
+ with self._collection.iter_docs(include_vector=False) as docs:
235
+ for doc in docs:
236
+ texts[doc.id] = doc.field(TEXT_FIELD) or ""
237
+ return texts
238
+
239
+ def _make_doc(self, name: str, text: str) -> Any:
240
+ """Build one zvec document for a tool from both embeddings."""
241
+ return zvec.Doc(
242
+ id=name,
243
+ vectors={
244
+ DENSE_FIELD: self._dense.embed(text),
245
+ SPARSE_FIELD: self._sparse.embed_document(text),
246
+ },
247
+ fields={TEXT_FIELD: text},
248
+ )
249
+
250
+ def sync(self, tools: Sequence[BaseTool], *, prune: bool = True) -> None:
251
+ """Bring the collection in line with the given tools.
252
+
253
+ New and changed tools are embedded and upserted. With prune=True,
254
+ stored documents whose tool is absent from the list are deleted.
255
+ Unchanged tools are left alone, so re-syncing the same tool set
256
+ embeds nothing.
257
+
258
+ Args:
259
+ tools: The tools the collection should hold afterwards.
260
+ prune: Also delete stored tools missing from the list. Set to
261
+ False when adding tools found in a request on the fly.
262
+
263
+ Raises:
264
+ ValueError: If two tools share a name or one has an empty name.
265
+ """
266
+ wanted: dict[str, str] = {}
267
+ for tool in tools:
268
+ if not tool.name.strip():
269
+ msg = f"Tool {tool.name!r} has an empty name after stripping whitespace"
270
+ raise ValueError(msg)
271
+ text = render_tool_text(tool)
272
+ if tool.name in wanted:
273
+ msg = f"Duplicate tool name {tool.name!r}; tool names must be unique"
274
+ raise ValueError(msg)
275
+ wanted[tool.name] = text
276
+
277
+ with self._lock:
278
+ collection = self._collection
279
+ if collection is None:
280
+ msg = "The index is closed; create a new ToolVectorIndex to reuse it"
281
+ raise RuntimeError(msg)
282
+ existing = self._stored_texts()
283
+ to_delete = [name for name in existing if prune and name not in wanted]
284
+ to_upsert = [
285
+ (name, text)
286
+ for name, text in wanted.items()
287
+ if name not in existing or existing[name] != text
288
+ ]
289
+ if to_delete:
290
+ collection.delete(to_delete)
291
+ for name in to_delete:
292
+ self._indexed.pop(name, None)
293
+ if to_upsert:
294
+ collection.upsert([self._make_doc(name, text) for name, text in to_upsert])
295
+ for name, text in to_upsert:
296
+ self._indexed[name] = text
297
+ collection.flush()
298
+ if to_upsert or to_delete:
299
+ logger.debug(
300
+ "Synced tool index: %d upsert(s), %d deletion(s)",
301
+ len(to_upsert),
302
+ len(to_delete),
303
+ )
304
+
305
+ def search(self, query: str, top_k: int) -> list[tuple[str, float]]:
306
+ """Return the top_k most similar tools as (name, score) pairs, best first.
307
+
308
+ Args:
309
+ query: Natural language text to search with.
310
+ top_k: Maximum number of tools to return.
311
+
312
+ Returns:
313
+ A list of (tool name, fused score) pairs, highest score first.
314
+
315
+ Raises:
316
+ TypeError: If query is not a string.
317
+ ValueError: If top_k is less than 1.
318
+ RuntimeError: If the index has been closed.
319
+ """
320
+ if not isinstance(query, str):
321
+ msg = f"Expected query to be str, got {type(query).__name__}"
322
+ raise TypeError(msg)
323
+ if top_k < 1:
324
+ msg = f"top_k must be >= 1, got {top_k}"
325
+ raise ValueError(msg)
326
+ query = query.strip()
327
+ if not query:
328
+ return []
329
+ collection = self._collection
330
+ if collection is None:
331
+ msg = "The index is closed; create a new ToolVectorIndex to reuse it"
332
+ raise RuntimeError(msg)
333
+ dense_vector = self._dense.embed(query)
334
+ sparse_vector = self._sparse.embed_query(query)
335
+ queries = [zvec.Query(field_name=DENSE_FIELD, vector=dense_vector)]
336
+ if sparse_vector:
337
+ # zvec rejects empty query vectors; skip the sparse clause instead.
338
+ queries.append(zvec.Query(field_name=SPARSE_FIELD, vector=sparse_vector))
339
+ results = collection.query(
340
+ queries=queries,
341
+ topk=top_k,
342
+ reranker=self._reranker,
343
+ )
344
+ hits: list[tuple[str, float]] = []
345
+ for doc in results:
346
+ score = float(doc.score) if doc.score is not None else 0.0
347
+ hits.append((doc.id, score))
348
+ return hits
349
+
350
+ def close(self) -> None:
351
+ """Flush and close the collection, releasing its file lock.
352
+
353
+ Closing an already closed index is a no-op. Do not close while
354
+ searches from other threads are still running.
355
+ """
356
+ with self._lock:
357
+ if self._collection is not None:
358
+ self._collection.close()
359
+ self._collection = None
360
+
361
+ def __enter__(self) -> ToolVectorIndex:
362
+ return self
363
+
364
+ def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
365
+ self.close()