deep-db-agents 0.1.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.
Files changed (60) hide show
  1. deep_db_agents/__init__.py +36 -0
  2. deep_db_agents/backend_registry.py +96 -0
  3. deep_db_agents/base.py +55 -0
  4. deep_db_agents/connection.py +57 -0
  5. deep_db_agents/dialects/__init__.py +31 -0
  6. deep_db_agents/dialects/duckdb/__init__.py +7 -0
  7. deep_db_agents/dialects/duckdb/dialect.py +138 -0
  8. deep_db_agents/dialects/duckdb/prompt.py +34 -0
  9. deep_db_agents/dialects/duckdb/tools.py +135 -0
  10. deep_db_agents/dialects/elasticsearch/__init__.py +7 -0
  11. deep_db_agents/dialects/elasticsearch/dialect.py +51 -0
  12. deep_db_agents/dialects/elasticsearch/prompt.py +28 -0
  13. deep_db_agents/dialects/elasticsearch/tools.py +53 -0
  14. deep_db_agents/dialects/mariadb/__init__.py +7 -0
  15. deep_db_agents/dialects/mariadb/dialect.py +39 -0
  16. deep_db_agents/dialects/mariadb/prompt.py +33 -0
  17. deep_db_agents/dialects/mongodb/__init__.py +7 -0
  18. deep_db_agents/dialects/mongodb/dialect.py +283 -0
  19. deep_db_agents/dialects/mongodb/prompt.py +21 -0
  20. deep_db_agents/dialects/mongodb/tools.py +184 -0
  21. deep_db_agents/dialects/mysql/__init__.py +7 -0
  22. deep_db_agents/dialects/mysql/dialect.py +97 -0
  23. deep_db_agents/dialects/mysql/prompt.py +33 -0
  24. deep_db_agents/dialects/mysql/tools.py +69 -0
  25. deep_db_agents/dialects/neo4j/__init__.py +7 -0
  26. deep_db_agents/dialects/neo4j/dialect.py +251 -0
  27. deep_db_agents/dialects/neo4j/prompt.py +21 -0
  28. deep_db_agents/dialects/neo4j/tools.py +119 -0
  29. deep_db_agents/dialects/opensearch/__init__.py +7 -0
  30. deep_db_agents/dialects/opensearch/dialect.py +51 -0
  31. deep_db_agents/dialects/opensearch/prompt.py +27 -0
  32. deep_db_agents/dialects/opensearch/tools.py +51 -0
  33. deep_db_agents/dialects/postgres/__init__.py +7 -0
  34. deep_db_agents/dialects/postgres/dialect.py +96 -0
  35. deep_db_agents/dialects/postgres/prompt.py +32 -0
  36. deep_db_agents/dialects/postgres/tools.py +76 -0
  37. deep_db_agents/dialects/search_base.py +519 -0
  38. deep_db_agents/dialects/sql_base.py +511 -0
  39. deep_db_agents/dialects/sqlite/__init__.py +7 -0
  40. deep_db_agents/dialects/sqlite/dialect.py +108 -0
  41. deep_db_agents/dialects/sqlite/prompt.py +32 -0
  42. deep_db_agents/dialects/sqlite/tools.py +33 -0
  43. deep_db_agents/exceptions.py +41 -0
  44. deep_db_agents/factory.py +287 -0
  45. deep_db_agents/guardrails.py +122 -0
  46. deep_db_agents/observability.py +117 -0
  47. deep_db_agents/pooling.py +54 -0
  48. deep_db_agents/prompts/__init__.py +6 -0
  49. deep_db_agents/prompts/orchestrator.py +45 -0
  50. deep_db_agents/prompts/shared.py +39 -0
  51. deep_db_agents/py.typed +0 -0
  52. deep_db_agents/query_errors.py +88 -0
  53. deep_db_agents/registry.py +75 -0
  54. deep_db_agents/tabular.py +66 -0
  55. deep_db_agents/url.py +112 -0
  56. deep_db_agents/workspace.py +158 -0
  57. deep_db_agents-0.1.0.dist-info/METADATA +449 -0
  58. deep_db_agents-0.1.0.dist-info/RECORD +60 -0
  59. deep_db_agents-0.1.0.dist-info/WHEEL +4 -0
  60. deep_db_agents-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,36 @@
