From 56a62616be5d4e68ac9a79673ab6d3173744bb78 Mon Sep 17 00:00:00 2001 From: Shawn Chen Date: Wed, 8 Jul 2026 10:30:53 +1200 Subject: [PATCH 1/2] =?UTF-8?q?agents:=20contract=200.2.0=20=E2=80=94=20ge?= =?UTF-8?q?nerate=20the=20tool=20surface=20from=20vendored=20descriptors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit descriptors.json (vendored byte-identical from the Python reference in chdb-io/chdb) becomes the single source of the model-visible tool surface: - new descriptors.mjs: CONTRACT_VERSION, loadDescriptors(), toolSpecs(dialect='anthropic'|'openai'|'mcp'), capabilities(); ChDBTool#toolSpecs(dialect) delegates to it. - framework.mjs AGENT_TOOL_DESCRIPTORS (the zod schemas the Vercel AI SDK / Mastra adapters consume) is now generated mechanically from the same file; the hand-written descriptor list is gone. Drift fixes against the Python reference, now conformance-tested: - a non-numeric maxRows/limit throws ChDBError(INVALID_ARGUMENT); previously Number(...) produced NaN, every NaN comparison is false, and the row cap was silently disabled. - maxBytes counts UTF-8 bytes of each row's compact JSON (Buffer.byteLength); String.length counted UTF-16 units, putting the truncation point up to ~3x later than the reference on non-ASCII rows. - an explicit empty-string database is rejected like the reference; the falsy check treated '' as "no database" and silently dropped the qualifier. conformance fixture re-synced (header record with contract_version, capability gating, new cases); CONTRACT.md mirror re-synced. Co-Authored-By: Claude Fable 5 --- integrations/agents/CONTRACT.md | 77 ++++++++++- integrations/agents/conformance/README.md | 46 ++++--- integrations/agents/conformance/cases.jsonl | 6 + integrations/agents/descriptors.d.mts | 40 ++++++ integrations/agents/descriptors.json | 124 ++++++++++++++++++ integrations/agents/descriptors.mjs | 104 +++++++++++++++ integrations/agents/framework.mjs | 103 ++++----------- integrations/agents/index.d.mts | 8 ++ integrations/agents/index.mjs | 1 + integrations/agents/tool.d.mts | 2 +- integrations/agents/tool.mjs | 94 ++++++------- .../integrations/agents-conformance.test.ts | 15 ++- test/v3/integrations/agents-tool.test.ts | 100 ++++++++++++++ 13 files changed, 567 insertions(+), 153 deletions(-) create mode 100644 integrations/agents/descriptors.d.mts create mode 100644 integrations/agents/descriptors.json create mode 100644 integrations/agents/descriptors.mjs diff --git a/integrations/agents/CONTRACT.md b/integrations/agents/CONTRACT.md index c349703..d72dfff 100644 --- a/integrations/agents/CONTRACT.md +++ b/integrations/agents/CONTRACT.md @@ -4,12 +4,16 @@ > [`chdb-io/chdb` at `chdb/agents/CONTRACT.md`](https://github.com/chdb-io/chdb/blob/main/chdb/agents/CONTRACT.md). > This is a synced mirror so the chdb-node implementation and its conformance > runner sit next to the contract they satisfy. Do not edit it here; change it -> upstream and re-sync. `conformance/cases.jsonl` and `conformance/fixtures/` are -> vendored the same way. +> upstream and re-sync. `conformance/cases.jsonl`, `conformance/fixtures/`, and +> `descriptors.json` are vendored the same way. > **Status: beta / experimental (introduced in chdb 4.2.0).** The surface may > 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 +> `capabilities()` in every binding. Downstream consumers probe capabilities +> instead of guessing from a package version (see *Versioning & capabilities*). One behavior, many bindings. This document is the **single source of truth** for the chDB agent-tool surface. `chdb.agents.ChDBTool` (Python) is the reference @@ -37,6 +41,53 @@ Bindings expose these as native tools (Python methods on `ChDBTool`; TS `chdbTools()`; MCP tools in mcp-clickhouse) but the **capability set, argument meaning, error classification, and safety guarantees are identical**. +## descriptors.json — the model-visible surface (single source) + +`descriptors.json` (next to this file; vendored byte-identical in every +binding) is the single source of truth for the **model-visible** surface: tool +names, description text, and argument schemas. Everything the model reads is +generated from it: + +- `tool_specs(dialect)` / `toolSpecs(dialect)` render it per runtime family — + `anthropic` (`input_schema`), `openai` (`{type: "function", function}`), + `mcp` (`inputSchema`). +- Framework adapters derive their native schema declarations from it (the TS + Vercel AI SDK / Mastra adapters generate zod schemas mechanically; schema- + declaring frameworks like CrewAI / smolagents generate their arg specs the + same way). Hand-copying a description into an adapter re-creates the drift + this file exists to kill. +- Changing `descriptors.json` **is a contract change**: bump `contract_version` + (in the file, the code constant, and the conformance fixture header) and sync + every vendored copy. + +## Versioning & capabilities + +- `CONTRACT_VERSION` (semver string) names the contract revision a binding + implements. It changes when `descriptors.json`, `conformance/cases.jsonl`, or + normative text here changes. It is intentionally **not** the package version. +- `capabilities()` returns `{contract_version, tools, features}`. `features` + flags the capability-gated parts of the contract, so a consumer asks *"does + this binding do X?"* instead of maintaining a version matrix: + `dataframe_query` (Python-only, co-located engine), `attachments`, + `file_allowlist`, `max_execution_time`, `async`, `streaming` (reserved, + currently `false` everywhere). +- Conformance cases carrying `"requires": ""` run only on bindings + whose `capabilities().features[]` is true; all other cases are core + and every binding must pass them. + +## Async + +Engine calls are synchronous in-process; how a binding exposes async is part of +the contract surface: + +- **Python** — `aquery()` / `acall()` run the sync call in a worker thread + (`asyncio.to_thread`), documented as such, never faked as native async. This + is what async-first frameworks (AutoGen, Pydantic AI) bind to, instead of + each re-implementing a thread-pool wrapper. +- **TypeScript** — `query()` / `call()` are natively Promise-based. +- The sync path (Python) remains the canonical one the conformance runner + drives; async forms must return identical results. + **Language-optional capability (not in the cross-language conformance):** `dataframe_query` — query in-process DataFrames via the `Python()` table function. Meaningful only in languages/deployments where the agent runtime and @@ -67,6 +118,13 @@ 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**. +- `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 + point by up to ~3× on non-ASCII data, splitting the corpus between bindings. +- A non-numeric per-call cap (`max_rows`, `limit`) is a typed + `INVALID_ARGUMENT` error, never a silently disabled cap. On the model-facing + `call()` path a JSON `null` for an optional argument means "omitted". ### P4 — Errors reach the model - The **tool-dispatch** path (`call()` / the tool executor) returns a structured @@ -75,6 +133,11 @@ to TS, which has no in-process pandas). Bindings may omit it. - Direct library methods raise a typed error (`ChDBError` and subclasses). - Error classification is shared: parse `Code: N. DB::Exception: . (TYPE)`; map by code (`164→READONLY`, `62→SYNTAX`, `46/47/60/81/115→UNKNOWN_*`). +- 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), + `UNKNOWN_TOOL` (bad `call()` name), `TOOL_ERROR` (any other non-engine + failure surfaced through the envelope). ### P5 — Resource and source controls (normative, optional-per-deployment) - **Query timeout** — an optional `max_execution_time` (seconds) bounds runaway @@ -99,7 +162,11 @@ future coercion must be opt-in and documented here. ## Conformance `conformance/cases.jsonl` is a language-neutral list of cases: setup-free inputs (they use `numbers()`, `file()` on `conformance/fixtures/`, `system.*`, params, -and write-blocking DDL) plus an expected outcome. Each binding ships a thin -runner (~30 LOC) that loads the fixture, invokes the named method, and asserts. -A binding is "contract-conformant" when every case passes. See +and write-blocking DDL) plus an expected outcome. Its first line is a header +record `{"fixture": ..., "contract_version": ...}` (no `id`), which runners +check against their binding's `CONTRACT_VERSION` and skip. A case with +`"requires": ""` runs only where `capabilities()` reports that feature +(everything else is core). Each binding ships a thin runner (~30 LOC) that +loads the fixture, invokes the named method, and asserts. A binding is +"contract-conformant" when every applicable case passes. See `conformance/README.md`. diff --git a/integrations/agents/conformance/README.md b/integrations/agents/conformance/README.md index 82cbe8a..4fc6d60 100644 --- a/integrations/agents/conformance/README.md +++ b/integrations/agents/conformance/README.md @@ -1,33 +1,48 @@ # Cross-language conformance fixture (vendored) -`cases.jsonl` is a language-neutral list of behaviors every chDB agent-tool -binding must satisfy. It is the executable half of `../CONTRACT.md`. - This is a **vendored mirror** of `chdb-io/chdb`'s `chdb/agents/conformance/`. The chdb-node runner loads *this* copy so the TypeScript binding verifies the **same** behaviors as the Python reference. When the upstream fixture changes, re-sync this directory. -Each line is one case: +`cases.jsonl` is a language-neutral list of behaviors every chDB agent-tool +binding must satisfy. It is the executable half of `../CONTRACT.md`. + +The first line is a header record (no `id`): ```json -{"id": "...", "pillar": "P1|P2|P3|P4|introspection|safety|catalog", "method": "query|call|list_databases|list_tables|describe|get_sample_data|list_functions", "args": {...}, "expect": {...}} +{"fixture": "chdb-agents-conformance", "contract_version": "..."} +``` + +Runners assert `contract_version` equals their binding's `CONTRACT_VERSION` +(so a stale vendored fixture fails loudly) and skip the record. Every other +line is one case: + +```json +{"id": "...", "pillar": "P1|P2|P3|P4|introspection", "method": "query|call|list_databases|list_tables|describe|get_sample_data|list_functions|dataframe_query", "args": {...}, "expect": {...}} ``` `args` for `method: "call"` are `{name, arguments}` (the tool-dispatch path); for every other method they are the method's keyword arguments. +A case may include `"requires": ""`: it runs only on bindings whose +`capabilities().features[]` is true (e.g. `dataframe_query` is +Python-only), and is skipped elsewhere. Cases without `requires` are core — +every binding must pass them. For `method: "dataframe_query"`, +`args.dataframes` maps each `Python()` reference to `{column: [values]}`, +which the runner materializes as a real DataFrame. + A case may include an optional `tool` object with constructor-level config (`max_execution_time`, `file_allowlist`, `attachments`, `read_only`, ...). When present, the runner builds a dedicated tool from it for that case; otherwise it -uses a read-only tool. `{{fixtures}}` is substituted inside `tool` too. +reuses a shared read-only tool. `{{fixtures}}` is substituted inside `tool` too. `expect` is one of: | key | meaning | |---|---| | `rows` | exact row list equality | -| `error_type` | the method must fail with this error `type` (rejecting path) | +| `error_type` | the method must fail with this error `type` (raising path) | | `truncated` + `row_count` | truncation flag and returned row count | | `row_count` | returned row count | | `contains_all` | every listed value present in the returned list | @@ -40,12 +55,13 @@ uses a read-only tool. `{{fixtures}}` is substituted inside `tool` too. ## Running it -- **Python** (reference, upstream): `python -m unittest tests.test_agents_conformance`. -- **TypeScript** (`chdb-node`): `npx vitest run test/v3/integrations/agents-conformance.test.ts` - — a thin runner that loads this same `cases.jsonl`, maps each `method` to - `ChDBTool`, and asserts identically. +- **Python** (reference): `python -m unittest tests.test_agents_conformance` + (loads this file, runs one read-only `ChDBTool`, asserts every case). +- **TypeScript** (`chdb-node`): a ~30-line runner loads this same file, maps each + `method` to `chdbTools()` / the `ChDBVector`-adjacent query surface, and + asserts identically. The fixture is vendored (or git-submoduled) so both repos + test the *same* behaviors. -Constructor keys in a case's `tool` object use the contract's snake_case -(`read_only`, `max_execution_time`, `file_allowlist`); the TS runner maps them to -the binding's camelCase options. A binding is **contract-conformant** when every -case passes. +A binding is **contract-conformant** when every case passes. To document an +intentional per-language divergence, add a case with the divergent expectation +and a `desc` explaining why — never diverge silently. diff --git a/integrations/agents/conformance/cases.jsonl b/integrations/agents/conformance/cases.jsonl index 487f0fa..d0b2ecb 100644 --- a/integrations/agents/conformance/cases.jsonl +++ b/integrations/agents/conformance/cases.jsonl @@ -1,3 +1,4 @@ +{"fixture": "chdb-agents-conformance", "contract_version": "0.2.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}]}} @@ -14,3 +15,8 @@ {"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": "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}]}} diff --git a/integrations/agents/descriptors.d.mts b/integrations/agents/descriptors.d.mts new file mode 100644 index 0000000..5f0ea6d --- /dev/null +++ b/integrations/agents/descriptors.d.mts @@ -0,0 +1,40 @@ +export declare const CONTRACT_VERSION: string + +export type ToolSpecDialect = 'anthropic' | 'openai' | 'mcp' + +export interface ToolParamDescriptor { + name: string + type: 'string' | 'integer' | 'object' + required?: boolean + description?: string +} + +export interface ToolDescriptor { + name: string + id: string + description: string + params: ToolParamDescriptor[] +} + +export interface Descriptors { + contract_version: string + tools: ToolDescriptor[] +} + +export interface Capabilities { + contract_version: string + tools: string[] + features: { + dataframe_query: boolean + attachments: boolean + file_allowlist: boolean + max_execution_time: boolean + async: boolean + streaming: boolean + [feature: string]: boolean + } +} + +export declare function loadDescriptors(): Descriptors +export declare function toolSpecs(dialect?: ToolSpecDialect): object[] +export declare function capabilities(): Capabilities diff --git a/integrations/agents/descriptors.json b/integrations/agents/descriptors.json new file mode 100644 index 0000000..82bccb4 --- /dev/null +++ b/integrations/agents/descriptors.json @@ -0,0 +1,124 @@ +{ + "$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", + "tools": [ + { + "name": "run_select_query", + "id": "chdb-run-select-query", + "description": "Run a read-only ClickHouse SQL query with chDB, an in-process ClickHouse engine (full SQL dialect: 1000+ functions, window functions, arrays, JSON). Pass values via `params` as {name:Type} placeholders (e.g. WHERE id = {id:Int64}); never concatenate values into the SQL. Read external data inline with table functions: file('path'[, 'format']), s3('url','format'), url(...), postgresql(...), mysql(...). Returns rows plus a `truncated` flag. First use list_tables / describe_table to learn the schema.", + "params": [ + { + "name": "sql", + "type": "string", + "required": true, + "description": "A complete read-only ClickHouse SQL statement; use {name:Type} placeholders for values." + }, + { + "name": "params", + "type": "object", + "description": "Values bound to the {name:Type} placeholders in `sql`." + } + ] + }, + { + "name": "list_databases", + "id": "chdb-list-databases", + "description": "List the databases available in the chDB session.", + "params": [] + }, + { + "name": "list_tables", + "id": "chdb-list-tables", + "description": "List the tables in a database (the current database if `database` is omitted).", + "params": [ + { + "name": "database", + "type": "string", + "description": "Database to list tables from; current database if omitted." + } + ] + }, + { + "name": "describe_table", + "id": "chdb-describe-table", + "description": "Describe the columns and types of a table (optionally database-qualified) or a table-function expression, e.g. s3('https://bucket/f.parquet','Parquet') or file('data.csv').", + "params": [ + { + "name": "target", + "type": "string", + "required": true, + "description": "A table name or a table-function expression, e.g. \"events\" or \"s3('s3://b/f.parquet','Parquet')\"." + }, + { + "name": "database", + "type": "string", + "description": "Database qualifier for a table name (invalid for a table function)." + } + ] + }, + { + "name": "get_sample_data", + "id": "chdb-get-sample-data", + "description": "Return a few sample rows from a table or table-function expression, to see real values before querying.", + "params": [ + { + "name": "target", + "type": "string", + "required": true, + "description": "A table name or a table-function expression." + }, + { + "name": "database", + "type": "string", + "description": "Database qualifier for a table name (invalid for a table function)." + }, + { + "name": "limit", + "type": "integer", + "description": "Number of sample rows (default 5)." + } + ] + }, + { + "name": "list_functions", + "id": "chdb-list-functions", + "description": "List available ClickHouse SQL functions, optionally filtered by an ILIKE pattern.", + "params": [ + { + "name": "like", + "type": "string", + "description": "ILIKE pattern to filter function names, e.g. \"%array%\"." + }, + { + "name": "limit", + "type": "integer", + "description": "Max function names to return (default 200)." + } + ] + }, + { + "name": "attach_file", + "id": "chdb-attach-file", + "description": "Register a local file as a queryable named table (a view over file()). Writable tools only; on a read-only tool this returns a READONLY error — declare files via the tool's attachments option instead.", + "params": [ + { + "name": "name", + "type": "string", + "required": true, + "description": "The table name to register the file under." + }, + { + "name": "path", + "type": "string", + "required": true, + "description": "Path to the local file." + }, + { + "name": "format", + "type": "string", + "description": "chDB/ClickHouse input format (auto-detected from the extension if omitted)." + } + ] + } + ] +} diff --git a/integrations/agents/descriptors.mjs b/integrations/agents/descriptors.mjs new file mode 100644 index 0000000..01f3c56 --- /dev/null +++ b/integrations/agents/descriptors.mjs @@ -0,0 +1,104 @@ +// Model-visible tool descriptors and the contract version/capability surface. +// +// descriptors.json (vendored byte-identical from the Python reference, +// chdb/agents/descriptors.json) is the single source of truth for the +// agent-tool names, descriptions, and argument schemas the model sees. This +// module turns it into framework-consumable specs, so adapters generate their +// schemas instead of hand-copying text that then drifts between languages: +// +// - toolSpecs(dialect) — JSON-schema tool definitions in the shape each +// runtime family expects ('anthropic' | 'openai' | 'mcp'). +// - capabilities() — { contract_version, tools, features }: what this binding +// implements, for downstream feature-probing (a consumer checks +// features.dataframe_query instead of guessing from the package version). + +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' +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' + +const DESCRIPTORS_PATH = join(dirname(fileURLToPath(import.meta.url)), 'descriptors.json') +let cache = null + +/** Return the parsed descriptors.json (cached after the first read). */ +export function loadDescriptors() { + if (cache == null) cache = JSON.parse(readFileSync(DESCRIPTORS_PATH, 'utf8')) + return cache +} + +function jsonSchema(params) { + const properties = {} + const required = [] + for (const p of params) { + const prop = { type: p.type } + if (p.description) prop.description = p.description + properties[p.name] = prop + if (p.required) required.push(p.name) + } + const schema = { type: 'object', properties } + if (required.length) schema.required = required + return schema +} + +/** + * Tool definitions generated from descriptors.json. + * 'anthropic': {name, description, input_schema} (also the historical + * ChDBTool#toolSpecs() shape); 'openai': {type: 'function', function: {...}}; + * 'mcp': {name, description, inputSchema}. + * @param {'anthropic'|'openai'|'mcp'} [dialect] + */ +export function toolSpecs(dialect = 'anthropic') { + const tools = loadDescriptors().tools + if (dialect === 'anthropic') { + return tools.map((t) => ({ + name: t.name, + description: t.description, + input_schema: jsonSchema(t.params), + })) + } + if (dialect === 'openai') { + return tools.map((t) => ({ + type: 'function', + function: { name: t.name, description: t.description, parameters: jsonSchema(t.params) }, + })) + } + if (dialect === 'mcp') { + return tools.map((t) => ({ + name: t.name, + description: t.description, + inputSchema: jsonSchema(t.params), + })) + } + throw new ChDBError( + `unknown toolSpecs dialect: ${JSON.stringify(dialect)} (expected 'anthropic', 'openai', or 'mcp')`, + { type: 'INVALID_ARGUMENT' }, + ) +} + +/** + * What this binding implements, keyed for downstream feature-probing. + * + * `features` marks the capability-gated parts of the contract: conformance + * cases carrying `"requires": ""` run only where that feature is true + * (dataframe_query is Python-only — the agent runtime and the engine share a + * process there; this binding's async is native, no worker thread needed). + */ +export function capabilities() { + return { + contract_version: CONTRACT_VERSION, + tools: loadDescriptors().tools.map((t) => t.name), + features: { + dataframe_query: false, + attachments: true, + file_allowlist: true, + max_execution_time: true, + async: true, + streaming: false, + }, + } +} diff --git a/integrations/agents/framework.mjs b/integrations/agents/framework.mjs index 884b5ef..5049bb5 100644 --- a/integrations/agents/framework.mjs +++ b/integrations/agents/framework.mjs @@ -6,6 +6,7 @@ // pull this in. import { z } from 'zod' +import { loadDescriptors } from './descriptors.mjs' import { ChDBTool } from './tool.mjs' /** @@ -32,81 +33,27 @@ export function resolveTool(opts = {}) { }) } -// The canonical tool set (names + argument schemas match CONTRACT.md and the -// Python reference / mcp-clickhouse). `name` is the contract name the model sees; -// `id` is the kebab form Mastra requires. The input schema is exactly the -// arguments ChDBTool.call() expects, so an adapter is `execute → tool.call(name, -// input)`. -export const AGENT_TOOL_DESCRIPTORS = [ - { - name: 'run_select_query', - id: 'chdb-run-select-query', - description: - 'Run a read-only ClickHouse SQL query with chDB, an in-process ClickHouse engine ' + - '(full SQL dialect: 1000+ functions, window functions, arrays, JSON). Pass values ' + - 'via `params` as {name:Type} placeholders (e.g. WHERE id = {id:Int64}); never ' + - 'concatenate values into the SQL. Read external data inline with table functions: ' + - "file('path'[, 'format']), s3('url','format'), url(...), postgresql(...), mysql(...). " + - 'Returns rows plus a `truncated` flag. First use list_tables / describe_table to learn the schema.', - schema: z.object({ - sql: z.string().describe('A complete read-only ClickHouse SQL statement; use {name:Type} placeholders for values.'), - params: z.record(z.string(), z.any()).optional().describe('Values bound to the {name:Type} placeholders in `sql`.'), - }), - }, - { - name: 'list_databases', - id: 'chdb-list-databases', - description: 'List the databases available in the chDB session.', - schema: z.object({}), - }, - { - name: 'list_tables', - id: 'chdb-list-tables', - description: 'List the tables in a database (the current database if `database` is omitted).', - schema: z.object({ - database: z.string().optional().describe('Database to list tables from; current database if omitted.'), - }), - }, - { - name: 'describe_table', - id: 'chdb-describe-table', - description: - 'Describe the columns and types of a table (optionally database-qualified) or a ' + - "table-function expression, e.g. s3('https://bucket/f.parquet','Parquet') or file('data.csv').", - schema: z.object({ - target: z.string().describe("A table name or a table-function expression, e.g. \"events\" or \"s3('s3://b/f.parquet','Parquet')\"."), - database: z.string().optional().describe('Database qualifier for a table name (invalid for a table function).'), - }), - }, - { - name: 'get_sample_data', - id: 'chdb-get-sample-data', - description: 'Return a few sample rows from a table or table-function expression, to see real values before querying.', - schema: z.object({ - target: z.string().describe('A table name or a table-function expression.'), - database: z.string().optional(), - limit: z.number().int().optional().describe('Number of sample rows (default 5).'), - }), - }, - { - name: 'list_functions', - id: 'chdb-list-functions', - description: 'List available ClickHouse SQL functions, optionally filtered by an ILIKE pattern.', - schema: z.object({ - like: z.string().optional().describe('ILIKE pattern to filter function names, e.g. "%array%".'), - limit: z.number().int().optional().describe('Max function names to return (default 200).'), - }), - }, - { - name: 'attach_file', - id: 'chdb-attach-file', - description: - 'Register a local file as a queryable named table (a view over file()). Writable tools only; ' + - 'on a read-only tool this returns a READONLY error — declare files via the tool\'s attachments option instead.', - schema: z.object({ - name: z.string().describe('The table name to register the file under.'), - path: z.string().describe('Path to the local file.'), - format: z.string().optional().describe('chDB/ClickHouse input format (auto-detected from the extension if omitted).'), - }), - }, -] +// The canonical tool set, GENERATED from descriptors.json (the cross-language +// single source of the model-visible surface — names, descriptions, argument +// schemas). `name` is the contract name the model sees; `id` is the kebab form +// Mastra requires. The zod schema is a mechanical rendering of the declared +// params, so the input shape is exactly the arguments ChDBTool.call() expects +// and an adapter is `execute → tool.call(name, input)`. Hand-editing the list +// here (instead of descriptors.json) is a contract violation. + +function zodParam(p) { + let t + if (p.type === 'string') t = z.string() + else if (p.type === 'integer') t = z.number().int() + else t = z.record(z.string(), z.any()) + if (p.description) t = t.describe(p.description) + return p.required ? t : t.optional() +} + +export const AGENT_TOOL_DESCRIPTORS = loadDescriptors().tools.map((t) => ({ + name: t.name, + id: t.id, + description: t.description, + schema: z.object(Object.fromEntries(t.params.map((p) => [p.name, zodParam(p)]))), +})) + diff --git a/integrations/agents/index.d.mts b/integrations/agents/index.d.mts index 714115f..f29c143 100644 --- a/integrations/agents/index.d.mts +++ b/integrations/agents/index.d.mts @@ -18,3 +18,11 @@ export { } from './errors.mjs' export type { ChDBErrorObject } from './errors.mjs' export { quoteIdent, quoteString, InvalidIdentifier } from './safety.mjs' +export { CONTRACT_VERSION, capabilities, loadDescriptors, toolSpecs } from './descriptors.mjs' +export type { + Capabilities, + Descriptors, + ToolDescriptor, + ToolParamDescriptor, + ToolSpecDialect, +} from './descriptors.mjs' diff --git a/integrations/agents/index.mjs b/integrations/agents/index.mjs index e10d351..81f19d7 100644 --- a/integrations/agents/index.mjs +++ b/integrations/agents/index.mjs @@ -6,6 +6,7 @@ // 'chdb/ai-sdk' and 'chdb/mastra', which are built on top of this. export { ChDBTool, QueryResult } from './tool.mjs' +export { CONTRACT_VERSION, capabilities, loadDescriptors, toolSpecs } from './descriptors.mjs' export { ChDBError, ChDBReadOnlyError, diff --git a/integrations/agents/tool.d.mts b/integrations/agents/tool.d.mts index 3f937dd..05d2fad 100644 --- a/integrations/agents/tool.d.mts +++ b/integrations/agents/tool.d.mts @@ -72,7 +72,7 @@ export class ChDBTool { getSampleData(target: string, opts?: { database?: string | null; limit?: number }): Promise listFunctions(opts?: { like?: string | null; limit?: number }): Promise attachFile(name: string, path: string, format?: string | null): Promise - toolSpecs(): Array<{ name: string; description: string; input_schema: object }> + toolSpecs(dialect?: import('./descriptors.mjs').ToolSpecDialect): object[] call(name: string, args?: Record): Promise close(): void } diff --git a/integrations/agents/tool.mjs b/integrations/agents/tool.mjs index fce320b..0b68ca6 100644 --- a/integrations/agents/tool.mjs +++ b/integrations/agents/tool.mjs @@ -18,9 +18,25 @@ // outside the cross-language base (see CONTRACT.md). import { Session } from '../../index.mjs' +import { toolSpecs } from './descriptors.mjs' import { ChDBError, ChDBReadOnlyError, parseError } from './errors.mjs' import { pathAllowed, quoteIdent, quoteString, scanFilePaths } from './safety.mjs' +// Coerce a numeric argument to an integer, or throw a typed INVALID_ARGUMENT. +// A non-numeric cap must fail loudly: Number('lots') is NaN, and every NaN +// comparison is false, so before this guard a garbage maxRows silently +// disabled the result cap (the Python reference raises instead — same +// behavior now, per CONTRACT.md). +function intArg(value, name) { + const n = Math.floor(Number(value)) + if (value == null || value === '' || !Number.isFinite(n)) { + throw new ChDBError(`${name} must be an integer, got ${JSON.stringify(value)}`, { + type: 'INVALID_ARGUMENT', + }) + } + return n +} + /** Result of a query: decoded rows plus honest truncation / stat metadata. */ export class QueryResult { constructor(rows, truncated, columnNames, elapsedS = null, bytesRead = null) { @@ -81,10 +97,10 @@ export class ChDBTool { } = opts this.readOnly = Boolean(readOnly) - this.maxRows = Math.max(1, Math.floor(Number(maxRows) || 1000)) - this.maxBytes = Math.max(1, Math.floor(Number(maxBytes) || 1_000_000)) + this.maxRows = Math.max(1, intArg(maxRows, 'maxRows')) + this.maxBytes = Math.max(1, intArg(maxBytes, 'maxBytes')) this.maxExecutionTime = - maxExecutionTime == null ? null : Math.max(0, Math.floor(Number(maxExecutionTime))) + maxExecutionTime == null ? null : Math.max(0, intArg(maxExecutionTime, 'maxExecutionTime')) // null = no allowlist (all paths allowed); a list = only these prefixes. this.fileAllowlist = fileAllowlist && fileAllowlist.length ? [...fileAllowlist] : null @@ -142,7 +158,7 @@ export class ChDBTool { throw new ChDBError('sql must be a non-empty string') } this.#enforceAllowlist(sql) - const cap = maxRows == null ? this.maxRows : Math.max(1, Math.floor(Number(maxRows))) + const cap = maxRows == null ? this.maxRows : Math.max(1, intArg(maxRows, 'maxRows')) let obj try { const hasParams = params && Object.keys(params).length > 0 @@ -163,10 +179,14 @@ export class ChDBTool { let rows = truncated ? data.slice(0, cap) : data // Secondary byte guard, applied whether or not the row cap already fired: a // few very large rows under maxRows must still be capped by maxBytes. + // Rows are measured in UTF-8 BYTES of their compact JSON encoding — + // String.length counts UTF-16 units ("汉" = 1) while the Python reference + // would count its ASCII-escaped form ("汉" = 6); UTF-8 bytes is the one + // measure both bindings can produce identically (CONTRACT.md P3). if (this.maxBytes) { let size = 0 for (let i = 0; i < rows.length; i++) { - size += JSON.stringify(rows[i]).length + size += Buffer.byteLength(JSON.stringify(rows[i]), 'utf8') if (size > this.maxBytes) { rows = rows.slice(0, i) truncated = true @@ -251,15 +271,20 @@ export class ChDBTool { // - otherwise target is a table identifier, backtick-quoted; with `database` // each part is quoted independently as `db`.`table` (a dotted name is never // mis-quoted as one identifier). + // `null`/`undefined` mean "not provided"; any other value (including '') is a + // real database argument and must be validated — an empty string flows into + // quoteIdent() and is rejected rather than silently treated as unqualified + // (a falsy check here once made '' skip qualification; the Python reference + // rejects it). #qualify(target, database = null) { if (target.includes('(')) { - if (database) { + if (database != null) { throw new ChDBError('database qualifier is not valid for a table-function target') } return target } const ident = quoteIdent(target) - return database ? `${quoteIdent(database)}.${ident}` : ident + return database != null ? `${quoteIdent(database)}.${ident}` : ident } /** @@ -279,7 +304,7 @@ export class ChDBTool { async getSampleData(target, { database = null, limit = 5 } = {}) { const ref = this.#qualify(target, database) - const n = Math.floor(Number(limit)) + const n = intArg(limit, 'limit') return this.query(`SELECT * FROM ${ref} LIMIT {n:UInt32}`, { params: { n }, maxRows: n, @@ -287,7 +312,7 @@ export class ChDBTool { } async listFunctions({ like = null, limit = 200 } = {}) { - const n = Math.floor(Number(limit)) + const n = intArg(limit, 'limit') let rows if (like) { const sql = @@ -302,50 +327,13 @@ export class ChDBTool { // ---- agent integration ------------------------------------------------ - /** JSON-schema tool definitions for auto-registration into any framework. */ - toolSpecs() { - const s = (properties) => ({ type: 'object', properties }) - return [ - { - name: 'run_select_query', - description: 'Run a read-only ClickHouse SQL query via chDB and return rows.', - input_schema: s({ sql: { type: 'string' }, params: { type: 'object' } }), - }, - { name: 'list_databases', description: 'List databases.', input_schema: s({}) }, - { - name: 'list_tables', - description: 'List tables in a database (current if omitted).', - input_schema: s({ database: { type: 'string' } }), - }, - { - name: 'describe_table', - description: 'Describe a table (optionally database-qualified) or table function.', - input_schema: s({ target: { type: 'string' }, database: { type: 'string' } }), - }, - { - name: 'get_sample_data', - description: 'Return a few sample rows from a table or table function.', - input_schema: s({ - target: { type: 'string' }, - database: { type: 'string' }, - limit: { type: 'integer' }, - }), - }, - { - name: 'list_functions', - description: 'List available SQL functions.', - input_schema: s({ like: { type: 'string' }, limit: { type: 'integer' } }), - }, - { - name: 'attach_file', - description: 'Register a local file as a queryable named table (writable tools only).', - input_schema: s({ - name: { type: 'string' }, - path: { type: 'string' }, - format: { type: 'string' }, - }), - }, - ] + /** + * Tool definitions for auto-registration into any framework, generated from + * descriptors.json (the single source of the model-visible surface). + * @param {'anthropic'|'openai'|'mcp'} [dialect] selects the shape + */ + toolSpecs(dialect = 'anthropic') { + return toolSpecs(dialect) } /** diff --git a/test/v3/integrations/agents-conformance.test.ts b/test/v3/integrations/agents-conformance.test.ts index 22fa771..8f27e57 100644 --- a/test/v3/integrations/agents-conformance.test.ts +++ b/test/v3/integrations/agents-conformance.test.ts @@ -6,6 +6,8 @@ import { dirname, resolve } from 'node:path' import { ChDBTool } from '../../../integrations/agents/tool.mjs' // @ts-ignore import { ChDBError } from '../../../integrations/agents/errors.mjs' +// @ts-ignore +import { CONTRACT_VERSION, capabilities } from '../../../integrations/agents/descriptors.mjs' // Runs the language-neutral agent-tool conformance fixture // (integrations/agents/conformance/cases.jsonl) against ChDBTool. This is the @@ -18,11 +20,14 @@ const HERE = dirname(fileURLToPath(import.meta.url)) const CONF = resolve(HERE, '../../../integrations/agents/conformance') const FIXTURES = resolve(CONF, 'fixtures') -const cases = readFileSync(resolve(CONF, 'cases.jsonl'), 'utf8') +const records = readFileSync(resolve(CONF, 'cases.jsonl'), 'utf8') .split('\n') .map((l) => l.trim()) .filter(Boolean) .map((l) => JSON.parse(l)) +// The first record without an "id" is the fixture header; everything else is a case. +const header = records.find((r) => r.id === undefined) +const cases = records.filter((r) => r.id !== undefined) // Replace the {{fixtures}} token in any string, recursively through objects. function sub(v: any): any { @@ -73,7 +78,15 @@ async function invoke(tool: any, c: any): Promise { describe('agents conformance (CONTRACT.md / cases.jsonl)', () => { expect(cases.length).toBeGreaterThan(0) + it('fixture header matches this binding contract version', () => { + expect(header, 'cases.jsonl must start with a header record').toBeDefined() + expect(header.contract_version).toBe(CONTRACT_VERSION) + }) + + const features = capabilities().features for (const c of cases) { + // capability-gated cases run only where the binding has the feature + if (c.requires && !features[c.requires]) continue it(`${c.id} [${c.pillar}]`, async () => { // A case may declare its own tool config; otherwise use a read-only tool. const tool = c.tool ? toolFrom(c.tool) : new ChDBTool({ readOnly: true }) diff --git a/test/v3/integrations/agents-tool.test.ts b/test/v3/integrations/agents-tool.test.ts index 12f98d9..038fd5c 100644 --- a/test/v3/integrations/agents-tool.test.ts +++ b/test/v3/integrations/agents-tool.test.ts @@ -6,6 +6,10 @@ import { ChDBTool } from '../../../integrations/agents/tool.mjs' import { ChDBError } from '../../../integrations/agents/errors.mjs' // @ts-ignore import { chdbTools } from '../../../integrations/ai-sdk.mjs' +// @ts-ignore +import { CONTRACT_VERSION, capabilities, loadDescriptors, toolSpecs } from '../../../integrations/agents/descriptors.mjs' +// @ts-ignore +import { AGENT_TOOL_DESCRIPTORS } from '../../../integrations/agents/framework.mjs' // Resource-lifetime behavior of the agents base + adapters (the non-conformance // concerns raised in review: owned-session cleanup, constructor error path, @@ -42,6 +46,102 @@ describe('ChDBTool resource lifetime', () => { }) }) +describe('descriptors as the single source (CONTRACT.md)', () => { + it('renders tool specs per dialect from descriptors.json', () => { + const anthropic = toolSpecs('anthropic') as any[] + const openai = toolSpecs('openai') as any[] + const mcp = toolSpecs('mcp') as any[] + expect(anthropic).toHaveLength(openai.length) + expect(anthropic).toHaveLength(mcp.length) + const run = anthropic[0] + expect(run.name).toBe('run_select_query') + expect(run.input_schema.required).toEqual(['sql']) + expect(run.input_schema.properties.sql.description).toBeTruthy() + expect(openai[0].type).toBe('function') + expect(openai[0].function.parameters).toEqual(run.input_schema) + expect(mcp[0].inputSchema).toEqual(run.input_schema) + }) + + it('rejects an unknown dialect with a typed error', () => { + let err: any + try { + toolSpecs('langchain' as any) + } catch (e) { + err = e + } + expect(err).toBeInstanceOf(ChDBError) + expect(err.type).toBe('INVALID_ARGUMENT') + }) + + it('generates the framework descriptors (zod) from descriptors.json', () => { + const declared = loadDescriptors().tools + expect(AGENT_TOOL_DESCRIPTORS.map((d: any) => d.name)).toEqual(declared.map((t: any) => t.name)) + for (let i = 0; i < declared.length; i++) { + const d = AGENT_TOOL_DESCRIPTORS[i] + expect(d.id).toBe(declared[i].id) + expect(d.description).toBe(declared[i].description) + expect(Object.keys(d.schema.shape)).toEqual(declared[i].params.map((p: any) => p.name)) + } + }) + + it('reports capabilities keyed to the contract version', () => { + const caps = capabilities() + expect(caps.contract_version).toBe(CONTRACT_VERSION) + expect(caps.contract_version).toBe(loadDescriptors().contract_version) + expect(caps.features.dataframe_query).toBe(false) // Python-only capability + expect(caps.tools).toEqual(loadDescriptors().tools.map((t: any) => t.name)) + }) + + it('ChDBTool#toolSpecs delegates to the generated specs', () => { + const t = new ChDBTool({ readOnly: true }) + try { + expect(t.toolSpecs()).toEqual(toolSpecs('anthropic')) + } finally { + t.close() + } + }) +}) + +describe('argument validation (CONTRACT.md P3)', () => { + it('throws a typed INVALID_ARGUMENT on a non-numeric per-call maxRows', async () => { + const t = new ChDBTool({ readOnly: true }) + try { + let err: any + try { + await t.query('SELECT 1', { maxRows: 'lots' as any }) + } catch (e) { + err = e + } + expect(err).toBeInstanceOf(ChDBError) + expect(err.type).toBe('INVALID_ARGUMENT') + } finally { + t.close() + } + }) + + it('throws a typed INVALID_ARGUMENT on a non-numeric constructor cap', () => { + let err: any + try { + new ChDBTool({ maxRows: 'lots' as any }) + } catch (e) { + err = e + } + expect(err).toBeInstanceOf(ChDBError) + expect(err.type).toBe('INVALID_ARGUMENT') + }) + + it('call() treats a JSON null limit as omitted (model-facing leniency)', async () => { + const t = new ChDBTool({ readOnly: true }) + try { + const out: any = await t.call('get_sample_data', { target: 'numbers(100)', limit: null }) + expect(out.ok).toBe(true) + expect(out.result.rowCount).toBe(5) + } finally { + t.close() + } + }) +}) + describe('adapter toolset close()', () => { it('exposes a non-enumerable close() that is not treated as a tool', () => { const s = new Session('') From 8e5d77d319d67512e075ed2e4ccb5753435decf2 Mon Sep 17 00:00:00 2001 From: Shawn Chen Date: Wed, 8 Jul 2026 11:53:13 +1200 Subject: [PATCH 2/2] agents: harden the descriptor and dispatch edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the contract-0.2.0 change, mirroring the Python reference: - loadDescriptors() returns a structuredClone of the cached parse, so a caller mutating the result can no longer corrupt what toolSpecs()/capabilities()/ AGENT_TOOL_DESCRIPTORS generate for everyone else in-process; a missing/broken descriptors.json now throws a diagnosable ChDBError instead of a raw fs error or SyntaxError. - An unknown param type in descriptors.json fails loudly in both renderings (jsonSchema and the zod codegen) instead of falling through to a permissive z.record — a typo in the shared asset must not silently degrade the model-visible argument schema. - call() validates the arguments payload before dispatch: a non-object (string/number/array) returns the {ok: false, INVALID_ARGUMENT} envelope. Previously a string would spread into {0: 'S', 1: 'E', ...} garbage and run anyway, while the Python reference raised — both bindings now return the same envelope (new conformance case p4_malformed_arguments). - The conformance runner enforces the fixture shape: the first record must be the header, every later record must carry an "id" — a case that lost its id fails the run instead of being silently reclassified as a second header. Tests: 66 passed (was 63). Co-Authored-By: Claude Fable 5 --- integrations/agents/CONTRACT.md | 3 ++ integrations/agents/conformance/cases.jsonl | 1 + integrations/agents/descriptors.mjs | 32 +++++++++++++++++-- integrations/agents/framework.mjs | 10 +++++- integrations/agents/tool.mjs | 16 +++++++++- .../integrations/agents-conformance.test.ts | 16 ++++++++-- test/v3/integrations/agents-tool.test.ts | 28 ++++++++++++++++ 7 files changed, 98 insertions(+), 8 deletions(-) diff --git a/integrations/agents/CONTRACT.md b/integrations/agents/CONTRACT.md index d72dfff..1355566 100644 --- a/integrations/agents/CONTRACT.md +++ b/integrations/agents/CONTRACT.md @@ -130,6 +130,9 @@ to TS, which has no in-process pandas). Bindings may omit it. - The **tool-dispatch** path (`call()` / the tool executor) returns a structured envelope `{ ok: false, error: { code, type, message } }` instead of throwing, so an agent can read the engine message and self-correct. +- That includes caller mistakes on the dispatch path itself: an unknown tool + name and a non-object `arguments` payload both come back as envelopes + (`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: . (TYPE)`; map by code (`164→READONLY`, `62→SYNTAX`, `46/47/60/81/115→UNKNOWN_*`). diff --git a/integrations/agents/conformance/cases.jsonl b/integrations/agents/conformance/cases.jsonl index d0b2ecb..f3cb1b9 100644 --- a/integrations/agents/conformance/cases.jsonl +++ b/integrations/agents/conformance/cases.jsonl @@ -20,3 +20,4 @@ {"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"}} diff --git a/integrations/agents/descriptors.mjs b/integrations/agents/descriptors.mjs index 01f3c56..f856cc1 100644 --- a/integrations/agents/descriptors.mjs +++ b/integrations/agents/descriptors.mjs @@ -25,16 +25,42 @@ export const CONTRACT_VERSION = '0.2.0' const DESCRIPTORS_PATH = join(dirname(fileURLToPath(import.meta.url)), 'descriptors.json') let cache = null -/** Return the parsed descriptors.json (cached after the first read). */ +/** + * Return the parsed descriptors.json (the file is read once and cached; each + * call returns a deep copy, so a caller mutating the result cannot corrupt + * what toolSpecs()/capabilities()/AGENT_TOOL_DESCRIPTORS generate for everyone + * else in-process). + */ export function loadDescriptors() { - if (cache == null) cache = JSON.parse(readFileSync(DESCRIPTORS_PATH, 'utf8')) - return cache + if (cache == null) { + try { + cache = JSON.parse(readFileSync(DESCRIPTORS_PATH, 'utf8')) + } catch (e) { + // A missing/broken descriptors.json takes down every toolSpecs()/ + // capabilities()/adapter path — surface it as a diagnosable typed error + // instead of a raw fs error or SyntaxError. + throw new ChDBError( + `cannot load agent tool descriptors from ${DESCRIPTORS_PATH}: ${(e && e.message) || e}`, + ) + } + } + return structuredClone(cache) } +// The param types descriptors.json may declare. An unknown type must fail +// loudly: silently rendering it as a permissive schema would degrade the +// model-visible argument contract without any signal. +const PARAM_TYPES = new Set(['string', 'integer', 'object']) + function jsonSchema(params) { const properties = {} const required = [] for (const p of params) { + if (!PARAM_TYPES.has(p.type)) { + throw new ChDBError( + `unknown descriptor param type ${JSON.stringify(p.type)} for ${JSON.stringify(p.name)} (expected one of ${[...PARAM_TYPES].join(', ')})`, + ) + } const prop = { type: p.type } if (p.description) prop.description = p.description properties[p.name] = prop diff --git a/integrations/agents/framework.mjs b/integrations/agents/framework.mjs index 5049bb5..c234bec 100644 --- a/integrations/agents/framework.mjs +++ b/integrations/agents/framework.mjs @@ -7,6 +7,7 @@ import { z } from 'zod' import { loadDescriptors } from './descriptors.mjs' +import { ChDBError } from './errors.mjs' import { ChDBTool } from './tool.mjs' /** @@ -45,7 +46,14 @@ function zodParam(p) { let t if (p.type === 'string') t = z.string() else if (p.type === 'integer') t = z.number().int() - else t = z.record(z.string(), z.any()) + else if (p.type === 'object') t = z.record(z.string(), z.any()) + else { + // an unknown type must fail loudly, not render as a permissive schema + // that silently degrades the model-visible argument contract + throw new ChDBError( + `unknown descriptor param type ${JSON.stringify(p.type)} for ${JSON.stringify(p.name)}`, + ) + } if (p.description) t = t.describe(p.description) return p.required ? t : t.optional() } diff --git a/integrations/agents/tool.mjs b/integrations/agents/tool.mjs index 0b68ca6..541527b 100644 --- a/integrations/agents/tool.mjs +++ b/integrations/agents/tool.mjs @@ -342,11 +342,25 @@ export class ChDBTool { * @returns {Promise<{ok: true, result: any} | {ok: false, error: {code:number, type:string, message:string}}>} */ async call(name, args = {}) { - const a = { ...(args || {}) } const methodName = TOOL_METHODS[name] if (!methodName) { return { ok: false, error: { code: 0, type: 'UNKNOWN_TOOL', message: 'unknown tool: ' + name } } } + // Caller mistakes on the dispatch path never throw (P4): a non-object + // arguments payload comes back as an envelope, same as an unknown tool. + // (Spreading a string would silently produce {0: 'S', 1: 'E', ...} garbage, + // and the Python reference would raise on dict("...").) + if (args != null && (typeof args !== 'object' || Array.isArray(args))) { + return { + ok: false, + error: { + code: 0, + type: 'INVALID_ARGUMENT', + message: 'arguments must be an object, got ' + (Array.isArray(args) ? 'array' : typeof args), + }, + } + } + const a = { ...(args || {}) } try { let result if (methodName === 'query') { diff --git a/test/v3/integrations/agents-conformance.test.ts b/test/v3/integrations/agents-conformance.test.ts index 8f27e57..58622b9 100644 --- a/test/v3/integrations/agents-conformance.test.ts +++ b/test/v3/integrations/agents-conformance.test.ts @@ -25,9 +25,19 @@ const records = readFileSync(resolve(CONF, 'cases.jsonl'), 'utf8') .map((l) => l.trim()) .filter(Boolean) .map((l) => JSON.parse(l)) -// The first record without an "id" is the fixture header; everything else is a case. -const header = records.find((r) => r.id === undefined) -const cases = records.filter((r) => r.id !== undefined) +// The FIRST record must be the fixture header (no "id") and every later record +// must be a case (with "id") — anything else is a malformed fixture and fails +// loudly instead of being silently reclassified (a case that lost its "id" +// must not vanish by being mistaken for a second header). +const [header, ...cases] = records +if (!header || header.id !== undefined) { + throw new Error('cases.jsonl must start with a header record (no "id")') +} +for (const r of cases) { + if (r.id === undefined) { + throw new Error('cases.jsonl has a non-header record without an "id": ' + JSON.stringify(r)) + } +} // Replace the {{fixtures}} token in any string, recursively through objects. function sub(v: any): any { diff --git a/test/v3/integrations/agents-tool.test.ts b/test/v3/integrations/agents-tool.test.ts index 038fd5c..4f41876 100644 --- a/test/v3/integrations/agents-tool.test.ts +++ b/test/v3/integrations/agents-tool.test.ts @@ -84,6 +84,19 @@ describe('descriptors as the single source (CONTRACT.md)', () => { } }) + it('loadDescriptors returns a defensive copy', () => { + // a caller mutating the result must not corrupt what toolSpecs()/ + // capabilities()/AGENT_TOOL_DESCRIPTORS generate for everyone else + const d = loadDescriptors() as any + d.contract_version = '9.9.9' + d.tools[0].name = 'mutated' + d.tools[0].params.length = 0 + const fresh = loadDescriptors() as any + expect(fresh.contract_version).toBe(CONTRACT_VERSION) + expect(fresh.tools[0].name).toBe('run_select_query') + expect((toolSpecs('anthropic') as any)[0].input_schema.required).toEqual(['sql']) + }) + it('reports capabilities keyed to the contract version', () => { const caps = capabilities() expect(caps.contract_version).toBe(CONTRACT_VERSION) @@ -140,6 +153,21 @@ describe('argument validation (CONTRACT.md P3)', () => { t.close() } }) + + it('call() returns an envelope for a non-object arguments payload', async () => { + // the dispatch path never throws for caller mistakes: without this guard a + // string would spread into {0: 'S', 1: 'E', ...} garbage and run anyway + const t = new ChDBTool({ readOnly: true }) + try { + for (const bad of ['SELECT 1', 42, ['sql']]) { + const out: any = await t.call('run_select_query', bad as any) + expect(out.ok).toBe(false) + expect(out.error.type).toBe('INVALID_ARGUMENT') + } + } finally { + t.close() + } + }) }) describe('adapter toolset close()', () => {