Skip to content

Commit c014b91

Browse files
authored
Bound SQL toolset query results by size, not just row count (#71317)
* Bound SQL toolset query results by size, not just row count A tool result stays in the model's message history for the rest of the run, so its cost is re-paid on every subsequent request. max_rows bounded rows, which says nothing about size: one row of a 3000-column table dwarfs a thousand rows of a narrow one, and the truncation happened after fetching the whole result, so the worker paid the full transfer cost for rows it then discarded. Three changes to the query tool of SQLToolset and DataFusionToolset: - The result is columnar. Column names are serialized once rather than once per row, and positional rows keep same-named columns that a dict per row silently collapsed. - max_result_bytes bounds the serialized payload. Rows are dropped from the end until it fits, and the result names the limit it hit so the agent can narrow its projection instead of paging. - SQLToolset fetches through DbApiHook.run's handler protocol with fetchmany rather than get_records, so rows past max_rows never leave the cursor. * Address review: trustworthy total_rows, byte-accurate budget, honest docs - total_rows: rowcount is only a query total on drivers that buffer the whole result. python-oracledb reports rows fetched so far, so after a capped fetch it equals the cap -- a 10M-row query would report total_rows: 51. Discard any count no larger than what was fetched; row_count is already the total when the result was not truncated. - Byte budget: ensure_ascii escaped each CJK character to six bytes instead of three, truncating a non-ASCII result several times earlier than the equivalent English one and charging the model for the escapes. Measure encoded bytes so max_result_bytes means what it says. - Truncation hint: "No row fits" was false whenever one wide row preceded narrow ones, and the partial case -- the common one -- carried no guidance at all. Name the row that stopped it, and hint on every byte-capped result. - A cursor that can neither describe nor fetch now raises instead of rendering as an empty table; the Exasol full-fetch path reports its exact total. - max_rows docs no longer imply the fetch bound reaches the database. No hook opens a server-side cursor here, so a client-buffering driver has already transferred the rows; only the Python conversion is skipped. - Changelog note for the output-shape change. * Correct docs that described the pre-fix truncation behaviour Rows are returned as a contiguous prefix, stopping at the first that does not fit the remaining budget. Three places still said "dropped from the end until the payload fits", which reads as though a wide row would be skipped and later ones packed in. Also: the max_rows parameter entry claimed rows beyond it are "never fetched from the cursor", contradicting the qualification the same page carries further down, and the query tool description told agents that `truncated` means more rows matched, when it also fires when the result was simply too large.
1 parent fd0f7e2 commit c014b91

8 files changed

Lines changed: 789 additions & 83 deletions

File tree

providers/common/ai/docs/changelog.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,18 @@
2525
Changelog
2626
---------
2727

28+
.. note::
29+
The ``query`` tool of ``SQLToolset`` and ``DataFusionToolset`` returns a different
30+
shape. Rows were a dict per row alongside a ``count`` of every matching row:
31+
``{"rows": [{"id": 1, "name": "a"}], "count": 900}``. They are now columnar, and
32+
``row_count`` counts the rows actually returned:
33+
``{"columns": ["id", "name"], "rows": [[1, "a"]], "row_count": 1}``. A truncated
34+
result also carries ``truncated_by`` -- ``max_rows`` or the new ``max_result_bytes``
35+
-- and ``total_rows`` appears only when the driver reports a trustworthy query
36+
total. The tool's own description states the new shape, so agents adapt without
37+
changes; update any system prompt that describes the old shape, and any code
38+
calling ``toolset.call_tool("query", ...)`` directly.
39+
2840
0.7.0
2941
.....
3042

providers/common/ai/docs/toolsets.rst

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,8 @@ Curated toolset wrapping
128128
* - ``get_schema``
129129
- Returns column names and types for a table
130130
* - ``query``
131-
- Executes a SQL query and returns rows as JSON
131+
- Executes a SQL query and returns bounded, columnar JSON (see
132+
:ref:`bounded-query-results`)
132133
* - ``check_query``
133134
- Validates SQL syntax without executing it
134135

@@ -203,6 +204,62 @@ Parameters
203204
Default ``False`` -- only SELECT-family and read-only metadata
204205
(``DESCRIBE``/``SHOW``) statements are permitted.
205206
- ``max_rows``: Maximum rows returned from the ``query`` tool. Default ``50``.
207+
Rows beyond it are not read out of a DBAPI cursor; what the driver has already
208+
transferred is its own call. See :ref:`bounded-query-results`.
209+
- ``max_result_bytes``: Budget for the serialized ``query`` result. Default 64 KiB.
210+
See :ref:`bounded-query-results`.
211+
212+
.. _bounded-query-results:
213+
214+
Bounded query results
215+
^^^^^^^^^^^^^^^^^^^^^
216+
217+
A tool result stays in the model's message history for the rest of the run, so its
218+
cost is re-paid on every subsequent model request. The ``query`` tool of both
219+
``SQLToolset`` and ``DataFusionToolset`` bounds that in three ways.
220+
221+
**The result is columnar.** Column names appear once, not once per row:
222+
223+
.. code-block:: json
224+
225+
{"columns": ["id", "name"], "rows": [[1, "Alice"], [2, "Bob"]], "row_count": 2}
226+
227+
On a table with thousands of columns the repeated names, not the values, are the bulk
228+
of a row-of-dicts payload. Positional rows also keep columns that share a name --
229+
``SELECT o.id, c.id`` -- which a dict per row silently collapsed to one.
230+
231+
**Rows are fetched, not filtered.** ``max_rows`` bounds what leaves the cursor, so a
232+
query matching a whole table costs the worker roughly what one matching ``max_rows``
233+
costs. How much is saved depends on the driver: with a server-side cursor the
234+
remaining rows are never sent, while a client-buffering driver (psycopg2's default
235+
cursor, MySQLdb) has already received them and only the per-row conversion is skipped.
236+
Hooks whose cursor is not DBAPI 2.0 (``ExasolHook`` passes a pyexasol statement) fall
237+
back to a full fetch, and ``DataFusionToolset`` materializes the full result in the
238+
engine before the toolset sees it; in both the payload is bounded but the transfer is
239+
not.
240+
241+
**A byte budget bounds the payload.** ``max_rows`` caps rows, which says nothing about
242+
size -- one row of a 3000-column table is larger than a thousand rows of a narrow one.
243+
``max_result_bytes`` is what actually bounds context. Rows are returned as a contiguous
244+
prefix: the result stops at the first row that does not fit the remaining budget rather
245+
than skipping it and packing later ones, so a single wide row early in the result ends
246+
it. The result says which limit it hit:
247+
248+
.. code-block:: json
249+
250+
{"columns": ["..."], "rows": ["..."], "row_count": 3,
251+
"truncated": true, "truncated_by": "max_result_bytes"}
252+
253+
``truncated_by`` is ``max_rows`` or ``max_result_bytes``. When not even one row fits,
254+
or the column names alone exceed the budget, the result carries a ``hint`` telling the
255+
agent to narrow its projection -- the only move that helps. ``total_rows`` is present
256+
when the driver reports a row count for the query; several (SQLite, some warehouse
257+
drivers) do not, and it is then omitted rather than guessed.
258+
259+
The default budget is deliberately generous: the columnar shape alone shrinks a wide
260+
result several-fold, so results that fit before still fit. Lower ``max_result_bytes``
261+
when an agent makes many queries in one run, since every result is re-paid on every
262+
later request.
206263

207264
``DataFusionToolset``
208265
---------------------
@@ -223,7 +280,8 @@ querying files on object stores (S3, local filesystem, Iceberg) via Apache DataF
223280
* - ``get_schema``
224281
- Returns column names and types for a table (Arrow schema)
225282
* - ``query``
226-
- Executes a SQL query and returns rows as JSON
283+
- Executes a SQL query and returns bounded, columnar JSON (see
284+
:ref:`bounded-query-results`)
227285

228286
Each :class:`~airflow.providers.common.sql.config.DataSourceConfig` entry
229287
registers a table backed by Parquet, CSV, Avro, or Iceberg data. Multiple
@@ -267,6 +325,8 @@ Parameters
267325
permitted. DataFusion on object stores is mostly read-only, but it does
268326
support DDL for in-memory tables; this guard blocks those by default.
269327
- ``max_rows``: Maximum rows returned from the ``query`` tool. Default ``50``.
328+
- ``max_result_bytes``: Budget for the serialized ``query`` result. Default 64 KiB.
329+
See :ref:`bounded-query-results`.
270330

271331
``LoggingToolset``
272332
------------------
@@ -616,10 +676,14 @@ No single layer is sufficient — they work together.
616676
``allowed_functions``. Fail-closed, but only as exact as the SQL parser. Not a
617677
security boundary -- always pair it with least-privilege database grants. See
618678
:ref:`allowed-tables-enforcement` below.
619-
* - **SQLToolset: max_rows**
620-
- Truncates query results to ``max_rows`` (default 50), preventing the
621-
agent from pulling entire tables into context.
622-
- Does not limit the number of queries the agent can make.
679+
* - **SQLToolset: max_rows / max_result_bytes**
680+
- Bounds a query result by rows (default 50) and by serialized size
681+
(default 64 KiB), preventing the agent from pulling entire tables into
682+
context.
683+
- Does not limit the number of queries the agent can make, and each result
684+
stays in message history for the rest of the run. Rows past ``max_rows``
685+
are not read out of the cursor, but a client-buffering driver has already
686+
transferred them -- this bounds context, not database or network load.
623687
* - **MCPToolset: external server**
624688
- Connects the agent to tools exposed by an MCP server, authenticated
625689
through an Airflow connection.
@@ -735,7 +799,8 @@ Recommended Configuration
735799
db_conn_id="analytics_readonly", # Connection with SELECT-only grants
736800
allowed_tables=["orders", "customers"], # Hide other tables from agent
737801
allow_writes=False, # Default — validates SQL
738-
max_rows=50, # Default — truncate large results
802+
max_rows=50, # Default — cap rows
803+
max_result_bytes=65536, # Default — cap bytes; lower it for wide tables
739804
)
740805
741806
**Agents that need to modify data** (use with caution):
@@ -763,8 +828,10 @@ Before deploying an agent task to production:
763828
agent can call any exposed tool with any arguments.
764829
4. **Read-only default**: Keep ``allow_writes=False`` unless the task
765830
specifically requires writes.
766-
5. **Row limits**: Set ``max_rows`` appropriate to the use case. Large
767-
result sets consume LLM context and increase cost.
831+
5. **Result limits**: Set ``max_rows`` and ``max_result_bytes`` appropriate to
832+
the use case. ``max_rows`` alone does not bound size -- on wide tables it is
833+
``max_result_bytes`` that keeps a result from dominating the context window for
834+
the rest of the run.
768835
6. **Model budget**: Configure pydantic-ai's ``model_settings`` (e.g.
769836
``max_tokens``) and ``retries`` to bound cost and prevent runaway loops.
770837
7. **System prompt**: Include safety instructions in ``system_prompt`` (e.g.

providers/common/ai/src/airflow/providers/common/ai/toolsets/datafusion.py

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@
3636
from pydantic_ai.tools import ToolDefinition
3737
from pydantic_ai.toolsets.abstract import AbstractToolset, ToolsetTool
3838

39+
from airflow.providers.common.ai.utils.query_results import (
40+
DEFAULT_MAX_RESULT_BYTES,
41+
QUERY_TOOL_DESCRIPTION as _QUERY_DESCRIPTION,
42+
build_query_result,
43+
)
3944
from airflow.providers.common.ai.utils.tool_definition import build_args_validator
4045

4146
if TYPE_CHECKING:
@@ -97,6 +102,15 @@ class DataFusionToolset(AbstractToolset[Any]):
97102
are permitted.
98103
:param max_rows: Maximum number of rows returned from the ``query`` tool.
99104
Default ``50``.
105+
:param max_result_bytes: Budget for the serialized ``query`` result, in bytes.
106+
Default 64 KiB. ``max_rows`` bounds rows, which says nothing about size: one
107+
row of a 3000-column table is larger than a thousand rows of a narrow one, and
108+
a tool result stays in the model's message history for the rest of the run, so
109+
its cost is re-paid on every subsequent request. Rows are returned as a
110+
contiguous prefix, stopping at the first that does not fit the remaining budget
111+
rather than skipping it and packing later ones, so one wide row early in the
112+
result ends it. The result reports which limit it hit so the agent can narrow
113+
its projection rather than page through the table.
100114
"""
101115

102116
def __init__(
@@ -105,12 +119,14 @@ def __init__(
105119
*,
106120
allow_writes: bool = False,
107121
max_rows: int = 50,
122+
max_result_bytes: int = DEFAULT_MAX_RESULT_BYTES,
108123
) -> None:
109124
if not datasource_configs:
110125
raise ValueError("datasource_configs must contain at least one DataSourceConfig")
111126
self._datasource_configs = datasource_configs
112127
self._allow_writes = allow_writes
113128
self._max_rows = max_rows
129+
self._max_result_bytes = max_result_bytes
114130
self._engine: DataFusionEngine | None = None
115131

116132
@property
@@ -133,7 +149,7 @@ async def get_tools(self, ctx: RunContext[Any]) -> dict[str, ToolsetTool[Any]]:
133149
for name, description, schema in (
134150
("list_tables", "List available table names.", _LIST_TABLES_SCHEMA),
135151
("get_schema", "Get column names and types for a table.", _GET_SCHEMA_SCHEMA),
136-
("query", "Execute a SQL query and return rows as JSON.", _QUERY_SCHEMA),
152+
("query", _QUERY_DESCRIPTION, _QUERY_SCHEMA),
137153
):
138154
tool_def = ToolDefinition(
139155
name=name,
@@ -199,16 +215,17 @@ def _query(self, sql: str) -> str:
199215
col_names = list(pydict.keys())
200216
num_rows = len(next(iter(pydict.values()), []))
201217

202-
result: list[dict[str, Any]] = [
203-
{col: pydict[col][i] for col in col_names} for i in range(min(num_rows, self._max_rows))
204-
]
205-
206-
truncated = num_rows > self._max_rows
207-
output: dict[str, Any] = {"rows": result, "count": num_rows}
208-
if truncated:
209-
output["truncated"] = True
210-
output["max_rows"] = self._max_rows
211-
return json.dumps(output, default=str)
218+
# DataFusion has already materialised the full result, so unlike SQLToolset
219+
# there is nothing left to avoid fetching -- only the payload is bounded.
220+
rows = [[pydict[col][i] for col in col_names] for i in range(min(num_rows, self._max_rows))]
221+
return build_query_result(
222+
col_names,
223+
rows,
224+
max_rows=self._max_rows,
225+
max_result_bytes=self._max_result_bytes,
226+
more_rows_available=num_rows > self._max_rows,
227+
total_rows=num_rows,
228+
)
212229
except SQLSafetyError as ex:
213230
log.warning("query failed SQL safety validation: %s", ex)
214231
raise

0 commit comments

Comments
 (0)