Skip to content

agents: contract 0.2.0 β€” descriptors.json as the single source, capabilities(), acall()#602

Open
ShawnChen-Sirius wants to merge 3 commits into
chdb-io:mainfrom
ShawnChen-Sirius:feat/agents-gate-g2
Open

agents: contract 0.2.0 β€” descriptors.json as the single source, capabilities(), acall()#602
ShawnChen-Sirius wants to merge 3 commits into
chdb-io:mainfrom
ShawnChen-Sirius:feat/agents-gate-g2

Conversation

@ShawnChen-Sirius

@ShawnChen-Sirius ShawnChen-Sirius commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

What

chdb.agents and its TypeScript twin (chdb-io/chdb-node integrations/agents) had started to drift: the model-visible tool descriptions existed in three hand-written copies (this package's tool_specs(), chdb-node's framework descriptor list, and the tool docstrings in the pending mcp-clickhouse chDB integration), the two bindings counted max_bytes differently, and the only version signal a downstream integration could pin against was __beta__ = True. This PR turns those into contract machinery:

  • chdb/agents/descriptors.json is now the single source of the model-visible surface (tool names, description text, argument schemas). tool_specs(dialect="anthropic" | "openai" | "mcp") and capabilities() are generated from it (module-level; ChDBTool.tool_specs() delegates). The file is vendored byte-identical in chdb-node, whose zod schemas are generated from it too (companion PR linked below).
  • CONTRACT_VERSION + capabilities() return {contract_version, tools, features}, giving downstream integrations a probeable version/feature surface instead of guessing from the package version. Feature keys: dataframe_query (Python-only), attachments, file_allowlist, max_execution_time, async, streaming (reserved, currently false).
  • acall() β€” async tool dispatch via a worker thread (aquery's twin), so async-first frameworks (AutoGen, Pydantic AI) bind to it instead of each re-implementing a thread-pool wrapper around call().

Cross-binding drift fixes (behavior changes)

  • max_bytes now counts UTF-8 bytes of each row's compact JSON. The previous ASCII-escaped character count put the truncation point up to ~2.6Γ— earlier than chdb-node's UTF-16 count on non-ASCII rows β€” a model-visible split between bindings. CONTRACT.md P3 now names the normative measure.
  • A non-numeric cap (max_rows / limit, per-call or constructor) raises a typed ChDBError with type="INVALID_ARGUMENT" instead of a bare ValueError.
  • On the model-facing call() envelope path, a JSON null optional argument now means "omitted" (models routinely send null for optional args); the direct method path stays strict.

Conformance fixture

conformance/cases.jsonl gains:

  • a header record carrying the fixture's contract_version β€” each binding's runner asserts it equals its own CONTRACT_VERSION, so a stale vendored fixture fails loudly;
  • capability gating via "requires": "<feature>" β€” dataframe_query gets its first fixture coverage (runs on Python, auto-skipped by the TypeScript runner);
  • new cases for every behavior above.

Tests

python -m pytest tests/test_agents_chdbtool.py tests/test_agents_conformance.py β€” 31 passed (21 conformance cases + unit tests for the dialects, version/descriptor parity, acall(), and argument validation).

Companion chdb-node PR: chdb-io/chdb-node#70

πŸ€– Generated with Claude Code

Note

Add contract 0.2.0 to chdb agents with descriptors.json, capabilities(), and acall()

  • Makes descriptors.json the single source of truth for tool surface (run_select_query, list_databases, list_tables, etc.) and introduces CONTRACT_VERSION = "0.2.0" in descriptors.py, exported via chdb.agents.
  • Adds capabilities() returning {contract_version, tools, features} and tool_specs(dialect) supporting anthropic, openai, and mcp dialects with typed INVALID_ARGUMENT errors for unknown dialects.
  • Hardens ChDBTool.__init__ to probe (not mutate) caller-provided sessions, raising CONFIG_MISMATCH when readonly state mismatches, and snapshots engine table functions for precise file-allowlist gating.
  • Rewrites allowlist enforcement in _enforce_allowlist using masked SQL scanning via find_source_calls; non-literal source arguments and out-of-allowlist paths now raise ACCESS_DENIED; dataframe_query is exempted via _permit_fns={'python'}.
  • Adds acall() as an async wrapper around call(), and call() now returns typed error envelopes for malformed/non-dict argument payloads instead of raising.
  • Risk: session construction now fails with CONFIG_MISMATCH if a caller-provided session is not in the expected readonly mode, which is a breaking change for callers that relied on silent mode coercion.

Macroscope summarized 61684f8.

…, acall()

The model-visible tool surface (names, description text, argument schemas)
moves out of code into chdb/agents/descriptors.json, vendored byte-identical
in every binding:

- tool_specs(dialect='anthropic'|'openai'|'mcp') and capabilities() are
  generated from it (module-level; ChDBTool.tool_specs delegates). Framework
  adapters derive their schema declarations from the same file instead of
  hand-copying descriptions that then drift between languages.
- CONTRACT_VERSION + capabilities() give downstream consumers a probe-able
  version/feature surface (previously only __beta__ = True and the package
  version to guess from).
- acall(): async tool dispatch via a worker thread (aquery's twin), so
  async-first frameworks stop re-implementing thread-pool wrappers around
  call().

Cross-binding drift fixes, now conformance-tested:

- max_bytes counts UTF-8 bytes of each row's compact JSON; the previous
  ASCII-escaped character count put the truncation point up to ~2.6x earlier
  than the TypeScript binding's UTF-16 count on non-ASCII rows.
- Non-numeric per-call caps (max_rows/limit) raise a typed
  ChDBError(INVALID_ARGUMENT) instead of a bare ValueError; on the call()
  envelope path a JSON null optional argument now means "omitted".

conformance/cases.jsonl gains a header record asserting the fixture's
contract_version against the binding, capability-gated cases
("requires": <feature> β€” dataframe_query gets its first fixture coverage),
and cases for the behaviors above.

Co-Authored-By: Claude Fable 5 <[email protected]>
@ShawnChen-Sirius

Copy link
Copy Markdown
Contributor Author

@chibugai, review it

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Aligns chdb.agents with a versioned β€œagent tool contract” by making descriptors.json the single source of truth for the model-visible tool surface, adding a probeable capabilities/version API, and introducing async tool dispatch (acall) to reduce cross-binding drift and simplify downstream integrations.

Changes:

  • Add contract machinery: descriptors.json β†’ generated tool_specs(dialect=...), plus CONTRACT_VERSION and capabilities().
  • Add async tool dispatch via ChDBTool.acall() and tighten numeric cap validation to raise typed INVALID_ARGUMENT.
  • Extend conformance fixture/runner to include a header with contract_version, capability-gated cases, and new behavior cases (UTF-8 max_bytes, null optional args on envelope path).

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/test_agents_conformance.py Updates conformance runner to support fixture headers and capability-gated cases; adds dataframe_query invocation.
tests/test_agents_chdbtool.py Adds unit tests for dialect generation, contract version/capabilities, descriptor/dispatch parity, and acall().
pyproject.toml Ships agents/descriptors.json in package data.
chdb/agents/tool.py Adds _int_arg typed validation, UTF-8 max_bytes counting, generated tool specs delegation, and acall().
chdb/agents/descriptors.py New module to load descriptors.json, generate dialect tool specs, and expose contract capabilities/version.
chdb/agents/descriptors.json New single-source contract file defining tool names/descriptions/param schemas and contract_version.
chdb/agents/CONTRACT.md Documents contract v0.2.0, descriptors.json as the single source, capabilities probing, async semantics, and normative max_bytes rules.
chdb/agents/conformance/README.md Documents fixture header record, capability gating, and dataframe_query case format.
chdb/agents/conformance/cases.jsonl Adds header record, new conformance cases for UTF-8 max_bytes, typed invalid caps, null optional args, and dataframe_query gating.
chdb/agents/init.py Re-exports CONTRACT_VERSION, capabilities, tool_specs, and load_descriptors as part of the public surface.

πŸ’‘ Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/test_agents_conformance.py Outdated
Comment thread chdb/agents/descriptors.py Outdated
@chibugai

chibugai commented Jul 7, 2026

Copy link
Copy Markdown

@ShawnChen-Sirius Reviewed at 0dbd6dc β€” OCR deep pass (opus-4-8, deterministic ruleset + LLM agent, --max-tools 40) over the diff: no findings. Manual read agrees. What I specifically verified:

  • max_bytes UTF-8 accounting β€” checked the arithmetic on p3_maxbytes_utf8: {"s":"汉字汉字汉字"} is 26 UTF-8 bytes/row compact, so cap 60 keeps exactly 2 of 5 rows. Under the old ensure_ascii counting the same row is 44 chars β†’ 1 row, so the fixture genuinely locks the new behavior (fails on old code).
  • _int_arg typed-error flow β€” direct method path raises INVALID_ARGUMENT; envelope path surfaces the same type via except ChDBError β†’ e.to_dict(). Both covered by the new conformance cases.
  • p2_empty_database_rejected β€” _qualify treats only None as "unqualified"; "" flows into quote_ident() and is rejected, reaching the envelope as TOOL_ERROR. Matches the fixture.
  • Envelope-only null-means-omitted β€” confined to call(); direct method path stays strict, as documented.
  • acall() / asyncio.to_thread β€” needs Python β‰₯ 3.9; fine with requires-python = ">=3.9" and the existing aquery precedent.
  • Packaging β€” agents/descriptors.json added to [tool.setuptools.package-data], so wheel installs ship the single-source file the module reads at import-adjacent time.

Local verification: ran tests/test_agents_chdbtool.py + tests/test_agents_conformance.py on Linux x86_64 (Python 3.12, chdb-core 26.5.0 engine): 31/31 pass, including the capability-gated optional_dataframe_query.

Copilot's two nits (lenient header detection in _load_fixture, load_descriptors() returning the cached object un-copied) are valid but minor β€” your call whether to address them here. No blocking findings from me; no inline comments to add.

Scope note: this review covers this PR only, not the companion chdb-io/chdb-node#70.

ShawnChen-Sirius and others added 2 commits July 8, 2026 11:52
Review follow-ups on the contract-0.2.0 change:

- load_descriptors() returns a deep copy of the cached parse, so a caller
  mutating the result can no longer corrupt what tool_specs()/capabilities()
  generate for everyone else in-process; a missing/broken descriptors.json now
  raises a diagnosable ChDBError instead of a raw IOError/JSONDecodeError.
- An unknown param type in descriptors.json fails loudly in _json_schema()
  instead of rendering verbatim β€” 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
  instead of raising from dict(arguments). The dispatch path now never throws
  for caller mistakes (CONTRACT.md P4 spells this out; new conformance case
  p4_malformed_arguments covers both bindings).
- 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: 33 passed (was 31).

Co-Authored-By: Claude Fable 5 <[email protected]>
Two safety hardenings on the agent-tool base. The scanner is upstreamed from
the copy the mcp-clickhouse chDB integration had to vendor because the
library lacked it β€” once this ships in a release, that vendored copy gets
deleted and the integration consumes the library version.

- The file_allowlist gate now scans MASKED SQL β€” string literals and comments
  blanked position-preserving, backtick/double-quote-wrapped function names
  matched β€” against the table functions the running engine exposes
  (system.table_functions snapshot, unioned with a static fallback), minus a
  shared safe-by-construction set. Every non-safe call must carry a literal
  source argument inside the allowlist; a computed argument is denied instead
  of skipped. Previously a literal-only regex over raw SQL meant `file`(...),
  file/**/(...), file(concat(...)), and any source function outside the five
  hard-coded names (mysql(), executable(), ...) all bypassed the gate.
  dataframe_query exempts only the Python() names it itself injects.

- A caller-provided session is now probed (SELECT getSetting('readonly')) and
  must match the declared read_only mode, else construction fails with a
  typed CONFIG_MISMATCH error. Previously the constructor ran SET readonly=2
  on the caller's shared session β€” irreversible by design, so it silently
  locked every other user of that session. Sessions the tool creates itself
  keep the set-at-construction behavior.

- Constructor setup failures now close the session the tool itself created
  before re-raising, instead of leaking it (the TypeScript binding already
  did this; Python gains the symmetric guard).

Conformance: five new scanner cases (backtick bypass, comment-between-name-
and-paren, computed argument, string-literal false-positive, allowlisted path
still passing) and the runner substitutes {{fixtures}} inside lists so tool
configs can carry allowlist paths.

Co-Authored-By: Claude Fable 5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants