Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,24 @@ jobs:
- name: Test
run: uv run pytest -v

floor:
name: Test at the declared dependency floor
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v6
with:
enable-cache: true

- name: Set up Python
run: uv python install 3.11

# `uv sync --locked` installs the resolved ceiling, so nothing else here exercises
# the versions `pyproject.toml` actually advertises support for.
- name: Test
run: uv run --isolated --resolution lowest-direct --all-groups --python 3.11 pytest -q

checks:
name: Lint and types
runs-on: ubuntu-latest
Expand Down
76 changes: 76 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,82 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`keys` and `expires_at` on `hotdata_create_managed_database`.** Both were available on the
framework call underneath and neither was reachable from the tool. `keys` declares a table's
natural key, which can only be set at creation: a table declared without one is keyless for
the rest of its life, so `upsert`, `update` and `delete` are rejected against it and an agent
cannot make a re-run idempotent. `expires_at` takes an RFC 3339 timestamp or a relative window
such as `"24h"`, which makes lifetime a property of the database rather than a cleanup
script's problem.

- **`mode` and `key` on `hotdata_load_managed_table`.** The load hardcoded `mode="replace"`, so
`append`, `upsert`, `update` and `delete` were unreachable and an agent could not top up a
table it had already loaded. A keyed mode called without `key` now raises before the file is
uploaded rather than being rejected by the engine at the far end of a transfer that had
already happened.

- **`DESTRUCTIVE_TOOL_NAMES`, and `metadata={"destructive": True}` on the tools it names.**
`HumanInTheLoopMiddleware(interrupt_on=...)` is keyed by tool name, so wiring approval meant
inferring the mutating set from naming. Only the load tool is in it: creating a database makes
something new rather than overwriting something existing. The constant holds the *default*
names, so a set built with `tool_name_suffix` should be filtered on the metadata instead —
the README shows both.

- **`TablePartitionKey` and `TableSortKey` re-exported**, since both now appear in
`hl.create_managed_database`'s signature and a caller should not need a second import from a
package this one's docs do not name.

- **`partition_by` and `sorted_by` on `hl.create_managed_database`.** Reachable from Python,
and deliberately not offered to a model: layout is permanent, the API has no ALTER path, and
undoing a choice means deleting the table and reloading it, which burns the table name in
that database. A model has no basis for choosing a partition transform and cannot undo a
wrong one.

### Fixed

- **Building the tools raised on `langchain-core` 1.0.0**, the version this package declares as
its floor. Its docstring parser reads a colon in a wrapped `Args:` continuation line as the
start of a new argument, so `Arg one by key in docstring not found in function signature`
aborted every `make_hotdata_tools` call. Newer `langchain-core` parses it without complaint,
which is why the locked build never saw it. Found by running the suite at the floor, not from
a report.

### Changed

- **CI now runs the suite at the declared dependency floor** with
`uv --resolution lowest-direct`, alongside the existing matrix. `uv sync --locked` installs
the resolved ceiling, so until now nothing exercised the versions `pyproject.toml` advertises
support for — which is how the `langchain-core` break above reached a release, and how the
framework floor drifted three minor versions behind what the package was developed against.

- **`hotdata-framework>=0.13.0`** (from `>=0.10.0`). 0.13.0 is where an `append` load became
retryable — before it, `append` was excluded from retries, so a load that hit
`409 RESOURCE_LOCKED` failed whatever `max_retries` was set to. This release makes `append`
reachable from a tool, so the floor moves with it. It also brings terminal-vs-transient 409
classification and `Retry-After` handling. The suite passes on every version from the old
floor to this one; the bump is about the failure modes the new load modes can hit, not a
broken build.

### Notes

- `format` and `result_id` on the load are named in the issue this closes and are not here.
They exist on the `LoadManagedTableRequest` model but on **no** released version of the
framework client, which hardcodes the fields it forwards — so they need an upstream change
rather than a wider signature in this package.

- The load tool now states what each keyed mode does to a matched row rather than naming the
three together. `delete` removes matched rows and inserts nothing, which the previous wording
left open to reading as delete-then-insert by key — a misreading that destroys rows and
reports success. A `keys` entry naming an undeclared table is now refused rather than
silently creating a keyless table, which cannot be corrected afterwards.

