agent-pack-haystack 0.0.1__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,721 @@
1
+ # SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import contextlib
6
+ import inspect
7
+ import json
8
+ from typing import Any
9
+
10
+ from haystack import Document, Pipeline
11
+ from haystack.components.rankers import MetaFieldGroupingRanker
12
+ from haystack.components.retrievers.types import TextRetriever
13
+ from haystack.core.serialization import generate_qualified_class_name
14
+ from haystack.document_stores.types import DocumentStore
15
+ from haystack.tools import ComponentTool, PipelineTool, Tool, Toolset
16
+ from haystack.utils.deserialization import deserialize_component_inplace
17
+
18
+ from haystack_integrations.agent_pack.advanced_rag import prompts
19
+
20
+ # Shared input schema for the retrieval tool. The property names MUST match the retriever's input sockets (component
21
+ # path) / the input_mapping keys (pipeline path). The filter grammar is placed in the `filters` description so the LLM
22
+ # can learn it from the tool spec.
23
+ _RETRIEVAL_TOOL_PARAMS = {
24
+ "type": "object",
25
+ "properties": {
26
+ "query": {
27
+ "type": "string",
28
+ "description": ("The search query: a short phrase or a few keywords describing the documents to retrieve."),
29
+ },
30
+ "filters": {"type": "object", "description": prompts.FILTER_GRAMMAR, "additionalProperties": True},
31
+ },
32
+ "required": ["query"],
33
+ }
34
+
35
+
36
+ def _accumulate_documents(current: list[Document] | None, new: list[Document]) -> list[Document]:
37
+ """
38
+ State handler: merge newly retrieved documents into the accumulated list, deduplicated by id.
39
+
40
+ :param current: The documents accumulated so far (insertion order preserved).
41
+ :param new: The documents from the latest retrieval call.
42
+ :returns: The merged, deduplicated list.
43
+ """
44
+ current = current or []
45
+ seen = {d.id for d in current}
46
+ merged = list(current)
47
+ for doc in new:
48
+ if doc.id not in seen:
49
+ seen.add(doc.id)
50
+ merged.append(doc)
51
+ return merged
52
+
53
+
54
+ def _format_retrieved_documents(documents: list[Document]) -> str:
55
+ """
56
+ Format retrieved documents as the tool-result string: short doc reference + metadata + content.
57
+
58
+ :param documents: The retrieved documents.
59
+ :returns: One block per document, or a message nudging the agent to relax the filters when empty.
60
+ """
61
+ # Length of the short document reference the LLM sees and cites (a prefix of `Document.id`).
62
+ doc_ref_len = 8
63
+
64
+ if not documents:
65
+ return (
66
+ "No documents matched. Try relaxing or removing the filters, or verify the filter "
67
+ "values with the metadata tools."
68
+ )
69
+ blocks = []
70
+ for doc in documents:
71
+ content = (doc.content or "").strip()
72
+ blocks.append(f"[doc {doc.id[:doc_ref_len]}]\n meta: {json.dumps(doc.meta, default=str)}\n {content}")
73
+ return "\n".join(blocks)
74
+
75
+
76
+ def _make_retriever_tool(
77
+ *, retriever: TextRetriever, name: str = "search_documents", description: str | None = None
78
+ ) -> ComponentTool:
79
+ """
80
+ `search_documents` = ComponentTool over a retriever component that accepts `query` and `filters`.
81
+
82
+ The tool exposes `query` plus an optional `filters` parameter whose description teaches the LLM the Haystack
83
+ filter grammar, and accumulates every retrieved document into the agent's `documents` state key.
84
+
85
+ :param retriever: A standalone retriever component (not added to a `Pipeline`) following the `TextRetriever`
86
+ protocol, i.e. its `run` method accepts `query` and `filters` and it returns a `documents` output.
87
+ :param name: The tool name the LLM sees.
88
+ :param description: The tool description the LLM sees. Defaults to a pre-made description of relevance
89
+ search with optional metadata filtering.
90
+ :returns: The retrieval tool.
91
+ """
92
+ input_sockets = getattr(retriever, "__haystack_input__", None)
93
+ input_names = set(input_sockets._sockets_dict.keys()) if input_sockets is not None else set()
94
+ missing = {"query", "filters"} - input_names
95
+ if missing:
96
+ msg = (
97
+ f"{type(retriever).__name__} does not accept the required inputs {sorted(missing)}. "
98
+ f"The retriever must take `query` and `filters`. Wrap embedding retrievers that take a "
99
+ f"`query_embedding` in haystack's `TextEmbeddingRetriever(retriever=..., text_embedder=...)`, "
100
+ f"or pass a retrieval `Pipeline` as the `retriever` instead."
101
+ )
102
+ raise ValueError(msg)
103
+ output_sockets = getattr(retriever, "__haystack_output__", None)
104
+ output_names = set(output_sockets._sockets_dict.keys()) if output_sockets is not None else set()
105
+ if "documents" not in output_names:
106
+ msg = (
107
+ f"{type(retriever).__name__} does not return a `documents` output, which the tool needs to "
108
+ f"collect the retrieved documents."
109
+ )
110
+ raise ValueError(msg)
111
+ return ComponentTool(
112
+ component=retriever,
113
+ name=name,
114
+ description=description or prompts.RETRIEVAL_TOOL_DESCRIPTION,
115
+ parameters=_RETRIEVAL_TOOL_PARAMS,
116
+ outputs_to_string={"source": "documents", "handler": _format_retrieved_documents},
117
+ outputs_to_state={"documents": {"source": "documents", "handler": _accumulate_documents}},
118
+ )
119
+
120
+
121
+ def _make_retrieval_pipeline_tool(
122
+ *,
123
+ pipeline: Pipeline,
124
+ input_mapping: dict[str, list[str]],
125
+ output_mapping: dict[str, str] | None = None,
126
+ name: str = "search_documents",
127
+ description: str | None = None,
128
+ ) -> PipelineTool:
129
+ """
130
+ `search_documents` = PipelineTool over a custom retrieval pipeline.
131
+
132
+ The tool exposes `query` plus an optional `filters` parameter whose description teaches the LLM the Haystack
133
+ filter grammar, and accumulates every retrieved document into the agent's `documents` state key. The wrapped
134
+ pipeline must therefore end up with a `documents` tool output — either through a pipeline output socket named
135
+ `documents`, or through `output_mapping`.
136
+
137
+ :param pipeline: The retrieval pipeline to wrap (e.g. embedder -> retriever, or hybrid retrieval).
138
+ :param input_mapping: Maps the tool inputs to pipeline input sockets. Must have exactly the keys "query" and
139
+ "filters", e.g. `{"query": ["embedder.text"], "filters": ["retriever.filters"]}`.
140
+ :param output_mapping: Optional map of pipeline output sockets to tool outputs,
141
+ e.g. `{"retriever.documents": "documents"}`. Must map one output to "documents" when provided. Can stay unset
142
+ when the pipeline itself exposes a `documents` output socket (each unmapped pipeline output becomes a tool
143
+ output named after its socket).
144
+ :param name: The tool name the LLM sees.
145
+ :param description: The tool description the LLM sees. Defaults to a pre-made description of relevance
146
+ search with optional metadata filtering.
147
+ :returns: The retrieval tool.
148
+ """
149
+ if set(input_mapping) != {"query", "filters"}:
150
+ msg = (
151
+ '`input_mapping` must have exactly the keys "query" and "filters", '
152
+ 'e.g. {"query": ["embedder.text"], "filters": ["retriever.filters"]}.'
153
+ )
154
+ raise ValueError(msg)
155
+ if output_mapping is not None:
156
+ if "documents" not in output_mapping.values():
157
+ msg = (
158
+ '`output_mapping` must map one of the pipeline outputs to "documents", '
159
+ 'e.g. {"retriever.documents": "documents"} — the tool needs it to collect the retrieved documents.'
160
+ )
161
+ raise ValueError(msg)
162
+ else:
163
+ leaf_socket_names = {socket for sockets in pipeline.outputs().values() for socket in sockets}
164
+ if "documents" not in leaf_socket_names:
165
+ msg = (
166
+ "The pipeline does not expose a `documents` output socket. Provide an `output_mapping` that maps "
167
+ 'one of the pipeline outputs to "documents", e.g. {"retriever.documents": "documents"} — the tool '
168
+ "needs it to collect the retrieved documents."
169
+ )
170
+ raise ValueError(msg)
171
+ return PipelineTool(
172
+ pipeline=pipeline,
173
+ name=name,
174
+ description=description or prompts.RETRIEVAL_TOOL_DESCRIPTION,
175
+ input_mapping=input_mapping,
176
+ output_mapping=output_mapping,
177
+ parameters=_RETRIEVAL_TOOL_PARAMS,
178
+ outputs_to_string={"source": "documents", "handler": _format_retrieved_documents},
179
+ outputs_to_state={"documents": {"source": "documents", "handler": _accumulate_documents}},
180
+ )
181
+
182
+
183
+ def _require_store_method(document_store: DocumentStore, method: str) -> None:
184
+ """
185
+ Raise if `document_store` does not implement `method` (the metadata methods are opt-in per store).
186
+
187
+ :param document_store: The document store to check.
188
+ :param method: The name of the method the store must implement.
189
+ :raises ValueError: If the store does not implement the method.
190
+ """
191
+ if not hasattr(document_store, method):
192
+ msg = (
193
+ f"{type(document_store).__name__} does not implement `{method}`, which this tool requires. "
194
+ f"Use a document store that supports metadata introspection."
195
+ )
196
+ raise ValueError(msg)
197
+
198
+
199
+ class ListMetadataFieldsTool(Tool):
200
+ """Tool that lists all metadata fields and their types from a document store."""
201
+
202
+ def __init__(self, document_store: DocumentStore) -> None:
203
+ """
204
+ Create the tool.
205
+
206
+ :param document_store: The document store to inspect. Must implement `get_metadata_fields_info`.
207
+ :raises ValueError: If the store does not implement `get_metadata_fields_info`.
208
+ """
209
+ _require_store_method(document_store, "get_metadata_fields_info")
210
+ self.document_store = document_store
211
+ super().__init__(
212
+ name="list_metadata_fields",
213
+ description=(
214
+ "Returns all metadata fields available on the documents and their types "
215
+ "(e.g. keyword, int, float). Call this FIRST to learn what fields you can filter on. "
216
+ "Returned field names do NOT include the 'meta.' prefix — add it when building a "
217
+ "filter (field 'year' becomes 'meta.year')."
218
+ ),
219
+ parameters={"type": "object", "properties": {}},
220
+ function=self._list_metadata_fields,
221
+ )
222
+
223
+ def _list_metadata_fields(self) -> str:
224
+ """
225
+ List the store's metadata fields and their types.
226
+
227
+ :returns: One line per field with its type, or a note that no fields were found.
228
+ """
229
+ fields_info = self.document_store.get_metadata_fields_info() # type: ignore[attr-defined]
230
+ if not fields_info:
231
+ return "No metadata fields found (the document store may be empty)."
232
+ lines = [f"- {field} ({info.get('type', 'unknown')})" for field, info in sorted(fields_info.items())]
233
+ return "Metadata fields (add the 'meta.' prefix when filtering):\n" + "\n".join(lines)
234
+
235
+ def to_dict(self) -> dict[str, Any]:
236
+ """Serialize the tool to a dictionary."""
237
+ return {
238
+ "type": generate_qualified_class_name(type(self)),
239
+ "data": {"document_store": self.document_store.to_dict()},
240
+ }
241
+
242
+ @classmethod
243
+ def from_dict(cls, data: dict[str, Any]) -> "ListMetadataFieldsTool":
244
+ """
245
+ Deserialize the tool from a dictionary.
246
+
247
+ :param data: The dictionary produced by `to_dict`.
248
+ :returns: The deserialized tool.
249
+ """
250
+ inner_data = data["data"]
251
+ deserialize_component_inplace(inner_data, key="document_store")
252
+ return cls(**inner_data)
253
+
254
+
255
+ class GetMetadataFieldValuesTool(Tool):
256
+ """Tool that returns the distinct values of a metadata field from a document store."""
257
+
258
+ # Cap on the number of values listed per call, protecting the agent's context window from high-cardinality fields
259
+ # (the total count is reported alongside when the store provides one).
260
+ _MAX_LISTED_VALUES = 100
261
+
262
+ def __init__(self, document_store: DocumentStore) -> None:
263
+ """
264
+ Create the tool.
265
+
266
+ :param document_store: The document store to inspect. Must implement `get_metadata_field_unique_values`.
267
+ :raises ValueError: If the store does not implement `get_metadata_field_unique_values`.
268
+ """
269
+ _require_store_method(document_store, "get_metadata_field_unique_values")
270
+ self.document_store = document_store
271
+ super().__init__(
272
+ name="get_metadata_field_values",
273
+ description=(
274
+ "Returns the distinct values of a metadata field. Use it before filtering on a "
275
+ "keyword or boolean field, so your filter uses values that actually exist "
276
+ "(filter values are matched exactly)."
277
+ ),
278
+ parameters={
279
+ "type": "object",
280
+ "properties": {
281
+ "field": {
282
+ "type": "string",
283
+ "description": "The metadata field name, as returned by list_metadata_fields.",
284
+ },
285
+ },
286
+ "required": ["field"],
287
+ },
288
+ function=self._get_metadata_field_values,
289
+ )
290
+
291
+ def _get_metadata_field_values(self, field: str) -> str:
292
+ """
293
+ List the distinct values of a metadata field.
294
+
295
+ :param field: The metadata field name, with or without the 'meta.' prefix (normalized by the document store).
296
+ :returns: The unique values of the field (listing capped for high-cardinality fields).
297
+ """
298
+ method = self.document_store.get_metadata_field_unique_values # type: ignore[attr-defined]
299
+ signature_params = inspect.signature(method).parameters
300
+
301
+ kwargs: dict[str, Any] = {}
302
+ # pgvector declares `search_term` and `from_` without defaults, so pass their neutral values when present.
303
+ if "search_term" in signature_params:
304
+ kwargs["search_term"] = None
305
+ if "from_" in signature_params:
306
+ kwargs["from_"] = 0
307
+ # Fetch up to the listing cap: without an explicit size, several stores return as few as 10 values by
308
+ # default, while others would fetch thousands only for us to drop them. Most paginating stores call the
309
+ # parameter `size` (Chroma, Weaviate, OpenSearch, Elasticsearch, ...); Qdrant calls it `limit`; some take
310
+ # neither and return everything (FAISS, AlloyDB, IBM DB).
311
+ requested_size: int | None = None
312
+ if "size" in signature_params:
313
+ kwargs["size"] = requested_size = self._MAX_LISTED_VALUES
314
+ elif "limit" in signature_params:
315
+ kwargs["limit"] = requested_size = self._MAX_LISTED_VALUES
316
+
317
+ result = method(field, **kwargs)
318
+ values, total = self._normalize_unique_values_result(result, requested_size=requested_size)
319
+ return self._format_field_values(field, values, total)
320
+
321
+ @staticmethod
322
+ def _normalize_unique_values_result(result: Any, *, requested_size: int | None) -> tuple[list[Any], int | None]:
323
+ """
324
+ Normalize the return shapes of `get_metadata_field_unique_values` across document stores.
325
+
326
+ The method is not standardized; three dialects exist:
327
+
328
+ - A `(values, total)` tuple (InMemory, Chroma, Weaviate, pgvector, Pinecone, Astra, ...): the int is the
329
+ total number of unique values, computed by the store independently of the requested page size.
330
+ - A `(values, cursor)` tuple from cursor-paginating stores (OpenSearch, Elasticsearch, FalkorDB): a dict
331
+ pointing at the next page sits in place of the count. A non-None cursor means more values exist and the
332
+ total is unknown; None means the returned values are the complete set.
333
+ - A bare list of values without any count. FAISS, AlloyDB and IBM DB return the complete value set, but
334
+ Qdrant silently truncates to its `limit` parameter — so a full page (`len(values) == requested_size`)
335
+ may be cut off and the total counts as unknown then.
336
+
337
+ :param result: The raw return value of the store method.
338
+ :param requested_size: The page size passed to the store's `size`/`limit` parameter, or None when the
339
+ store takes neither and returns everything.
340
+ :returns: The values, plus the total number of unique values — None when the store did not report a
341
+ total and more values may exist beyond the returned ones.
342
+ """
343
+ if isinstance(result, tuple):
344
+ values, second = list(result[0]), result[1]
345
+ if isinstance(second, int):
346
+ return values, second
347
+ return values, len(values) if second is None else None
348
+ values = list(result)
349
+ if requested_size is not None and len(values) >= requested_size:
350
+ return values, None
351
+ return values, len(values)
352
+
353
+ def _format_field_values(self, field: str, values: list[Any], total: int | None) -> str:
354
+ """
355
+ Format the unique values of a field as the tool-result string.
356
+
357
+ :param field: The metadata field name (without the 'meta.' prefix).
358
+ :param values: The unique values returned by the store.
359
+ :param total: The total number of unique values (may exceed `len(values)`), or None when the store did
360
+ not report a total and more values may exist.
361
+ :returns: The listing (capped at `_MAX_LISTED_VALUES`), or a note when the field has no values.
362
+ """
363
+ if not values:
364
+ return f"Field '{field}' has no values in the document store."
365
+ shown = values[: self._MAX_LISTED_VALUES]
366
+ listing = ", ".join(str(v) for v in shown)
367
+ if total is None:
368
+ return f"Field '{field}' has at least {len(shown)} unique values: {listing} … more values may exist"
369
+ suffix = f" … and {total - len(shown)} more" if total > len(shown) else ""
370
+ return f"Field '{field}' has {total} unique values: {listing}{suffix}"
371
+
372
+ def to_dict(self) -> dict[str, Any]:
373
+ """Serialize the tool to a dictionary."""
374
+ return {
375
+ "type": generate_qualified_class_name(type(self)),
376
+ "data": {"document_store": self.document_store.to_dict()},
377
+ }
378
+
379
+ @classmethod
380
+ def from_dict(cls, data: dict[str, Any]) -> "GetMetadataFieldValuesTool":
381
+ """
382
+ Deserialize the tool from a dictionary.
383
+
384
+ :param data: The dictionary produced by `to_dict`.
385
+ :returns: The deserialized tool.
386
+ """
387
+ inner_data = data["data"]
388
+ deserialize_component_inplace(inner_data, key="document_store")
389
+ return cls(**inner_data)
390
+
391
+
392
+ class GetMetadataFieldRangeTool(Tool):
393
+ """Tool that returns the minimum and maximum values of a metadata field from a document store."""
394
+
395
+ def __init__(self, document_store: DocumentStore) -> None:
396
+ """
397
+ Create the tool.
398
+
399
+ :param document_store: The document store to inspect. Must implement `get_metadata_field_min_max`.
400
+ :raises ValueError: If the store does not implement `get_metadata_field_min_max`.
401
+ """
402
+ _require_store_method(document_store, "get_metadata_field_min_max")
403
+ self.document_store = document_store
404
+ super().__init__(
405
+ name="get_metadata_field_range",
406
+ description=(
407
+ "Returns the minimum and maximum values of a metadata field. Use it before "
408
+ "filtering on numeric fields (int, float) or orderable ones such as ISO dates."
409
+ ),
410
+ parameters={
411
+ "type": "object",
412
+ "properties": {
413
+ "field": {
414
+ "type": "string",
415
+ "description": "The metadata field name, as returned by list_metadata_fields.",
416
+ }
417
+ },
418
+ "required": ["field"],
419
+ },
420
+ function=self._get_metadata_field_range,
421
+ )
422
+
423
+ def _get_metadata_field_range(self, field: str) -> str:
424
+ """
425
+ Get the minimum and maximum values of a metadata field.
426
+
427
+ :param field: The metadata field name, with or without the 'meta.' prefix (normalized by the document store).
428
+ :returns: The min and max of the field, or a note that the field has no orderable values.
429
+ """
430
+ result = self.document_store.get_metadata_field_min_max(field) # type: ignore[attr-defined]
431
+ if result.get("min") is None and result.get("max") is None:
432
+ return f"Field '{field}' has no numeric or orderable values in the document store."
433
+ return f"Field '{field}': min={result.get('min')}, max={result.get('max')}"
434
+
435
+ def to_dict(self) -> dict[str, Any]:
436
+ """Serialize the tool to a dictionary."""
437
+ return {
438
+ "type": generate_qualified_class_name(type(self)),
439
+ "data": {"document_store": self.document_store.to_dict()},
440
+ }
441
+
442
+ @classmethod
443
+ def from_dict(cls, data: dict[str, Any]) -> "GetMetadataFieldRangeTool":
444
+ """
445
+ Deserialize the tool from a dictionary.
446
+
447
+ :param data: The dictionary produced by `to_dict`.
448
+ :returns: The deserialized tool.
449
+ """
450
+ inner_data = data["data"]
451
+ deserialize_component_inplace(inner_data, key="document_store")
452
+ return cls(**inner_data)
453
+
454
+
455
+ # Fields used to put filter-fetched documents into a sensible reading order: group the splits of the same parent file
456
+ # together, then sort them by their position in the file. From each tuple, the first field present in all fetched
457
+ # documents wins; when no field covers every document, the first present in any of them (see `_pick_order_field`).
458
+ # Haystack's document splitters auto-add `split_id` (ordinal) and `split_idx_start` (character offset), some add
459
+ # `page_number` and `source_id`; converters add the file name/path.
460
+ _GROUP_FIELDS = ("file_name", "file_path", "source_id")
461
+ _SORT_FIELDS = ("split_id", "split_idx_start", "page_number")
462
+
463
+
464
+ def _pick_order_field(candidates: tuple[str, ...], documents: list[Document]) -> str | None:
465
+ """
466
+ Pick the first candidate present in all documents, falling back to the first present in any of them.
467
+
468
+ Preferring full coverage keeps one document with an odd extra field (e.g. a stray `file_name`) from outranking a
469
+ field every document carries; the any-fallback still orders mixed corpora as well as their partial metadata allows
470
+ (documents missing the field are placed last).
471
+
472
+ :param candidates: The candidate field names, in priority order.
473
+ :param documents: The fetched documents.
474
+ :returns: The field to use, or None when no candidate is present in any document.
475
+ """
476
+ fallback = None
477
+ for field in candidates:
478
+ docs_with_field = sum(1 for doc in documents if field in doc.meta)
479
+ if docs_with_field == len(documents) and docs_with_field > 0:
480
+ return field
481
+ if docs_with_field > 0 and fallback is None:
482
+ fallback = field
483
+ return fallback
484
+
485
+
486
+ def _order_documents_for_reading(documents: list[Document]) -> list[Document]:
487
+ """
488
+ Put filter-fetched documents into reading order.
489
+
490
+ Filter-fetched documents carry no relevance order, so they are grouped by their parent file (a `_GROUP_FIELDS`
491
+ field, via `MetaFieldGroupingRanker`) and sorted by their position within it (a `_SORT_FIELDS` field), each
492
+ selected by `_pick_order_field`.
493
+
494
+ :param documents: The fetched documents.
495
+ :returns: The documents, grouped and sorted when the meta fields allow it.
496
+ """
497
+ group_by = _pick_order_field(_GROUP_FIELDS, documents)
498
+ sort_by = _pick_order_field(_SORT_FIELDS, documents)
499
+ # Both ordering paths sort documents by the raw values of `sort_by`, which raises a TypeError when the field
500
+ # holds mixed, non-comparable types (e.g. int in one document, str in another) — the ranker sorts internally
501
+ # via `list.sort` with the same kind of key. Suppress it and keep the original order then.
502
+ if group_by:
503
+ with contextlib.suppress(TypeError):
504
+ ranker = MetaFieldGroupingRanker(group_by=group_by, sort_docs_by=sort_by)
505
+ return ranker.run(documents=documents)["documents"]
506
+ elif sort_by:
507
+ # Sort by the field value, placing documents with a missing value last.
508
+ with contextlib.suppress(TypeError):
509
+ return sorted(documents, key=lambda d: (sort_by not in d.meta, d.meta.get(sort_by)))
510
+ return documents
511
+
512
+
513
+ def _format_fetch_result(result: dict[str, Any]) -> str:
514
+ """
515
+ Format a fetch-by-filter result: the documents plus paging guidance when more matched than were shown.
516
+
517
+ :param result: The result from `FetchDocumentsByFilterTool._fetch_documents`, containing `documents`,
518
+ `total_matched` and `offset`.
519
+ :returns: The formatted documents, or a message nudging the agent to adjust the filter or page onwards.
520
+ """
521
+ documents: list[Document] = result["documents"]
522
+ total_matched: int = result["total_matched"]
523
+ offset: int = result["offset"]
524
+ if not documents:
525
+ if total_matched > 0:
526
+ return f"No documents at offset {offset}: only {total_matched} documents match the filter."
527
+ return "No documents matched the filter. Verify the field names and values with the metadata tools."
528
+ formatted = _format_retrieved_documents(documents)
529
+ remaining = total_matched - offset - len(documents)
530
+ if remaining > 0:
531
+ formatted += (
532
+ f"\n… and {remaining} more documents matched; call again with offset={offset + len(documents)} "
533
+ f"to continue, or narrow the filter."
534
+ )
535
+ return formatted
536
+
537
+
538
+ def _fetch_documents_tool_params(max_docs: int) -> dict[str, Any]:
539
+ """
540
+ Build the input schema for the fetch-by-filter tool: a (required) filter, an optional doc cap and a page offset.
541
+
542
+ :param max_docs: The configured ceiling for `max_docs`, embedded in the schema.
543
+ :returns: The JSON schema.
544
+ """
545
+ return {
546
+ "type": "object",
547
+ "properties": {
548
+ "filters": {"type": "object", "description": prompts.FETCH_FILTER_GRAMMAR, "additionalProperties": True},
549
+ "max_docs": {
550
+ "type": "integer",
551
+ "minimum": 1,
552
+ "maximum": max_docs,
553
+ "description": f"How many documents to return at most. Defaults to the maximum, {max_docs}.",
554
+ },
555
+ "offset": {
556
+ "type": "integer",
557
+ "minimum": 0,
558
+ "description": (
559
+ "How many matching documents to skip before returning results. "
560
+ "Use it to page through match sets larger than max_docs. Defaults to 0."
561
+ ),
562
+ },
563
+ },
564
+ "required": ["filters"],
565
+ }
566
+
567
+
568
+ class FetchDocumentsByFilterTool(Tool):
569
+ """
570
+ Tool that fetches documents directly from a document store by metadata filter.
571
+
572
+ Unlike a scored retrieval tool, this fetches without any relevance ranking, so an agent can grab specific documents
573
+ (e.g. a known title or source file) without going through a relevance search. The fetched documents are put into
574
+ reading order first: grouped by their parent file (`file_name`/`file_path`/`source_id`) and sorted by their
575
+ position within it (`split_id`/`split_idx_start`/`page_number`), using whichever of those metadata fields the
576
+ documents carry. Match sets larger than `max_docs` are paged: each call returns one page plus the total match
577
+ count, and the tool's `offset` input continues where the previous page ended.
578
+ """
579
+
580
+ def __init__(self, document_store: DocumentStore, max_docs: int = 10, max_fetch_factor: int = 10) -> None:
581
+ """
582
+ Create the tool.
583
+
584
+ :param document_store: The document store to fetch documents from.
585
+ :param max_docs: Ceiling on the number of documents shown to the agent per fetch. Unlike scored retrieval, a
586
+ filter fetch is not bounded by a retriever's `top_k`, so this caps the tool result instead. The LLM can
587
+ request fewer via the tool's optional `max_docs` input, but never more.
588
+ :param max_fetch_factor: How many times the `max_docs` ceiling a filter may match before the fetch is
589
+ refused outright (when the store supports `count_documents_by_filter`) — the refusal is surfaced to the
590
+ LLM as an error it can recover from by narrowing the filter.
591
+ """
592
+ self.document_store = document_store
593
+ self.max_docs = max_docs
594
+ self.max_fetch_factor = max_fetch_factor
595
+ super().__init__(
596
+ name="fetch_documents_by_filter",
597
+ description=prompts.FILTER_RETRIEVER_TOOL_DESCRIPTION,
598
+ parameters=_fetch_documents_tool_params(max_docs),
599
+ # We purposefully return a dict with `documents`, `total_matched` and `offset`
600
+ function=self._fetch_documents,
601
+ # This converts the dict into a single string and expects all three keys to be present
602
+ outputs_to_string={"handler": _format_fetch_result},
603
+ # This accumulates the documents into the agent's state
604
+ outputs_to_state={"documents": {"source": "documents", "handler": _accumulate_documents}},
605
+ )
606
+
607
+ def _fetch_documents(self, filters: dict[str, Any], max_docs: int | None = None, offset: int = 0) -> dict[str, Any]:
608
+ """
609
+ Fetch the documents matching a metadata filter, in reading order.
610
+
611
+ :param filters: The metadata filter, in Haystack filter syntax.
612
+ :param max_docs: How many documents to return at most; clamped to the configured ceiling (`self.max_docs`),
613
+ which is also the default.
614
+ :param offset: How many matching documents to skip before returning results, for paging through match sets
615
+ larger than `max_docs`. Pages line up across calls when the documents carry the reading-order metadata
616
+ fields or the store returns matches in a consistent order.
617
+ :returns: One page of the ordered documents, the page's offset, plus how many documents matched the filter
618
+ overall. Formatted for the LLM by `_format_fetch_result`.
619
+ :raises ValueError: If the filter matches far more documents than can be shown (see below). The `Agent`
620
+ surfaces this to the LLM as an error tool message, so it can recover by narrowing the filter.
621
+ """
622
+ effective_max = max(1, min(max_docs, self.max_docs)) if max_docs is not None else self.max_docs
623
+ offset = max(0, offset)
624
+ # The threshold is based on the configured `self.max_docs` ceiling, not the per-call `effective_max`: it
625
+ # bounds the cost of fetching the whole match set, which is the same however few documents the LLM asked
626
+ # to see — requesting a smaller page must not make the fetch more likely to be refused.
627
+ fetch_limit = self.max_fetch_factor * self.max_docs
628
+ counter = getattr(self.document_store, "count_documents_by_filter", None)
629
+ if counter is not None:
630
+ count = counter(filters=filters)
631
+ if count == 0:
632
+ return {"documents": [], "total_matched": 0, "offset": offset}
633
+ # `filter_documents` has no limit parameter, so on a broad filter a db could return thousands of docs only
634
+ # for us to show `max_docs` of them. When the store supports `count_documents_by_filter`, refuse over-broad
635
+ # filters without fetching anything.
636
+ if count > fetch_limit:
637
+ msg = (
638
+ f"Filter matches {count} documents, but at most {self.max_docs} can be shown per fetch — retrieving"
639
+ f" that many would be slow and the result would be mostly truncated anyway, so nothing was fetched."
640
+ f" This tool is meant for grabbing a small, specific set of documents. Add more conditions to "
641
+ f"narrow the filter, or use a relevance-based search to get only the most relevant documents."
642
+ )
643
+ raise ValueError(msg)
644
+ documents = _order_documents_for_reading(self.document_store.filter_documents(filters=filters))
645
+ return {
646
+ "documents": documents[offset : offset + effective_max],
647
+ "total_matched": len(documents),
648
+ "offset": offset,
649
+ }
650
+
651
+ def to_dict(self) -> dict[str, Any]:
652
+ """Serialize the tool to a dictionary."""
653
+ return {
654
+ "type": generate_qualified_class_name(type(self)),
655
+ "data": {
656
+ "document_store": self.document_store.to_dict(),
657
+ "max_docs": self.max_docs,
658
+ "max_fetch_factor": self.max_fetch_factor,
659
+ },
660
+ }
661
+
662
+ @classmethod
663
+ def from_dict(cls, data: dict[str, Any]) -> "FetchDocumentsByFilterTool":
664
+ """
665
+ Deserialize the tool from a dictionary.
666
+
667
+ :param data: The dictionary produced by `to_dict`.
668
+ :returns: The deserialized tool.
669
+ """
670
+ inner_data = data["data"]
671
+ deserialize_component_inplace(inner_data, key="document_store")
672
+ return cls(**inner_data)
673
+
674
+
675
+ class DocumentStoreToolset(Toolset):
676
+ """
677
+ All document-store-backed tools as one unit.
678
+
679
+ Bundles the three metadata inspection tools (`ListMetadataFieldsTool`, `GetMetadataFieldValuesTool`,
680
+ `GetMetadataFieldRangeTool`) and the direct `FetchDocumentsByFilterTool`, so they can be handed to an `Agent`
681
+ (or combined with a retrieval tool) as a single object.
682
+ """
683
+
684
+ def __init__(self, document_store: DocumentStore, max_fetched_docs: int = 10) -> None:
685
+ """
686
+ Create the toolset.
687
+
688
+ :param document_store: The document store all tools run against. Must implement the metadata introspection
689
+ methods (`get_metadata_fields_info`, `get_metadata_field_unique_values`, `get_metadata_field_min_max`).
690
+ :param max_fetched_docs: Maximum number of documents `fetch_documents_by_filter` shows per fetch (see
691
+ `FetchDocumentsByFilterTool.max_docs`).
692
+ """
693
+ self.document_store = document_store
694
+ self.max_fetched_docs = max_fetched_docs
695
+ super().__init__(
696
+ tools=[
697
+ ListMetadataFieldsTool(document_store),
698
+ GetMetadataFieldValuesTool(document_store),
699
+ GetMetadataFieldRangeTool(document_store),
700
+ FetchDocumentsByFilterTool(document_store, max_docs=max_fetched_docs),
701
+ ]
702
+ )
703
+
704
+ def to_dict(self) -> dict[str, Any]:
705
+ """Serialize the toolset to a dictionary."""
706
+ return {
707
+ "type": generate_qualified_class_name(type(self)),
708
+ "data": {"document_store": self.document_store.to_dict(), "max_fetched_docs": self.max_fetched_docs},
709
+ }
710
+
711
+ @classmethod
712
+ def from_dict(cls, data: dict[str, Any]) -> "DocumentStoreToolset":
713
+ """
714
+ Deserialize the toolset from a dictionary.
715
+
716
+ :param data: The dictionary produced by `to_dict`.
717
+ :returns: The deserialized toolset.
718
+ """
719
+ inner_data = data["data"]
720
+ deserialize_component_inplace(inner_data, key="document_store")
721
+ return cls(**inner_data)