feat: full-text search and schema discovery tools for LangChain agents - #43
Conversation
The lock already carried 0.9.0/0.8.0 after #35, but the floors stayed at >=0.3.0/>=0.4.1, so a resolve that ignores the lock could still select framework 0.4.1. That version uploads through `POST /v1/files`, a route the API no longer serves, so `load_managed_table` fails with a bare `Not Found`. 0.9.0 uses the upload session/finalize flow. Also adds a `demo` dependency group (langchain, langchain-openai) for the end-to-end script; the package itself still depends only on langchain-core.
Exposes the engine's existing BM25 index to LangChain, adds a schema introspection tool, and rewrites the tool descriptions so the model can plan against the engine's real contract. `hotdata_search_text` (hotdata_langchain/search.py) wraps `bm25_search(...)`. The corpus is pinned when the tool is built rather than chosen by the model: nothing in the tool surface lets an agent discover which columns carry a BM25 index, and the engine errors outright instead of falling back to a scan when one is missing. Register the factory per corpus to expose several. Three engine behaviours are encoded in the SQL it builds — results come back in rowid order so ranking is requested explicitly; `k` is passed as the `bm25_search` fourth argument because `ORDER BY` blocks limit pushdown and the scan would otherwise fall back to a much larger default bound; and the projection defaults to the searched column so a wide table cannot flood the context. `hotdata_describe_tables` (hotdata_langchain/schema.py) reads `information_schema`, listing tables with their column counts, or one table's columns and types. Registered by default. Without it an agent guesses column names from the shape of data it has already seen. The tool descriptions now state the dialect, the supported constructs, where to look schema up, and the two constraints that silently produce wrong calls: SQL cannot rank text, and an aggregate query must reference a column (`COUNT(*)` alone is rejected). Every claim was checked against a live workspace and is pinned by tests/test_descriptions.py. This is not cosmetic — with a one-line description an agent wrote Postgres full-text SQL, which the engine rejects, and the run aborted; with the contract stated it takes the search-then-SQL path with no system-prompt guidance at all. The database tools also steer callers towards ids, since names are non-unique display labels. The search description deliberately names no mechanism, and a test enforces that it never says bm25/vector/hnsw, so hybrid retrieval can land underneath without changing the contract the model was given. Also fixes two things that were already broken on main: the README quickstart used `create_tool_calling_agent`/`AgentExecutor`, neither of which exists in LangChain v1, and both the quickstart and examples/langchain_basic.py ran SQL with no database scope, which the API now rejects. demo/ runs the whole path against a real workspace — managed database, data load, index build, direct search, then an agent choosing between search and SQL. docs/ carries the verified engine contract and the roadmap, checked in while the repo is small so the reasoning stays with the code.
| tools = hl.make_hotdata_tools( | ||
| client, | ||
| database=DATABASE, | ||
| max_rows=args.k, |
There was a problem hiding this comment.
nit: max_rows=args.k also caps the SQL tool's rows (not blocking).
max_rows in make_hotdata_tools is shared by hotdata_execute_sql and hotdata_search_text, so with the default --k 5 the agent's aggregate query in step 6 silently comes back truncated to 5 rows — to_records(max_rows=...) drops the rest while metadata.row_count still reports the true count. That is precisely the failure mode demo/README.md attributes to the model ("one run aggregated over the handful of matched listings instead of all listings in those neighbourhoods"), so the demo may be undercutting its own headline result.
Search hits and SQL rows are different budgets; suggest decoupling them:
| max_rows=args.k, | |
| max_rows=100, |
(and keep search_k=args.k as-is below).
| "table": f"{records[0]['table_schema']}.{records[0]['table_name']}", | ||
| "columns": [{"name": r["column_name"], "type": r["data_type"]} for r in records], | ||
| } | ||
| if len(records) == max_columns: |
There was a problem hiding this comment.
nit: truncated_at fires on an exact fit (not blocking).
A table with exactly max_columns columns returns exactly max_columns records and gets flagged as truncated, telling the model part of the schema is missing when it is complete — which is the one case where it might go guess a column name instead of trusting the list.
Fetching one extra row distinguishes the two:
result = client.execute_sql(table_columns_sql(table, limit=max_columns + 1), database=database)
records = result.to_records()
...
truncated = len(records) > max_columns
records = records[:max_columns]| def hotdata_search_text(query: str, k: int | None = None) -> str: | ||
| """Search indexed text by relevance and return ranked rows as JSON.""" | ||
| return bm25_search_json( | ||
| client, | ||
| table=table, | ||
| column=column, | ||
| query=query, | ||
| k=default_k if k is None else k, | ||
| columns=columns, | ||
| max_rows=max_rows, | ||
| database=database, | ||
| ) |
There was a problem hiding this comment.
nit: the model-supplied k has no ceiling (not blocking).
k goes straight into bm25_search(..., k) and the trailing LIMIT — and per the docstring the fourth argument is exactly what bounds the scan. A model that emits k: 100000 (a plausible hallucination when told "ask for more with 'k'") makes the engine rank and ship 100k rows, which are then thrown away down to max_rows in Python. The construction-time k is caller-controlled; the invocation-time one is not.
Clamping to max_rows costs nothing and keeps the two bounds consistent:
def hotdata_search_text(query: str, k: int | None = None) -> str:
"""Search indexed text by relevance and return ranked rows as JSON."""
return bm25_search_json(
client,
table=table,
column=column,
query=query,
k=default_k if k is None else min(k, max_rows),
columns=columns,
max_rows=max_rows,
database=database,
)|
|
||
| ```python | ||
| tools = [ | ||
| *hl.make_hotdata_tools(client, database="sf_airbnb"), | ||
| hl.make_hotdata_search_tool( | ||
| client, table="default.public.listings", column="description", | ||
| name="search_listings", database="sf_airbnb", | ||
| ), | ||
| hl.make_hotdata_search_tool( | ||
| client, table="default.public.reviews", column="comments", | ||
| name="search_reviews", database="sf_airbnb", | ||
| ), | ||
| ] | ||
| ``` |
There was a problem hiding this comment.
nit: this recipe silently gives up the SQL description's search steer (not blocking).
make_hotdata_tools(client, database="sf_airbnb") here is called without search_table/search_column, so has_search is false and sql_tool_description falls back to the no-search branch — the SQL tool never mentions search_listings/search_reviews. The multi-corpus path documented here therefore loses exactly the behaviour the PR description shows to be load-bearing (12-token description → to_tsvector → aborted run).
There is currently no way to tell make_hotdata_tools "search tools exist under these names" without also pinning one corpus: search_tool_name is ignored unless search_table/search_column are set. Worth either accepting a search_tool_names: Sequence[str] (or letting search_tool_name stand alone) so sql_tool_description can name them, or at minimum noting in this section that callers taking this path should pass their own description= to the SQL tool.
| ensure_bm25_index(client, db.default_connection_id) | ||
|
|
||
| step("4. Tools") | ||
| available = table_columns(client) |
There was a problem hiding this comment.
super nit: this hand-rolls schema discovery with SELECT * ... LIMIT 1 while the PR ships hl.describe_tables_json for exactly this (not blocking). Using the new helper here would both shorten step 4 and exercise the tool the demo is advertising.
| #: Cap on columns returned for a single table, so one wide table cannot flood the context. | ||
| DEFAULT_MAX_COLUMNS = 200 | ||
|
|
||
| _IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") |
There was a problem hiding this comment.
super nit: _IDENTIFIER_RE is defined identically in search.py:20 (not blocking). Two copies of the validator that stands between LLM-authored strings and a SQL literal is the kind of duplication that drifts — worth a shared _sql.py with _IDENTIFIER_RE / _validate_identifier.
- Clamp a model-supplied `k` to `max_rows`. It fed straight into `bm25_search(..., k)`, which is what bounds the scan, so a hallucinated `k: 100000` had the engine rank and ship rows that Python then discarded. The caller's own `k` stays untouched; only the model's is clamped. - `describe_tables` no longer reports a complete schema as truncated. A table with exactly `max_columns` columns returned exactly that many rows and got flagged, telling the model part of the schema was missing — the one thing likely to send it back to guessing column names. It now reads one row past the cap to tell exact-fit from truncated. - The demo passed `max_rows=args.k`, which caps the SQL tool as well as search. With the default `--k 5` the agent's aggregate silently came back truncated to five rows while `metadata.row_count` still reported the true count. Search hits and SQL rows are different budgets; the SQL one is now separate. Verified against the live engine: the aggregate returns ten neighbourhood rows where it previously returned five. - The demo hand-rolled schema discovery with `SELECT * ... LIMIT 1`; it now uses `describe_tables_json`, exercising the tool it advertises. - The README's multi-corpus recipe called `make_hotdata_tools` with no `search_table`/`search_column`, so `sql_tool_description` fell back to its no-search branch and never named the search tools — losing exactly the steer the descriptions exist to provide. It now configures the first corpus through the factory and adds further ones alongside. - `IDENTIFIER_RE`/`validate_identifier`/`quote_literal` move to `_sql.py`. Two copies of the validator standing between model-authored strings and a SQL literal is the kind of duplication that drifts.
| result = client.execute_sql(table_columns_sql(table, limit=max_columns + 1), database=database) | ||
| records = result.to_records() | ||
| if not records: | ||
| return json.dumps( | ||
| {"table": table, "columns": [], "error": f"no table named {table!r} in this database"}, | ||
| indent=2, | ||
| ) | ||
| truncated = len(records) > max_columns | ||
| records = records[:max_columns] |
There was a problem hiding this comment.
nit: the exact-fit fix opens an IndexError path for max_columns < 1 (not blocking).
make_hotdata_describe_tables_tool(client, max_columns=0) now queries LIMIT 1, gets one row, passes the if not records guard, sets truncated = 1 > 0, then slices records[:0] — and line 93's records[0]['table_schema'] raises IndexError. Before the max_columns + 1 change the same call emitted LIMIT 0, returned no rows and fell into the "no table named" branch: wrong, but not an exception.
make_hotdata_search_tool validates its analogous bound (max_rows must be >= 1); max_columns has no equivalent guard. Cheapest fix is to mirror it in the factory:
if max_columns < 1:
raise ValueError(f"max_columns must be >= 1, got {max_columns}")(and/or clamp inside describe_tables_json, since it is also public API).
| def table_overview_sql() -> str: | ||
| """Return SQL listing every table in the scoped database with its column count.""" | ||
| return ( | ||
| "SELECT table_schema, table_name, COUNT(column_name) AS column_count " | ||
| "FROM information_schema.columns " | ||
| "GROUP BY table_schema, table_name " | ||
| "ORDER BY table_schema, table_name" | ||
| ) |
There was a problem hiding this comment.
super nit: the overview has no row cap while the column list does (not blocking).
DEFAULT_MAX_COLUMNS exists "so one wide table cannot flood the context", but the no-argument path — the one an agent calls first, unprompted — returns a row per table with no bound. A database with a few thousand tables floods exactly the context the other branch protects. A LIMIT here plus a truncated_at in the payload would make the two paths consistent.
| **What this step reliably demonstrates is the routing**, not the arithmetic. Across | ||
| five runs the agent called the search tool every time and reached for the schema tool | ||
| in four, which is the behaviour the demo exists to show. The final table was right in | ||
| four of five — one run aggregated over the handful of matched listings instead of all | ||
| listings in those neighbourhoods. Answering a compound question correctly is a property | ||
| of the model, not of these tools, so read the printed tool calls as the result and treat | ||
| the prose answer as illustrative. |
There was a problem hiding this comment.
super nit: this five-run measurement predates the max_rows fix (not blocking).
The 5/5, 4/5 and "right in four of five" numbers were gathered while the demo still passed max_rows=args.k, so the SQL tool's aggregate was capped at five rows during those runs. The failure described here (aggregating over matched listings rather than the neighbourhoods) is a distinct, query-construction mistake, so the attribution to the model still reads as correct — but the numbers themselves are stale, and the fix commit only re-verified the row count, not the five-run pass rate. Either re-run the five and update, or note that the figures were taken before the row budget was decoupled.
The exact-fit fix in the previous commit introduced a crash: reading one row past the cap means `max_columns=0` queries `LIMIT 1`, gets a row, passes the empty-result guard, slices the list to empty and then indexes it — `IndexError`. Before that change the same call emitted `LIMIT 0` and fell into the "no table named" branch, which was wrong but did not raise. `max_columns` is now validated in both `describe_tables_json` and the tool factory, mirroring how `make_hotdata_search_tool` already guards `max_rows`. demo/README.md's five-run figures were gathered before the search and SQL row budgets were decoupled, so they described a configuration that no longer exists. Re-measured against the current one: the search and schema tools are called in 5/5 runs and the aggregate matches the whole-neighbourhood figures in 5/5, up from 4/5. Two runs recovered from a bad query after the error was fed back. The text now reports those numbers and is explicit that five runs are not a guarantee, since answering a compound question is a property of the model. The unbounded table overview raised in the same review is recorded on #40 rather than fixed here: the no-argument path returns a row per table with no cap, while the per-table path caps columns.
| where = f"WHERE table_name = '{name}'" | ||
| if schema is not None: | ||
| where += f" AND table_schema = '{schema}'" |
There was a problem hiding this comment.
nit: a bare table name silently merges same-named tables across schemas (not blocking).
With no schema part the filter is WHERE table_name = 'listings' only, so if two schemas each hold a listings, describe_tables_json gets both schemas' rows back (ordered table_schema, table_name, ordinal_position) and flattens them into a single payload: line 97 labels it from records[0] — public.listings — while line 98 lists the union of both tables' columns.
The bare form is the one the tool description advertises first ("a table name ('listings' or 'public.listings')"), so the model will use it. The result is that it's told public.listings has columns that only exist in staging.listings — the exact "guess a column that isn't there" failure this tool was added to prevent, except now it looks authoritative. The max_columns + 1 cap makes it worse at the margin: with two 150-column listings tables and the default cap, the payload reads public.listings, 200 columns, truncated_at: 200.
Cheapest fix is to detect it rather than merge — the query already selects table_schema, so the ambiguity is visible in the records:
schemas = {r["table_schema"] for r in records}
if len(schemas) > 1:
return json.dumps(
{
"table": table,
"columns": [],
"error": f"{table!r} is ambiguous: it exists in {sorted(schemas)}; "
"qualify it as 'schema.table'",
},
indent=2,
)(placed after the if not records guard, before truncated is computed). Alternatively keep only records[0]'s schema and note the others.
There was a problem hiding this comment.
Prior findings all addressed: _sql.py shares the identifier/literal validators, describe_tables_json reads one row past the cap and max_columns < 1 is rejected in both the helper and the factory, model-supplied k is clamped to max_rows, the demo decouples SQL_MAX_ROWS from --k and discovers columns through describe_tables_json, the README multi-corpus recipe pins the first corpus with the reason inline, and demo/README.md re-measures five runs on the fixed budgets. One non-blocking nit left inline.
Note: I could not run pytest/ruff in this sandbox, so the "109 tests, clean" claim is unverified here — CI covers it.
Exposes RuntimeDB's existing BM25 index as a LangChain tool, adds schema introspection, and rewrites the tool descriptions so a model can plan against the engine's real contract.
hotdata_search_textWraps
bm25_search(...). The corpus is pinned when the tool is built rather than chosen by the model: nothing in the tool surface lets an agent discover which columns carry a BM25 index, and the engine errors outright instead of falling back to a scan when one is missing. Callmake_hotdata_search_toolonce per corpus to expose several — the agent then routes on the descriptions.Three verified engine behaviours are encoded in the SQL it builds:
ORDER BY score DESCis explicit. Confirmed live — without it the scores came back8.788, 8.092, 8.034, 8.254, 8.496.kis passed as the fourth argument, not left to the trailingLIMIT.ORDER BYblocks limit pushdown, so a query relying onLIMITalone lets the scan fall back to the engine's much larger default bound. Correctness is unaffected and the cost was not measurable at this corpus size, so this is a scan-bound difference rather than an observed slowdown — but passing it is free.score). The demo table is 85 columns wide; returning all of them would flood the model's context.hotdata_describe_tablesReads
information_schema, listing tables with their column counts or one table's columns and types, capped so a wide table cannot flood the context. Registered by default,describe_tables=Falseto omit.It closes a real gap: without it an agent guesses column names off the shape of data it has already seen. In an early run it produced
AVG(review_scores_rating)for a column never shown to it — correct only because the fixture is a well-known public dataset. On proprietary data that guess fails.Tool descriptions
The substantive change, and not originally on the roadmap. Descriptions now state the dialect, the supported constructs, where to look schema up, and the two constraints that silently produce wrong calls: SQL cannot rank text, and an aggregate query must reference at least one column (
COUNT(*)alone is rejected,COUNT(<column>)works).This is not cosmetic:
to_tsvector, query rejected, run abortedEvery claim was checked against a live workspace, which corrected two of my own earlier assumptions —
LIKE/ILIKEdo work (as substring tests, without ranking), and the aggregate rule is "must reference a column", not "ungrouped aggregates fail" (MIN/MAX/AVGungrouped are fine).tests/test_descriptions.pypins the load-bearing wording.The search description deliberately names no mechanism, and a test enforces that it never says bm25/vector/hnsw, so hybrid retrieval can land underneath without changing the contract the model was given.
The database tools also steer callers towards ids, since names are non-unique display labels — groundwork for #38.
Also fixes, both already broken on main
create_tool_calling_agent/AgentExecutor, neither of which exists in LangChain v1.examples/langchain_basic.pyran SQL with no database scope, which the API now rejects witha database is required. The example now runs.Dependency floors
Raises
hotdata-framework>=0.9.0/hotdata>=0.8.0. The lock already carried those after #35, but the floors still allowed framework 0.4.1 — which uploads throughPOST /v1/files, a route the API no longer serves, soload_managed_tablefails with a bareNot Found. Supersedes #31 (a subset of the same bump, since dependabot has already closed it).Verification
109 tests, ruff and format clean, no new mypy errors (the 18 pre-existing ones are untyped test functions, unchanged).
Verified end to end against a real workspace rather than only in CI: managed database creation, a 7,535-row load, BM25 index build, direct search, and a full agent loop with LangSmith tracing. That live path is how the framework 0.4.1 breakage was found — no test in the repo touches the upload route.
Repeating the agent step five times: the search tool was called 5/5 and the schema tool 4/5, so the routing behaviour is reliable. The final prose answer was right in 4/5 — one run aggregated over the matched listings instead of all listings in the matched neighbourhoods. That is a model property rather than something these tools control, and
demo/README.mdsays so plainly instead of implying the output is always right.demo/anddocs/demo/runs the whole path against a real workspace and is safe to re-run. It works with any tool-calling model — pass--model '<provider>:<model>'; steps 1–5 need no model at all.docs/carries the verified engine contract, the roadmap, and the VectorStore plan, checked in while the repo is small so the reasoning stays with the code.docs/engine-contract.mdis the source of truth the descriptions encode — if engine behaviour changes, a description is now lying to the model.Follow-ups
Filed as #36–#42, grouped by code surface. Nothing here blocks on them: #38 (address databases by id only) is the next intended change, and #23 is stale and can be dealt with separately.