- Retrying a failed `append` from an agent still duplicates rows, and no version fixes that.
Re-sending the same upload replays the server's receipt, but a tool call has no memory across
turns, so a repeat stages a fresh upload with no receipt to replay. The load tool's
description now says so, and points at `replace` or a keyed `upsert` instead.

## [0.14.0] - 2026-08-31

### Added
Expand Down
83 changes: 79 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ id of each.
| `hotdata_execute_sql` | Run a SQL query and return rows as JSON |
| `hotdata_list_managed_databases` | List available instant databases, with the id of each |
| `hotdata_create_managed_database` | Create a new instant database and return its id |
| `hotdata_load_managed_table` | Load a parquet file — local path or URL — into a managed table, addressed by database id |
| `hotdata_load_managed_table` | Load a parquet file — local path or URL — into a managed table, addressed by database id, replacing or merging with what it holds |
| `hotdata_describe_tables` | List tables, or one table's columns, types and how many rows hold a value |
| `hotdata_search_text` | Search an indexed column by text relevance, fused with meaning where the table supports it (opt-in — see below) |
| `hotdata_search_semantic` | Search an indexed column by meaning; replaces the above when the column carries a vector index |
Expand All @@ -79,6 +79,77 @@ tools = hl.make_hotdata_tools(client, database_id="dbid...", describe_tables=Fal
listing databases is itself a read, so the set it removes is the instant-database workflow
rather than everything that writes.

### Keys and lifetime are set once, at creation

A table's natural key can only be declared when the database is created. A table made without
one is keyless for the rest of its life, and `upsert`, `update` and `delete` are rejected
against it — so an agent that will load the same table twice needs `keys` on the first call or
it can never make a re-run idempotent. `expires_at` takes an RFC 3339 timestamp or a relative
window like `"24h"`; without it the database lives until something deletes it, which turns
lifetime into a cleanup script rather than a property of the thing created.

A keyed load with no `key` raises before the file is uploaded rather than after, since the
engine would reject it at the far end of a transfer that had already happened. A `keys` entry
naming a table the same call is not declaring is refused too, since it could never take effect.

The three keyed modes match an incoming row to an existing one and then differ in what they do
with it, so they are worth stating separately:

| `mode` | Matched row | Row that matches nothing |
|---|---|---|
| `upsert` | replaced | inserted |
| `update` | replaced | ignored |
| `delete` | **removed** | ignored |

`delete` inserts nothing. The rows you upload choose which existing rows to remove; they are
not added to the table.

**Do not repeat a failed `append`.** Re-sending the same upload replays the server's receipt
instead of applying the load twice, but a tool call has no memory across turns: a repeat stages
a fresh upload, which has no receipt to replay, and the rows land a second time. `replace` and
a keyed `upsert` both reach the same state however many times they run, so prefer those for
anything an agent might retry — and `handle_errors=True` makes a retry likely, since the
failure goes back to the model rather than ending the run.

`partition_by` and `sorted_by` are on `hl.create_managed_database` but not on the create tool.
Layout is permanent — the API has no ALTER path, and undoing a choice means deleting the table
and reloading it, which burns the table name in that database — so it is set by whoever builds
the tools, not chosen per call by a model:

```python

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

super nit: drop the blank first line of the code block (not blocking). Removing the from hotdata_framework import TableSortKey line left the blank line that separated it from the call.

db = hl.create_managed_database(
client, name="events", tables=["spans"],
keys={"spans": ["span_id"]},
sorted_by={"spans": [hl.TableSortKey(column="start_time")]},
)
```

### Wiring approval around the tools that can destroy data

`HumanInTheLoopMiddleware(interrupt_on=...)` is keyed by tool name, so the mutating set has to
be readable rather than inferred from naming:

```python
from langchain.agents.middleware import HumanInTheLoopMiddleware

middleware = HumanInTheLoopMiddleware(
interrupt_on=dict.fromkeys(hl.DESTRUCTIVE_TOOL_NAMES, True),
)
```

`DESTRUCTIVE_TOOL_NAMES` holds the **default** names. A set built with `tool_name_suffix`
carries different ones, so read the marking off the built tools instead:

```python
tools = hl.make_hotdata_tools(client, database_id="dbid...", tool_name_suffix="sales")
names = [t.name for t in tools if (t.metadata or {}).get("destructive")]
```

Creating a database is not in the set. It makes something new rather than overwriting
something existing, and gating it would put an approval in front of the one call an agent has
to make before it can do anything at all.

## Letting the model recover from a failed call

The tools raise on failure, which is right in a script and wrong in an agent: an exception out
Expand Down Expand Up @@ -207,12 +278,16 @@ created = tools["hotdata_create_managed_database"].invoke({
"name": "sales", # a display label, not an identifier
"schema_name": "public",
"tables": "orders,customers",
"keys": {"orders": ["id"]}, # only settable at creation
"expires_at": "7d", # or an RFC 3339 timestamp; omit to keep it forever
})

tools["hotdata_load_managed_table"].invoke({
"database_id": json.loads(created)["id"],
"table": "orders",
"file": "/path/to/orders.parquet", # or "https://example.com/orders.parquet"
"mode": "upsert", # default is "replace"
"key": ["id"],
})
```

Expand Down Expand Up @@ -650,9 +725,9 @@ tools = hl.make_hotdata_tools(client, database_id="dbid...")

**Databases are addressed by id, never by name.** A database name is a display label and is
not unique, so a name lookup can silently resolve to the wrong database — and the agent's
`hotdata_load_managed_table` overwrites the table it loads into. Passing a name raises
`KeyError` — or, under `handle_errors=True`, returns it to the model rather than resolving
anything. Ids come from `client.list_managed_databases()`, the
`hotdata_load_managed_table` overwrites the table it loads into unless a mode says otherwise.
Passing a name raises `KeyError` — or, under `handle_errors=True`, returns it to the model
rather than resolving anything. Ids come from `client.list_managed_databases()`, the
`hotdata_list_managed_databases` tool, or the response of a create.

The id is resolved once when the tools are built, so a bad id fails there rather than on the
Expand Down
15 changes: 14 additions & 1 deletion hotdata_langchain/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,18 @@
except PackageNotFoundError:
__version__ = "0.0.0+unknown"

from hotdata_framework import HotdataClient, ManagedDatabase, QueryResult, from_env
from hotdata_framework import (
HotdataClient,
ManagedDatabase,
QueryResult,
TablePartitionKey,
TableSortKey,
from_env,
)

from hotdata_langchain._sql import DISTANCE_FUNCTIONS, DistanceMetric
from hotdata_langchain.databases import (
LoadMode,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: re-export TablePartitionKey and TableSortKey (not blocking).

Both types now sit in the public signature of hl.create_managed_database (databases.py:172-173). Line 10 of this file already re-exports HotdataClient, ManagedDatabase and QueryResult from the framework for exactly that reason, so the convention is established.

Without the re-export, a caller who reached hl.create_managed_database through hotdata_langchain must add a second import from a package that is not named in this package's own docs. The new README example demonstrates the cost: from hotdata_framework import TableSortKey.

Add both names to line 10 and to __all__.

create_managed_database,
list_managed_databases_json,
load_managed_table,
Expand Down Expand Up @@ -75,6 +83,7 @@
DEFAULT_LIST_DATABASES_TOOL_NAME,
DEFAULT_LOAD_TABLE_TOOL_NAME,
DEFAULT_SQL_TOOL_NAME,
DESTRUCTIVE_TOOL_NAMES,
execute_sql_json,
make_hotdata_tools,
result_rows_for_llm,
Expand All @@ -94,6 +103,7 @@
"DEFAULT_SEARCH_TOOL_NAME",
"DEFAULT_SEMANTIC_TOOL_NAME",
"DEFAULT_SQL_TOOL_NAME",
"DESTRUCTIVE_TOOL_NAMES",
"DISTANCE_COLUMN",
"DISTANCE_FUNCTIONS",
"RRF_K",
Expand All @@ -106,12 +116,15 @@
"HotdataClient",
"HotdataToolError",
"HotdataVectorStore",
"LoadMode",
"ManagedDatabase",
"QueryResult",
"SearchIndex",
"SearchRoute",
"SearchStrategy",
"SearchableColumn",
"TablePartitionKey",
"TableSortKey",
"__version__",
"bm25_search_json",
"bm25_search_sql",
Expand Down
Loading
Loading