This is an automated email from the ASF dual-hosted git repository.
kaxil pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 6c2e7b36460 Fail the attempt when `SQLToolset` cannot reach its
database (#76783)
6c2e7b36460 is described below
commit 6c2e7b3646062af0c72528869a211274a775e8c3
Author: Kaxil Naik <[email protected]>
AuthorDate: Thu Nov 12 14:55:50 2026 +0000
Fail the attempt when `SQLToolset` cannot reach its database (#76783)
SQLToolset handed every exception back to the model as a ModelRetry,
including an unreachable host, rejected credentials and a missing
connection. No SQL change fixes those: the model either answered without
data, so the task succeeded with an apology as its output, or spent its
retries. Under durable execution the recorded ModelRetry also replayed
the outage on retry instead of querying again.
Failing to reach the database now raises: resolving the connection's
hook, building the hook's URI (which can fetch credentials), the
hook's get_conn (which DbApiHook.run uses to connect), building the
SQLAlchemy inspector for list_tables and get_schema, and
a SQLAlchemy DBAPIError without a statement or with
connection_invalidated. A raised call is not recorded, so a durable
retry runs it again. An error from the statement itself stays a
ModelRetry, so the model can still correct its SQL and the correction
replays. Inside CodeMode the harness still hands the error back to the
model; the docs say so.
The error is SQLToolsetConnectionError in
airflow.providers.common.ai.exceptions, so a retry policy can match it.
---
providers/common/ai/docs/code_mode.rst | 3 +
providers/common/ai/docs/durable_execution.rst | 5 +-
providers/common/ai/docs/toolsets/index.rst | 11 +-
providers/common/ai/docs/toolsets/sql.rst | 39 ++++-
providers/common/ai/docs/troubleshooting.rst | 11 ++
.../src/airflow/providers/common/ai/exceptions.py | 14 ++
.../airflow/providers/common/ai/toolsets/sql.py | 99 ++++++++---
.../unit/common/ai/durable/test_capability.py | 66 ++++++-
.../ai/tests/unit/common/ai/sqlite_connection.py | 85 +++++++++
.../unit/common/ai/tools/test__from_toolset.py | 18 +-
.../ai/tests/unit/common/ai/toolsets/test_sql.py | 190 +++++++++++++++++----
11 files changed, 472 insertions(+), 69 deletions(-)
diff --git a/providers/common/ai/docs/code_mode.rst
b/providers/common/ai/docs/code_mode.rst
index b4a5bb7b3b8..242c6917103 100644
--- a/providers/common/ai/docs/code_mode.rst
+++ b/providers/common/ai/docs/code_mode.rst
@@ -98,6 +98,9 @@ Code mode turns off :doc:`tool_approval`. A tool that needs
approval and is called from ``run_code`` does not run: the model gets an error
back and may
carry on without it, so the task can still succeed. To keep such a tool out of
``run_code``,
list the other tools in ``CodeMode(tools=[...])``; called directly, it fails
the task.
+The same goes for any exception a tool raises from inside ``run_code``: the
model gets it
+back as an error to work around, so a ``SQLToolset`` call that cannot reach
its database
+does not fail the task as it does when called directly.
Importing ``CodeMode`` in the Dag file loads pydantic-ai-harness and Monty
each time the file is
parsed. Keep code mode agents in their own Dag file if the rest of the file
does not need them.
diff --git a/providers/common/ai/docs/durable_execution.rst
b/providers/common/ai/docs/durable_execution.rst
index 01e6dc07408..bfb1f9b223c 100644
--- a/providers/common/ai/docs/durable_execution.rst
+++ b/providers/common/ai/docs/durable_execution.rst
@@ -259,7 +259,10 @@ A tool that raises ``ModelRetry``, ``ToolFailed``,
``ApprovalRequired`` or
approval or deferred tools with ``durable=True``, so there such a call fails
the task
with ``UnsupportedToolDeferralError``, as it would without ``durable``. A tool
that raises
any other exception runs again on retry; once it has failed the run on two
attempts in a
-row, the next retry asks the model again, as described above.
+row, the next retry asks the model again, as described above. ``SQLToolset``
uses that
+split: a statement the database rejects is a ``ModelRetry`` and replays, while
failing to
+reach the database raises, so the retry queries it again rather than handing
the model the
+old failure.
A task can run more than one agent, and an agent's tool can run another agent;
each
agent run is replayed on its own, and one a tool started is replayed under
that tool
diff --git a/providers/common/ai/docs/toolsets/index.rst
b/providers/common/ai/docs/toolsets/index.rst
index fef8edee861..5c66466b514 100644
--- a/providers/common/ai/docs/toolsets/index.rst
+++ b/providers/common/ai/docs/toolsets/index.rst
@@ -271,8 +271,15 @@ When the model calls a tool with arguments that fail its
schema, or the tool ask
model to try again (``ModelRetry``), the error goes back to the model so it
can correct
the call. What counts differs by toolset:
-- ``SQLToolset`` and ``DataFusionToolset`` turn every query error into
``ModelRetry``,
- so a misspelled column and a dropped connection both count.
+- ``SQLToolset`` counts an error the database returns for a statement, such as
a
+ misspelled column, and a statement it refuses itself (a write while
+ ``allow_writes`` is off, a table outside ``allowed_tables``). Failing to
reach the
+ database fails the run straight away, except through a hook that only
connects when
+ the statement runs; see :doc:`sql`.
+- ``DataFusionToolset`` counts a query naming a column or table that does not
exist and,
+ while ``allow_writes`` is off, a statement it refuses itself (a write, or
SQL it cannot
+ parse). Other errors from running a query go back to the model as a failed
result
+ without using the budget.
- ``HookToolset`` counts invalid arguments and a call that supplies a pinned
argument. An exception from the hook itself fails the run straight away.
- ``ObjectStorageToolset`` counts invalid arguments only. A path that does not
exist or
diff --git a/providers/common/ai/docs/toolsets/sql.rst
b/providers/common/ai/docs/toolsets/sql.rst
index 399262d5f37..914451fb2d2 100644
--- a/providers/common/ai/docs/toolsets/sql.rst
+++ b/providers/common/ai/docs/toolsets/sql.rst
@@ -458,15 +458,36 @@ subqueries and joins.
walk rejects once ``allowed_tables`` is active. Statements that modify
data, ``COPY`` among them, are rejected either way while ``allow_writes``
is ``False``.
-- It does not classify failures. A connection error or a typo in a column
- name reaching ``list_tables``, ``get_schema`` or ``query`` becomes one
- ``ModelRetry``, so the two are treated the same way until the retry budget
- (:ref:`toolset-retry-budget`) runs out and the task fails for Airflow to
retry. Two paths do not raise:
- ``check_query`` catches its own errors and reports them back as a normal
- ``{"valid": false, ...}`` result, and ``get_schema`` returns a normal
- ``{"error": ...}`` result instead of raising when the requested table is
- outside ``allowed_tables``; other ``get_schema`` failures still raise and
- still become a ``ModelRetry``.
+- It tells an unreachable database from a bad statement by when the error
+ happens, not by what it says. Failing to reach the database fails the
+ attempt straight away for Airflow to retry: the connection is not defined,
+ connecting fails (an unreachable host, rejected credentials), SQLAlchemy
+ cannot be set up for ``list_tables`` or ``get_schema`` (a dialect it cannot
+ load, a token it cannot fetch), or SQLAlchemy reports the connection lost
+ while they run. With :doc:`../durable_execution` the retry queries the
+ database again instead of replaying the failure. The task fails with
+ :class:`~airflow.providers.common.ai.exceptions.SQLToolsetConnectionError`,
+ whose message reads ``Could not reach the database of connection '<id>':
...``,
+ or ``Connection '<id>' does not provide a DbApiHook`` when the connection is
+ of the wrong type. Match that class in a retry policy
+ (:doc:`../retry_policies`) to retry an unreachable database on a different
+ schedule from other failures. When the driver's message carries a secret the
+ masker cannot remove from the exception, the task fails with a
``RuntimeError``
+ holding the masked message instead, and a rule on the class does not match
it.
+ Inside :doc:`../code_mode` the split does not hold: a
+ tool the model's code calls hands any exception back to the model as a
+ retry, this one included.
+ Once connected, an error from the statement becomes a ``ModelRetry`` so the
+ model can correct it, bounded by the retry budget
+ (:ref:`toolset-retry-budget`). A connection that drops while a ``query``
+ runs falls in this second group, since the driver reports it like any
+ other statement error; the model usually runs the query again, on a new
+ connection. So does any failure of a hook whose ``get_conn`` does not open
+ a network connection, since nothing is reached until the statement runs.
+ Two paths do not raise: ``check_query`` catches its own errors and reports
+ them back as a normal ``{"valid": false, ...}`` result, and ``get_schema``
+ returns a normal ``{"error": ...}`` result instead of raising when the
+ requested table is outside ``allowed_tables``.
**A real example.** ``example_pydantic_ai_hook.py`` builds an agent around
``SQLToolset`` inside a plain ``@task`` function, with no operator involved:
diff --git a/providers/common/ai/docs/troubleshooting.rst
b/providers/common/ai/docs/troubleshooting.rst
index 52ff66d09ec..40dd6e7c025 100644
--- a/providers/common/ai/docs/troubleshooting.rst
+++ b/providers/common/ai/docs/troubleshooting.rst
@@ -49,6 +49,17 @@ Model and connection errors
version suffixes, for example), use the matching vendor connection type
instead of
the generic one.
+``Could not reach the database of connection '...'``
+ A ``SQLToolset`` tool could not reach the database the connection points
at: the
+ connection is not defined, the host is unreachable, the credentials were
rejected,
+ the driver or SQLAlchemy dialect failed to load or connect, or the
connection was
+ lost while listing tables or reading a schema.
+ The attempt fails with ``SQLToolsetConnectionError`` instead of handing
the error to
+ the model, and Airflow's retry runs the call again. The driver's own
message follows
+ the colon. A connection that
+ is not defined, or a SQLAlchemy dialect that is not installed, fails every
retry the
+ same way: define the connection or install the package. See
:doc:`toolsets/sql`.
+
An ``ImportError`` for ``pydantic_ai.models.<vendor>``, the vendor SDK, or
``pydantic_ai_harness``
The provider is installed without the extra for that vendor, or without
``code-mode``
for ``pydantic_ai_harness``. Install it, quoting the
diff --git a/providers/common/ai/src/airflow/providers/common/ai/exceptions.py
b/providers/common/ai/src/airflow/providers/common/ai/exceptions.py
index 201445725db..829084d806e 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/exceptions.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/exceptions.py
@@ -76,6 +76,20 @@ class UnsupportedToolDeferralError(AirflowFailException):
"""
+class SQLToolsetConnectionError(AirflowException):
+ """
+ Raised when a ``SQLToolset`` tool cannot reach its database.
+
+ The connection is not defined or does not supply a ``DbApiHook``,
connecting fails, or
+ SQLAlchemy cannot be set up or loses the connection. No change to the SQL
fixes it, so
+ the attempt fails for Airflow to retry instead of the model getting the
error. Match it
+ in a retry policy to treat an unreachable database differently from other
failures.
+
+ When the driver's message carries a secret the masker cannot remove from
the exception,
+ the toolset raises a ``RuntimeError`` with the masked message instead.
+ """
+
+
class DurableToolMetadataError(AirflowFailException):
"""
Raised when a tool's ``airflow`` metadata is not ``True``, ``False`` or
``None``.
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/toolsets/sql.py
b/providers/common/ai/src/airflow/providers/common/ai/toolsets/sql.py
index 5da4eaaf48e..4c647276d24 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/toolsets/sql.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/toolsets/sql.py
@@ -22,6 +22,8 @@ from contextlib import suppress
from typing import TYPE_CHECKING, Any
try:
+ from sqlalchemy.exc import DBAPIError
+
from airflow.providers.common.ai.utils.sql_validation import (
SQLSafetyError,
collect_table_references,
@@ -40,6 +42,7 @@ from pydantic_ai.exceptions import ModelRetry
from pydantic_ai.tools import ToolDefinition
from pydantic_ai.toolsets.abstract import ToolsetTool
+from airflow.providers.common.ai.exceptions import SQLToolsetConnectionError
from airflow.providers.common.ai.utils.masking import dumps_masked
from airflow.providers.common.ai.utils.query_results import (
DEFAULT_MAX_COLUMNS,
@@ -55,9 +58,10 @@ from airflow.providers.common.ai.utils.toolset_base import
AirflowToolset, valid
from airflow.providers.common.compat.sdk import BaseHook
if TYPE_CHECKING:
- from collections.abc import Sequence
+ from collections.abc import Callable, Sequence
from pydantic_ai._run_context import RunContext
+ from sqlalchemy.engine.reflection import Inspector
# Sentinel distinguishing "caller did not pass ``allowed_tables``" (expose
every
@@ -105,6 +109,33 @@ _CHECK_QUERY_SCHEMA: dict[str, Any] = {
}
+def _build_connection_error(conn_id: str, error: Exception) ->
SQLToolsetConnectionError:
+ return SQLToolsetConnectionError(f"Could not reach the database of
connection {conn_id!r}: {error}")
+
+
+def _raise_connection_failures(get_conn: Callable[[], Any], conn_id: str) ->
Callable[[], Any]:
+ """Wrap a hook's ``get_conn`` so a failure to connect is told apart from a
failing statement."""
+
+ def wrapper() -> Any:
+ try:
+ return get_conn()
+ except Exception as e:
+ raise _build_connection_error(conn_id, e) from e
+
+ return wrapper
+
+
+def _is_lost_connection(error: Exception) -> bool:
+ """
+ Whether SQLAlchemy reports ``error`` as a connection failure rather than a
failing statement.
+
+ ``list_tables`` and ``get_schema`` go through the SQLAlchemy inspector,
which raises a
+ failure to connect without a statement and marks a connection lost
mid-statement as
+ invalidated.
+ """
+ return isinstance(error, DBAPIError) and (error.statement is None or
error.connection_invalidated)
+
+
def _trusted_row_count(cursor: Any, *, fetched: int) -> int | None:
"""
Return the driver's row count for the query, or ``None`` when it cannot
mean that.
@@ -184,12 +215,14 @@ class SQLToolset(ToolPrefixMixin, AirflowToolset):
Uses a :class:`~airflow.providers.common.sql.hooks.sql.DbApiHook` resolved
lazily from the given ``db_conn_id``.
- When a tool fails, the database's error message is returned to the agent
as a
- retry (:class:`pydantic_ai.ModelRetry`) so the model can correct its SQL
within
- the run instead of failing the task. ``pydantic-ai`` bounds this by the
tool's
- ``max_retries``, so an unrecoverable error -- a bad connection or an auth
- failure -- exhausts the retries and fails the task for Airflow to retry.
The
- toolset does not inspect the error type or message.
+ When the statement fails, the database's error message is returned to the
agent as
+ a retry (:class:`pydantic_ai.ModelRetry`) so the model can correct its SQL
within
+ the run, bounded by the tool's ``max_retries``. When the toolset cannot
reach the
+ database -- the connection is missing or misconfigured, the host is
unreachable,
+ the credentials are rejected, or SQLAlchemy reports the connection lost --
the
+ error is raised instead, failing the attempt for Airflow to retry: no
change to
+ the SQL can fix it. Under durable execution a raised call is not recorded,
so the
+ retry runs it against the database again rather than replaying the failure.
:param db_conn_id: Airflow connection ID for the database. Templated when
the
toolset is passed to ``AgentOperator`` / ``@task.agent``, so each task
@@ -384,16 +417,30 @@ class SQLToolset(ToolPrefixMixin, AirflowToolset):
def _get_db_hook(self) -> DbApiHook:
if self._hook is None:
- connection = BaseHook.get_connection(self._db_conn_id)
- hook = connection.get_hook()
+ try:
+ hook = BaseHook.get_connection(self._db_conn_id).get_hook()
+ except Exception as e:
+ raise _build_connection_error(self._db_conn_id, e) from e
if not isinstance(hook, DbApiHook):
- raise ValueError(
+ raise SQLToolsetConnectionError(
f"Connection {self._db_conn_id!r} does not provide a
DbApiHook. "
f"Got {type(hook).__name__}."
)
+ # DbApiHook.run and every in-tree override connect through
get_conn, so this is
+ # where the query tool's connection failures surface.
+ hook.get_conn = _raise_connection_failures(hook.get_conn,
self._db_conn_id) # type: ignore[method-assign]
self._hook = hook
return self._hook
+ def _get_inspector(self) -> Inspector:
+ hook = self._get_db_hook()
+ # Building the inspector fetches credentials (Databricks), loads the
SQLAlchemy dialect
+ # and connects, none of which goes through get_conn.
+ try:
+ return hook.inspector
+ except Exception as e:
+ raise _build_connection_error(self._db_conn_id, e) from e
+
# ------------------------------------------------------------------
# AbstractToolset interface
# ------------------------------------------------------------------
@@ -446,12 +493,15 @@ class SQLToolset(ToolPrefixMixin, AirflowToolset):
raise ValueError(f"Unknown tool: {name!r}")
try:
return await self.run_blocking(self._run_tool, base, tool_args)
+ except SQLToolsetConnectionError:
+ # Handed to the model as a retry, a connection failure makes it
give up and
+ # answer without data, or spend its retries on something no SQL
can fix.
+ raise
except Exception as e:
- # Hand the database's own error back to the agent as a retry so it
can
- # read the message and fix its SQL within the run. pydantic-ai
bounds
- # this by the tool's max_retries, so an unrecoverable error (a bad
- # connection, an auth failure) exhausts the budget and fails the
task
- # for Airflow to retry, rather than being silently worked around.
+ if _is_lost_connection(e):
+ raise _build_connection_error(self._db_conn_id, e) from e
+ # The statement failed: hand the database's own error back to the
agent so it
+ # can fix its SQL within the run, bounded by the tool's
max_retries.
raise ModelRetry(
f"The {name} tool failed: {e}\n"
f"Use the {self._build_tool_name('list_tables')} and
{self._build_tool_name('get_schema')} tools "
@@ -480,7 +530,7 @@ class SQLToolset(ToolPrefixMixin, AirflowToolset):
return schema, table
def _list_tables(self) -> str:
- hook = self._get_db_hook()
+ inspector = self._get_inspector()
tables: list[str] = []
# Dedupe by (schema, table) so a table reachable both qualified and
via the
# default schema (e.g. "public.users" and "users" with
schema="public") is
@@ -496,13 +546,13 @@ class SQLToolset(ToolPrefixMixin, AirflowToolset):
# Schemas referenced by qualified allowed_tables entries: introspect
each
# and return matching tables fully qualified so they round-trip to
get_schema.
for schema in sorted(self._qualified_schemas):
- for name in hook.inspector.get_table_names(schema=schema):
+ for name in inspector.get_table_names(schema=schema):
add(schema, name, f"{schema}.{name}")
# Default schema: used for allow-all and unqualified allowed_tables
entries.
# Names stay bare to preserve the single-schema behaviour.
if self._include_default_schema:
- for name in hook.inspector.get_table_names(schema=self._schema):
+ for name in inspector.get_table_names(schema=self._schema):
add(self._schema, name, name)
return dumps_masked(tables)
@@ -511,8 +561,10 @@ class SQLToolset(ToolPrefixMixin, AirflowToolset):
schema, table = self._split_table_identifier(table_name)
if not self._is_ref_allowed("", schema, table):
return dumps_masked({"error": f"Table {table_name!r} is not in the
allowed tables list."})
- hook = self._get_db_hook()
- columns = hook.get_table_schema(table, schema=schema)
+ columns = [
+ {"name": col["name"], "type": str(col["type"])}
+ for col in self._get_inspector().get_columns(table, schema=schema)
+ ]
return build_schema_result(
columns,
max_columns=self._max_columns,
@@ -524,7 +576,12 @@ class SQLToolset(ToolPrefixMixin, AirflowToolset):
def _dialect_for_validation(self) -> str | None:
"""Resolve the hook's sqlglot dialect so DESCRIBE/SHOW validate
correctly."""
hook = self._get_db_hook()
- return resolve_sqlglot_dialect(getattr(hook, "dialect_name", None))
+ # dialect_name builds the hook's URI, which can fetch credentials
(Databricks).
+ try:
+ dialect_name = getattr(hook, "dialect_name", None)
+ except Exception as e:
+ raise _build_connection_error(self._db_conn_id, e) from e
+ return resolve_sqlglot_dialect(dialect_name)
def _query(self, sql: str) -> str:
hook = self._get_db_hook()
diff --git
a/providers/common/ai/tests/unit/common/ai/durable/test_capability.py
b/providers/common/ai/tests/unit/common/ai/durable/test_capability.py
index 7d643479abf..f23827c94c9 100644
--- a/providers/common/ai/tests/unit/common/ai/durable/test_capability.py
+++ b/providers/common/ai/tests/unit/common/ai/durable/test_capability.py
@@ -40,6 +40,7 @@ from pydantic_ai.mcp import MCPToolset
from pydantic_ai.messages import (
ModelMessage,
ModelResponse,
+ RetryPromptPart,
TextPart,
ToolCallPart,
ToolReturn,
@@ -59,11 +60,13 @@ from airflow.providers.common.ai.durable.capability import (
_JournalCodec,
)
from airflow.providers.common.ai.durable.journal import DurableJournal,
Unrecordable, journal_scope
-from airflow.providers.common.ai.exceptions import DurableJournalError
+from airflow.providers.common.ai.exceptions import DurableJournalError,
SQLToolsetConnectionError
+from airflow.providers.common.ai.toolsets.sql import SQLToolset
from airflow.providers.common.ai.utils.toolset_base import AirflowToolset,
ensure_masked
from airflow.providers.common.compat.sdk import AirflowFailException
from unit.common.ai.durable.memory_storage import MemoryStorage
+from unit.common.ai.sqlite_connection import create_orders_table, run_sql,
sqlite_connection
from unit.common.ai.tool_parts import is_tool_result, is_tool_retry
if TYPE_CHECKING:
@@ -942,6 +945,21 @@ def clock_then_lookup(calls: Calls, *, fail_final:
list[bool]):
return model_fn
+def _query_then_answer(sql: str, *, fail_final: list[bool]):
+ """A model that runs ``sql`` once, then answers with whatever the tool
sent back, error or rows."""
+
+ def model_fn(messages: list[ModelMessage], info: AgentInfo) ->
ModelResponse:
+ if responses_so_far(messages) == 0:
+ return ModelResponse(parts=[ToolCallPart("query", {"sql": sql})])
+ if fail_final[0]:
+ raise RuntimeError("worker died")
+ answered = messages[-1].parts[0]
+ assert isinstance(answered, (RetryPromptPart, ToolReturnPart))
+ return ModelResponse(parts=[TextPart(f"answer: {answered.content}")])
+
+ return model_fn
+
+
class TestToolOptOut:
"""A tool whose metadata says ``{"airflow": False}`` runs live on every
attempt."""
@@ -1266,6 +1284,52 @@ class TestGetCallableToolsetId:
assert _get_callable_toolset_id(function) is None
+class TestSQLToolsetErrors:
+ """A database that could not be reached is queried again on retry; a
rejected statement replays."""
+
+ @staticmethod
+ def _agent(model_fn) -> Agent[None, str]:
+ return Agent(
+ FunctionModel(model_fn),
+ name="analyst",
+ toolsets=[SQLToolset("warehouse")],
+ capabilities=[AirflowDurability()],
+ )
+
+ @pytest.mark.asyncio
+ async def test_a_query_that_could_not_connect_runs_again_on_retry(self,
memory_storage, tmp_path):
+ path = tmp_path / "warehouse" / "warehouse.db" # its directory does
not exist yet
+ model_fn = _query_then_answer("SELECT count(*) FROM orders",
fail_final=[False])
+
+ with sqlite_connection("warehouse", path):
+ # A model handed the failure would answer with it; the attempt
fails instead.
+ with pytest.raises(
+ SQLToolsetConnectionError, match="connection 'warehouse':
.*unable to open database file"
+ ):
+ await attempt(memory_storage, self._agent(model_fn))
+ create_orders_table(path)
+ result = await attempt(memory_storage, self._agent(model_fn))
+
+ assert '"rows":[[2]]' in result.output
+
+ @pytest.mark.asyncio
+ async def test_a_query_naming_a_missing_column_replays_on_retry(self,
memory_storage, tmp_path):
+ path = tmp_path / "warehouse.db"
+ create_orders_table(path)
+ fail_final = [True]
+ model_fn = _query_then_answer("SELECT amount FROM orders",
fail_final=fail_final)
+
+ with sqlite_connection("warehouse", path):
+ with pytest.raises(RuntimeError):
+ await attempt(memory_storage, self._agent(model_fn))
+ # Run live, the query would now succeed; replayed, it fails as it
did.
+ run_sql(path, "ALTER TABLE orders ADD COLUMN amount REAL")
+ fail_final[0] = False
+ result = await attempt(memory_storage, self._agent(model_fn))
+
+ assert "no such column: amount" in result.output
+
+
class Answer(BaseModel):
rows: int
diff --git a/providers/common/ai/tests/unit/common/ai/sqlite_connection.py
b/providers/common/ai/tests/unit/common/ai/sqlite_connection.py
new file mode 100644
index 00000000000..e5ef7f801df
--- /dev/null
+++ b/providers/common/ai/tests/unit/common/ai/sqlite_connection.py
@@ -0,0 +1,85 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+A real ``DbApiHook`` over a SQLite file, for tests that need a database to
fail like one.
+
+The sqlite provider is not a dependency of this one, so the hook is defined
here over the
+standard library's ``sqlite3``. Statements run through ``DbApiHook.run`` and
introspection
+through the SQLAlchemy inspector, so the errors the toolset sees are the
driver's own.
+"""
+
+from __future__ import annotations
+
+import sqlite3
+from collections.abc import Iterator
+from contextlib import closing, contextmanager
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import patch
+
+from airflow.providers.common.compat.sdk import AirflowNotFoundException
+from airflow.providers.common.sql.hooks.sql import DbApiHook
+
+
+class SQLiteFileHook(DbApiHook):
+ conn_name_attr = "sqlite_conn_id"
+ default_conn_name = "sqlite_default"
+
+ def __init__(self, path: Path) -> None:
+ super().__init__()
+ self.path = path
+
+ def get_conn(self) -> sqlite3.Connection:
+ return sqlite3.connect(self.path)
+
+ def get_uri(self) -> str:
+ return f"sqlite:///{self.path}"
+
+
+def run_sql(path: Path, *statements: str) -> None:
+ """Run ``statements`` against the database at ``path``, creating it if
needed."""
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with closing(sqlite3.connect(path)) as connection:
+ for statement in statements:
+ connection.execute(statement)
+ connection.commit()
+
+
+def create_orders_table(path: Path) -> None:
+ """Create the database at ``path`` with an ``orders`` table of two rows."""
+ run_sql(
+ path,
+ "CREATE TABLE orders (id INTEGER PRIMARY KEY, status TEXT)",
+ "INSERT INTO orders (status) VALUES ('shipped'), ('pending')",
+ )
+
+
+@contextmanager
+def sqlite_connection(conn_id: str, path: Path) -> Iterator[None]:
+ """Resolve the Airflow connection ``conn_id`` to a fresh
:class:`SQLiteFileHook` over ``path``."""
+
+ def get_connection(requested: str) -> SimpleNamespace:
+ if requested != conn_id:
+ raise AirflowNotFoundException(f"The conn_id `{requested}` isn't
defined")
+ return SimpleNamespace(get_hook=lambda: SQLiteFileHook(path))
+
+ with patch(
+ "airflow.providers.common.ai.toolsets.sql.BaseHook.get_connection",
+ autospec=True,
+ side_effect=get_connection,
+ ):
+ yield
diff --git
a/providers/common/ai/tests/unit/common/ai/tools/test__from_toolset.py
b/providers/common/ai/tests/unit/common/ai/tools/test__from_toolset.py
index 68f6d7233a7..f3b65545d43 100644
--- a/providers/common/ai/tests/unit/common/ai/tools/test__from_toolset.py
+++ b/providers/common/ai/tests/unit/common/ai/tools/test__from_toolset.py
@@ -42,6 +42,7 @@ from airflow.providers.common.ai.tools._from_toolset import (
from airflow.providers.common.ai.toolsets.hook import HookToolset
from airflow.providers.common.ai.toolsets.sql import SQLToolset
+from unit.common.ai.sqlite_connection import sqlite_connection
from unit.common.ai.toolsets.test_sql import _make_mock_db_hook
@@ -114,11 +115,20 @@ class TestSQLToolsetAirflowTools:
assert "sql" in result.content
ts._hook.run.assert_not_called()
- def test_a_database_that_keeps_failing_fails_the_call(self):
- """Bad credentials are not the model's to fix; the tool's retry budget
ends the loop."""
+ def test_a_database_that_cannot_be_reached_fails_the_first_call(self,
tmp_path):
+ ts = SQLToolset("warehouse")
+
+ with (
+ sqlite_connection("warehouse", tmp_path / "missing" /
"warehouse.db"),
+ pytest.raises(ToolCallError, match="Could not reach the database
of connection 'warehouse'"),
+ ):
+ asyncio.run(_by_name(ts.airflow_tools())["query"].call({"sql":
"SELECT status FROM orders"}))
+
+ def test_a_statement_that_keeps_failing_fails_the_call(self):
+ """The tool's retry budget ends a loop the model cannot correct its
way out of."""
ts = SQLToolset("pg_default")
ts._hook = _make_mock_db_hook()
- ts._hook.run.side_effect = ConnectionError("authentication failed")
+ ts._hook.run.side_effect = RuntimeError("canceling statement due to
statement timeout")
query = _by_name(ts.airflow_tools())["query"]
first = asyncio.run(query.call({"sql": "SELECT 1"}))
@@ -132,7 +142,7 @@ class TestSQLToolsetAirflowTools:
ts = SQLToolset("pg_default")
ts._hook = _make_mock_db_hook()
ts._hook.run.side_effect = RuntimeError(
- f"could not connect to postgresql://svc:{registered_secret}@db"
+ f'invalid input syntax for type uuid:
"postgresql://svc:{registered_secret}@db"'
)
result =
asyncio.run(_by_name(ts.airflow_tools())["query"].call({"sql": "SELECT 1"}))
diff --git a/providers/common/ai/tests/unit/common/ai/toolsets/test_sql.py
b/providers/common/ai/tests/unit/common/ai/toolsets/test_sql.py
index 57256030a57..cdbe3bf86a8 100644
--- a/providers/common/ai/tests/unit/common/ai/toolsets/test_sql.py
+++ b/providers/common/ai/tests/unit/common/ai/toolsets/test_sql.py
@@ -18,6 +18,8 @@ from __future__ import annotations
import asyncio
import json
+import sqlite3
+import traceback
from unittest.mock import MagicMock, PropertyMock, patch
import pytest
@@ -26,11 +28,15 @@ from pydantic_ai.exceptions import ModelRetry
from pydantic_ai.toolsets.abstract import ToolsetTool
from pydantic_core import ValidationError
from sqlalchemy.engine.reflection import Inspector
+from sqlalchemy.exc import OperationalError
+from airflow.providers.common.ai.exceptions import SQLToolsetConnectionError
from airflow.providers.common.ai.toolsets.sql import SQLToolset
from airflow.providers.common.ai.utils.tool_definition import
_SUPPORTS_RETURN_SCHEMA
from airflow.providers.common.sql.hooks.sql import DbApiHook
+from unit.common.ai.sqlite_connection import SQLiteFileHook,
create_orders_table, sqlite_connection
+
class _FakeCursor:
"""
@@ -63,7 +69,7 @@ def _make_mock_db_hook(
mock = MagicMock(spec=DbApiHook)
mock.inspector = MagicMock(spec=Inspector)
mock.inspector.get_table_names.return_value = table_names or ["users",
"orders"]
- mock.get_table_schema.return_value = table_schema or [
+ mock.inspector.get_columns.return_value = table_schema or [
{"name": "id", "type": "INTEGER"},
{"name": "name", "type": "VARCHAR"},
]
@@ -83,12 +89,15 @@ def _assert_executed(hook, sql: str) -> None:
assert hook.run.call_args.args[0] == sql
+def _call(ts: SQLToolset, name: str, tool_args: dict) -> str:
+ return asyncio.run(
+ ts.call_tool(name, tool_args, ctx=MagicMock(spec=RunContext),
tool=MagicMock(spec=ToolsetTool))
+ )
+
+
def _query(ts: SQLToolset, sql: str) -> dict:
"""Run the ``query`` tool and decode its JSON result."""
- result = asyncio.run(
- ts.call_tool("query", {"sql": sql}, ctx=MagicMock(spec=RunContext),
tool=MagicMock(spec=ToolsetTool))
- )
- return json.loads(result)
+ return json.loads(_call(ts, "query", {"sql": sql}))
class TestSQLToolsetInit:
@@ -281,17 +290,6 @@ class TestSQLToolsetListTables:
tables = json.loads(result)
assert tables == ["orders"]
- def test_introspection_error_raises_model_retry(self):
- """A failure while listing tables is returned to the agent as a
retry."""
- ts = SQLToolset("pg_default")
- mock_hook = _make_mock_db_hook()
- mock_hook.inspector.get_table_names.side_effect = Exception("could not
connect to server")
- ts._hook = mock_hook
-
- with pytest.raises(ModelRetry) as exc_info:
- asyncio.run(ts.call_tool("list_tables", {}, ctx=MagicMock(),
tool=MagicMock()))
- assert "could not connect to server" in exc_info.value.message
-
class TestSQLToolsetGetSchema:
def test_returns_column_info(self):
@@ -307,7 +305,7 @@ class TestSQLToolsetGetSchema:
"columns": [{"name": "id", "type": "INTEGER"}, {"name": "name",
"type": "VARCHAR"}],
"column_count": 2,
}
- mock_hook.get_table_schema.assert_called_once_with("users",
schema=None)
+ mock_hook.inspector.get_columns.assert_called_once_with("users",
schema=None)
def test_name_contains_filters_the_columns(self):
"""``name_contains`` threads from the tool call through to the bounded
result."""
@@ -368,13 +366,13 @@ class TestSQLToolsetGetSchema:
assert "error" in data
assert "secrets" in data["error"]
# The guard must short-circuit before touching the database.
- ts._hook.get_table_schema.assert_not_called()
+ ts._hook.inspector.get_columns.assert_not_called()
def test_introspection_error_raises_model_retry(self):
"""A failure while reading a table's schema is returned to the agent
as a retry."""
ts = SQLToolset("pg_default")
ts._hook = _make_mock_db_hook()
- ts._hook.get_table_schema.side_effect = Exception('relation "users"
does not exist')
+ ts._hook.inspector.get_columns.side_effect = Exception('relation
"users" does not exist')
with pytest.raises(ModelRetry) as exc_info:
asyncio.run(
@@ -600,13 +598,11 @@ class TestSQLToolsetQuery:
[
Exception("001003 (42000): SQL compilation error: unexpected
'rows'"),
RuntimeError("type mismatch"),
- ConnectionError("could not connect to server"),
],
)
def test_query_error_is_returned_to_agent_as_model_retry(self, error):
- """Any error from the query, whatever its type, is handed back to the
agent as a retry with
- the database's own message. The toolset never inspects the error type
or text; pydantic-ai's
- max_retries bounds the loop, so an unrecoverable error still fails the
task."""
+ """Any error once connected, whatever its type, is handed back to the
agent as a retry with
+ the database's own message; pydantic-ai's max_retries bounds the
loop."""
ts = SQLToolset("pg_default")
ts._hook = _make_mock_db_hook()
ts._hook.run.side_effect = error
@@ -626,8 +622,8 @@ class TestSQLToolsetQuery:
):
ts = SQLToolset("pg_default")
ts._hook = _make_mock_db_hook()
- ts._hook.run.side_effect = ConnectionError(
- f'connection to "db:5432" failed: password "{registered_secret}"
rejected'
+ ts._hook.run.side_effect = RuntimeError(
+ f'syntax error at or near "{registered_secret}": password
"{registered_secret}" rejected'
)
with pytest.raises(ModelRetry) as exc_info:
@@ -782,7 +778,7 @@ class TestSQLToolsetHookResolution:
ts = SQLToolset("bad_conn")
- with pytest.raises(ValueError, match="does not provide a DbApiHook"):
+ with pytest.raises(SQLToolsetConnectionError, match="does not provide
a DbApiHook"):
ts._get_db_hook()
@patch("airflow.providers.common.ai.toolsets.sql.BaseHook", autospec=True)
@@ -800,6 +796,134 @@ class TestSQLToolsetHookResolution:
mock_base_hook.get_connection.assert_called_once()
+class TestSQLToolsetConnectionFailures:
+ """Failing to reach the database raises; a statement the database rejects
goes back to the model."""
+
+ @pytest.mark.parametrize(
+ ("name", "tool_args"),
+ [
+ ("query", {"sql": "SELECT status FROM orders"}),
+ ("list_tables", {}),
+ ("get_schema", {"table_name": "orders"}),
+ ],
+ )
+ def test_raises_when_the_database_cannot_be_opened(self, tmp_path, name,
tool_args):
+ ts = SQLToolset("warehouse")
+
+ with (
+ sqlite_connection("warehouse", tmp_path / "missing" /
"warehouse.db"),
+ pytest.raises(
+ SQLToolsetConnectionError, match="connection 'warehouse':
.*unable to open database file"
+ ),
+ ):
+ _call(ts, name, tool_args)
+
+ def test_query_naming_a_missing_column_goes_back_to_the_model(self,
tmp_path):
+ """SQLite raises OperationalError for this too, so the exception class
cannot decide it."""
+ path = tmp_path / "warehouse.db"
+ create_orders_table(path)
+ ts = SQLToolset("warehouse")
+
+ with sqlite_connection("warehouse", path), pytest.raises(ModelRetry,
match="no such column: amount"):
+ _call(ts, "query", {"sql": "SELECT amount FROM orders"})
+
+ @pytest.mark.parametrize(
+ ("connection_invalidated", "expected"),
+ [(True, SQLToolsetConnectionError), (False, ModelRetry)],
+ ids=["connection-lost", "statement-rejected"],
+ )
+ def test_introspection_error_raises_only_when_the_connection_was_lost(
+ self, connection_invalidated, expected
+ ):
+ ts = SQLToolset("warehouse")
+ ts._hook = _make_mock_db_hook()
+ ts._hook.inspector.get_table_names.side_effect = OperationalError(
+ "SELECT name FROM sqlite_master",
+ {},
+ sqlite3.OperationalError("disk I/O error"),
+ connection_invalidated=connection_invalidated,
+ )
+
+ with pytest.raises(expected, match="disk I/O error"):
+ _call(ts, "list_tables", {})
+
+ def test_a_connection_that_is_not_defined_raises(self, tmp_path):
+ ts = SQLToolset("missing")
+
+ with (
+ sqlite_connection("warehouse", tmp_path / "warehouse.db"),
+ pytest.raises(
+ SQLToolsetConnectionError,
+ match="Could not reach the database of connection 'missing':
.*isn't defined",
+ ),
+ ):
+ _call(ts, "query", {"sql": "SELECT 1"})
+
+ @pytest.mark.parametrize(
+ ("name", "tool_args"), [("list_tables", {}), ("get_schema",
{"table_name": "orders"})]
+ )
+ def test_introspection_raises_when_sqlalchemy_cannot_be_set_up(self,
tmp_path, name, tool_args):
+ """A failure before any connect, such as a dialect SQLAlchemy cannot
load, is no SQL error either."""
+ create_orders_table(tmp_path / "warehouse.db")
+ ts = SQLToolset("warehouse")
+
+ with (
+ sqlite_connection("warehouse", tmp_path / "warehouse.db"),
+ patch.object(SQLiteFileHook, "get_uri", autospec=True,
return_value="nosuchdialect://"),
+ pytest.raises(SQLToolsetConnectionError, match="connection
'warehouse': .*nosuchdialect"),
+ ):
+ _call(ts, name, tool_args)
+
+ @pytest.mark.parametrize(
+ ("name", "tool_args"),
+ [
+ ("query", {"sql": "SELECT status FROM orders"}),
+ ("list_tables", {}),
+ ("get_schema", {"table_name": "orders"}),
+ ],
+ )
+ def test_raises_when_building_the_uri_fails(self, tmp_path, name,
tool_args):
+ """Some hooks fetch credentials to build their URI, before any
statement is sent."""
+ create_orders_table(tmp_path / "warehouse.db")
+ ts = SQLToolset("warehouse")
+
+ with (
+ sqlite_connection("warehouse", tmp_path / "warehouse.db"),
+ patch.object(
+ SQLiteFileHook,
+ "get_uri",
+ autospec=True,
+ side_effect=RuntimeError("token endpoint unreachable"),
+ ),
+ pytest.raises(
+ SQLToolsetConnectionError, match="connection 'warehouse':
token endpoint unreachable"
+ ),
+ ):
+ _call(ts, name, tool_args)
+
+ @pytest.mark.enable_redact
+ def test_a_connection_failure_carrying_a_secret_is_raised_masked(self,
tmp_path, registered_secret):
+ """Raised rather than handed to the model, the error still passes
through the toolset's masking."""
+ ts = SQLToolset("warehouse")
+
+ with (
+ sqlite_connection("warehouse", tmp_path / "warehouse.db"),
+ patch.object(
+ SQLiteFileHook,
+ "get_conn",
+ autospec=True,
+ side_effect=sqlite3.OperationalError(
+ f"could not open postgresql://svc:{registered_secret}@db"
+ ),
+ ),
+ pytest.raises(SQLToolsetConnectionError) as exc_info,
+ ):
+ _call(ts, "query", {"sql": "SELECT 1"})
+
+ assert registered_secret not in
"".join(traceback.format_exception(exc_info.value))
+ assert "postgresql://svc:***@db" in str(exc_info.value)
+
+
class TestSQLToolsetMultiSchema:
"""Schema-qualified allowed_tables span multiple schemas in one
database."""
@@ -808,7 +932,7 @@ class TestSQLToolsetMultiSchema:
hook = MagicMock(spec=DbApiHook)
hook.inspector = MagicMock()
hook.inspector.get_table_names.side_effect = lambda schema=None:
tables_by_schema.get(schema, [])
- hook.get_table_schema.return_value = [{"name": "id", "type":
"INTEGER"}]
+ hook.inspector.get_columns.return_value = [{"name": "id", "type":
"INTEGER"}]
return hook
def test_list_tables_spans_multiple_schemas(self):
@@ -860,7 +984,9 @@ class TestSQLToolsetMultiSchema:
)
)
assert result == {"columns": [{"name": "id", "type": "INTEGER"}],
"column_count": 1}
-
ts._hook.get_table_schema.assert_called_once_with("DEPLOYMENT_IMAGE_DETAILS",
schema="MODEL_ASTRO")
+ ts._hook.inspector.get_columns.assert_called_once_with(
+ "DEPLOYMENT_IMAGE_DETAILS", schema="MODEL_ASTRO"
+ )
def test_get_schema_blocks_table_outside_allowed_schema(self):
ts = SQLToolset("sf", allowed_tables=["MODEL_ASTRO.X"])
@@ -874,14 +1000,14 @@ class TestSQLToolsetMultiSchema:
)
)
assert "error" in result
- ts._hook.get_table_schema.assert_not_called()
+ ts._hook.inspector.get_columns.assert_not_called()
def test_get_schema_unqualified_uses_default_schema(self):
ts = SQLToolset("pg", schema="public")
ts._hook = self._schema_aware_hook({"public": ["users"]})
asyncio.run(ts.call_tool("get_schema", {"table_name": "users"},
ctx=MagicMock(), tool=MagicMock()))
- ts._hook.get_table_schema.assert_called_once_with("users",
schema="public")
+ ts._hook.inspector.get_columns.assert_called_once_with("users",
schema="public")
def test_list_tables_matches_case_insensitively(self):
"""Snowflake reflects unquoted names lowercased; uppercase
allowed_tables still match."""
@@ -914,7 +1040,9 @@ class TestSQLToolsetMultiSchema:
)
)
assert "error" not in result
-
ts._hook.get_table_schema.assert_called_once_with("deployment_image_details",
schema="MODEL_ASTRO")
+ ts._hook.inspector.get_columns.assert_called_once_with(
+ "deployment_image_details", schema="MODEL_ASTRO"
+ )
def test_list_tables_deduplicates_same_table(self):
"""A table listed both qualified and unqualified appears once."""