QuerysourceToolkit¶
Tenant-scoped QuerySource tools for agents (parrot_tools.querysource.QuerysourceToolkit, tool prefix qs).
It replaces QSourceTool (FEAT-558, hard cut — see Migrating from QSourceTool
below) with a typed, tenant-aware surface over QuerySource query-slugs (public.queries) and MultiQuery
pipelines. All access is in-process, through the installed querysource package — there are no HTTP calls to
the QuerySource REST API.
Configuration¶
| Argument | Default | Meaning |
|---|---|---|
programs |
None |
allowlist of program_slug values; None = unrestricted |
allow_write |
False |
enables qs_save_multiquery and destination (Output) steps |
allow_raw_sql |
False |
inline {"query": …} pipeline nodes (ignored — always forbidden — when restricted) |
allow_external_sources |
True |
files / sources sections |
include_sql |
True |
qs_describe_slug returns the SQL / pipeline JSON |
max_rows |
200 |
row cap pushed into QuerySource as querylimit |
forced_conditions |
{} |
conditions merged last on every execution (permanent_filter precedence) |
dsn |
querysource asyncpg_url |
catalog connection used to read public.queries |
multiquery_timeout |
600 |
seconds qs_run_multiquery waits before raising a timeout error |
from parrot_tools.querysource import QuerysourceToolkit
# Unrestricted, read-only (default)
toolkit = QuerysourceToolkit()
# Tenant-restricted to the "pokemon" program, allowed to persist pipelines it composes
toolkit = QuerysourceToolkit(programs=["pokemon"], allow_write=True)
Tools¶
Generated tool names use the qs prefix (tool_prefix="qs"). Nine tools are always present; the tenth
(qs_save_multiquery) appears only when the toolkit is constructed with allow_write=True, and is marked
requires_confirmation (HITL) via confirming_tools.
qs_get_dialect_reference— Returns the QuerySource conditions dialect: which keys are options, which become placeholders, which become WHERE filters, the WHERE value grammar with examples, and the@variablesthis deployment accepts as values (e.g.@today). Call this before building conditions.qs_list_slugs— Lists query-slugs visible to this toolkit (allowlist-filtered).searchmatches slug or description. Optionaltenantargument selects a QuerySource tenant store schema.qs_describe_slug— Explains a slug: placeholders and types, stored defaults, filtering/fields/ordering/grouping, provider, program, and — when the toolkit is configured withinclude_sql— the SQL or pipeline JSON.dry_run=Truealso returns the rendered query viaQS.dry_run()(this performs provider setup, not a pure catalog read). Optionaltenantargument selects a QuerySource tenant store schema.qs_execute_slug— Runs a query-slug.placeholdersfill the slug's declared conditions (seeqs_describe_slug);filteradds WHERE clauses in the dialect grammar (seeqs_get_dialect_reference);fields,ordering,groupingoverride the stored projection;limitis capped at the toolkit'smax_rows;refreshbypasses the QuerySource cache. Returns bounded rows plusreturned_rows/total_rows/truncated. Optionaltenantargument selects a QuerySource tenant store schema.qs_list_components— Lists MultiQuery pipeline components (Operators, Transformations, Sources, Destinations) with their JSON schema and a usage example — the same catalog asGET /api/v3/qs/components. Optionalcategoryfilter.qs_validate_pipeline— Validates a MultiQuery pipeline without running it: structural rules (known step names, ≥1 source, Join/Merge arity) plus this toolkit's policy — everyqueries[*]slug must be one this instance may execute; raw SQL nodes, external sources and destination (write) steps are reported when the configuration forbids them.qs_run_multiquery— Runs a MultiQuery pipeline inline (pipeline, the JSON with queries/Join/Concat/…/Output) or a saved multi-query slug (slug). Every referenced slug must be executable by this toolkit; raw SQL nodes, external sources and destination steps follow the instance configuration (seeqs_validate_pipeline). Results are bounded per frame.qs_build_linked_surface— (FEAT-598) Emits a linked A2UI surface for a query-slug: checks the slug (andtenant) against the allowlist, derivesparamsfromqs_describe_slug, buildsconditions(forced keys becomelocked;@variablesare rejected), probes the slug withquerylimit=1to validate the component's axes/columns and dtypes against the real columns, and returns a definition-only envelope (rows: [],snapshot_at: null) that the renderer fills on mount. Passsnapshot=Trueonly when viewers cannot fetch for themselves (share links, offline export): it then runs the full query and embeds ≤ 500 rows. Returns{a2ui_envelope, artifacts}. See A2UI linked surfaces.
The transform field supports three forms: inline DSL (ops), catalogued renderer modules (ref), and
server-side python transformers (python). When using transform.python, specify the registered transformer
name, optionally set input_alias (defaults to "source"), and optionally specify output to select which
DataFrame from the result. Python transforms are terminal — no further transforms can be chained after them.
At build time, if the transformer name is not registered with the server, the tool raises InvalidConditionsError.
- qs_build_linked_dashboard — (FEAT-610, linked dashboards) Emits ONE linked A2UI dashboard whose data
sources are owned by the dashboard. sources maps a key to {slug, request?, tenant?, refresh?,
transform?}; each is fetched once on load and shared by every widget that reads it. Each widget
{key, component, section?} declares exactly one data origin:
- source: "<key>" — binds the dashboard source's rows directly (six KPICards over one kpis query that
computes six aggregates: {"source": "kpis", "component": {"component": "KPICard", "value": "total_visits"}});
- source + transform: {ops: [...]} — a derived view (kind: "derived" on the wire) computed from the
parent's full frame with the transform DSL, on the client and on the server, without another fetch — e.g. a
grid shows every row of rows while a pie chart groups those same rows by category;
- slug (+ request?, tenant?, refresh?) — the widget's own query-slug source, refreshed independently
(the FEAT-610 shape, still supported);
- data: [...] — inline rows (≤ 500) baked into the data model, never refreshed.
KPIs, charts and tables are laid out in rows. KPICards name their column in value. Filters accept the
JSONB operators @>, <@, @>|, -> and ->> in the {op: value} filter form (querysource >= 5.1).
Derived views aggregate what the parent fetched (bounded by the 5000-row fetch cap): when the full data set
is larger, put the aggregation in the parent's request (fields + grouping) and derive from that.
Same snapshot semantics as qs_build_linked_surface: definition-only by default (one-row probe per
query-slug source; derived views are validated over the probed parent), full fetch + ≤ 500 rows per source
only with snapshot=True.
- qs_save_multiquery (only when allow_write=True) — Persists a validated MultiQuery pipeline as a
query-slug owned by program (forced to the single allowed program when this toolkit is tenant-restricted).
Requires operator opt-in (allow_write) and user confirmation. Refuses to overwrite a slug owned by another
program; set overwrite=True to update your own.
Tenancy semantics¶
Tenancy is a static program_slug allowlist passed at construction time (programs=[...]), re-checked on
every call against public.queries — there is no positive authorization cache, so a program_slug
change takes effect immediately. None means unrestricted.
The qs_list_slugs, qs_describe_slug, qs_execute_slug, qs_build_linked_surface, and qs_build_linked_dashboard tools accept an optional tenant argument for selecting a QuerySource tenant store schema. Omit it for public/legacy slugs. Routing, not security.
| Capability | Restricted (programs=[...]) |
Unrestricted (programs=None) |
|---|---|---|
| Query-slug access | Only rows whose program_slug is in the allowlist; a foreign slug raises TenantDeniedError from describe_slug, execute_slug, run_multiquery and save_multiquery before any QS/MultiQS object is constructed |
Any program |
list_slugs |
Filtered server-side to allowed programs | All programs (or one, via the program argument) |
Inline raw SQL nodes ({"query": …} / {"raw_query": …}) |
Always rejected, regardless of allow_raw_sql |
Rejected unless allow_raw_sql=True |
files / sources pipeline sections |
Accepted (not program-scoped) unless allow_external_sources=False |
Same — accepted unless allow_external_sources=False |
Destination steps (Output writes, e.g. tableOutput, dwh, s3, sharepoint) |
Rejected unless allow_write=True |
Same — rejected unless allow_write=True |
save_multiquery |
Forces program_slug to the single allowed program (or the explicit program argument, if it is in the allowlist) |
Requires an explicit program argument |
Conditions dialect quick reference¶
qs_get_dialect_reference returns a DialectReference verified against querysource 4.5.11. Summary:
Option keys — these become QuerySource options, not placeholders or WHERE filters: fields, querylimit
(alias _limit), _offset (paged/page for page-based pagination), ordering (alias order_by),
grouping (alias group_by), filter (alias where_cond), refresh, filter_options, qry_options,
hierarchy, distinct, add_fields, tablename, schema, database, slug, conditions.
Placeholder rules:
- A slug's stored conditions are default values for the placeholders in its SQL (e.g. {firstdate});
cond_definition declares their types.
- Merge order: stored defaults < your flat keys < your nested conditions. Your values win.
- Any key that is not an option key and not a declared placeholder becomes a WHERE filter — put ad-hoc column
filters in filter, and only declared names in placeholders.
- Values starting with @ call a deployment variable function (see variables), e.g. @today.
WHERE grammar (filter argument):
| Form | Meaning |
|---|---|
| col: 'v' | col = 'v' |
| col: '!v' or 'col!': 'v' | col != 'v' |
| col: ['a', 'b'] | col IN ('a', 'b'); 'col!': [...] → NOT IN |
| col: ['>=', 10] | col >= 10 (first item must be one of operators_list_form: <, >, >=, <=, <>, !=, IS NOT, IS) |
| col: {'>': 10} | col > 10 (single key from operators_dict_form: >=, <=, <>, !=, <, >) |
| col: 'BETWEEN 1 AND 5' | (col BETWEEN 1 AND 5) — must not contain ;, --, /*, UNION, SELECT |
| col: 'null' / '!null' | IS NULL / IS NOT NULL |
| col: true | col = True |
Keys must be identifier-safe ([A-Za-z0-9_.] after stripping suffix characters |!~#@:); the parser
silently drops unsafe keys/operators, so this toolkit rejects them up front instead (InvalidConditionsError).
Example (from qs_get_dialect_reference): filtering epson_field_activity by date range —
{"slug": "epson_field_activity", "placeholders": {"firstdate": "2026-08-09", "lastdate": "2026-08-15"}}
Migrating from QSourceTool¶
QSourceTool (deleted in FEAT-558) exposed a single free-form conditions dict with no grammar. Map its
usage to the new typed tools:
QSourceTool (removed) |
QuerysourceToolkit replacement |
|---|---|
query_slug argument |
slug argument (same value, e.g. epson_field_activity) on qs_execute_slug / qs_describe_slug |
conditions dict — declared placeholders (e.g. {"firstdate": ..., "lastdate": ...}) |
qs_execute_slug(placeholders={...}) — validated against the slug's cond_definition first |
conditions dict — ad-hoc WHERE clauses (fields not declared by the slug) |
qs_execute_slug(filter={...}) in the dialect grammar (see above); validated up front instead of being silently dropped by the SQL parser |
conditions["fields"] / ["group_by"] / ["order_by"] |
fields=[...], grouping=[...], ordering=[...] typed arguments |
conditions["querylimit"] / free row limit |
limit=..., capped by the toolkit's max_rows — never unbounded |
| No tenancy — any slug was accessible | programs=[...] allowlist enforced on every call, before any query executes |
| No discovery — the LLM had to already know a slug existed | qs_list_slugs / qs_describe_slug — browse and inspect what is available |
| No MultiQuery support | qs_list_components, qs_validate_pipeline, qs_run_multiquery, qs_save_multiquery |
| Raw SQL was never exposed either | Still never exposed as a tool argument; allow_raw_sql only affects inline MultiQuery pipeline nodes for unrestricted instances, and is always forbidden when tenant-restricted |