Files
supabase/apps/studio/lib/ai/tools/notebook-tools.ts
Saxon Fletcher dcac820571 feat(studio): add assistant notebook run tool (#49361)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Assistant feature and data-handling plumbing.

## Stack context

This stack is based on #49352 (`chore/assistant-tool-outcomes`) and
assumes #49350–#49352 merge first.

Review bottom to top:

1. #49361 — assistant notebook run tool
2. #49362 — assistant notebook run UI
3. #49364 — terminal-state polish

## What is the current behavior?

The Assistant can read and edit notebooks, but it cannot execute all
saved query cells as one approved operation.

## What is the new behavior?

- Adds a `run_notebook` tool with one approval gate for the complete
notebook.
- Executes database and log cells sequentially in notebook order.
- Rejects stale runs when the notebook changed after the Assistant read
it.
- Resolves primary and read-replica connections and forwards
authorization to log and replica requests.
- Shares rows with the model only when the organization's AI
data-sharing level permits it.
- Strictly validates and sanitizes persisted notebook-run output before
replaying message history.
- Registers the tool in prompts, filtering, mocks, and tool
construction.

## How to test manually

This is the tool/data layer; use the top-of-stack preview from #49364
for the complete UI while checking these behaviors.

1. In Explorer, create and save a notebook named **Assistant run smoke
test** with:
   - a markdown cell
   - a working database query
   - a working Logs query
- a database query that returns no rows, such as `select 1 where false`
2. Open the AI Assistant and ask: **Read the “Assistant run smoke test”
notebook and analyze it using its current results.**
3. Confirm the Assistant reads the notebook and requests one
`run_notebook` approval for all query cells, rather than requesting one
approval per cell.
4. Approve the run. Confirm database and Logs queries execute in
notebook order, the markdown cell is not executed, and the Assistant
responds only after the complete run finishes.
5. Start another run but do not approve it yet. In another tab, edit and
save the notebook. Return to the pending approval and approve it.
6. Confirm the stale run is rejected, the Assistant reads the latest
notebook version, and a new approval is required.
7. Optional privacy check: set the organization AI data-sharing level to
schema-only, run a query containing a recognizable value, and confirm
the value remains visible in the notebook result UI but is not repeated
in the Assistant's answer.

## Automated test

`mise exec node@22 -- pnpm --dir apps/studio exec vitest --run
lib/ai/tool-filter.test.ts lib/ai/tools/index.test.ts
lib/ai/tools/mock-tools.test.ts lib/ai/tools/notebook-tools.test.ts
lib/ai/tools/tool-sanitizer.test.ts`

80 tests pass at this stack boundary.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added AI-assisted notebook execution for database and log cells, with
approval, freshness checks, replica support, and per-cell error
handling.
- Added notebook deletion and database discovery and validation for
notebook management.
  - Added configurable privacy controls for notebook results.
  - Added request header support for analytics SQL execution.

- **Bug Fixes**
- Improved replica lookup handling so other notebook cells can continue
when one lookup fails.
- Prevented invalid, unauthorized, or overly detailed notebook execution
results from being exposed.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Saxon Fletcher <SaxonF@users.noreply.github.com>
2026-08-25 18:16:33 +10:00

500 lines
19 KiB
TypeScript

import { acceptUntrustedSql, untrustedSql } from '@supabase/pg-meta'
import { tool, type JSONValue } from 'ai'
import { z } from 'zod'
import {
sanitizeNotebookRunOutput,
type NotebookRunCellOutput,
type NotebookRunOutput,
} from './notebook-run-output'
import { resolveLogTimeRange } from '@/components/interfaces/QuerySources/LogTimeRange.utils'
import { deleteContents } from '@/data/content/content-delete-mutation'
import { getContent } from '@/data/content/content-infinite-query'
import {
applyNotebookOperations,
describeNotebookOperationError,
notebookOperationsSchema,
} from '@/data/content/notebooks/notebook-operations'
import { getNotebook } from '@/data/content/notebooks/notebook-query'
import {
agentNotebookSchema,
toWireNotebook,
type WritableCell,
type WritableNotebook,
} from '@/data/content/notebooks/notebook-schema'
import { createNotebook, upsertNotebook } from '@/data/content/notebooks/notebook-upsert-mutation'
import { executeLogsSql } from '@/data/logs/execute-logs-sql-mutation'
import { acceptUntrustedLogsSql, untrustedLogSql } from '@/data/logs/safe-analytics-sql'
import { QUERY_SOURCE_REGISTRY } from '@/data/query-sources/query-source-registry'
import { getReadReplicas } from '@/data/read-replicas/replicas-query'
import { executeSql } from '@/data/sql/execute-sql-mutation'
import { applyAutoLimit } from '@/data/sql/utils'
import type { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi'
import type { Notebooks } from '@/types'
export type NotebookToolsContext = {
projectRef?: string
connectionString?: string
authorization?: string
aiOptInLevel?: AiOptInLevel
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message
if (typeof error === 'object' && error !== null && 'message' in error) {
const message = (error as { message?: unknown }).message
if (typeof message === 'string') return message
}
return 'An unexpected error occurred while running this query.'
}
const notebookToolErrorMetadataSchema = z.object({
exposeToAssistant: z.boolean(),
})
export type NotebookToolErrorMetadata = z.infer<typeof notebookToolErrorMetadataSchema>
const notebookToolErrorSchema = notebookToolErrorMetadataSchema.extend({
tag: z.literal('notebook_tool_error'),
message: z.string(),
})
export type EncodedNotebookToolError = z.infer<typeof notebookToolErrorSchema>
/** Thrown by update_notebook for failures the assistant can act on by retrying. */
export class NotebookToolError extends Error {
readonly metadata: NotebookToolErrorMetadata
constructor(message: string, metadata: NotebookToolErrorMetadata) {
super(message)
this.name = 'NotebookToolError'
this.metadata = notebookToolErrorMetadataSchema.parse(metadata)
}
}
/** Called from the stream's `onError` (generate-v4.ts), which still has the live Error. */
export function encodeNotebookToolError(error: unknown): string | null {
if (!(error instanceof NotebookToolError)) return null
return JSON.stringify(
notebookToolErrorSchema.parse({
...error.metadata,
tag: 'notebook_tool_error',
message: error.message,
})
)
}
/** Called from the history filter (generate-assistant-response.ts) on a persisted errorText. */
export function decodeNotebookToolError(errorText: string): EncodedNotebookToolError | null {
let parsed: unknown
try {
parsed = JSON.parse(errorText)
} catch {
return null
}
const result = notebookToolErrorSchema.safeParse(parsed)
return result.success ? result.data : null
}
/**
* Rejects a made-up `database_identifier` before it's written: it would pass schema
* validation (it's just a string) but silently break the cell at run time, since
* QueryEditor's connection-string lookup can't resolve an identifier that isn't real.
*
* @throws NotebookToolError if any cell has a `database_identifier` that isn't in the
* list of valid identifiers for this project.
*/
function assertValidDatabaseIdentifiers(
cells: ReadonlyArray<{ _tag: string; database_identifier?: string }>,
validIdentifiers: Set<string>
): void {
for (const cell of cells) {
if (cell._tag !== 'database_cell' || cell.database_identifier === undefined) continue
if (!validIdentifiers.has(cell.database_identifier)) {
throw new NotebookToolError(
`Unknown database_identifier "${cell.database_identifier}" — call list_databases to see this project's valid identifiers.`,
{ exposeToAssistant: true }
)
}
}
}
export const getNotebookTools = (ctx: NotebookToolsContext = {}) => {
const { projectRef, connectionString, authorization, aiOptInLevel = 'schema' } = ctx
const authHeaders = authorization ? { Authorization: authorization } : undefined
return {
list_databases: tool({
description:
'List the databases available for this project — the primary and any read replicas.',
inputSchema: z.object({}),
execute: async () => {
const databases = await getReadReplicas({ projectRef }, undefined, authHeaders)
return {
databases: (databases ?? []).map((database) => ({
identifier: database.identifier,
is_primary: database.identifier === projectRef,
region: database.region,
status: database.status,
})),
}
},
}),
list_notebooks: tool({
description: 'List the notebooks saved for this project',
inputSchema: z.object({
cursor: z
.string()
.optional()
.describe('Cursor from a previous call, used to fetch the next page.'),
limit: z
.number()
.int()
.positive()
.max(100)
.default(20)
.describe('Max number of notebooks to return.'),
sort_by: z
.enum(['name', 'inserted_at'])
.optional()
.describe(
'Field to sort notebooks by. There is no "updated_at" sort — use "inserted_at" for creation order.'
),
}),
execute: async ({ cursor, limit, sort_by }) => {
const { content, cursor: nextCursor } = await getContent(
{ projectRef, type: 'notebook', limit, cursor, sort: sort_by },
undefined,
authHeaders
)
return {
notebooks: content.map((notebook) => ({
id: notebook.id,
name: notebook.name,
description: notebook.description,
visibility: notebook.visibility,
updated_at: notebook.updated_at,
cell_count: (notebook.content as Notebooks.Content).cells.length,
})),
cursor: nextCursor,
}
},
}),
get_notebook: tool({
description:
'Get a single notebook by id, including the markdown text and resolved SQL of every cell.',
inputSchema: z.object({
id: z.string().describe('The id of the notebook to fetch.'),
}),
execute: async ({ id }) => {
const notebook = await getNotebook({ projectRef, id }, undefined, authHeaders)
// toWireNotebook discards the `unchecked_sql` brand for display purposes only — the
// result is returned to the agent, never written back.
return {
id: notebook.id,
name: notebook.name,
description: notebook.description,
visibility: notebook.visibility,
updated_at: notebook.updated_at,
cells: toWireNotebook(notebook.content).cells,
}
},
}),
run_notebook: tool({
description:
'Asks the user to run every database and logs query cell in a notebook, in notebook order, and returns each result according to the organization data-sharing permissions. Requires one user approval before any cell executes.',
inputSchema: z.object({
id: z.string().describe('The id of the notebook to run.'),
expected_updated_at: z
.string()
.describe(
'The `updated_at` received from `get_notebook`. The run is rejected if the notebook changed since.'
),
}),
needsApproval: true,
execute: async ({ id, expected_updated_at }) => {
const notebook = await getNotebook({ projectRef, id }, undefined, authHeaders)
if (notebook.updated_at !== expected_updated_at) {
throw new NotebookToolError(
`Notebook "${id}" changed since expected_updated_at (${expected_updated_at}); it is now ${notebook.updated_at}. Call get_notebook again and reissue run_notebook against the current content.`,
{ exposeToAssistant: true }
)
}
const queryCells = notebook.content.cells.filter(
(cell) => cell._tag === 'database_cell' || cell._tag === 'log_cell'
)
const shouldLookupReplica = queryCells.some(
(cell) =>
cell._tag === 'database_cell' &&
cell.database_identifier !== undefined &&
cell.database_identifier !== projectRef
)
let databases: Awaited<ReturnType<typeof getReadReplicas>> = []
let replicaLookupFailure: { error: unknown } | undefined
if (shouldLookupReplica) {
try {
databases = await getReadReplicas({ projectRef }, undefined, authHeaders)
} catch (error) {
replicaLookupFailure = { error }
}
}
const cells: NotebookRunCellOutput[] = []
// This execute function only runs after the AI SDK approval gate above resolves true.
// That approval is the explicit user gesture which promotes every persisted cell's
// untrusted SQL immediately before execution. Run sequentially to preserve notebook
// order and avoid racing cells that may depend on earlier writes.
for (const cell of queryCells) {
const title = cell.title?.trim() || 'Untitled query'
try {
if (cell._tag === 'log_cell') {
const result = await executeLogsSql({
projectRef: projectRef ?? '',
sql: acceptUntrustedLogsSql(cell.unchecked_sql),
range: resolveLogTimeRange(cell.time_range),
endpoint: QUERY_SOURCE_REGISTRY.logs.endpoint,
headers: authHeaders,
})
if (result.error) throw result.error
cells.push({
cell_id: cell._id,
title,
source: 'logs',
status: 'success',
// The analytics response crossed a JSON fetch boundary, although its shared
// data-layer type remains `unknown[]` because individual endpoints vary.
rows: result.rows as JSONValue[],
})
continue
}
const isReplicaTarget =
cell.database_identifier !== undefined && cell.database_identifier !== projectRef
if (isReplicaTarget && replicaLookupFailure !== undefined) {
throw replicaLookupFailure.error
}
const selectedConnectionString = !isReplicaTarget
? connectionString
: databases?.find((database) => database.identifier === cell.database_identifier)
?.connectionString
if (!selectedConnectionString) {
throw new Error(
`Unable to run query: connection string is missing for database "${cell.database_identifier ?? projectRef ?? 'primary'}"`
)
}
const limitedSql = applyAutoLimit(
acceptUntrustedSql(cell.unchecked_sql),
cell.row_limit
)
const { result } = await executeSql<JSONValue[]>(
{
projectRef,
connectionString: selectedConnectionString,
sql: limitedSql.sql,
isStatementTimeoutDisabled: true,
},
undefined,
authHeaders
)
cells.push({
cell_id: cell._id,
title,
source: 'database',
status: 'success',
rows: Array.isArray(result) ? result : [],
})
} catch (error) {
cells.push({
cell_id: cell._id,
title,
source: cell._tag === 'log_cell' ? 'logs' : 'database',
status: 'error',
error: { message: getErrorMessage(error) },
})
}
}
return {
id: notebook.id,
name: notebook.name,
updated_at: notebook.updated_at,
cells,
} satisfies NotebookRunOutput
},
toModelOutput: ({ output }) => ({
type: 'json',
value: sanitizeNotebookRunOutput(output, aiOptInLevel),
}),
}),
create_notebook: tool({
description:
'Asks the user to create a new notebook with the given cells. Requires user approval before creating.',
inputSchema: z.object({
name: z.string().describe('A short, descriptive name for the notebook.'),
description: z
.string()
.optional()
.describe('A short description of what the notebook is for.'),
content: agentNotebookSchema.describe(
'The notebook content: a schema version and an ordered list of cells (markdown, database, or log). Cells must not include an id — one is assigned when the notebook is saved.'
),
}),
needsApproval: true,
execute: async ({ name, description, content }) => {
if (
content.cells.some(
(cell) => cell._tag === 'database_cell' && cell.database_identifier !== undefined
)
) {
const databases = await getReadReplicas({ projectRef }, undefined, authHeaders)
assertValidDatabaseIdentifiers(
content.cells,
new Set((databases ?? []).map((database) => database.identifier))
)
}
// This approval gate is the user gesture that promotes each cell's SQL from
// untrusted to safe — keep the promotion here, not in a shared helper, so it's
// auditable directly alongside the `needsApproval: true` above.
const cells: WritableNotebook['cells'] = content.cells.map((cell): WritableCell => {
switch (cell._tag) {
case 'markdown_cell':
return cell
case 'database_cell':
return { ...cell, sql: acceptUntrustedSql(untrustedSql(cell.sql)) }
case 'log_cell':
return { ...cell, sql: acceptUntrustedLogsSql(untrustedLogSql(cell.sql)) }
}
})
const result = await createNotebook(
{
projectRef: projectRef ?? '',
name,
description,
content: { schema_version: content.schema_version, cells },
},
undefined,
authHeaders
)
return { id: result.id, name }
},
}),
update_notebook: tool({
description:
'Asks the user to apply an ordered list of cell operations (insert, replace, delete, move) to an existing notebook. Requires user approval before updating. Re-fetches the notebook right before applying the operations and rejects the update if it changed since expected_updated_at.',
inputSchema: z.object({
id: z.string().describe('The id of the notebook to update.'),
expected_updated_at: z
.string()
.describe(
'The `updated_at` you received from `get_notebook`. The update is rejected if the notebook changed since.'
),
operations: notebookOperationsSchema.describe(
'An ordered list of operations to apply to the notebook, addressing existing cells by id.'
),
}),
needsApproval: true,
execute: async ({ id, expected_updated_at, operations }) => {
const newCells = operations.flatMap((operation) =>
operation._tag === 'insert_cell' || operation._tag === 'replace_cell'
? [operation.cell]
: []
)
if (
newCells.some(
(cell) => cell._tag === 'database_cell' && cell.database_identifier !== undefined
)
) {
const databases = await getReadReplicas({ projectRef }, undefined, authHeaders)
assertValidDatabaseIdentifiers(
newCells,
new Set((databases ?? []).map((database) => database.identifier))
)
}
const notebook = await getNotebook({ projectRef, id }, undefined, authHeaders)
if (notebook.updated_at !== expected_updated_at) {
throw new NotebookToolError(
`Notebook "${id}" changed since expected_updated_at (${expected_updated_at}); it is now ${notebook.updated_at}. Call get_notebook again and reissue update_notebook against the current content.`,
{ exposeToAssistant: true }
)
}
// The result of applyNotebookOperations is never written or executed as-is — every
// cell is re-promoted via acceptUntrustedSql/acceptUntrustedLogsSql further down in
// this same execute, right before the PUT.
const wireNotebook = toWireNotebook(notebook.content)
const result = applyNotebookOperations(wireNotebook, operations)
if (!result.success) {
throw new NotebookToolError(describeNotebookOperationError(result.error), {
exposeToAssistant: true,
})
}
// Same promotion as create_notebook above, inlined here for the same auditability
// reason: it must stay visible next to this tool's own `needsApproval: true`.
const cells: WritableNotebook['cells'] = result.notebook.cells.map((cell): WritableCell => {
switch (cell._tag) {
case 'markdown_cell':
return cell
case 'database_cell':
return { ...cell, sql: acceptUntrustedSql(untrustedSql(cell.sql)) }
case 'log_cell':
return { ...cell, sql: acceptUntrustedLogsSql(untrustedLogSql(cell.sql)) }
}
})
await upsertNotebook(
{
projectRef: projectRef ?? '',
id,
name: notebook.name,
description: notebook.description,
content: { schema_version: result.notebook.schema_version, cells },
},
undefined,
authHeaders
)
// `previous_content` lets the client re-derive the diff it already showed for
// approval, without re-fetching a notebook that's now post-update. Never reaches
// the model — see toModelOutput below.
return { id, name: notebook.name, previous_content: wireNotebook }
},
toModelOutput: ({ output }) => ({
type: 'json',
value: { id: output.id, name: output.name },
}),
}),
delete_notebook: tool({
description: 'Asks the user to delete a notebook. Requires user approval before deleting.',
inputSchema: z.object({
id: z.string().describe('The id of the notebook to delete.'),
}),
needsApproval: true,
execute: async ({ id }) => {
const notebook = await getNotebook({ projectRef, id }, undefined, authHeaders)
await deleteContents({ projectRef: projectRef ?? '', ids: [id] }, undefined, authHeaders)
return { id, name: notebook.name }
},
}),
}
}