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
61 changes: 55 additions & 6 deletions integrations/agents/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
> change in a minor release while it stabilizes across bindings; pin a version if
> you depend on it.
>
> **Contract version: `0.2.0`** — exposed as `CONTRACT_VERSION` and via
> **Contract version: `0.3.0`** — exposed as `CONTRACT_VERSION` and via
> `capabilities()` in every binding. Downstream consumers probe capabilities
> instead of guessing from a package version (see *Versioning & capabilities*).

Expand Down Expand Up @@ -125,6 +125,19 @@ to TS, which has no in-process pandas). Bindings may omit it.
- Every result is capped by `max_rows` (and a `max_bytes` guard). When the engine
produced more than the cap, the result sets `truncated = true`. Truncation is
**never silent**.
- The row cap is **also enforced engine-side** (0.3.0): the session sets
`max_result_rows = max_rows + 1`, `result_overflow_mode='break'`,
`max_block_size=8192` — an unbounded SELECT stops at block granularity
instead of materializing everything before the trim, and the `+1` keeps the
`truncated` flag exact. A per-call `max_rows` above the constructor cap is
clamped to it (the engine bound is fixed at construction).
- The engine bound guards mistakes, not adversaries: a `SETTINGS` clause can
override engine limits under readonly=2 (the client-side trim still applies).
Engine quirk on affected builds (fixed upstream, unreleased): a query-level
`SETTINGS` clause persists for the whole session — cases using one run on a
dedicated tool.
- Truncated envelope results carry a `hint` (narrow, don't re-run unchanged);
wording binding-identical.
- `max_bytes` counts **UTF-8 bytes of each row's compact JSON encoding** (no
spaces, non-ASCII kept raw). This is normative because it is model-visible:
counting UTF-16 units or ASCII-escaped characters instead moves the truncation
Expand All @@ -142,22 +155,58 @@ to TS, which has no in-process pandas). Bindings may omit it.
(`UNKNOWN_TOOL` / `INVALID_ARGUMENT`), never as a raised exception.
- Direct library methods raise a typed error (`ChDBError` and subclasses).
- Error classification is shared: parse `Code: N. DB::Exception: <msg>. (TYPE)`;
map by code (`164→READONLY`, `62→SYNTAX`, `46/47/60/81/115→UNKNOWN_*`).
map by code (`164→READONLY`, `62→SYNTAX`, `46/47/60/81/115→UNKNOWN_*`,
`158/159/241/307/396→RESOURCE` — rows / timeout / memory / bytes / result-size limits).
- **The resource family (0.3.0)** gets its own error class
(`ChDBResourceError` or equivalent) and a `hint` field: the SQL was valid,
narrow it — don't abandon, don't retry unchanged. Hint wording
binding-identical.
- Binding-side validation failures (no engine round-trip) use `code: 0` with a
shared `type`: `INVALID_ARGUMENT` (bad argument value, e.g. a non-numeric
cap or an unknown `tool_specs` dialect), `ACCESS_DENIED` (allowlist),
cap or an unknown `tool_specs` dialect), `ALLOWLIST_DENIED` (allowlist;
**renamed from `ACCESS_DENIED` in 0.3.0** — it collided with engine error
497 of the same name),
`CONFIG_MISMATCH` (a caller-provided session whose readonly state conflicts
with the declared mode), `UNKNOWN_TOOL` (bad `call()` name), `TOOL_ERROR`
(any other non-engine failure surfaced through the envelope).
with the declared mode), `NETWORK_TIMEOUT` (the network watchdog abandoned a
query referencing a remote source — carries a hint; see P5), `UNKNOWN_TOOL`
(bad `call()` name), `TOOL_ERROR` (any other non-engine failure surfaced
through the envelope, including any query attempted after a watchdog
abandonment poisoned the tool).