1
+ """deep-db-agents: factory for Deep Agents specialized on different databases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .connection import ConnectionConfig
6
+ from .exceptions import (
7
+ DeepDbAgentError,
8
+ GuardrailError,
9
+ InvalidDbUrlError,
10
+ InvalidMultiAgentConfigError,
11
+ QueryNotAllowedError,
12
+ UnsupportedSchemeError,
13
+ )
14
+ from .factory import create_db_agent, create_deep_db_agents, create_deep_db_multi_agent
15
+ from .guardrails import GuardrailConfig
16
+ from .observability import SessionMetrics, configure_logging
17
+ from .registry import available_schemes
18
+
19
+ __all__ = [
20
+ "create_deep_db_agents",
21
+ "create_deep_db_multi_agent",
22
+ "create_db_agent",
23
+ "GuardrailConfig",
24
+ "ConnectionConfig",
25
+ "SessionMetrics",
26
+ "configure_logging",
27
+ "available_schemes",
28
+ "DeepDbAgentError",
29
+ "InvalidDbUrlError",
30
+ "UnsupportedSchemeError",
31
+ "InvalidMultiAgentConfigError",
32
+ "QueryNotAllowedError",
33
+ "GuardrailError",
34
+ ]
35
+
36
+ __version__ = "0.1.0"
@@ -0,0 +1,96 @@
1
+ """Singleton registry of ``BackendProtocol`` backends indexed by UUID.
2
+
3
+ Allows registering a backend instance (derived from deepagents' ``BackendProtocol``),
4
+ obtaining an opaque identifier that can later be used to retrieve or remove it,
5
+ without letting the reference to the object circulate through the system.
6
+
7
+ The registry is shared at the process level: whoever registers a backend (``add``)
8
+ is responsible for removing it (``remove``) at the end of the agent's lifetime,
9
+ otherwise the instance stays referenced for the entire process lifetime.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import threading
15
+ import uuid
16
+
17
+ from deepagents.backends.protocol import BackendProtocol
18
+
19
+
20
+ class BERegistry:
21
+ """Singleton ``UUID -> BackendProtocol`` registry.
22
+
23
+ Every instantiation returns the same object, so the registry is shared at the
24
+ process level. Creation and mutations are protected by a lock: tools may run
25
+ in parallel threads (parallel tool calls in the tool node).
26
+
27
+ >>> reg = BERegistry()
28
+ >>> key = reg.add(my_backend)
29
+ >>> reg.get(key) is my_backend
30
+ True
31
+ >>> reg.remove(key)
32
+ >>> reg.get(key) is None
33
+ True
34
+ """
35
+
36
+ _instance: BERegistry | None = None
37
+ _instance_lock = threading.Lock()
38
+
39
+ def __new__(cls) -> BERegistry:
40
+ """Create or return the singleton instance.
41
+
42
+ Returns:
43
+ BERegistry: The single shared instance of the registry.
44
+ """
45
+ if cls._instance is None:
46
+ with cls._instance_lock:
47
+ if cls._instance is None: # double-checked: avoids duplicate instances
48
+ instance = super().__new__(cls)
49
+ instance._backends = {}
50
+ instance._lock = threading.Lock()
51
+ cls._instance = instance
52
+ return cls._instance
53
+
54
+ def add(self, backend: BackendProtocol) -> str:
55
+ """Register a backend and return its identifier.
56
+
57
+ Args:
58
+ backend: The ``BackendProtocol`` instance to register.
59
+
60
+ Returns:
61
+ str: The UUID (as a string) associated with the registered backend.
62
+
63
+ Raises:
64
+ TypeError: If ``backend`` is not a ``BackendProtocol`` instance.
65
+ """
66
+ if not isinstance(backend, BackendProtocol):
67
+ raise TypeError(f"Expected a BackendProtocol, got {type(backend).__name__!r}.")
68
+ key = str(uuid.uuid4())
69
+ with self._lock:
70
+ self._backends[key] = backend
71
+ return key
72
+
73
+ def get(self, key: str) -> BackendProtocol | None:
74
+ """Retrieve a registered backend.
75
+
76
+ Args:
77
+ key: The UUID string previously returned by :meth:`add`.
78
+
79
+ Returns:
80
+ BackendProtocol | None: The registered backend, or ``None`` if
81
+ ``key`` is not registered.
82
+ """
83
+ with self._lock:
84
+ return self._backends.get(key)
85
+
86
+ def remove(self, key: str) -> None:
87
+ """Remove a registered backend.
88
+
89
+ Args:
90
+ key: The UUID string previously returned by :meth:`add`.
91
+
92
+ Returns:
93
+ None. This is a no-op if ``key`` does not exist.
94
+ """
95
+ with self._lock:
96
+ self._backends.pop(key, None)
deep_db_agents/base.py ADDED
@@ -0,0 +1,55 @@
1
+ """Abstract interface that every database dialect must implement."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from collections.abc import Sequence
7
+
8
+ from langchain_core.tools import BaseTool
9
+
10
+ from .connection import ConnectionConfig
11
+ from .guardrails import GuardrailConfig
12
+ from .observability import SessionMetrics
13
+
14
+
15
+ class DbDialect(ABC):
16
+ """Specialization of the agent for a database type.
17
+
18
+ A dialect provides two things: the **generic system prompt** that instructs the
19
+ agent on how to operate efficiently on that database, and the set of **tools**
20
+ (with credentials injected) to interact with it.
21
+ """
22
+
23
+ #: URL schemes handled by this dialect (e.g. ``("postgres", "postgresql")``).
24
+ schemes: tuple[str, ...] = ()
25
+
26
+ #: Optional session counters, injected by the factory; tools update them if present.
27
+ _metrics: SessionMetrics | None = None
28
+
29
+ @abstractmethod
30
+ def system_prompt(self) -> str:
31
+ """Return the generic prompt for this database, concatenated with the user's own.
32
+
33
+ Returns:
34
+ str: The dialect-specific system prompt text.
35
+ """
36
+
37
+ @abstractmethod
38
+ def build_tools(
39
+ self,
40
+ conn: ConnectionConfig,
41
+ guardrails: GuardrailConfig,
42
+ materialize_enable: bool = False,
43
+ ) -> Sequence[BaseTool]:
44
+ """Build the LangChain tools bound to the connection and guardrails.
45
+
46
+ Args:
47
+ conn: Connection parameters (host/port/credentials or file path).
48
+ guardrails: Hard safety thresholds enforced by the tool wrappers.
49
+ materialize_enable: Whether to expose the tool(s) that materialize large
50
+ results to file instead of returning them inline.
51
+
52
+ Returns:
53
+ Sequence[BaseTool]: The tools built for this dialect, with credentials
54
+ captured in their closures.
55
+ """
@@ -0,0 +1,57 @@
1
+ """Connection configuration passed to dialects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ConnectionConfig:
11
+ """Connection parameters for a database.
12
+
13
+ ``credential`` is a free-form dictionary whose content depends on the specific DB
14
+ (e.g. ``{"user": ..., "password": ...}`` or ``{"secret_key": ...}``). Well-known keys
15
+ (``database``/``db``) are exposed as convenience properties.
16
+
17
+ Attributes:
18
+ scheme: The URL scheme identifying the dialect (e.g. ``"postgres"``).
19
+ host: Hostname for network databases, or ``None`` for file-based databases.
20
+ port: Port for network databases, or ``None`` for file-based databases.
21
+ credential: Free-form credential/connection dictionary.
22
+ path: File/directory path for file-based databases (``sqlite``/``duckdb``);
23
+ ``None`` for network databases. ``:memory:`` denotes an in-memory database.
24
+ """
25
+
26
+ scheme: str
27
+ host: str | None
28
+ port: int | None
29
+ credential: dict[str, Any] = field(default_factory=dict)
30
+ #: File/directory path for file-based databases (``sqlite``/``duckdb``); ``None`` for
31
+ #: network databases. ``:memory:`` denotes an in-memory database.
32
+ path: str | None = None
33
+
34
+ @property
35
+ def database(self) -> str | None:
36
+ """Return the logical database/schema name, if provided in the credentials.
37
+
38
+ Returns:
39
+ str | None: The value of the ``database`` or ``db`` credential key, or
40
+ ``None`` if neither is present.
41
+ """
42
+ return self.credential.get("database") or self.credential.get("db")
43
+
44
+ def __repr__(self) -> str:
45
+ """Return a repr with credentials masked.
46
+
47
+ Returns:
48
+ str: A string representation of this config where ``credential`` content
49
+ is masked so passwords never leak into logs, tracebacks, or error messages.
50
+ """
51
+ # Mask the contents of ``credential`` so passwords never end up in logs,
52
+ # tracebacks, or error messages when the object is printed.
53
+ cred = "{…}" if self.credential else "{}"
54
+ return (
55
+ f"ConnectionConfig(scheme={self.scheme!r}, host={self.host!r}, "
56
+ f"port={self.port!r}, credential={cred}, path={self.path!r})"
57
+ )
@@ -0,0 +1,31 @@
1
+ """Imports all dialects to populate the registry when the package is imported.
2
+
3
+ The side-effect import is intentional: each module registers its own dialect via the
4
+ ``@register`` decorator.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from . import ( # noqa: F401
10
+ duckdb,
11
+ elasticsearch,
12
+ mariadb,
13
+ mongodb,
14
+ mysql,
15
+ neo4j,
16
+ opensearch,
17
+ postgres,
18
+ sqlite,
19
+ )
20
+
21
+ __all__ = [
22
+ "mysql",
23
+ "mariadb",
24
+ "postgres",
25
+ "mongodb",
26
+ "neo4j",
27
+ "sqlite",
28
+ "duckdb",
29
+ "elasticsearch",
30
+ "opensearch",
31
+ ]
@@ -0,0 +1,7 @@
1
+ """DuckDB dialect."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .dialect import DuckDBDialect
6
+
7
+ __all__ = ["DuckDBDialect"]
@@ -0,0 +1,138 @@
1
+ """Full DuckDB dialect (``duckdb`` package).
2
+
3
+ DuckDB is a file-based analytical SQL database: the URL ``duckdb://<path>`` indicates the
4
+ file (SQLAlchemy style: ``duckdb:///rel.duckdb`` relative to the working dir,
5
+ ``duckdb:////abs.duckdb`` absolute) or a **folder** (trailing slash, e.g. ``duckdb:///lake/``)
6
+ interpreted as a data lake: the parquet/csv/json files inside it are exposed as tables. It
7
+ reuses ``SqlDialect``'s logic; the execution timeout is enforced by ``FileSqlDialect``'s
8
+ watchdog.
9
+
10
+ In data lake mode the views are created only once per dialect instance (a snapshot of the
11
+ folder taken on the first call). Warning: with ``duckdb://:memory:`` every tool call opens a
12
+ brand-new, empty database (the connection is per-call).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import threading
18
+ from typing import Any
19
+
20
+ from ...connection import ConnectionConfig
21
+ from ...guardrails import GuardrailConfig
22
+ from ...registry import register
23
+ from ..sql_base import FileSqlDialect
24
+ from . import tools
25
+ from .prompt import DUCKDB_SYSTEM_PROMPT
26
+
27
+
28
+ @register("duckdb")
29
+ class DuckDBDialect(FileSqlDialect):
30
+ """Agent specialized on DuckDB."""
31
+
32
+ schemes = ("duckdb",)
33
+
34
+ def __init__(self) -> None:
35
+ """Initializes the dialect with an empty data-lake connection cache."""
36
+ # Data-lake connection cache (per dialect instance, i.e. per agent): the folder glob
37
+ # and the view creation happen only once.
38
+ self._datalake_con: Any = None
39
+ self._datalake_folder: str | None = None
40
+ self._datalake_lock = threading.Lock()
41
+
42
+ def system_prompt(self) -> str:
43
+ """Returns the DuckDB-specific system prompt.
44
+
45
+ Returns:
46
+ The system prompt text for the DuckDB agent.
47
+ """
48
+ return DUCKDB_SYSTEM_PROMPT
49
+
50
+ def _connect(self, conn: ConnectionConfig, guardrails: GuardrailConfig):
51
+ """Opens a DuckDB connection, either to a file or to a cached data-lake view set.
52
+
53
+ Args:
54
+ conn: Connection configuration; ``conn.path`` is either a database file path or a
55
+ data-lake folder (trailing slash / existing directory).
56
+ guardrails: Guardrail configuration (unused here; the execution timeout is
57
+ enforced separately by the watchdog).
58
+
59
+ Returns:
60
+ A DuckDB connection (or cursor, in data-lake mode) ready for query execution.
61
+ """
62
+ if self.is_directory(conn):
63
+ folder = self.resolve_path(conn.path)
64
+ with self._datalake_lock:
65
+ if self._datalake_con is None or self._datalake_folder != folder:
66
+ self._datalake_con = tools.connect_datalake(folder)
67
+ self._datalake_folder = folder
68
+ # cursor() is an independent connection on the same in-memory DB: closing it
69
+ # at the end of the tool call does not close the parent connection that holds
70
+ # the views.
71
+ # Note: the schema is a snapshot of the folder taken on the first call; files
72
+ # added/removed during the session are not reflected.
73
+ return self._datalake_con.cursor()
74
+ path = self.resolve_path(conn.path)
75
+ # read_only allows concurrent connections (parallel tool calls); :memory: does not.
76
+ return tools.connect_file(path, read_only=path != ":memory:")
77
+
78
+ def _interrupt_target(self, connection: Any, cursor: Any) -> Any:
79
+ """Returns the object on which the timeout watchdog should call ``interrupt()``.
80
+
81
+ In DuckDB the cursor is an independent connection: the query runs there, not on the
82
+ parent connection.
83
+
84
+ Args:
85
+ connection: The parent DuckDB connection.
86
+ cursor: The DuckDB cursor (an independent connection) executing the query.
87
+
88
+ Returns:
89
+ The cursor, since that is where the running query lives.
90
+ """
91
+ return cursor
92
+
93
+ def _estimate_rows(self, cursor, sql: str) -> int:
94
+ """Estimates the number of rows a query would return.
95
+
96
+ Args:
97
+ cursor: An open DB-API cursor.
98
+ sql: The SELECT statement to estimate.
99
+
100
+ Returns:
101
+ The estimated row count from DuckDB's ``EXPLAIN`` plan, or 0 if it could not be
102
+ determined.
103
+ """
104
+ return tools.estimate_rows(cursor, sql)
105
+
106
+ def _list_tables_sql(self, conn: ConnectionConfig) -> str:
107
+ """Builds the SQL statement listing the tables of the current database.
108
+
109
+ Args:
110
+ conn: Connection configuration (unused; the query relies on
111
+ ``information_schema``, which is schema-agnostic).
112
+
113
+ Returns:
114
+ A SQL SELECT statement returning one table name per row.
115
+ """
116
+ return (
117
+ "SELECT table_name FROM information_schema.tables "
118
+ "WHERE table_schema NOT IN ('information_schema', 'pg_catalog') "
119
+ "ORDER BY table_name"
120
+ )
121
+
122
+ def _describe_table_sql(self, table: str) -> tuple[str, tuple[Any, ...]]:
123
+ """Builds the parameterized SQL statement describing a table's columns.
124
+
125
+ Args:
126
+ table: Name of the table to describe.
127
+
128
+ Returns:
129
+ A tuple ``(sql, params)`` where ``sql`` is a parameterized SELECT and ``params``
130
+ are the bind parameters (the table name).
131
+ """
132
+ return (
133
+ "SELECT column_name, data_type, is_nullable "
134
+ "FROM information_schema.columns "
135
+ "WHERE table_name = ? "
136
+ "ORDER BY ordinal_position",
137
+ (table,),
138
+ )
@@ -0,0 +1,34 @@
1
+ """Generic system prompt for the DuckDB-specialized agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ...prompts.shared import CONTEXT_PRINCIPLES
6
+
7
+ DUCKDB_SYSTEM_PROMPT = f"""\
8
+ You are an expert DuckDB agent. You operate on a DuckDB database through a set of read-only \
9
+ tools. The source can be a single ``.duckdb`` file or a "data lake" folder whose files \
10
+ (parquet/csv/json) are exposed as tables. Your goal is to answer the user's questions \
11
+ accurately while consuming as little context as possible.
12
+
13
+ ## Context management principles
14
+ {CONTEXT_PRINCIPLES}
15
+
16
+ ## DuckDB specifics
17
+ - Use DuckDB syntax (standard SQL, very close to PostgreSQL): double quotes for \
18
+ identifiers ("table"."column"), `LIMIT n OFFSET m`, analytical functions and `QUALIFY`.
19
+ - DuckDB is columnar and optimized for analytics: ALWAYS push aggregations and filters into \
20
+ the engine (`GROUP BY`, `SUM`, `AVG`, window functions) instead of extracting and computing \
21
+ by hand.
22
+ - In data lake mode the tables are views over the folder's files: use `list_tables` to \
23
+ discover which files are available and `describe_table` for the inferred columns.
24
+
25
+ ## Available tools
26
+ - `list_tables`, `describe_table`: explore the schema (or the data lake files) before querying.
27
+ - `count_rows`: count (with an optional filter) BEFORE extracting, to assess the volume.
28
+ - `sample_rows`: small preview of a table.
29
+ - `run_query`: runs a SELECT with forced LIMIT and pagination, plus an EXPLAIN estimate.
30
+ - `materialize_query`: saves a large result to file (Parquet/CSV) and returns only metadata, \
31
+ preview and statistics — use it for analysis/charts on large volumes.
32
+
33
+ Always proceed step by step: explore the schema, count, then extract or aggregate.
34
+ """
@@ -0,0 +1,135 @@
1
+ """Driver-specific helpers for DuckDB (``duckdb`` package)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import glob
6
+ import os
7
+ import re
8
+
9
+ # File extension -> DuckDB reader function, for the data lake (path = folder).
10
+ _READERS = {
11
+ ".parquet": "read_parquet",
12
+ ".csv": "read_csv_auto",
13
+ ".tsv": "read_csv_auto",
14
+ ".json": "read_json_auto",
15
+ ".ndjson": "read_json_auto",
16
+ }
17
+
18
+ # Captures cardinality estimates in the EXPLAIN plan text ("~N rows" or "EC: N").
19
+ _ESTIMATE_RE = re.compile(r"(?:EC:\s*|~\s*)([\d,]+)")
20
+
21
+
22
+ def _import_duckdb():
23
+ """Lazily imports the ``duckdb`` package.
24
+
25
+ Returns:
26
+ The imported ``duckdb`` module.
27
+
28
+ Raises:
29
+ ImportError: If the ``duckdb`` package is not installed.
30
+ """
31
+ try:
32
+ import duckdb
33
+ except ImportError as exc: # pragma: no cover - depends on the installed extra
34
+ raise ImportError(
35
+ "The DuckDB dialect requires the 'duckdb' extra (pip install 'deep-db-agents[duckdb]')."
36
+ ) from exc
37
+ return duckdb
38
+
39
+
40
+ def _sanitize_view_name(name: str) -> str:
41
+ """Turns a file name into a valid SQL identifier (for data-lake views).
42
+
43
+ Args:
44
+ name: The raw file name (without extension) to sanitize.
45
+
46
+ Returns:
47
+ A valid SQL identifier derived from ``name``, prefixed with ``_`` if it would
48
+ otherwise be empty or start with a digit.
49
+ """
50
+ safe = re.sub(r"\W", "_", name)
51
+ if not safe or safe[0].isdigit():
52
+ safe = "_" + safe
53
+ return safe
54
+
55
+
56
+ def register_datalake_views(con, folder: str) -> list[str]:
57
+ """Creates a view for each data file (parquet/csv/json) in ``folder``.
58
+
59
+ Exposes the files as queryable tables with regular SQL, so the SQL dialect's tools work
60
+ unmodified. Each view is named after the (sanitized) file name without its extension.
61
+
62
+ Args:
63
+ con: An open DuckDB connection on which to create the views.
64
+ folder: Path to the data-lake folder to scan for data files.
65
+
66
+ Returns:
67
+ The list of created view names.
68
+ """
69
+ created: list[str] = []
70
+ for filepath in sorted(glob.glob(os.path.join(folder, "*"))):
71
+ ext = os.path.splitext(filepath)[1].lower()
72
+ reader = _READERS.get(ext)
73
+ if not reader:
74
+ continue
75
+ view = _sanitize_view_name(os.path.splitext(os.path.basename(filepath))[0])
76
+ escaped = filepath.replace("'", "''")
77
+ con.execute(f"CREATE OR REPLACE VIEW \"{view}\" AS SELECT * FROM {reader}('{escaped}')")
78
+ created.append(view)
79
+ return created
80
+
81
+
82
+ def connect_file(path: str, *, read_only: bool = True):
83
+ """Opens a connection to a DuckDB file.
84
+
85
+ ``read_only=True`` allows concurrent connections to the same file (needed when tool calls
86
+ run in parallel): in write mode DuckDB holds an exclusive lock. Query duration is bounded
87
+ separately by the watchdog calling ``interrupt()`` (see ``FileSqlDialect``).
88
+
89
+ Args:
90
+ path: Path to the DuckDB database file.
91
+ read_only: If True, opens the connection in read-only mode.
92
+
93
+ Returns:
94
+ An open DuckDB connection.
95
+ """
96
+ duckdb = _import_duckdb()
97
+ return duckdb.connect(path, read_only=read_only)
98
+
99
+
100
+ def connect_datalake(folder: str):
101
+ """Opens an in-memory DuckDB connection with a view for each data file in the folder.
102
+
103
+ Args:
104
+ folder: Path to the data-lake folder to expose as tables.
105
+
106
+ Returns:
107
+ An in-memory DuckDB connection with one view registered per recognized data file.
108
+ """
109
+ duckdb = _import_duckdb()
110
+ con = duckdb.connect(":memory:")
111
+ register_datalake_views(con, folder)
112
+ return con
113
+
114
+
115
+ def estimate_rows(cursor, sql: str) -> int:
116
+ """Estimates the row count by reading the cardinality from the ``EXPLAIN`` plan.
117
+
118
+ This is best-effort: any failure to run or parse ``EXPLAIN`` results in an estimate of 0
119
+ rather than raising.
120
+
121
+ Args:
122
+ cursor: An open DuckDB cursor.
123
+ sql: The SELECT statement to estimate.
124
+
125
+ Returns:
126
+ The largest cardinality estimate found in the EXPLAIN plan text, or 0 if none could
127
+ be extracted.
128
+ """
129
+ try:
130
+ cursor.execute(f"EXPLAIN {sql}")
131
+ text = "\n".join(str(row[-1]) for row in cursor.fetchall())
132
+ except Exception: # noqa: BLE001 - the estimate is best-effort, never blocking on its own
133
+ return 0
134
+ nums = [int(m.replace(",", "")) for m in _ESTIMATE_RE.findall(text)]
135
+ return max(nums) if nums else 0
@@ -0,0 +1,7 @@
1
+ """Elasticsearch dialect."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .dialect import ElasticsearchDialect
6
+
7
+ __all__ = ["ElasticsearchDialect"]
@@ -0,0 +1,51 @@
1
+ """Complete Elasticsearch dialect (official ``elasticsearch`` driver).
2
+
3
+ Reuses the entire tool logic from :mod:`..search_base` (shared by Elasticsearch and
4
+ OpenSearch); this module only provides the driver-specific connection opening.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from ...connection import ConnectionConfig
12
+ from ...guardrails import GuardrailConfig
13
+ from ...registry import register
14
+ from ..search_base import SearchDialect
15
+ from . import tools
16
+ from .prompt import ELASTICSEARCH_SYSTEM_PROMPT
17
+
18
+
19
+ @register("elasticsearch")
20
+ class ElasticsearchDialect(SearchDialect):
21
+ """Agent specialized on Elasticsearch.
22
+
23
+ Access is restricted to the index(es) configured in ``credential["index"]``
24
+ (single name, CSV, or a ``*`` pattern); every tool inherited from
25
+ :class:`SearchDialect` validates the requested index against this scope before
26
+ querying the cluster.
27
+ """
28
+
29
+ schemes = ("elasticsearch",)
30
+
31
+ def system_prompt(self) -> str:
32
+ """Return the Elasticsearch-specific system prompt.
33
+
34
+ Returns:
35
+ str: The system prompt text for the Elasticsearch agent.
36
+ """
37
+ return ELASTICSEARCH_SYSTEM_PROMPT
38
+
39
+ def _connect(self, conn: ConnectionConfig, guardrails: GuardrailConfig | None = None) -> Any:
40
+ """Open the driver connection using the given configuration and guardrails.
41
+
42
+ Args:
43
+ conn: Connection configuration (host, port, credentials) for the cluster.
44
+ guardrails: Optional guardrail configuration; its ``query_timeout_s``, if
45
+ set, is forwarded as the request timeout for the driver client.
46
+
47
+ Returns:
48
+ Any: An initialized Elasticsearch client instance.
49
+ """
50
+ timeout = guardrails.query_timeout_s if guardrails else None
51
+ return tools.connect(conn, request_timeout=timeout)
@@ -0,0 +1,28 @@
1
+ """Generic system prompt for the Elasticsearch-specialized agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ...prompts.shared import CONTEXT_PRINCIPLES
6
+
7
+ ELASTICSEARCH_SYSTEM_PROMPT = f"""\
8
+ You are an expert Elasticsearch agent. You operate on an Elasticsearch cluster through
9
+ read-only tools (Query DSL). Your goal is to answer while consuming as little context as
10
+ possible.
11
+
12
+ ## Context management principles
13
+ {CONTEXT_PRINCIPLES}
14
+
15
+ ## Elasticsearch specifics
16
+ - Push work into the cluster: use `count` to size a query before searching, and use
17
+ `aggregate` (terms/metric aggregations) to summarize data instead of downloading documents
18
+ with `run_query`/`search_query` and aggregating them by hand.
19
+ - Always request only the fields you need and rely on the forced `size`/`from` pagination of
20
+ the search tools instead of asking for everything at once.
21
+ - Explore the index scope first with `list_indices` and `describe_index` (field mapping)
22
+ before writing a Query DSL clause.
23
+ - For simple text/filter searches prefer `search_query` (Lucene-like `query_string` syntax,
24
+ e.g. 'status:active AND price:[10 TO 50]'); use `run_query` with a full Query DSL clause
25
+ for anything more structured (bool/match/range/term...).
26
+ - You can only operate on the index(es) configured for this agent; requests for indices
27
+ outside that scope are rejected by the tools.
28
+ """