Skip to content

Commit e4d397f

Browse files
authored
Bound get_schema results for very wide tables in the SQL toolsets (#74017)
## What `get_schema` in `SQLToolset` and `DataFusionToolset` returned every column of a table with no bound. On a table with a few thousand columns that is a six-figure-byte tool result, and because an agent calls `get_schema` before it can write a query, the result enters message history early and its cost is re-paid on every later model request. On a several-thousand-column table an agent could exhaust its context before issuing a single query, even after #71317 bounded the `query` tool. #71317 deliberately left `get_schema` alone, because the same fix does not transfer: column names are the information the agent needs to write SQL, so truncating to the first N columns leaves it unable to reference or discover the rest. ## How `get_schema` now mirrors the `query` tool's bounded-output contract: - It returns a JSON object `{"columns": [{"name", "type"}, ...], "column_count": N}` instead of a bare list. - It accepts an optional `name_contains` substring filter, so the agent can ask for the columns relevant to its question on a wide table. - Above `max_columns` (new constructor argument, default 100) or the existing `max_result_bytes` budget, the full list is replaced by a bounded summary: `column_count`, a `type_histogram`, a `sample_columns` preview, and `truncated` / `truncated_by` / `hint` that name the limit hit and point back at `name_contains`. Both toolsets move together. The shared logic lives in `build_schema_result` next to `build_query_result` in `query_results.py`. The provider changelog records the tool-result shape change, and the toolset docs gain a "Bounded schema results" section. --- ##### Was generative AI tooling used to co-author this PR? - [X] Yes - Claude Code (Opus 4.8) Generated-by: Claude Code (Opus 4.8) following [the guidelines](https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#gen-ai-assisted-contributions) --- 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 53e43f2 commit e4d397f

9 files changed

Lines changed: 585 additions & 33 deletions

File tree

‎providers/common/ai/docs/changelog.rst‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,18 @@ Changelog
3939
attempt is checked and counted on its own, unchanged. See :ref:`the cross-attempt usage
4040
budget <agent-usage-budget>`.
4141

42+
.. note::
43+
``get_schema`` on ``SQLToolset`` and ``DataFusionToolset`` now returns a JSON object
44+
``{"columns": [{"name", "type"}, ...], "column_count": N}`` instead of a bare JSON array of
45+
columns. The tool also accepts an optional ``name_contains`` substring filter, and on a table
46+
with more columns than ``max_columns`` (default 100), or one whose serialized columns exceed
47+
``max_result_bytes``, it returns a bounded summary (``column_count``, a ``type_histogram`` and a
48+
``sample_columns`` preview, with ``truncated``, ``truncated_by`` and a ``hint``) in place of the
49+
full list. Update any system prompt or direct ``call_tool("get_schema", ...)`` caller that read
50+
the old top-level array: check ``truncated`` first, then read ``result["columns"]`` on a full
51+
result or ``result["sample_columns"]`` on a summary. A summary carries no ``columns`` key, so
52+
``result["columns"]`` raises ``KeyError`` on any table wide enough to be summarized.
53+
4254
0.10.0
4355
......
4456

‎providers/common/ai/docs/toolsets/datafusion.rst‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ querying files on object stores (S3, GCS, local filesystem, Iceberg) via Apache
3737
* - ``list_tables``
3838
- Lists registered table names
3939
* - ``get_schema``
40-
- Returns column names and types for a table (Arrow schema)
40+
- Returns a table's columns (Arrow schema) as JSON, with a ``name_contains``
41+
filter and a bounded summary on very wide tables (see
42+
:ref:`bounded-schema-results`)
4143
* - ``query``
4244
- Executes a SQL query and returns bounded, columnar JSON (see
4345
:ref:`bounded-query-results`)
@@ -84,8 +86,11 @@ Parameters
8486
permitted. DataFusion on object stores is mostly read-only, but it does
8587
support DDL for in-memory tables; this guard blocks those by default.
8688
- ``max_rows``: Maximum rows returned from the ``query`` tool. Default ``50``.
87-
- ``max_result_bytes``: Budget for the serialized ``query`` result. Default 64 KiB.
88-
See :ref:`bounded-query-results`.
89+
- ``max_result_bytes``: Budget for the serialized ``query`` result, and the byte backstop
90+
that also triggers the ``get_schema`` summary. Default 64 KiB.
91+
See :ref:`bounded-query-results` and :ref:`bounded-schema-results`.
92+
- ``max_columns``: Maximum columns ``get_schema`` returns in full. Default ``100``.
93+
Above it the result becomes a bounded summary. See :ref:`bounded-schema-results`.
8994
- ``max_retries``: How many times the model may correct a failed call to these
9095
tools. Default ``None``, the agent's ``retries``. See :ref:`toolset-retry-budget`.
9196

‎providers/common/ai/docs/toolsets/sql.rst‎

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ Curated toolset wrapping
3030
* - ``list_tables``
3131
- Lists available table names (filtered by ``allowed_tables`` if set)
3232
* - ``get_schema``
33-
- Returns column names and types for a table
33+
- Returns a table's columns as JSON, with a ``name_contains`` filter and a
34+
bounded summary on very wide tables (see :ref:`bounded-schema-results`)
3435
* - ``query``
3536
- Executes a SQL query and returns bounded, columnar JSON (see
3637
:ref:`bounded-query-results`)
@@ -203,8 +204,11 @@ Parameters
203204
- ``max_rows``: Maximum rows returned from the ``query`` tool. Default ``50``.
204205
Rows beyond it are not read out of a DBAPI cursor; what the driver has already
205206
transferred is its own call. See :ref:`bounded-query-results`.
206-
- ``max_result_bytes``: Budget for the serialized ``query`` result. Default 64 KiB.
207-
See :ref:`bounded-query-results`.
207+
- ``max_result_bytes``: Budget for the serialized ``query`` result, and the byte backstop
208+
that also triggers the ``get_schema`` summary. Default 64 KiB.
209+
See :ref:`bounded-query-results` and :ref:`bounded-schema-results`.
210+
- ``max_columns``: Maximum columns ``get_schema`` returns in full. Default ``100``.
211+
Above it the result becomes a bounded summary. See :ref:`bounded-schema-results`.
208212
- ``max_retries``: How many times the model may correct a failed call to these
209213
tools. Default ``None``, the agent's ``retries``. See :ref:`toolset-retry-budget`.
210214

@@ -263,6 +267,37 @@ result several-fold, so results that fit before still fit. Lower ``max_result_by
263267
when an agent makes many queries in one run, since every result is re-paid on every
264268
later request.
265269

270+
.. _bounded-schema-results:
271+
272+
Bounded schema results
273+
----------------------
274+
275+
``get_schema`` has the same context problem as ``query`` but cannot be solved the same
276+
way. Column names are what the agent needs to write SQL, so truncating to the first N
277+
columns would leave it unable to reference or discover the rest. The tool filters and
278+
summarizes instead.
279+
280+
**Filter with** ``name_contains``. Pass a case-insensitive substring to get back only
281+
the columns whose name contains it, so on a very wide table the agent asks for the
282+
columns relevant to its question rather than all of them.
283+
284+
**A summary replaces a very wide list.** Above ``max_columns`` (default ``100``), or when
285+
the serialized columns exceed ``max_result_bytes``, the full list is replaced by a
286+
summary that reports the shape and points at the filter:
287+
288+
.. code-block:: json
289+
290+
{"column_count": 3200, "truncated": true, "truncated_by": "max_columns",
291+
"hint": "...", "type_histogram": {"VARCHAR": 2000, "NUMBER": 1200},
292+
"sample_columns": [{"name": "id", "type": "NUMBER"}]}
293+
294+
``truncated_by`` is ``max_columns`` or ``max_result_bytes``. ``type_histogram`` counts
295+
columns per type (capped to the most common, the tail folded into one entry), and
296+
``sample_columns`` previews the first columns -- both only while they fit the budget, so
297+
a pathologically small budget still returns the ``column_count`` and ``hint``. A filtered
298+
call echoes ``name_contains`` and adds ``total_columns`` so a subset is never mistaken for
299+
the whole table.
300+
266301
When to choose it
267302
-----------------
268303

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

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,12 @@
3737

3838
from airflow.providers.common.ai.utils.masking import dumps_masked
3939
from airflow.providers.common.ai.utils.query_results import (
40+
DEFAULT_MAX_COLUMNS,
4041
DEFAULT_MAX_RESULT_BYTES,
42+
GET_SCHEMA_TOOL_DESCRIPTION as _GET_SCHEMA_DESCRIPTION,
4143
QUERY_TOOL_DESCRIPTION as _QUERY_DESCRIPTION,
4244
build_query_result,
45+
build_schema_result,
4346
)
4447
from airflow.providers.common.ai.utils.tool_definition import build_args_validator
4548
from airflow.providers.common.ai.utils.toolset_base import AirflowToolset, validate_max_retries
@@ -61,6 +64,13 @@
6164
"type": "object",
6265
"properties": {
6366
"table_name": {"type": "string", "description": "Name of the table to inspect."},
67+
"name_contains": {
68+
"type": "string",
69+
"description": (
70+
"Return only columns whose name contains this substring (case-insensitive). "
71+
"Use it to find the relevant columns on a very wide table."
72+
),
73+
},
6474
},
6575
"required": ["table_name"],
6676
}
@@ -112,7 +122,8 @@ class DataFusionToolset(AirflowToolset):
112122
:param max_rows: Maximum number of rows returned from the ``query`` tool.
113123
Default ``50``. The query is limited to ``max_rows + 1`` rows, so a large
114124
result is never fully materialized; the extra row only signals truncation.
115-
:param max_result_bytes: Budget for the serialized ``query`` result, in bytes.
125+
:param max_result_bytes: Budget for the serialized ``query`` result, in bytes, and the
126+
byte backstop that also triggers the ``get_schema`` summary (see ``max_columns``).
116127
Default 64 KiB. ``max_rows`` bounds rows, which says nothing about size: one
117128
row of a 3000-column table is larger than a thousand rows of a narrow one, and
118129
a tool result stays in the model's message history for the rest of the run, so
@@ -121,6 +132,11 @@ class DataFusionToolset(AirflowToolset):
121132
rather than skipping it and packing later ones, so one wide row early in the
122133
result ends it. The result reports which limit it hit so the agent can narrow
123134
its projection rather than page through the table.
135+
:param max_columns: Maximum number of columns ``get_schema`` returns in full. Default
136+
``100``. Above it -- or when the serialized columns exceed ``max_result_bytes`` --
137+
the full list is replaced by a bounded summary (column count, a type histogram, a
138+
sample of columns) that points the agent at the ``name_contains`` filter, so a
139+
several-thousand-column table cannot exhaust the context before a query is written.
124140
:param max_retries: How many times the model may correct a failed call to one of these
125141
tools before the run fails. ``None`` (the default) uses the agent's tool retry
126142
budget, its ``retries``, as pydantic-ai's own toolsets do.
@@ -133,6 +149,7 @@ def __init__(
133149
allow_writes: bool = False,
134150
max_rows: int = 50,
135151
max_result_bytes: int = DEFAULT_MAX_RESULT_BYTES,
152+
max_columns: int = DEFAULT_MAX_COLUMNS,
136153
max_retries: int | None = None,
137154
) -> None:
138155
self._max_retries = validate_max_retries(max_retries)
@@ -142,6 +159,7 @@ def __init__(
142159
self._allow_writes = allow_writes
143160
self._max_rows = max_rows
144161
self._max_result_bytes = max_result_bytes
162+
self._max_columns = max_columns
145163
self._engine: DataFusionEngine | None = None
146164

147165
@property
@@ -164,7 +182,7 @@ async def get_tools(self, ctx: RunContext[Any]) -> dict[str, ToolsetTool[Any]]:
164182

165183
for name, description, schema in (
166184
("list_tables", "List available table names.", _LIST_TABLES_SCHEMA),
167-
("get_schema", "Get column names and types for a table.", _GET_SCHEMA_SCHEMA),
185+
("get_schema", _GET_SCHEMA_DESCRIPTION, _GET_SCHEMA_SCHEMA),
168186
("query", _QUERY_DESCRIPTION, _QUERY_SCHEMA),
169187
):
170188
tool_def = ToolDefinition(
@@ -192,7 +210,9 @@ async def execute_tool(
192210
if name == "list_tables":
193211
return await self.run_blocking(self._list_tables)
194212
if name == "get_schema":
195-
return await self.run_blocking(self._get_schema, tool_args["table_name"])
213+
return await self.run_blocking(
214+
self._get_schema, tool_args["table_name"], tool_args.get("name_contains")
215+
)
196216
if name == "query":
197217
return await self.run_blocking(self._query, tool_args["sql"])
198218
raise ValueError(f"Unknown tool: {name!r}")
@@ -206,7 +226,7 @@ def _list_tables(self) -> str:
206226
log.warning("list_tables failed: %s", ex)
207227
return dumps_masked({"error": str(ex)})
208228

209-
def _get_schema(self, table_name: str) -> str:
229+
def _get_schema(self, table_name: str, name_contains: str | None = None) -> str:
210230
engine = self._get_engine()
211231
# session_context lookup is required here instead of engine.registered_tables,
212232
# because registered_tables only tracks tables registered via datasource config.
@@ -220,7 +240,12 @@ def _get_schema(self, table_name: str) -> str:
220240
# TODO: refactor engine.get_schema() to return JSON and update this accordingly
221241
table = engine.session_context.table(table_name)
222242
columns = [{"name": f.name, "type": str(f.type)} for f in table.schema()]
223-
return dumps_masked(columns)
243+
return build_schema_result(
244+
columns,
245+
max_columns=self._max_columns,
246+
max_result_bytes=self._max_result_bytes,
247+
name_contains=name_contains,
248+
)
224249

225250
def _query(self, sql: str) -> str:
226251
try:

‎providers/common/ai/src/airflow/providers/common/ai/toolsets/sql.py‎

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,12 @@
4242

4343
from airflow.providers.common.ai.utils.masking import dumps_masked
4444
from airflow.providers.common.ai.utils.query_results import (
45+
DEFAULT_MAX_COLUMNS,
4546
DEFAULT_MAX_RESULT_BYTES,
47+
GET_SCHEMA_TOOL_DESCRIPTION as _GET_SCHEMA_DESCRIPTION,
4648
QUERY_TOOL_DESCRIPTION as _QUERY_DESCRIPTION,
4749
build_query_result,
50+
build_schema_result,
4851
)
4952
from airflow.providers.common.ai.utils.tool_definition import build_args_validator, return_schema_kwargs
5053
from airflow.providers.common.ai.utils.toolset_base import AirflowToolset, validate_max_retries
@@ -73,6 +76,13 @@
7376
"type": "object",
7477
"properties": {
7578
"table_name": {"type": "string", "description": "Name of the table to inspect."},
79+
"name_contains": {
80+
"type": "string",
81+
"description": (
82+
"Return only columns whose name contains this substring (case-insensitive). "
83+
"Use it to find the relevant columns on a very wide table."
84+
),
85+
},
7686
},
7787
"required": ["table_name"],
7888
}
@@ -241,7 +251,8 @@ class SQLToolset(AirflowToolset):
241251
the time the first row is read, so only the per-row Python conversion is
242252
skipped. Treat this as a bound on what the agent is shown, not as a guarantee
243253
that ``SELECT * FROM huge_table`` is cheap.
244-
:param max_result_bytes: Budget for the serialized ``query`` result, in bytes.
254+
:param max_result_bytes: Budget for the serialized ``query`` result, in bytes, and the
255+
byte backstop that also triggers the ``get_schema`` summary (see ``max_columns``).
245256
Default 64 KiB. ``max_rows`` bounds rows, which says nothing about size: one
246257
row of a 3000-column table is larger than a thousand rows of a narrow one, and
247258
a tool result stays in the model's message history for the rest of the run, so
@@ -250,6 +261,11 @@ class SQLToolset(AirflowToolset):
250261
rather than skipping it and packing later ones, so one wide row early in the
251262
result ends it. The result reports which limit it hit so the agent can narrow
252263
its projection rather than page through the table.
264+
:param max_columns: Maximum number of columns ``get_schema`` returns in full. Default
265+
``100``. Above it -- or when the serialized columns exceed ``max_result_bytes`` --
266+
the full list is replaced by a bounded summary (column count, a type histogram, a
267+
sample of columns) that points the agent at the ``name_contains`` filter, so a
268+
several-thousand-column table cannot exhaust the context before a query is written.
253269
:param max_retries: How many times the model may correct a failed call to one of these
254270
tools before the run fails. ``None`` (the default) uses the agent's tool retry
255271
budget, its ``retries``, as pydantic-ai's own toolsets do.
@@ -269,6 +285,7 @@ def __init__(
269285
allow_writes: bool = False,
270286
max_rows: int = 50,
271287
max_result_bytes: int = DEFAULT_MAX_RESULT_BYTES,
288+
max_columns: int = DEFAULT_MAX_COLUMNS,
272289
max_retries: int | None = None,
273290
) -> None:
274291
self._max_retries = validate_max_retries(max_retries)
@@ -294,6 +311,7 @@ def __init__(
294311
self._allow_writes = allow_writes
295312
self._max_rows = max_rows
296313
self._max_result_bytes = max_result_bytes
314+
self._max_columns = max_columns
297315
self._hook: DbApiHook | None = None
298316

299317
# Canonical ``(catalog, schema, table)`` view of allowed_tables for membership
@@ -379,7 +397,7 @@ async def get_tools(self, ctx: RunContext[Any]) -> dict[str, ToolsetTool[Any]]:
379397

380398
for name, description, schema in (
381399
("list_tables", "List available table names in the database.", _LIST_TABLES_SCHEMA),
382-
("get_schema", "Get column names and types for a table.", _GET_SCHEMA_SCHEMA),
400+
("get_schema", _GET_SCHEMA_DESCRIPTION, _GET_SCHEMA_SCHEMA),
383401
("query", _QUERY_DESCRIPTION, _QUERY_SCHEMA),
384402
("check_query", "Validate SQL syntax without executing it.", _CHECK_QUERY_SCHEMA),
385403
):
@@ -431,7 +449,7 @@ def _run_tool(self, name: str, tool_args: dict[str, Any]) -> str:
431449
if name == "list_tables":
432450
return self._list_tables()
433451
if name == "get_schema":
434-
return self._get_schema(tool_args["table_name"])
452+
return self._get_schema(tool_args["table_name"], tool_args.get("name_contains"))
435453
if name == "query":
436454
return self._query(tool_args["sql"])
437455
return self._check_query(tool_args["sql"])
@@ -475,13 +493,18 @@ def add(schema: str | None, name: str, display: str) -> None:
475493

476494
return dumps_masked(tables)
477495

478-
def _get_schema(self, table_name: str) -> str:
496+
def _get_schema(self, table_name: str, name_contains: str | None = None) -> str:
479497
schema, table = self._split_table_identifier(table_name)
480498
if not self._is_ref_allowed("", schema, table):
481499
return dumps_masked({"error": f"Table {table_name!r} is not in the allowed tables list."})
482500
hook = self._get_db_hook()
483501
columns = hook.get_table_schema(table, schema=schema)
484-
return dumps_masked(columns)
502+
return build_schema_result(
503+
columns,
504+
max_columns=self._max_columns,
505+
max_result_bytes=self._max_result_bytes,
506+
name_contains=name_contains,
507+
)
485508

486509
def _dialect_for_validation(self) -> str | None:
487510
"""Resolve the hook's sqlglot dialect so DESCRIBE/SHOW validate correctly."""

0 commit comments

Comments
 (0)