# Lexsi Data Science Agent — Tool Reference (v0) What's in the registry right now, how each tool works, and how they fit together in a run. Generated from the current code, not from the design doc — when those disagree, this file wins. --- ## 1. Agent loop in one minute A run = one user question. The loop: 1. Build a system prompt: framing + active dataset summary + tool catalogue. 2. Ask the LLM for a JSON decision: call a tool **OR** emit a final answer. 3. If tool call: validate args via the tool's pydantic schema, run, append the observation to the conversation, loop. 4. If final answer: stop and return. Implementation: [`lexsi_ds/agent/loop.py`](../lexsi_ds/agent/loop.py). Hard rail: `max_steps=12` (v0 default). On exhaustion the loop asks once for a final answer with `force_final=True`. The loop has **no opinion about which tools to call** that's the agent's job. The planner sees: - A short system framing ([`PLANNER_SYSTEM`](../lexsi_ds/agent/prompts.py)) with 11 numbered behavior rules (use `text_to_sql` for non-trivial SQL, retry on empty SQL results, call `summarize_result` after `predict`, etc.). - The active dataset summary (id, kind, table list, KG availability). - The tool catalogue — every registered tool's `name` + `description` + JSON schema + `returns` hint rendered into the prompt verbatim. The planner responds with a SINGLE JSON object, one of: ```json {"thought": "...", "tool": "", "args": {...}} {"thought": "...", "final_answer": ""} ``` --- ## 2. The tool contract Every tool implements [`lexsi_ds/agent/tools/base.py`](../lexsi_ds/agent/tools/base.py): ```python @dataclass(frozen=True) class ToolSpec: name: str description: str # full paragraph; LLM sees verbatim args_schema: type[BaseModel] returns: str # one-line observation shape @dataclass class ToolResult: ok: bool summary: str # short observation text the LLM sees payload: Any = None # rich Python (DataFrames, dicts) — LLM never sees error: str | None = None meta: dict[str, Any] = {} # timing, provider trace ids, etc. class Tool(Protocol): spec: ToolSpec def run(self, args: BaseModel, ctx: AgentContext) -> ToolResult: ... ``` **Two-channel design** is the most important pattern: `summary` is for the planner (keep under ~200 tokens), `payload` is for the UI and downstream tools (rich Python, never serialised into the prompt). The distinction is what keeps prompts small as runs grow. ### `AgentContext` — what tools share From [`lexsi_ds/agent/context.py`](../lexsi_ds/agent/context.py): | Field | Purpose | |---|---| | `dataset: DatasetHandle` | Active dataset (PKDD / upload / S3 connector) | | `run_id: str` | UUID-derived per-run id; tools use it to mint unique resource names | | `org, text_project, tab_project, tab_projects` | Lexsi SDK handles | | `llm: LLMClient` | Planner + summarizer LLM | | `cache: dict[str, Any]` | Run-scoped scratchpad shared between tools (see §4) | | `duck(read_only=…)` | Open a fresh DuckDB connection against the active dataset | ### Registry pattern [`lexsi_ds/agent/tools/__init__.py`](../lexsi_ds/agent/tools/__init__.py) maps name → `Tool` instance. Adding a tool = drop a file with a `TOOL: Tool` module-level constant + add a line to `REGISTRY`. The loop never imports a tool directly. --- ## 3. Tool catalogue Ten tools across four groups. Order in the table matches the order in the system prompt (related tools grouped so the LLM sees them in a sensible neighborhood). | Group | Tool | Side effects | Lexsi SDK? | |---|---|---|---| | Discovery | `connect_datalake` | binds new dataset | `create_data_connectors` | | Discovery | `inspect_data` | none | no | | Discovery | `query_kg` | none | no | | Retrieval | `text_to_sql` | LLM call only | optional | | Retrieval | `run_sql` | writes `cache["sql_result:*"]` | no | | Retrieval | `sample_values` | none | no | | Modeling | `train_tabular_model` | trains a Lexsi model, mints a project | `TabularProject.upload_data + train_model` | | Modeling | `predict` | uploads predict tag, runs inference | `TabularProject.model_inference` | | Modeling | `explain_prediction` | per-case SHAP | `case_predict` + `xai_*` | | Narration | `summarize_result` | LLM call only | `TextProject.chat_completion` | ### 3.1 `connect_datalake` **Source:** [`tools/connect_datalake.py`](../lexsi_ds/agent/tools/connect_datalake.py) Register an S3 datalake via Lexsi `create_data_connectors` and bind one or more parquet files as queryable DuckDB views. After a successful call the connector becomes the active dataset; subsequent `inspect_data` / `run_sql` calls operate against the bound views. | Arg | Type | Default | Notes | |---|---|---|---| | `kind` | `Literal["s3"]` | `"s3"` | GCS / SFTP / GDrive are fast-follow | | `bucket` | `str` | — | required | | `prefix` | `str` | `""` | key prefix under the bucket | | `region` | `str` | `"us-east-1"` | | | `aws_access_key_id` | `str \| None` | reads `AWS_ACCESS_KEY_ID` from env | not logged, not surfaced | | `aws_secret_access_key` | `str \| None` | reads `AWS_SECRET_ACCESS_KEY` | same | | `files` | `list[str]` | — | each file → a view named after its stem | **Observation:** list of bound view names + the Lexsi connector id. **Use when:** user wants their own data instead of the bundled PKDD. ### 3.2 `inspect_data` **Source:** [`tools/inspect_data.py`](../lexsi_ds/agent/tools/inspect_data.py) Return the schema (tables, columns, types, comments) for the active dataset as Markdown. Cheap and side-effect-free. | Arg | Type | Default | Notes | |---|---|---|---| | `table_filter` | `list[str] \| None` | `None` | If omitted, all tables | **Observation:** Markdown schema with table names, column types, and KG-attached pointers (when the dataset has a KG, the observation suggests `query_kg` for disambiguation). **Cap:** `_SCHEMA_BUDGET_CHARS = 10000` — truncates between tables, never mid-table, so the LLM always sees coherent column lists. **Use when:** unfamiliar dataset, OR mid-run to remind the planner which columns exist. ### 3.3 `query_kg` **Source:** [`tools/query_kg.py`](../lexsi_ds/agent/tools/query_kg.py) Explore the business knowledge graph attached to the active dataset. The KG knows which column to pick when wording is ambiguous ("customer" → `fin_client`, not `fin_account`), which join paths are correct, and what business metrics exist. | Arg | Type | Default | Notes | |---|---|---|---| | `section` | `Literal[…] \| None` | `None` | concepts / disambigs / metrics / join_paths / tables | | `search` | `str \| None` | `None` | Free-text — pass the user's question **verbatim**, not extracted keywords | | `node` | `str \| None` | `None` | Fetch one node by label or full id | | `top_k` | `int` | `8` | Max results for `search=` | Three modes: - No args → outline of all sections. - `search=` / `section=` → top-K matches or full section listing. - `node=` → one node's full record. **Why pass the full question:** the KG indexes on trigger phrases like "lives in", "owned by", "customer of". Single nouns lose disambiguation signal. If initial scores are weak, the tool auto-broadens using the active run's question. **Use when:** the question contains potentially-ambiguous wording. **Critical:** the planner is instructed to call this *before* writing SQL on any ambiguous noun. ### 3.4 `text_to_sql` **Source:** [`tools/text_to_sql.py`](../lexsi_ds/agent/tools/text_to_sql.py) Convert a natural-language question into DuckDB SQL against the active dataset. The default SQL-writer — `run_sql` inline is reserved for trivial single-table queries. | Arg | Type | Default | Notes | |---|---|---|---| | `question` | `str` | — | required | | `mode` | `Literal["analytic","predictive","auto"]` | `"auto"` | see below | Modes: | Mode | Output | |---|---| | `analytic` | One SQL string that answers the question directly. | | `predictive` | Two SQLs (`context_sql` for label-known rows, `predict_sql` for label-unknown) + a `task` spec (`task_type`, `target_column`, `entity_column`). Wired straight into `train_tabular_model` + `predict` next. | | `auto` | Picks based on keywords (`predict`, `forecast`, `likely`, `will`, `propensity`, …). | **Schema rendering:** For PKDD, uses the introspected `RawSchema` with DDL + comments. For uploaded/connector datasets, builds a plain DESCRIBE-style listing from `ctx.dataset.tables`. **Why the default:** the planner is hard-rule-instructed (see [`PLANNER_SYSTEM` rule 6](../lexsi_ds/agent/prompts.py)) to call `text_to_sql` for any SQL involving JOINs, conditional aggregates, or string-value filters. Inline `run_sql(sql=…)` allowed only for trivial single-table queries. The tool sees the full KG and is far less likely to pick the wrong join path. ### 3.5 `run_sql` **Source:** [`tools/run_sql.py`](../lexsi_ds/agent/tools/run_sql.py) Execute DuckDB-compatible SQL against the active dataset and return the result as a DataFrame. | Arg | Type | Default | Notes | |---|---|---|---| | `sql` | `str` | — | required | | `label` | `str \| None` | `None` | Human label later tools can reference | **Observation:** row count × col count + head preview (max 20 rows). **Payload:** the full `pd.DataFrame`. **Cache writes:** - `ctx.cache["last_sql_result"]` = the DataFrame (always). - `ctx.cache["sql_result: