import { z } from 'zod' import { WorkspaceService, type CompletedJob } from '$lib/gen' import type { DataTableTables } from '$lib/gen/types.gen' import { runScript } from '$lib/components/jobs/utils' import { createToolDef, executeTestRun, type Tool, type ToolDisplayMessage, type ChatJobResultFormat } from './shared' /** * Workspace-scoped datatable tools, with no app whitelist and no creation policy. * * Datatables are workspace-level managed PostgreSQL databases. The backend * endpoints used here (`list_datatable_tables`, `get_datatable_table_schema`) * and SQL execution (`datatable://`) are gated only by workspace * membership, so these tools need no app context and operate directly on the * workspace. This is the unrestricted counterpart to the app-mode datatable * tools in `app/core.ts`, which additionally filter by the app's whitelist. */ // ============= Utility ============= /** Memoize a factory function - the factory is only called once, on first access */ const memo = (factory: () => T): (() => T) => { let cached: T | undefined return () => (cached ??= factory()) } // ============= Pure workspace-scoped operations ============= /** List all datatables configured in the workspace, with their schema/table names. */ export async function listDatatables(workspace: string): Promise { return await WorkspaceService.listDataTableTables({ workspace }) } /** Get the columns (column_name -> compact_type) of one datatable table. */ export async function getDatatableColumns( workspace: string, datatableName: string, schemaName: string, tableName: string ): Promise> { const schema = await WorkspaceService.getDataTableTableSchema({ workspace, datatableName, schemaName, tableName }) return schema.columns } // ============= Error helpers ============= /** * The backend returns "datatable not found" when no datatable with that * name is configured in the workspace settings. That is a hard, blocking * prerequisite (not a transient failure), so we surface an explicit, actionable * message instead of the raw internal error. */ function isDatatableNotConfiguredError(error: string | null | undefined): boolean { return typeof error === 'string' && /datatable\s+\S+\s+not found/i.test(error) } function datatableNotConfiguredMessage(datatableName: string): string { return ( `Datatable "${datatableName}" is not configured in this workspace, so this operation cannot run. ` + `Datatables are not created by SQL — they must be set up first by the user in the workspace settings ` + `(Workspace settings → Data Tables) before any table can be queried or created. ` + `This is a required, blocking prerequisite: do not retry on this or another name. ` + `Tell the user they need to configure a datatable (e.g. named "${datatableName}") in their workspace settings, then try again.` ) } const NO_DATATABLES_CONFIGURED_MESSAGE = 'No datatables are configured in this workspace. Datatable operations (querying data, creating or altering tables) are blocked until a datatable exists. ' + 'Datatables are not created by SQL — the user must set one up in the workspace settings (Workspace settings → Data Tables) first. ' + 'Do not call exec_datatable_sql or assume a "main" datatable exists; instead tell the user this is a required prerequisite and ask them to configure a datatable in their workspace settings.' // ============= Tool definitions ============= const getListDatatablesSchema = memo(() => z.object({})) const getListDatatablesToolDef = memo(() => createToolDef( getListDatatablesSchema(), 'list_datatables', 'List datatables configured in the workspace with schema and table names only. Does not include column definitions. Use this directly for table-list or available-tables summaries. Only call get_datatable_table_schema when column names/types are required.' ) ) const getGetDatatableTableSchemaSchema = memo(() => z.object({ datatable_name: z.string().describe('The datatable name to inspect, e.g. "main".'), schema_name: z.string().describe('The schema name, e.g. "public".'), table_name: z.string().describe('The table name to inspect.') }) ) const getGetDatatableTableSchemaToolDef = memo(() => createToolDef( getGetDatatableTableSchemaSchema(), 'get_datatable_table_schema', 'Get column definitions for one datatable table. Do not call this for row counts or table-list summaries; list_datatables is enough for those.' ) ) const getExecDatatableSqlSchema = memo(() => z.object({ datatable_name: z .string() .describe( 'The name of the datatable to query (e.g., "main"). Must be one of the datatables configured in the workspace.' ), sql: z .string() .describe( 'The SQL query to execute. Supports SELECT, INSERT, UPDATE, DELETE, CREATE TABLE, ALTER TABLE, DROP TABLE, etc. For SELECT queries, results are returned as an array of objects. A newly created table will appear in list_datatables automatically.' ), background: z .boolean() .optional() .describe( 'Run in the background without waiting. Set true for queries you expect to be slow (large scans, heavy migrations) — you will be notified when it finishes. Leave unset for normal queries, which wait briefly and only background automatically if slow.' ), wait_seconds: z .number() .optional() .describe( 'How many seconds to wait for the query in-turn before it detaches into the background jobs tray. Defaults to 15. Raise it (capped at 120) for a query you expect to finish in ~30–60s and want the result in this same turn. Ignored when background is true.' ) }) ) const getExecDatatableSqlToolDef = memo(() => createToolDef( getExecDatatableSqlSchema(), 'exec_datatable_sql', 'Execute a SQL query on a workspace datatable. Use this to explore data, test queries, create/alter tables, or make changes. Creating a table is a normal CREATE TABLE statement — no registration step is needed.' ) ) /** Maximum rows returned to the model for a SELECT query. */ const MAX_ROWS = 100 /** * Shape a finished datatable SQL job into the model-visible result + tool card: * SELECT rows capped at MAX_ROWS, DDL/DML reported as zero rows, and the "datatable * not configured" backend error rewritten as the actionable blocking message. * * Pure and keyed only on the datatable name so it can run from BOTH the inline * completion path (executeTestRun.formatCompletion) AND a detached/rehydrated job * reconstructed from its persisted ChatJobResultFormat (see formatChatJobCompletion). */ export function formatDatatableSqlCompletion( job: CompletedJob, datatableName: string ): { llmText: string; card: Partial } { if (job.success) { // Successful runs always carry a `result` array (empty for DDL/DML), // so SELECT rows and zero-row statements share one reporting path. const rows = Array.isArray(job.result) ? (job.result as Record[]) : [] const rowCount = rows.length const payload = rowCount > MAX_ROWS ? { success: true, rowCount, result: rows.slice(0, MAX_ROWS), note: `Showing first ${MAX_ROWS} of ${rowCount} rows` } : { success: true, rowCount, result: rows } return { llmText: JSON.stringify(payload, null, 2), card: { content: `Query returned ${rowCount} row(s)`, result: JSON.stringify(job.result, null, 2) } } } const raw = (job.result as any)?.error?.message ?? (typeof job.result === 'string' ? job.result : JSON.stringify(job.result)) ?? // JSON.stringify(undefined) is `undefined` (not a string), so a failed // job with no result would otherwise yield an empty error message. 'Unknown error' const errorMsg = isDatatableNotConfiguredError(raw) ? datatableNotConfiguredMessage(datatableName) : raw return { llmText: JSON.stringify({ success: false, error: errorMsg }), card: { content: `Error: ${errorMsg}`, error: errorMsg } } } /** * Reconstruct a tool's terminal formatter from the serializable descriptor stored * on a ChatJob. Lets a background job that detached (and may have survived a reload, * losing any in-memory closure) still report through its launching tool's result * contract. Dispatches on `kind`; extend as more tools gain persisted formatters. */ export function formatChatJobCompletion( job: CompletedJob, resultFormat: ChatJobResultFormat ): { llmText: string; card: Partial } { switch (resultFormat.kind) { case 'datatable': return formatDatatableSqlCompletion(job, resultFormat.datatableName) } } /** * The unrestricted workspace datatable tools, for registration in global mode. * Helper-free: each tool reads `workspace` directly from the tool call params. */ export function getDatatableTools(): Tool<{}>[] { return [ { def: getListDatatablesToolDef(), planModeSafe: true, fn: async ({ workspace, toolId, toolCallbacks }) => { toolCallbacks.setToolStatus(toolId, { content: 'Listing datatables...' }) try { const metadata = await listDatatables(workspace) if (metadata.length === 0) { toolCallbacks.setToolStatus(toolId, { content: 'No datatables configured — set one up in workspace settings' }) return NO_DATATABLES_CONFIGURED_MESSAGE } const totalTables = metadata.reduce( (acc, datatable) => acc + Object.values(datatable.schemas).reduce((sum, tables) => sum + tables.length, 0), 0 ) toolCallbacks.setToolStatus(toolId, { content: `Listed ${metadata.length} datatable(s) with ${totalTables} table(s)` }) return JSON.stringify(metadata, null, 2) } catch (e) { const errorMsg = `Error listing datatables: ${e instanceof Error ? e.message : String(e)}` toolCallbacks.setToolStatus(toolId, { content: errorMsg, error: errorMsg }) return errorMsg } } }, { def: getGetDatatableTableSchemaToolDef(), planModeSafe: true, fn: async ({ args, workspace, toolId, toolCallbacks }) => { const parsedArgs = getGetDatatableTableSchemaSchema().parse(args) toolCallbacks.setToolStatus(toolId, { content: `Getting schema for ${parsedArgs.datatable_name}.${parsedArgs.schema_name}.${parsedArgs.table_name}...` }) try { const columns = await getDatatableColumns( workspace, parsedArgs.datatable_name, parsedArgs.schema_name, parsedArgs.table_name ) toolCallbacks.setToolStatus(toolId, { content: `Retrieved schema for ${parsedArgs.schema_name}.${parsedArgs.table_name}` }) return JSON.stringify( { datatable_name: parsedArgs.datatable_name, schema_name: parsedArgs.schema_name, table_name: parsedArgs.table_name, columns }, null, 2 ) } catch (e) { const raw = e instanceof Error ? e.message : String(e) const errorMsg = isDatatableNotConfiguredError(raw) ? datatableNotConfiguredMessage(parsedArgs.datatable_name) : `Error getting table schema: ${raw}` toolCallbacks.setToolStatus(toolId, { content: errorMsg, error: errorMsg }) return errorMsg } } }, { def: getExecDatatableSqlToolDef(), requiresConfirmation: true, confirmationMessage: 'Execute SQL on datatable', showDetails: true, fn: async ({ args, workspace, toolId, toolCallbacks }) => { const parsedArgs = getExecDatatableSqlSchema().parse(args) const name = parsedArgs.datatable_name // Route through executeTestRun so a slow query detaches into the jobs // tray (and honors `background`) instead of blocking the chat turn, // while keeping the datatable-specific result/error shaping. return executeTestRun({ jobStarter: () => runScript({ workspace, requestBody: { language: 'postgresql', content: parsedArgs.sql, args: { database: `datatable://${name}` } } }), workspace, toolCallbacks, toolId, contextName: 'script', label: `SQL · ${name}`, background: parsedArgs.background, detachAfterMs: parsedArgs.wait_seconds == null ? undefined : Math.max(0, parsedArgs.wait_seconds) * 1000, startMessage: `Executing SQL on "${name}"...`, runningMessage: `SQL running on "${name}"...`, // Inline path shapes the result here; the descriptor mirrors it so a // detached/rehydrated completion reconstructs the same contract. formatCompletion: (job) => formatDatatableSqlCompletion(job, name), resultFormat: { kind: 'datatable', datatableName: name } }) } } ] }