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.
- deep_db_agents/__init__.py +36 -0
- deep_db_agents/backend_registry.py +96 -0
- deep_db_agents/base.py +55 -0
- deep_db_agents/connection.py +57 -0
- deep_db_agents/dialects/__init__.py +31 -0
- deep_db_agents/dialects/duckdb/__init__.py +7 -0
- deep_db_agents/dialects/duckdb/dialect.py +138 -0
- deep_db_agents/dialects/duckdb/prompt.py +34 -0
- deep_db_agents/dialects/duckdb/tools.py +135 -0
- deep_db_agents/dialects/elasticsearch/__init__.py +7 -0
- deep_db_agents/dialects/elasticsearch/dialect.py +51 -0
- deep_db_agents/dialects/elasticsearch/prompt.py +28 -0
- deep_db_agents/dialects/elasticsearch/tools.py +53 -0
- deep_db_agents/dialects/mariadb/__init__.py +7 -0
- deep_db_agents/dialects/mariadb/dialect.py +39 -0
- deep_db_agents/dialects/mariadb/prompt.py +33 -0
- deep_db_agents/dialects/mongodb/__init__.py +7 -0
- deep_db_agents/dialects/mongodb/dialect.py +283 -0
- deep_db_agents/dialects/mongodb/prompt.py +21 -0
- deep_db_agents/dialects/mongodb/tools.py +184 -0
- deep_db_agents/dialects/mysql/__init__.py +7 -0
- deep_db_agents/dialects/mysql/dialect.py +97 -0
- deep_db_agents/dialects/mysql/prompt.py +33 -0
- deep_db_agents/dialects/mysql/tools.py +69 -0
- deep_db_agents/dialects/neo4j/__init__.py +7 -0
- deep_db_agents/dialects/neo4j/dialect.py +251 -0
- deep_db_agents/dialects/neo4j/prompt.py +21 -0
- deep_db_agents/dialects/neo4j/tools.py +119 -0
- deep_db_agents/dialects/opensearch/__init__.py +7 -0
- deep_db_agents/dialects/opensearch/dialect.py +51 -0
- deep_db_agents/dialects/opensearch/prompt.py +27 -0
- deep_db_agents/dialects/opensearch/tools.py +51 -0
- deep_db_agents/dialects/postgres/__init__.py +7 -0
- deep_db_agents/dialects/postgres/dialect.py +96 -0
- deep_db_agents/dialects/postgres/prompt.py +32 -0
- deep_db_agents/dialects/postgres/tools.py +76 -0
- deep_db_agents/dialects/search_base.py +519 -0
- deep_db_agents/dialects/sql_base.py +511 -0
- deep_db_agents/dialects/sqlite/__init__.py +7 -0
- deep_db_agents/dialects/sqlite/dialect.py +108 -0
- deep_db_agents/dialects/sqlite/prompt.py +32 -0
- deep_db_agents/dialects/sqlite/tools.py +33 -0
- deep_db_agents/exceptions.py +41 -0
- deep_db_agents/factory.py +287 -0
- deep_db_agents/guardrails.py +122 -0
- deep_db_agents/observability.py +117 -0
- deep_db_agents/pooling.py +54 -0
- deep_db_agents/prompts/__init__.py +6 -0
- deep_db_agents/prompts/orchestrator.py +45 -0
- deep_db_agents/prompts/shared.py +39 -0
- deep_db_agents/py.typed +0 -0
- deep_db_agents/query_errors.py +88 -0
- deep_db_agents/registry.py +75 -0
- deep_db_agents/tabular.py +66 -0
- deep_db_agents/url.py +112 -0
- deep_db_agents/workspace.py +158 -0
- deep_db_agents-0.1.0.dist-info/METADATA +449 -0
- deep_db_agents-0.1.0.dist-info/RECORD +60 -0
- deep_db_agents-0.1.0.dist-info/WHEEL +4 -0
- 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,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,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
|
+
"""
|