### P5 — Resource and source controls (normative, optional-per-deployment)
- **Query timeout** — an optional `max_execution_time` (seconds) bounds runaway
queries at the engine (`TIMEOUT_EXCEEDED`). Off by default; set per deployment.
- **Memory bound (0.3.0)** — optional `max_memory_usage` (bytes); exceeding
raises `MEMORY_LIMIT_EXCEEDED`. Off by default.
- **Network watchdog (0.3.0)** — `network_timeout` (seconds, default 60,
None/0 disables) applies to queries referencing a network-source table
function (the shared `NETWORK_TABLE_FUNCTIONS` set; `file()` and local lake
variants exempt). Two layers: at construction the session gets a fast-fail
baseline (`http_connection_timeout` / `http_send_timeout` /
`http_receive_timeout` = min(nt, 10), `http_max_tries=1`,
`http_make_head_request=0` — the TLS handshake is bounded by
max(send, receive), not by connection_timeout, and one attempt costs ~4-5x
the setting); at query time the engine call runs under a `network_timeout`
deadline and expiry raises/envelopes `NETWORK_TIMEOUT` with a hint. An
engine-side timeout (`Poco::TimeoutException`) on a network-source query
gets the same hint attached. The watchdog is load-bearing, not cosmetic:
a black-holed HTTPS endpoint hangs the engine call unboundedly on affected
builds (blocked TLS handshake ignores every engine-side timeout), so there
is no error to translate unless the binding imposes one. The abandoned call
cannot be cancelled: afterwards the tool is **poisoned** (further queries →
`TOOL_ERROR`, `close()` leaves the session alone). The parked session is
released — closed when tool-owned — if the abandoned call ever settles; it
leaks for process lifetime only if the call never returns. In bindings with
a single-active-session constraint (TS), constructing a replacement tool may
need to wait for that settle. Bindings document this; they don't hide it.
- **Result-bytes backstop (0.3.0)** — optional `max_result_bytes`; break mode
truncates **without a flag**, so set it well above `max_bytes` and treat it
as an OOM backstop only. Off by default.
- **File allowlist** — an optional list of path prefixes. When set, raw SQL is
scanned for **every table function the running engine exposes** (live
`system.table_functions` snapshot, unioned with a static fallback) that is
not in the shared safe-by-construction set: each such call must carry a
**literal** source argument inside the allowlist, else `ACCESS_DENIED`. The
**literal** source argument inside the allowlist, else `ALLOWLIST_DENIED`. The
scan runs over masked SQL — string literals and comments blanked
position-preserving, backtick/double-quote-wrapped function names matched —
so a path-looking string never false-positives and quoting, comments, or a
Expand Down
16 changes: 11 additions & 5 deletions integrations/agents/conformance/cases.jsonl
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{"fixture": "chdb-agents-conformance", "contract_version": "0.2.0"}
{"fixture": "chdb-agents-conformance", "contract_version": "0.3.0"}
{"id": "p1_create_blocked", "pillar": "P1", "desc": "read-only blocks DDL at the engine", "method": "query", "args": {"sql": "CREATE TABLE conformance_t (a Int32) ENGINE = Memory"}, "expect": {"error_type": "READONLY"}}
{"id": "p1_escape_blocked", "pillar": "P1", "desc": "read-only cannot be lowered (no escape)", "method": "query", "args": {"sql": "SET readonly = 0"}, "expect": {"error_type": "READONLY"}}
{"id": "p1_file_allowed", "pillar": "P1", "desc": "CANARY: readonly=2 still allows file() (readonly=1 would reject)", "method": "query", "args": {"sql": "SELECT toInt32(count()) AS c FROM file('{{fixtures}}/sample.csv')"}, "expect": {"rows": [{"c": 2}]}}
Expand All @@ -13,16 +13,22 @@
{"id": "introspect_sample_limit", "pillar": "introspection", "desc": "get_sample_data honors limit", "method": "get_sample_data", "args": {"target": "numbers(100)", "limit": 2}, "expect": {"row_count": 2}}
{"id": "introspect_list_functions", "pillar": "introspection", "desc": "list_functions returns names", "method": "list_functions", "args": {"limit": 50}, "expect": {"min_len": 1}}
{"id": "safety_timeout", "pillar": "safety", "desc": "max_execution_time bounds a runaway query", "tool": {"max_execution_time": 1}, "method": "query", "args": {"sql": "SELECT count() FROM numbers(100000000000)"}, "expect": {"error_type": "TIMEOUT_EXCEEDED"}}
{"id": "safety_allowlist", "pillar": "safety", "desc": "file_allowlist blocks file() paths outside the allowlist", "tool": {"file_allowlist": ["/nonexistent-prefix/"]}, "method": "query", "args": {"sql": "SELECT * FROM file('{{fixtures}}/sample.csv')"}, "expect": {"error_type": "ACCESS_DENIED"}}
{"id": "safety_allowlist", "pillar": "safety", "desc": "file_allowlist blocks file() paths outside the allowlist", "tool": {"file_allowlist": ["/nonexistent-prefix/"]}, "method": "query", "args": {"sql": "SELECT * FROM file('{{fixtures}}/sample.csv')"}, "expect": {"error_type": "ALLOWLIST_DENIED"}}
{"id": "catalog_attachments", "pillar": "catalog", "desc": "a read-only tool can query files attached at construction", "tool": {"read_only": true, "attachments": {"rep": "{{fixtures}}/sample.csv"}}, "method": "query", "args": {"sql": "SELECT toInt32(count()) AS c FROM rep"}, "expect": {"rows": [{"c": 2}]}}
{"id": "p3_maxbytes_utf8", "pillar": "P3", "desc": "max_bytes counts UTF-8 bytes of compact JSON (not UTF-16 units or ASCII-escaped chars)", "tool": {"max_bytes": 60}, "method": "query", "args": {"sql": "SELECT '汉字汉字汉字' AS s FROM numbers(5)"}, "expect": {"truncated": true, "row_count": 2}}
{"id": "p3_maxrows_non_numeric", "pillar": "P3", "desc": "a non-numeric per-call max_rows is a typed error, never a silently disabled cap", "method": "query", "args": {"sql": "SELECT 1", "max_rows": "lots"}, "expect": {"error_type": "INVALID_ARGUMENT"}}
{"id": "p3_limit_non_numeric", "pillar": "P3", "desc": "a non-numeric get_sample_data limit is a typed error", "method": "get_sample_data", "args": {"target": "numbers(5)", "limit": "many"}, "expect": {"error_type": "INVALID_ARGUMENT"}}
{"id": "p2_empty_database_rejected", "pillar": "P2", "desc": "an explicit empty-string database is rejected, not silently treated as unqualified", "method": "call", "args": {"name": "describe_table", "arguments": {"target": "tables", "database": ""}}, "expect": {"envelope_ok": false, "error_type": "TOOL_ERROR"}}
{"id": "optional_dataframe_query", "pillar": "optional", "desc": "co-located DataFrame query via Python() (capability-gated: dataframe_query)", "requires": "dataframe_query", "method": "dataframe_query", "args": {"sql": "SELECT toInt32(sum(a)) AS s FROM Python(t)", "dataframes": {"t": {"a": [1, 2, 3]}}}, "expect": {"rows": [{"s": 6}]}}
{"id": "p4_malformed_arguments", "pillar": "P4", "desc": "a non-object call() arguments payload returns a typed envelope, not a throw", "method": "call", "args": {"name": "run_select_query", "arguments": "SELECT 1"}, "expect": {"envelope_ok": false, "error_type": "INVALID_ARGUMENT"}}
{"id": "p1_scanner_backtick_bypass", "pillar": "P1", "desc": "a backtick-quoted function name cannot bypass the allowlist gate", "tool": {"file_allowlist": ["/nonexistent-prefix/"]}, "method": "query", "args": {"sql": "SELECT * FROM `file`('{{fixtures}}/sample.csv')"}, "expect": {"error_type": "ACCESS_DENIED"}}
{"id": "p1_scanner_comment_between", "pillar": "P1", "desc": "a comment between function name and paren cannot hide the call from the gate", "tool": {"file_allowlist": ["/nonexistent-prefix/"]}, "method": "query", "args": {"sql": "SELECT * FROM file/**/('{{fixtures}}/sample.csv')"}, "expect": {"error_type": "ACCESS_DENIED"}}
{"id": "p1_scanner_non_literal_arg", "pillar": "P1", "desc": "a computed (non-literal) source argument is denied under an allowlist, not skipped", "tool": {"file_allowlist": ["/nonexistent-prefix/"]}, "method": "query", "args": {"sql": "SELECT * FROM file(concat('{{fixtures}}','/sample.csv'))"}, "expect": {"error_type": "ACCESS_DENIED"}}
{"id": "p1_scanner_backtick_bypass", "pillar": "P1", "desc": "a backtick-quoted function name cannot bypass the allowlist gate", "tool": {"file_allowlist": ["/nonexistent-prefix/"]}, "method": "query", "args": {"sql": "SELECT * FROM `file`('{{fixtures}}/sample.csv')"}, "expect": {"error_type": "ALLOWLIST_DENIED"}}
{"id": "p1_scanner_comment_between", "pillar": "P1", "desc": "a comment between function name and paren cannot hide the call from the gate", "tool": {"file_allowlist": ["/nonexistent-prefix/"]}, "method": "query", "args": {"sql": "SELECT * FROM file/**/('{{fixtures}}/sample.csv')"}, "expect": {"error_type": "ALLOWLIST_DENIED"}}
{"id": "p1_scanner_non_literal_arg", "pillar": "P1", "desc": "a computed (non-literal) source argument is denied under an allowlist, not skipped", "tool": {"file_allowlist": ["/nonexistent-prefix/"]}, "method": "query", "args": {"sql": "SELECT * FROM file(concat('{{fixtures}}','/sample.csv'))"}, "expect": {"error_type": "ALLOWLIST_DENIED"}}
{"id": "p1_scanner_string_literal_inert", "pillar": "P1", "desc": "a path-looking string literal does not false-positive the gate", "tool": {"file_allowlist": ["/nonexistent-prefix/"]}, "method": "query", "args": {"sql": "SELECT 'file(''/etc/passwd'')' AS s"}, "expect": {"rows": [{"s": "file('/etc/passwd')"}]}}
{"id": "p1_scanner_allowlisted_ok", "pillar": "P1", "desc": "an allowlisted literal path still works under the gate", "tool": {"file_allowlist": ["{{fixtures}}"]}, "method": "query", "args": {"sql": "SELECT toInt32(count()) AS c FROM file('{{fixtures}}/sample.csv')"}, "expect": {"rows": [{"c": 2}]}}
{"id": "p3_engine_row_bound", "pillar": "P3", "desc": "an unbounded over-cap SELECT is bounded engine-side (max_result_rows=cap+1, break) and still flagged", "method": "query", "args": {"sql": "SELECT toInt32(number) AS n FROM numbers(100000)"}, "expect": {"truncated": true, "row_count": 1000}}
{"id": "p3_percall_cap_clamped", "pillar": "P3", "desc": "a per-call max_rows above the constructor cap is clamped (the engine bound was fixed at construction; honoring more would truncate silently)", "tool": {"max_rows": 5}, "method": "query", "args": {"sql": "SELECT toInt32(number) AS n FROM numbers(10)", "max_rows": 50}, "expect": {"truncated": true, "row_count": 5}}
{"id": "p5_resource_error_typed", "pillar": "P5", "desc": "an engine resource limit surfaces as its engine type (resource family: 158/159/241/307/396); own tool: a SETTINGS clause persists on a chdb session (upstream quirk)", "tool": {}, "method": "query", "args": {"sql": "SELECT number FROM numbers(100) SETTINGS max_result_rows = 10, result_overflow_mode = 'throw'"}, "expect": {"error_type": "TOO_MANY_ROWS_OR_BYTES"}}
{"id": "p4_truncation_hint", "pillar": "P4", "desc": "a truncated envelope result carries a recovery hint for the model", "method": "call", "args": {"name": "run_select_query", "arguments": {"sql": "SELECT toInt32(number) AS n FROM numbers(2000)"}}, "expect": {"envelope_ok": true, "result_has_hint": true}}
{"id": "p4_resource_hint", "pillar": "P4", "desc": "a resource-limit error envelope carries a recovery hint (narrow, don't abandon or retry unchanged); own tool: a SETTINGS clause persists on a chdb session (upstream quirk)", "tool": {}, "method": "call", "args": {"name": "run_select_query", "arguments": {"sql": "SELECT number FROM numbers(100) SETTINGS max_result_rows = 10, result_overflow_mode = 'throw'"}}, "expect": {"envelope_ok": false, "error_type": "TOO_MANY_ROWS_OR_BYTES", "error_has_hint": true}}
{"id": "p5_network_baseline_settings", "pillar": "P5", "desc": "network_timeout injects the engine-side fast-fail network baseline (tries=1, no HEAD probe, small connect/send/receive timeouts — the TLS handshake is bounded by max(send, receive))", "tool": {"network_timeout": 30}, "method": "query", "args": {"sql": "SELECT toInt32(getSetting('http_max_tries')) AS tries, toInt32(getSetting('http_make_head_request')) AS head, toInt32(getSetting('http_connection_timeout')) AS conn, toInt32(getSetting('http_send_timeout')) AS snd, toInt32(getSetting('http_receive_timeout')) AS rcv"}, "expect": {"rows": [{"tries": 1, "head": 0, "conn": 10, "snd": 10, "rcv": 10}]}}
2 changes: 2 additions & 0 deletions integrations/agents/descriptors.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export interface Capabilities {
attachments: boolean
file_allowlist: boolean
max_execution_time: boolean
resource_caps: boolean
network_watchdog: boolean
async: boolean
streaming: boolean
[feature: string]: boolean
Expand Down
2 changes: 1 addition & 1 deletion integrations/agents/descriptors.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$comment": "Single source of truth for the model-visible agent-tool surface (names, descriptions, argument schemas). Governed by CONTRACT.md; a copy of this file is vendored byte-identical in every chdb-io binding (chdb-node integrations/agents/descriptors.json). Bindings GENERATE their tool_specs()/framework schemas from this file — hand-editing a generated schema instead of this file is a contract violation. Changing this file is a contract change: bump contract_version here, in the code constant, and in conformance/cases.jsonl's header.",
"contract_version": "0.2.0",
"contract_version": "0.3.0",
"tools": [
{
"name": "run_select_query",
Expand Down
8 changes: 7 additions & 1 deletion integrations/agents/descriptors.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { ChDBError } from './errors.mjs'
// The agent-tool contract version (semver). Bumped whenever descriptors.json,
// conformance/cases.jsonl, or normative CONTRACT.md text changes. Tests assert
// it equals the contract_version field of both data files.
export const CONTRACT_VERSION = '0.2.0'
export const CONTRACT_VERSION = '0.3.0'

const DESCRIPTORS_PATH = join(dirname(fileURLToPath(import.meta.url)), 'descriptors.json')
let cache = null
Expand Down Expand Up @@ -123,6 +123,12 @@ export function capabilities() {
attachments: true,
file_allowlist: true,
max_execution_time: true,
// engine-enforced result/memory bounds + typed resource errors with
// a recovery hint (contract 0.3.0)
resource_caps: true,
// binding-side deadline for network-source queries + engine-side
// fast-fail baseline (contract 0.3.0)
network_watchdog: true,
async: true,
streaming: false,
},
Expand Down
16 changes: 15 additions & 1 deletion integrations/agents/errors.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,35 @@ export interface ChDBErrorObject {
code: number
type: string
message: string
/** Model-facing recovery instruction (present on resource-limit errors). */
hint?: string
}

export class ChDBError extends Error {
code: number
type: string
constructor(message: string, opts?: { code?: number; type?: string })
hint: string | null
constructor(message: string, opts?: { code?: number; type?: string; hint?: string | null })
toObject(): ChDBErrorObject
}

/** A write/DDL was rejected because the tool session is read-only (code 164). */
export class ChDBReadOnlyError extends ChDBError {}
/**
* The query hit an engine resource limit (rows / bytes / time / memory);
* the SQL is valid — carries a `hint` telling the model to narrow the query.
*/
export class ChDBResourceError extends ChDBError {}
/** Parse / type / argument error in the submitted SQL. */
export class ChDBSyntaxError extends ChDBError {}
/** Unknown table / database / function / column / setting. */
export class ChDBUnknownObjectError extends ChDBError {}

/** The recovery instruction attached to every resource-limit error (binding-identical wording). */
export declare const RESOURCE_HINT: string

/** The recovery instruction attached to a NETWORK_TIMEOUT watchdog error (binding-identical wording). */
export declare const NETWORK_HINT: string

/** Return a typed ChDBError for a raw engine exception or message string. */
export function parseError(excOrMessage: unknown): ChDBError
Loading
Loading