Files
supabase/apps/studio/lib/ai/tools/notebook-tools.ts
Saxon FletcherandClaude Opus 5.5 5e59b6047e chore(studio): extend Assistant response time and handle timeouts (#50892)
## Problem

- Assistant responses were capped at 120 seconds and 10 steps, which is
too short for longer reasoning or multi-step tool work.
- When the hosting platform ended a request at that limit, the
connection just dropped. The user got no explanation, and "Thinking…"
and tool rows kept spinning.
- Studio's own tools ignored the request's abort signal, so a stop,
disconnect or deadline couldn't cancel their in-flight requests.
- Aborted responses never closed their Braintrust span. Under TanStack
Start, the remote MCP client was only released on `res.on('close')`,
which the adapter never emits.

## Solution

Uses AI SDK options instead of custom stream handling:

- `maxDuration` goes to 300s and the step limit to 20. `streamText({
timeout: { totalMs } })` stops the response at 270s, leaving time to
finish the stream before the platform cutoff.
- `toUIMessageStream({ messageMetadata })` marks an aborted response
`timedOut: true`. `Chat` ignores `abort` chunks, so the client reads
this flag instead and shows a timeout alert with Retry. The flag is
saved with the message, so the alert survives a reload.
- `toUIMessageStream({ onEnd })` aborts the request whenever the stream
ends, releasing the MCP client on both runtimes. `streamText({ onAbort
})` ends the Braintrust span.
- Studio tools pass the SDK's `abortSignal` to their fetches. MCP tools
already did.
- Reasoning and server-tool rows that never finished show "Response
interrupted" instead of a spinner or "Ran X ✓".

There's no per-tool timeout. Approved SQL and migrations can
legitimately run longer, and aborting the HTTP request doesn't stop the
query in Postgres.

## Review instructions

1. Run the unit tests: `cd apps/studio && pnpm vitest run
lib/api/generate-v4.test.ts lib/ai components/ui/AIAssistantPanel`
2. To see a timeout without waiting 4.5 minutes, temporarily set
`ASSISTANT_TIMEOUT_MS` in `apps/studio/lib/ai/assistant-timeout.ts` to
`15_000` and run `pnpm dev:studio`.
3. Ask the Assistant something that needs several tool calls or long
reasoning, for example "Audit my schema for missing indexes and RLS
gaps, then write the fixes."
4. After 15 seconds, check that:
- the response stops and a "Assistant response timed out" alert appears
with Retry
- any in-progress reasoning or tool row shows "Response interrupted"
instead of spinning
   - Retry starts a new response
   - reloading the page still shows the alert on that chat
5. Stop a response with the Stop button before the deadline. It should
stop without the timeout alert.
6. With the default 270s, confirm that a normal response completes as
before.

## Checklist

Check all before review:

- [ ] I have read
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
- [ ] If I wrote a new docs topic or edited an existing topic, I used
the `/write-the-docs` or `/edit-the-docs` skill, which references
[WORD_LIST](https://github.com/supabase/supabase/blob/master/apps/docs/WORD_LIST.md)
and the docs
[CONTRIBUTING](https://github.com/supabase/supabase/blob/master/apps/docs/CONTRIBUTING.md)
guide


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

* **Improvements**
* AI assistant responses can now run for up to five minutes, supporting
longer requests.
* When a response times out, the assistant displays a message suggesting
you retry or ask for a smaller change.
* Incomplete responses now show a “Response interrupted” notice, and
loading indicators stop when generation ends.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:44:39 +10:00

501 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 (_input, { abortSignal }) => {
const databases = await getReadReplicas({ projectRef }, abortSignal, 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 }, { abortSignal }) => {
const { content, cursor: nextCursor } = await getContent(
{ projectRef, type: 'notebook', limit, cursor, sort: sort_by },
abortSignal,
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 }, { abortSignal }) => {
const notebook = await getNotebook({ projectRef, id }, abortSignal, 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 }, { abortSignal }) => {
const notebook = await getNotebook({ projectRef, id }, abortSignal, 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 }, abortSignal, 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,
signal: abortSignal,
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,
},
abortSignal,
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 }, { abortSignal }) => {
if (
content.cells.some(
(cell) => cell._tag === 'database_cell' && cell.database_identifier !== undefined
)
) {
const databases = await getReadReplicas({ projectRef }, abortSignal, 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 },
},
abortSignal,
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 }, { abortSignal }) => {
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 }, abortSignal, authHeaders)
assertValidDatabaseIdentifiers(
newCells,
new Set((databases ?? []).map((database) => database.identifier))
)
}
const notebook = await getNotebook({ projectRef, id }, abortSignal, 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 ?? undefined,
content: { schema_version: result.notebook.schema_version, cells },
},
abortSignal,
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 }, { abortSignal }) => {
const notebook = await getNotebook({ projectRef, id }, abortSignal, authHeaders)
await deleteContents({ projectRef: projectRef ?? '', ids: [id] }, abortSignal, authHeaders)
return { id, name: notebook.name }
},
}),
}
}