Bound SQL toolset query results by size, not just row count - #71317
Open
kaxil wants to merge 3 commits into
Open
Bound SQL toolset query results by size, not just row count#71317kaxil wants to merge 3 commits into
kaxil wants to merge 3 commits into
Conversation
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.
…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.
Contributor
There was a problem hiding this comment.
Pull request overview
This PR updates the Common AI SQL toolsets to keep query tool results cheap to retain in LLM message history by switching to a columnar result format, adding a max_result_bytes payload budget, and (for SQLToolset) fetching through DbApiHook.run(handler=...) with fetchmany(max_rows + 1) to avoid pulling unneeded rows out of the cursor.
Changes:
- Introduce a shared
build_query_result()helper that emits bounded, columnar JSON with truncation metadata (truncated_by,hint, optionaltotal_rows). - Update
SQLToolsetandDataFusionToolsetqueryto use the new shape and budget;SQLToolsetalso switches to a bounded cursor fetch pattern. - Add/adjust unit tests and provider docs/changelog to describe the new
queryoutput contract and configuration.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| providers/common/ai/src/airflow/providers/common/ai/utils/query_results.py | New shared helper for bounded, columnar query tool results and tool description text. |
| providers/common/ai/src/airflow/providers/common/ai/toolsets/sql.py | SQLToolset.query now uses capped fetch via run(handler=...) and emits bounded columnar results; adds max_result_bytes. |
| providers/common/ai/src/airflow/providers/common/ai/toolsets/datafusion.py | DataFusionToolset.query now emits bounded columnar results; adds max_result_bytes. |
| providers/common/ai/tests/unit/common/ai/utils/test_query_results.py | New unit tests for payload shape, truncation semantics, and byte accounting. |
| providers/common/ai/tests/unit/common/ai/toolsets/test_sql.py | Updates/expands tests for new output shape, bounded fetch behavior, and truncation metadata. |
| providers/common/ai/tests/unit/common/ai/toolsets/test_datafusion.py | Updates tests for new output shape and truncation metadata. |
| providers/common/ai/docs/toolsets.rst | Documents the new bounded/columnar result contract and new configuration parameter. |
| providers/common/ai/docs/changelog.rst | Notes the breaking/behavioral change in query tool output shape and guidance for callers/prompts. |
Suppressed comments (1)
providers/common/ai/docs/toolsets.rst:244
- This docs section says rows are “dropped from the end until the payload fits”, but the actual implementation (
build_query_result) returns a contiguous prefix and stops at the first row that doesn’t fit the byte budget (it won’t skip an oversized row and include later ones). Adjusting the wording here will better set expectations for users reading the docs.
**A byte budget bounds the payload.** ``max_rows`` caps rows, which says nothing about
size -- one row of a 3000-column table is larger than a thousand rows of a narrow one.
``max_result_bytes`` is what actually bounds context. Rows are dropped from the end
until the payload fits, and the result says which limit it hit:
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
SQLToolset'squerytool returns a JSON dict per row and caps the result withmax_rows. Neither bounds what actually costs money.A tool result stays in the model's message history for the rest of the agent run, so its size is re-paid on every subsequent model request. On wide tables that gets expensive fast, and
max_rowsdoes not help:hook.get_records(sql)pulls the entire result set into the worker androws[: self._max_rows]then discards most of it. The full transfer cost is already paid for rows nothing ever sees.DataFusionToolset'squerytool has the same payload shape and the same row-only cap.Solution
Three changes to the
querytool of both toolsets.Columnar result. Column names are serialized once instead of once per row:
{"columns": ["id", "name"], "rows": [[1, "Alice"], [2, "Bob"]], "row_count": 2}Positional rows also fix a correctness bug:
SELECT o.id, c.idproduced a dict with oneidkey, silently dropping a column.max_result_bytesbudget (default 64 KiB). Rows are dropped from the end until the serialized payload fits. The result names the limit it hit --truncated_byismax_rowsormax_result_bytes-- so the agent can narrow its projection rather than page through the table. When not even one row fits, or the column names alone exceed the budget, the result carries ahintsaying so.Bounded fetch.
SQLToolsetfetches throughDbApiHook.run's handler protocol withfetchmany(max_rows + 1)instead ofget_records. Rows past the cap never leave the cursor; the extra row is what makes "there is more" knowable without fetching the rest.Measurements
Real SQLite database, 1200-column table, default settings:
The memory figure is SQLite, whose driver genuinely stops producing rows. On a
client-buffering driver the result has already been transferred by the time the first
row is read, and only the Python-object conversion is skipped -- a real saving, but a
smaller one. No Airflow hook opens a server-side cursor on this path, so treat the
row cap as a bound on what the agent is shown rather than on database or network load.
Asked to run
SELECT * FROM wideand report what it received, a model given noexplanation of the format answered:
which is the property that matters for a shape change: the agent reads a bounded result
as bounded rather than as an empty or complete table, and aligns positional values to
columnswithout being told how.Design decisions
Why a byte budget rather than offloading the result to XCom or object storage. Offloading is the better answer for keeping the full result available to downstream tasks, but it needs a result-locator contract and a backend, which belongs with the task-state/checkpoint work rather than in the toolset. Bounding the payload is orthogonal and useful either way -- an offloaded result still needs a bounded digest in the message history.
Why the default budget is generous. 64 KiB is roughly 16k tokens: large enough that ordinary queries are unaffected, small enough that no single result dominates a context window. The columnar shape alone shrinks a wide result several-fold, so results that fit before still fit -- the budget only bites where the payload was already pathological. Deployments whose agents make many queries per run should lower it.
Why
fetchmanyrather than pushing aLIMITinto the SQL. Rewriting user SQL is dialect-sensitive and changes the meaning of aggregate queries.run(handler=...)is the documentedDbApiHookextension point thatget_recordsis itself built on, and the same pathSQLExecuteQueryOperatoruses.Why
total_rowsis often absent.rowcountis only a query total on drivers that buffer the whole result up front. Others report rows fetched so far -- python-oracledb documents exactly that forSELECT-- so after a capped fetch it equals the cap. A ten-million-row Oracle query would otherwise reporttotal_rows: 51, which reads as authoritative and is wrong. A count no larger than what was fetched is indistinguishable from that case, so it is discarded; nothing is lost when the result was not truncated, becauserow_countis already the total there. Agents that need an exact total canSELECT COUNT(*).Why the payload is measured in UTF-8 bytes with
ensure_ascii=False. With the default escaping, one CJK character costs six bytes instead of three, so an identical result in Japanese would be truncated several times earlier than in English and the model would pay several times the tokens for it.Tradeoffs and limitations
count(total matched) is replaced byrow_count(rows returned) plus optionaltotal_rows;rowsholds positional lists instead of dicts. The oldcountwas the more confusing of the two -- it reported the full match count next to a truncatedrowsarray. Thequerytool description states the new shape, so agents get it in-band.hintin the result tells it so.ExasolHookhands its handler a pyexasol statement that signals "produced rows" throughresult_typerather thandescription. Bounding the fetch there needs driver-specific knowledge, so those keep the previous full-fetch behaviour; the payload is still bounded.DataFusionToolsetbounds the payload only. The engine materializes the full result before the toolset sees it, so there is nothing left to avoid fetching.BigQueryHook.get_records'locationguard no longer applies. It raises a clear "Need to specify 'location'" error thatrun()does not; a BigQuery connection withoutlocationnow fails further down instead. BigQuery is otherwise unaffected by the switch.get_schemais still unbounded. On a 3000-column table it returns 3000 column entries, and an agent usually calls it before querying. Truncating it is not obviously right -- those column names are information the agent needs to write SQL at all -- so it wants a different answer (column search or filtering) and is left alone here.Usage
Was generative AI tooling used to co-author this PR?
{pr_number}.significant.rst, in airflow-core/newsfragments. You can add this file in a follow-up commit after the PR is created so you know the PR number.