diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.utils.ts b/apps/studio/components/ui/AIAssistantPanel/Message.utils.ts index 82a9d2e8cd9..01b078911ce 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Message.utils.ts +++ b/apps/studio/components/ui/AIAssistantPanel/Message.utils.ts @@ -1,8 +1,7 @@ import { untrustedSql } from '@supabase/pg-meta' import { z, type SafeParseReturnType } from 'zod' -import { notebookOperationsSchema } from '@/data/content/notebooks/notebook-operations' -import { agentNotebookSchema, notebookSchema } from '@/data/content/notebooks/notebook-schema' +import { agentNotebookSchema } from '@/data/content/notebooks/notebook-schema' // Splits markdown into alternating [plain, code, plain, code, ...] segments. // Odd-indexed segments are already inside code spans/fences and should be left alone. @@ -135,12 +134,6 @@ export const createNotebookInputSchema = z.object({ content: agentNotebookSchema, }) -export const updateNotebookInputSchema = z.object({ - id: z.string(), - expected_updated_at: z.string(), - operations: notebookOperationsSchema, -}) - export const deleteNotebookInputSchema = z.object({ id: z.string(), }) @@ -152,10 +145,6 @@ export const runNotebookInputSchema = z.object({ export const notebookToolOutputSchema = z.object({ id: z.string(), name: z.string() }) -export const updateNotebookToolOutputSchema = notebookToolOutputSchema.extend({ - previous_content: notebookSchema.optional(), -}) - export const rateMessageResponseSchema = z.object({ category: z.enum([ 'sql_generation', diff --git a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx index 40858d2af01..089bc787f75 100644 --- a/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/NotebookProposalRenderer.tsx @@ -13,8 +13,6 @@ import { createNotebookInputSchema, deleteNotebookInputSchema, notebookToolOutputSchema, - updateNotebookInputSchema, - updateNotebookToolOutputSchema, } from './Message.utils' import { AlertError } from '@/components/ui/AlertError' import { hasDiscardableChanges } from '@/data/content/notebooks/notebook-cache' @@ -25,6 +23,7 @@ import { } from '@/data/content/notebooks/notebook-operations' import { useNotebookQuery } from '@/data/content/notebooks/notebook-query' import { toWireNotebook } from '@/data/content/notebooks/notebook-schema' +import { contract as updateNotebookContract } from '@/lib/ai/tools/update-notebook' import { useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state' export type NotebookProposalMode = 'create' | 'update' | 'delete' @@ -386,7 +385,7 @@ function UpdateNotebookProposal({ }: NotebookProposalStepPropsWithOutput) { const { ref } = useParams() const notebooksSnap = useNotebooksStateSnapshot() - const parsedInput = updateNotebookInputSchema.safeParse(input) + const parsedInput = updateNotebookContract.inputSchema.safeParse(input) const isCompleted = state === 'output-available' const hasUnsavedLocalChanges = confirmState === 'approval-requested' && @@ -415,7 +414,7 @@ function UpdateNotebookProposal({ } if (isCompleted) { - const parsedOutput = updateNotebookToolOutputSchema.safeParse(output) + const parsedOutput = updateNotebookContract.outputSchema.safeParse(output) const notebookName = parsedOutput.success ? parsedOutput.data.name : undefined const isOutputForRequestedNotebook = parsedOutput.success && parsedOutput.data.id === parsedInput.data.id diff --git a/apps/studio/eslint.config.cjs b/apps/studio/eslint.config.cjs index df0b9aad068..667637022f1 100644 --- a/apps/studio/eslint.config.cjs +++ b/apps/studio/eslint.config.cjs @@ -39,6 +39,37 @@ const NO_MODULE_SCOPE_CREATE_CLIENT = { 'Do not construct a Supabase client at module scope in API route files — the TanStack server evaluates every route module at boot, so a missing env var (e.g. SUPABASE_URL on platform) crashes every route. Construct it lazily inside the handler (see lib/api/self-hosted-admin.ts).', } +const LUCIDE_ICON_IMPORT_PATTERNS = [ + { + group: ['lucide-react'], + importNamePattern: '^(?!.*Icon$)(?!Lucide)(?!icons$)(?!create)', + message: + 'Import Lucide icons with the Icon suffix (e.g. `CheckIcon`, not `Check` or `Check as CheckIcon`).', + }, +] + +const BASE_RESTRICTED_IMPORT_PATHS = [ + { + name: 'components/ui/DataTable/DataTableColumn/DataTableColumnHeader', + message: 'Use TanStackTableHeadSort from ui-patterns/Table instead.', + }, +] + +const APPROVAL_GATE_MESSAGE = + 'Promote untrusted SQL through the `gate` that approvalGatedToolFromContract passes to execute.' +const TOOL_MODULE_RESTRICTED_IMPORT_PATHS = [ + { + name: '@supabase/pg-meta', + importNames: ['acceptUntrustedSql'], + message: APPROVAL_GATE_MESSAGE, + }, + { + name: '@/data/logs/safe-analytics-sql', + importNames: ['acceptUntrustedLogsSql'], + message: APPROVAL_GATE_MESSAGE, + }, +] + module.exports = defineConfig([ { files: ['**/*.ts', '**/*.tsx'] }, supabaseConfig, @@ -62,20 +93,8 @@ module.exports = defineConfig([ 'no-restricted-imports': [ 'error', { - paths: [ - { - name: 'components/ui/DataTable/DataTableColumn/DataTableColumnHeader', - message: 'Use TanStackTableHeadSort from ui-patterns/Table instead.', - }, - ], - patterns: [ - { - group: ['lucide-react'], - importNamePattern: '^(?!.*Icon$)(?!Lucide)(?!icons$)(?!create)', - message: - 'Import Lucide icons with the Icon suffix (e.g. `CheckIcon`, not `Check` or `Check as CheckIcon`).', - }, - ], + paths: BASE_RESTRICTED_IMPORT_PATHS, + patterns: LUCIDE_ICON_IMPORT_PATTERNS, }, ], 'barrel-files/avoid-re-export-all': 'error', @@ -119,6 +138,18 @@ module.exports = defineConfig([ 'no-restricted-syntax': ['error', ...ANALYTICS_SQL_RESTRICTED_SYNTAX], }, }, + { + files: ['lib/ai/tools/*.server.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [...BASE_RESTRICTED_IMPORT_PATHS, ...TOOL_MODULE_RESTRICTED_IMPORT_PATHS], + patterns: LUCIDE_ICON_IMPORT_PATTERNS, + }, + ], + }, + }, // API route modules are eagerly imported by the TanStack server at boot, so // module-scope side effects there are especially dangerous. This block also // re-includes the analytics selectors because flat config replaces (not diff --git a/apps/studio/lib/ai/generate-assistant-response.utils.test.ts b/apps/studio/lib/ai/generate-assistant-response.utils.test.ts index 7bce339e1a4..0f06dc84ca7 100644 --- a/apps/studio/lib/ai/generate-assistant-response.utils.test.ts +++ b/apps/studio/lib/ai/generate-assistant-response.utils.test.ts @@ -2,7 +2,7 @@ import type { ToolUIPart, UIMessage } from 'ai' import { describe, expect, it } from 'vitest' import { prepareMessagesForModel } from './generate-assistant-response.utils' -import { encodeNotebookToolError, NotebookToolError } from './tools/notebook-tools' +import { encodeNotebookToolError, NotebookToolError } from './tools/notebook-tool-error' function assistantMessage(parts: UIMessage['parts']): UIMessage { return { id: 'msg-1', role: 'assistant', parts } diff --git a/apps/studio/lib/ai/generate-assistant-response.utils.ts b/apps/studio/lib/ai/generate-assistant-response.utils.ts index 3d2851656ec..1c82b83cbef 100644 --- a/apps/studio/lib/ai/generate-assistant-response.utils.ts +++ b/apps/studio/lib/ai/generate-assistant-response.utils.ts @@ -1,7 +1,7 @@ import { isToolUIPart, type DynamicToolUIPart, type ToolUIPart, type UIMessage } from 'ai' import type { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi' -import { decodeNotebookToolError } from '@/lib/ai/tools/notebook-tools' +import { decodeNotebookToolError } from '@/lib/ai/tools/notebook-tool-error' import { sanitizeMessagePart } from '@/lib/ai/tools/tool-sanitizer' const INVALID_TOOL_STATES = [ diff --git a/apps/studio/lib/ai/message-utils.test.ts b/apps/studio/lib/ai/message-utils.test.ts index a0be44767f2..d5acac0b328 100644 --- a/apps/studio/lib/ai/message-utils.test.ts +++ b/apps/studio/lib/ai/message-utils.test.ts @@ -1,4 +1,4 @@ -import type { DynamicToolUIPart, ToolUIPart, UIMessage } from 'ai' +import type { DynamicToolUIPart, UIMessage } from 'ai' import { describe, expect, it } from 'vitest' import { @@ -6,7 +6,6 @@ import { isManualApprovalRequested, prepareMessagesForAPI, } from './message-utils' -import { createAssistantMessageWithUpdateNotebookTool } from './test-fixtures' const makeApprovalPart = (id: string, isAutomatic = false): DynamicToolUIPart => ({ @@ -263,38 +262,4 @@ describe('prepareMessagesForAPI', () => { expect(result[2]).toEqual(messages[2]) expect(result[3]).not.toHaveProperty('results') }) - - it('strips update_notebook previous_content before re-uploading to the API', () => { - const messages = [createAssistantMessageWithUpdateNotebookTool()] - - const result = prepareMessagesForAPI(messages) - - expect((result[0].parts[0] as ToolUIPart).output).toEqual({ - id: 'notebook-1', - name: 'Signup funnel', - }) - }) - - it('does not mutate the original message parts when stripping previous_content', () => { - const messages = [createAssistantMessageWithUpdateNotebookTool()] - const originalParts = messages[0].parts - - prepareMessagesForAPI(messages) - - expect(messages[0].parts).toBe(originalParts) - expect((originalParts[0] as ToolUIPart).output).toHaveProperty('previous_content') - }) - - it('leaves an update_notebook output without previous_content unchanged', () => { - const messages = [ - createAssistantMessageWithUpdateNotebookTool({ id: 'notebook-1', name: 'Signup funnel' }), - ] - - const result = prepareMessagesForAPI(messages) - - expect((result[0].parts[0] as ToolUIPart).output).toEqual({ - id: 'notebook-1', - name: 'Signup funnel', - }) - }) }) diff --git a/apps/studio/lib/ai/message-utils.ts b/apps/studio/lib/ai/message-utils.ts index 4d86fad7a87..bb00d524a2c 100644 --- a/apps/studio/lib/ai/message-utils.ts +++ b/apps/studio/lib/ai/message-utils.ts @@ -8,15 +8,6 @@ import { type UIPart = UIMessagePart -/** Strips `update_notebook`'s `previous_content` snapshot — display-only, never re-uploaded. */ -function stripNotebookSnapshot(part: UIPart): UIPart { - if (!isToolUIPart(part) || part.type !== 'tool-update_notebook') return part - if (!part.output || typeof part.output !== 'object') return part - - const { previous_content, ...sanitizedOutput } = part.output as Record - return { ...part, output: sanitizedOutput } as UIPart -} - /** * Prepares messages for API transmission by cleaning and limiting history */ @@ -35,11 +26,6 @@ export function prepareMessagesForAPI(messages: UIMessage[]): UIMessage[] { if (message.role === 'assistant' && message.results) { delete cleanedMessage.results } - // Map into a new array rather than mutating in place — `parts` is shared by reference - // with the locally persisted message the assistant panel reads from. - if (cleanedMessage.parts) { - cleanedMessage.parts = cleanedMessage.parts.map(stripNotebookSnapshot) - } return cleanedMessage as UIMessage }) diff --git a/apps/studio/lib/ai/notebook-cache-invalidation.ts b/apps/studio/lib/ai/notebook-cache-invalidation.ts index 581ade7beab..fee7fbb4a69 100644 --- a/apps/studio/lib/ai/notebook-cache-invalidation.ts +++ b/apps/studio/lib/ai/notebook-cache-invalidation.ts @@ -4,17 +4,22 @@ import type { ToolUIPart, UIMessage } from 'ai' import { notebookToolOutputSchema } from '@/components/ui/AIAssistantPanel/Message.utils' import { contentKeys } from '@/data/content/keys' import { evictNotebookFromCaches } from '@/data/content/notebooks/notebook-cache' +import type { ToolName } from '@/lib/ai/tool-filter' import { notebooksState } from '@/state/notebooks/notebooks-state' export type NotebookCacheEffect = | { _tag: 'upserted'; toolCallId: string; id: string } | { _tag: 'deleted'; toolCallId: string; id: string } -const NOTEBOOK_MUTATION_TOOL_TYPES = new Set([ - 'tool-create_notebook', - 'tool-update_notebook', - 'tool-delete_notebook', -]) +const NOTEBOOK_MUTATION_TOOLS = [ + 'create_notebook', + 'update_notebook', + 'delete_notebook', +] as const satisfies readonly ToolName[] + +const NOTEBOOK_MUTATION_TOOL_TYPES: ReadonlySet = new Set( + NOTEBOOK_MUTATION_TOOLS.map((name) => `tool-${name}`) +) function isNotebookMutationPart(part: UIMessage['parts'][number]): part is ToolUIPart { return NOTEBOOK_MUTATION_TOOL_TYPES.has(part.type) diff --git a/apps/studio/lib/ai/tool-filter.test.ts b/apps/studio/lib/ai/tool-filter.test.ts index c936c2f498b..68e0386125e 100644 --- a/apps/studio/lib/ai/tool-filter.test.ts +++ b/apps/studio/lib/ai/tool-filter.test.ts @@ -9,6 +9,7 @@ import { TOOL_CATEGORY_MAP, toolSetValidationSchema, } from './tool-filter' +import { contract as updateNotebookContract } from './tools/update-notebook' describe('TOOL_CATEGORY_MAP', () => { it('should categorize tools correctly', () => { @@ -18,6 +19,23 @@ describe('TOOL_CATEGORY_MAP', () => { }) }) +describe('tools with a contract', () => { + const tools = { + update_notebook: { description: 'Update a notebook', execute: vitest.fn() }, + } as unknown as ToolSet + + it('is gated by the contract minOptInLevel, not the category map', () => { + expect(updateNotebookContract.minOptInLevel).toBe('schema') + expect(Object.hasOwn(TOOL_CATEGORY_MAP, 'update_notebook')).toBe(false) + + const blocked = filterToolsByOptInLevel(tools, 'disabled').update_notebook + const allowed = filterToolsByOptInLevel(tools, 'schema').update_notebook + + expect(blocked.description).toContain('Requires opting in') + expect(allowed.description).toBe('Update a notebook') + }) +}) + describe('tool allowance by opt-in level', () => { // Helper function to get allowed tools for a given opt-in level function getAllowedTools(optInLevel: string) { diff --git a/apps/studio/lib/ai/tool-filter.ts b/apps/studio/lib/ai/tool-filter.ts index 266e2712daa..4ea1cef33a5 100644 --- a/apps/studio/lib/ai/tool-filter.ts +++ b/apps/studio/lib/ai/tool-filter.ts @@ -1,6 +1,7 @@ import type { Tool, ToolSet } from 'ai' import { z } from 'zod' +import { getToolContract, type ContractToolName } from './tools/registry' import type { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi' // Add the DatabaseExtension type import @@ -27,6 +28,7 @@ export const toolSetValidationSchema = z.record( 'search_docs', 'get_advisors', 'query_logs', + 'get_active_incidents', // Local tools 'execute_sql', @@ -107,9 +109,10 @@ export const TOOL_CATEGORIES = { type ToolCategory = (typeof TOOL_CATEGORIES)[keyof typeof TOOL_CATEGORIES] /** - * Mapping of tool names to their categories + * Mapping of tool names to their categories. Tools that have migrated to a contract + * (`TOOL_CONTRACTS`) declare their own `minOptInLevel` instead and aren't listed here. */ -export const TOOL_CATEGORY_MAP: Record = { +export const TOOL_CATEGORY_MAP: Record, ToolCategory> = { // UI tools - always available execute_sql: TOOL_CATEGORIES.UI, deploy_edge_function: TOOL_CATEGORIES.UI, @@ -134,7 +137,6 @@ export const TOOL_CATEGORY_MAP: Record = { get_notebook: TOOL_CATEGORIES.SCHEMA, run_notebook: TOOL_CATEGORIES.SCHEMA, create_notebook: TOOL_CATEGORIES.SCHEMA, - update_notebook: TOOL_CATEGORIES.SCHEMA, delete_notebook: TOOL_CATEGORIES.SCHEMA, getSchemaTables: TOOL_CATEGORIES.SCHEMA, getRlsKnowledge: TOOL_CATEGORIES.SCHEMA, @@ -164,18 +166,31 @@ function getMinimumOptInLevel(category: ToolCategory): AiOptInLevel | null { } } +function isCategorizedTool(name: string): name is keyof typeof TOOL_CATEGORY_MAP { + return Object.hasOwn(TOOL_CATEGORY_MAP, name) +} + +/** + * The lowest opt-in level a tool needs, `null` if it's always available, or `undefined` if the + * tool isn't on the allowlist. A contract's `minOptInLevel` wins over the category map. + */ +function getToolMinimumLevel(toolName: string): AiOptInLevel | null | undefined { + const contract = getToolContract(toolName) + if (contract) return contract.minOptInLevel + if (isCategorizedTool(toolName)) return getMinimumOptInLevel(TOOL_CATEGORY_MAP[toolName]) + return undefined +} + /** * Check if a tool is allowed based on the current opt-in level */ function isToolAllowed(toolName: string, aiOptInLevel: AiOptInLevel): boolean { - const category = TOOL_CATEGORY_MAP[toolName] + const minimumLevel = getToolMinimumLevel(toolName) - if (!category) { + if (minimumLevel === undefined) { return false } - const minimumLevel = getMinimumOptInLevel(category) - // UI tools are always allowed if (!minimumLevel) { return true @@ -218,7 +233,7 @@ export function createPrivacyMessageTool(toolInstance: Tool) { export function filterToolsByOptInLevel(tools: ToolSet, aiOptInLevel: AiOptInLevel): ToolSet { return Object.fromEntries( Object.entries(tools) - .filter(([toolName]) => TOOL_CATEGORY_MAP[toolName] !== undefined) + .filter(([toolName]) => getToolMinimumLevel(toolName) !== undefined) .map(([toolName, toolInstance]) => { if (isToolAllowed(toolName, aiOptInLevel)) { return [toolName, toolInstance] diff --git a/apps/studio/lib/ai/tools/approval-gate.ts b/apps/studio/lib/ai/tools/approval-gate.ts new file mode 100644 index 00000000000..2af458894cf --- /dev/null +++ b/apps/studio/lib/ai/tools/approval-gate.ts @@ -0,0 +1,18 @@ +import { acceptUntrustedSql } from '@supabase/pg-meta' + +import { acceptUntrustedLogsSql } from '@/data/logs/safe-analytics-sql' + +/** + * What an approval-gated tool's `execute` uses to turn untrusted SQL into trusted SQL. + * + * Promoting untrusted SQL is only valid once the user has approved the tool call. The SDK + * calls `execute` only after approval, and `approvalGatedToolFromContract` is the only place + * that hands this gate to an `execute`. `*.server.ts` tool files can't import the raw + * `accept*` functions (see `eslint.config.cjs`), so they can't promote SQL without it. + */ +export type ApprovalGate = { + acceptUntrustedSql: typeof acceptUntrustedSql + acceptUntrustedLogsSql: typeof acceptUntrustedLogsSql +} + +export const approvalGate: ApprovalGate = { acceptUntrustedSql, acceptUntrustedLogsSql } diff --git a/apps/studio/lib/ai/tools/auth-headers.ts b/apps/studio/lib/ai/tools/auth-headers.ts new file mode 100644 index 00000000000..446671f5050 --- /dev/null +++ b/apps/studio/lib/ai/tools/auth-headers.ts @@ -0,0 +1,14 @@ +/** + * Headers for forwarding the caller's credentials to the platform API. + * Returns `undefined` when there's nothing to forward. + */ +export function getAuthHeaders( + authorization: string | undefined, + { contentType = false }: { contentType?: boolean } = {} +): Record | undefined { + if (!authorization) return undefined + return { + ...(contentType && { 'Content-Type': 'application/json' }), + Authorization: authorization, + } +} diff --git a/apps/studio/lib/ai/tools/fallback-tools.ts b/apps/studio/lib/ai/tools/fallback-tools.ts index 925d191f2c6..5af651325a4 100644 --- a/apps/studio/lib/ai/tools/fallback-tools.ts +++ b/apps/studio/lib/ai/tools/fallback-tools.ts @@ -4,6 +4,8 @@ import { IS_PLATFORM } from 'common' import { stripIndent } from 'common-tags' import { z } from 'zod' +import { getAuthHeaders } from './auth-headers' +import type { ToolContext } from './types' import { getDatabaseFunctions } from '@/data/database-functions/database-functions-query' import { getDatabasePolicies } from '@/data/database-policies/database-policies-query' import { executeSql } from '@/data/sql/execute-sql-mutation' @@ -15,17 +17,16 @@ export const getFallbackTools = ({ cookie, authorization, includeSchemaMetadata, -}: { +}: ToolContext & { projectRef: string connectionString: string cookie?: string - authorization?: string includeSchemaMetadata: boolean }) => { const headers = { 'Content-Type': 'application/json', ...(cookie && { cookie }), - ...(authorization && { Authorization: authorization }), + ...getAuthHeaders(authorization), } return { diff --git a/apps/studio/lib/ai/tools/notebook-tool-error.ts b/apps/studio/lib/ai/tools/notebook-tool-error.ts new file mode 100644 index 00000000000..ef5b1a7004a --- /dev/null +++ b/apps/studio/lib/ai/tools/notebook-tool-error.ts @@ -0,0 +1,71 @@ +import { z } from 'zod' + +const notebookToolErrorMetadataSchema = z.object({ + exposeToAssistant: z.boolean(), +}) + +export type NotebookToolErrorMetadata = z.infer + +const notebookToolErrorSchema = notebookToolErrorMetadataSchema.extend({ + tag: z.literal('notebook_tool_error'), + message: z.string(), +}) + +export type EncodedNotebookToolError = z.infer + +/** 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, + }) + ) +} + +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. + */ +export function assertValidDatabaseIdentifiers( + cells: ReadonlyArray<{ _tag: string; database_identifier?: string }>, + validIdentifiers: Set +): 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 } + ) + } + } +} diff --git a/apps/studio/lib/ai/tools/notebook-tools.test.ts b/apps/studio/lib/ai/tools/notebook-tools.test.ts index af03d02c41c..451c0e7a039 100644 --- a/apps/studio/lib/ai/tools/notebook-tools.test.ts +++ b/apps/studio/lib/ai/tools/notebook-tools.test.ts @@ -6,9 +6,9 @@ import { sanitizeNotebookRunOutput, type NotebookRunOutput } from './notebook-ru import { decodeNotebookToolError, encodeNotebookToolError, - getNotebookTools, NotebookToolError, -} from './notebook-tools' +} from './notebook-tool-error' +import { getNotebookTools } from './notebook-tools' import type { AgentNotebook } from '@/data/content/notebooks/notebook-schema' import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw' diff --git a/apps/studio/lib/ai/tools/notebook-tools.ts b/apps/studio/lib/ai/tools/notebook-tools.ts index 010f6204e59..2229c2c02ed 100644 --- a/apps/studio/lib/ai/tools/notebook-tools.ts +++ b/apps/studio/lib/ai/tools/notebook-tools.ts @@ -2,19 +2,18 @@ import { acceptUntrustedSql, untrustedSql } from '@supabase/pg-meta' import { tool, type JSONValue } from 'ai' import { z } from 'zod' +import { getAuthHeaders } from './auth-headers' import { sanitizeNotebookRunOutput, type NotebookRunCellOutput, type NotebookRunOutput, } from './notebook-run-output' +import { assertValidDatabaseIdentifiers, NotebookToolError } from './notebook-tool-error' +import type { ToolContext } from './types' +import { createTool as createUpdateNotebookTool } from './update-notebook.server' 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, @@ -22,23 +21,15 @@ import { type WritableCell, type WritableNotebook, } from '@/data/content/notebooks/notebook-schema' -import { createNotebook, upsertNotebook } from '@/data/content/notebooks/notebook-upsert-mutation' +import { createNotebook } 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) { @@ -48,80 +39,9 @@ function getErrorMessage(error: unknown): string { return 'An unexpected error occurred while running this query.' } -const notebookToolErrorMetadataSchema = z.object({ - exposeToAssistant: z.boolean(), -}) - -export type NotebookToolErrorMetadata = z.infer - -const notebookToolErrorSchema = notebookToolErrorMetadataSchema.extend({ - tag: z.literal('notebook_tool_error'), - message: z.string(), -}) - -export type EncodedNotebookToolError = z.infer - -/** 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 -): 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 = {}) => { +export const getNotebookTools = (ctx: ToolContext = {}) => { const { projectRef, connectionString, authorization, aiOptInLevel = 'schema' } = ctx - const authHeaders = authorization ? { Authorization: authorization } : undefined + const authHeaders = getAuthHeaders(authorization) return { list_databases: tool({ @@ -394,95 +314,7 @@ export const getNotebookTools = (ctx: NotebookToolsContext = {}) => { 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 }, - }), - }), + update_notebook: createUpdateNotebookTool(ctx), delete_notebook: tool({ description: 'Asks the user to delete a notebook. Requires user approval before deleting.', inputSchema: z.object({ diff --git a/apps/studio/lib/ai/tools/registry.test.ts b/apps/studio/lib/ai/tools/registry.test.ts new file mode 100644 index 00000000000..1a83a862b88 --- /dev/null +++ b/apps/studio/lib/ai/tools/registry.test.ts @@ -0,0 +1,48 @@ +import type { ToolUIPart } from 'ai' +import { describe, expect, expectTypeOf, it } from 'vitest' + +import { sanitizeContractPart, type StudioUIMessage } from './registry' + +describe('TOOL_CONTRACTS', () => { + it('types contract tool parts on StudioUIMessage', () => { + type Part = Extract< + StudioUIMessage['parts'][number], + { type: 'tool-update_notebook'; state: 'output-available' } + > + expectTypeOf().toHaveProperty('expected_updated_at') + expectTypeOf().toHaveProperty('name') + }) +}) + +describe('sanitizeContractPart', () => { + const part = { + type: 'tool-update_notebook', + toolCallId: 'call-1', + state: 'output-available', + input: {}, + output: { + id: 'notebook-1', + name: 'Signup funnel', + previous_content: { schema_version: 1, cells: [] }, + }, + } as ToolUIPart + + it('drops previous_content from the model output on history replay', () => { + expect(sanitizeContractPart(part, 'schema')?.output).toEqual({ + id: 'notebook-1', + name: 'Signup funnel', + }) + }) + + it('drops an output that does not match the schema', () => { + expect(sanitizeContractPart({ ...part, output: 'oops' } as ToolUIPart, 'schema')?.output).toBe( + undefined + ) + }) + + it('returns undefined for tools without a contract', () => { + expect( + sanitizeContractPart({ ...part, type: 'tool-execute_sql' } as ToolUIPart, 'schema') + ).toBe(undefined) + }) +}) diff --git a/apps/studio/lib/ai/tools/registry.ts b/apps/studio/lib/ai/tools/registry.ts new file mode 100644 index 00000000000..d126e26bf34 --- /dev/null +++ b/apps/studio/lib/ai/tools/registry.ts @@ -0,0 +1,58 @@ +import type { InferUITools, Tool, ToolUIPart, UIDataTypes, UIMessage } from 'ai' + +import type { ToolName } from '../tool-filter' +import type { ToolContract, ToolContractInput, ToolContractOutput } from './types' +import { contract as updateNotebook } from './update-notebook' +import type { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi' + +/** + * Every tool that has migrated to a contract. Tools not listed here are still described + * by hand in the places the contract replaces. + */ +export const TOOL_CONTRACTS = { + update_notebook: updateNotebook, +} as const satisfies Partial> + +export type ContractToolName = keyof typeof TOOL_CONTRACTS + +type ContractTools = { + [K in ContractToolName]: Tool< + ToolContractInput<(typeof TOOL_CONTRACTS)[K]>, + ToolContractOutput<(typeof TOOL_CONTRACTS)[K]> + > +} + +export type ContractUITools = InferUITools + +/** + * A message whose `tool-` parts are typed for every contract tool. The first two type + * parameters are the SDK defaults, since the assistant types neither message metadata nor + * custom data parts. + */ +export type StudioUIMessage = UIMessage + +/** The contract for a migrated tool, or `undefined` if the tool hasn't been migrated. */ +export function getToolContract(toolName: string): ToolContract | undefined { + return Object.hasOwn(TOOL_CONTRACTS, toolName) + ? TOOL_CONTRACTS[toolName as ContractToolName] + : undefined +} + +/** + * Applies the contract's `toModelOutput` to a persisted tool part, for history replay (where + * the SDK doesn't call it). Returns `undefined` when the tool has no contract. An output that + * doesn't match the contract's schema is dropped rather than passed through unchecked. + */ +export function sanitizeContractPart( + part: Part, + optInLevel: AiOptInLevel +): Part | undefined { + const contract = getToolContract(part.type.slice('tool-'.length)) + if (!contract) return undefined + + const parsed = contract.outputSchema.safeParse(part.output) + return { + ...part, + output: parsed.success ? contract.toModelOutput(parsed.data, optInLevel) : undefined, + } +} diff --git a/apps/studio/lib/ai/tools/report-tools.ts b/apps/studio/lib/ai/tools/report-tools.ts index 8a22c1e6022..d80a365014c 100644 --- a/apps/studio/lib/ai/tools/report-tools.ts +++ b/apps/studio/lib/ai/tools/report-tools.ts @@ -1,18 +1,15 @@ import { tool } from 'ai' import { z } from 'zod' +import { getAuthHeaders } from './auth-headers' +import type { ToolContext } from './types' import { getContentById } from '@/data/content/content-id-query' import { getContent } from '@/data/content/content-query' import type { Dashboards, SqlSnippets } from '@/types' -export type ReportToolsContext = { - projectRef?: string - authorization?: string -} - -export const getReportTools = (ctx: ReportToolsContext = {}) => { +export const getReportTools = (ctx: ToolContext = {}) => { const { projectRef, authorization } = ctx - const authHeaders = authorization ? { Authorization: authorization } : undefined + const authHeaders = getAuthHeaders(authorization) return { list_reports: tool({ diff --git a/apps/studio/lib/ai/tools/schema-tools.ts b/apps/studio/lib/ai/tools/schema-tools.ts index e2c48f5fead..eea9451ec9d 100644 --- a/apps/studio/lib/ai/tools/schema-tools.ts +++ b/apps/studio/lib/ai/tools/schema-tools.ts @@ -1,17 +1,15 @@ import { tool } from 'ai' import { z } from 'zod' +import { getAuthHeaders } from './auth-headers' +import type { ToolContext } from './types' import { getDatabasePolicies } from '@/data/database-policies/database-policies-query' export const getSchemaTools = ({ projectRef, connectionString, authorization, -}: { - projectRef: string - connectionString: string - authorization?: string -}) => ({ +}: ToolContext & { projectRef: string; connectionString: string }) => ({ list_policies: tool({ description: 'Get existing RLS policies for a given schema', inputSchema: z.object({ @@ -25,7 +23,7 @@ export const getSchemaTools = ({ schemas, }, abortSignal, - authorization ? { Authorization: authorization } : undefined + getAuthHeaders(authorization) ) const formattedPolicies = data diff --git a/apps/studio/lib/ai/tools/studio-tools.ts b/apps/studio/lib/ai/tools/studio-tools.ts index 1daab72a6da..f219a3efb14 100644 --- a/apps/studio/lib/ai/tools/studio-tools.ts +++ b/apps/studio/lib/ai/tools/studio-tools.ts @@ -2,6 +2,8 @@ import { acceptUntrustedSql, untrustedSql } from '@supabase/pg-meta' import { tool } from 'ai' import { z } from 'zod' +import { getAuthHeaders } from './auth-headers' +import type { ToolContext } from './types' import { deployEdgeFunction } from '@/data/edge-functions/edge-functions-deploy-mutation' import { executeSql } from '@/data/sql/execute-sql-mutation' import type { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi' @@ -54,18 +56,9 @@ export const loadKnowledgeInputSchema = z.object({ .describe('The knowledge to load'), }) -export type StudioToolsContext = { - projectRef?: string - connectionString?: string - authorization?: string - aiOptInLevel?: AiOptInLevel -} - -export const getStudioTools = (ctx: StudioToolsContext = {}) => { +export const getStudioTools = (ctx: ToolContext = {}) => { const { projectRef, connectionString, authorization, aiOptInLevel = 'schema' } = ctx - const authHeaders = authorization - ? { 'Content-Type': 'application/json', Authorization: authorization } - : undefined + const authHeaders = getAuthHeaders(authorization, { contentType: true }) return { // Tools that wait on the user use `needsApproval` with a server-side `execute`, not a diff --git a/apps/studio/lib/ai/tools/tool-from-contract.ts b/apps/studio/lib/ai/tools/tool-from-contract.ts new file mode 100644 index 00000000000..104bb1a31f2 --- /dev/null +++ b/apps/studio/lib/ai/tools/tool-from-contract.ts @@ -0,0 +1,46 @@ +import type { Tool, ToolExecutionOptions } from 'ai' + +import { approvalGate, type ApprovalGate } from './approval-gate' +import type { ToolContext, ToolContract } from './types' + +/** + * Builds the SDK tool for a contract. Description, input schema, approval and the model's + * view of the output all come from the contract; only `execute` is supplied. + */ +export function toolFromContract( + contract: ToolContract, + { aiOptInLevel = 'schema' }: ToolContext, + execute: (input: TInput, options: ToolExecutionOptions) => PromiseLike | TOutput +): Tool { + const { toModelOutput } = contract + + // The SDK's `Tool` type is conditional on whether `TOutput` is `never`, which TypeScript + // can't resolve for a generic `TOutput`. Each factory's `ToolFactory` + // annotation checks the concrete types instead. + return { + description: contract.description, + inputSchema: contract.inputSchema, + needsApproval: contract.needsApproval, + execute, + toModelOutput: ({ output }: { output: TOutput }) => ({ + type: 'json' as const, + value: toModelOutput(output, aiOptInLevel), + }), + } as unknown as Tool +} + +/** + * Like `toolFromContract`, for a contract that requires user approval. `execute` also receives + * the `ApprovalGate`, which is how it gets the functions that promote untrusted SQL. + */ +export function approvalGatedToolFromContract( + contract: ToolContract & { readonly needsApproval: true }, + ctx: ToolContext, + execute: ( + input: TInput, + options: ToolExecutionOptions, + gate: ApprovalGate + ) => PromiseLike | TOutput +): Tool { + return toolFromContract(contract, ctx, (input, options) => execute(input, options, approvalGate)) +} diff --git a/apps/studio/lib/ai/tools/tool-sanitizer.ts b/apps/studio/lib/ai/tools/tool-sanitizer.ts index b8693ad3b57..82b81af2893 100644 --- a/apps/studio/lib/ai/tools/tool-sanitizer.ts +++ b/apps/studio/lib/ai/tools/tool-sanitizer.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { lowerOptInLevel, optInLevelSchema, type ToolName } from '../tool-filter' import { sanitizeNotebookRunOutput } from './notebook-run-output' +import { sanitizeContractPart } from './registry' import type { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi' interface ToolSanitizer { @@ -43,21 +44,6 @@ const executeSqlSanitizer: ToolSanitizer = { }, } -// `previous_content` is UI-only and must never reach the model — `toModelOutput` on the -// tool covers the same turn, but history replay skips it, so it's stripped here too. -const updateNotebookSanitizer: ToolSanitizer = { - toolName: 'update_notebook', - sanitize: (tool) => { - if (!tool.output || typeof tool.output !== 'object') return tool - - const { previous_content, ...sanitizedOutput } = tool.output as Record - return { - ...tool, - output: sanitizedOutput, - } - }, -} - const runNotebookSanitizer: ToolSanitizer = { toolName: 'run_notebook', sanitize: (tool, optInLevel) => ({ @@ -68,7 +54,6 @@ const runNotebookSanitizer: ToolSanitizer = { export const ALL_TOOL_SANITIZERS = { [executeSqlSanitizer.toolName]: executeSqlSanitizer, - [updateNotebookSanitizer.toolName]: updateNotebookSanitizer, [runNotebookSanitizer.toolName]: runNotebookSanitizer, } @@ -78,6 +63,8 @@ export function sanitizeMessagePart( ): UIMessage['parts'][number] { if (part.type.startsWith('tool-')) { const toolPart = part as ToolUIPart + const contractPart = sanitizeContractPart(toolPart, optInLevel) + if (contractPart) return contractPart const toolName = toolPart.type.slice('tool-'.length) const sanitizer = ALL_TOOL_SANITIZERS[toolName] if (sanitizer) { diff --git a/apps/studio/lib/ai/tools/types.ts b/apps/studio/lib/ai/tools/types.ts new file mode 100644 index 00000000000..54dbae60a4e --- /dev/null +++ b/apps/studio/lib/ai/tools/types.ts @@ -0,0 +1,46 @@ +import type { JSONValue, Tool } from 'ai' +import type { z } from 'zod' + +import type { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi' + +export type ToolContext = { + projectRef?: string + connectionString?: string + authorization?: string + aiOptInLevel?: AiOptInLevel +} + +/** + * Isomorphic: zod and pure functions only, so nothing here may import the data layer. + */ +export type ToolContract = { + name: string + description: string + inputSchema: z.ZodType + outputSchema: z.ZodType + /** Lowest opt-in level at which the tool runs. `null` means always available. */ + minOptInLevel: AiOptInLevel | null + needsApproval: boolean + /** + * What the model may see of the output. Required, so a contract can't forget to decide. + * Applied to the current turn and again on history replay, so it must be safe to run on + * its own result. Return the output unchanged only if all of it is safe at every level. + */ + toModelOutput(output: TOutput, optInLevel: AiOptInLevel): JSONValue +} + +export type ToolContractInput = z.infer +export type ToolContractOutput = z.infer + +/** The server half of a tool: builds the executable tool for a contract. */ +export type ToolFactory = ( + ctx: ToolContext +) => Tool, ToolContractOutput> + +/** + * A server half whose `execute` runs after the user approves. Only a contract declared with + * `needsApproval: true` satisfies the constraint. Build it with `approvalGatedToolFromContract`, + * which gives `execute` the `ApprovalGate` it needs to promote untrusted SQL. + */ +export type ApprovalGatedToolFactory = + ToolFactory diff --git a/apps/studio/lib/ai/tools/update-notebook.server.ts b/apps/studio/lib/ai/tools/update-notebook.server.ts new file mode 100644 index 00000000000..9dd67033f55 --- /dev/null +++ b/apps/studio/lib/ai/tools/update-notebook.server.ts @@ -0,0 +1,97 @@ +import { untrustedSql } from '@supabase/pg-meta' + +import { getAuthHeaders } from './auth-headers' +import { assertValidDatabaseIdentifiers, NotebookToolError } from './notebook-tool-error' +import { approvalGatedToolFromContract } from './tool-from-contract' +import type { ApprovalGatedToolFactory } from './types' +import { contract } from './update-notebook' +import { + applyNotebookOperations, + describeNotebookOperationError, +} from '@/data/content/notebooks/notebook-operations' +import { getNotebook } from '@/data/content/notebooks/notebook-query' +import { + toWireNotebook, + type WritableCell, + type WritableNotebook, +} from '@/data/content/notebooks/notebook-schema' +import { upsertNotebook } from '@/data/content/notebooks/notebook-upsert-mutation' +import { untrustedLogSql } from '@/data/logs/safe-analytics-sql' +import { getReadReplicas } from '@/data/read-replicas/replicas-query' + +export const createTool: ApprovalGatedToolFactory = (ctx) => { + const { projectRef } = ctx + const authHeaders = getAuthHeaders(ctx.authorization) + + return approvalGatedToolFromContract( + contract, + ctx, + async ({ id, expected_updated_at, operations }, { abortSignal }, gate) => { + 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 gate.acceptUntrustedSql/gate.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, inlined here for the same auditability reason: it + // must stay visible in the function the approval gate guards. `gate` is only handed to + // an `execute` that runs after the user approves. + const cells: WritableNotebook['cells'] = result.notebook.cells.map((cell): WritableCell => { + switch (cell._tag) { + case 'markdown_cell': + return cell + case 'database_cell': + return { ...cell, sql: gate.acceptUntrustedSql(untrustedSql(cell.sql)) } + case 'log_cell': + return { ...cell, sql: gate.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 + ) + + return { id, name: notebook.name, previous_content: wireNotebook } + } + ) +} diff --git a/apps/studio/lib/ai/tools/update-notebook.ts b/apps/studio/lib/ai/tools/update-notebook.ts new file mode 100644 index 00000000000..40cfefea35b --- /dev/null +++ b/apps/studio/lib/ai/tools/update-notebook.ts @@ -0,0 +1,38 @@ +import { z } from 'zod' + +import type { ToolContract } from './types' +import { notebookOperationsSchema } from '@/data/content/notebooks/notebook-operations' +import { notebookSchema } from '@/data/content/notebooks/notebook-schema' + +const 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.' + ), +}) + +/** + * `previous_content` lets the client re-derive the diff it already showed for approval, + * without re-fetching a notebook that's now post-update. It is UI-only: `toModelOutput` drops it. + */ +const outputSchema = z.object({ + id: z.string(), + name: z.string(), + previous_content: notebookSchema.optional(), +}) + +export const contract = { + name: 'update_notebook', + 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, + outputSchema, + minOptInLevel: 'schema', + needsApproval: true, + toModelOutput: ({ id, name }) => ({ id, name }), +} as const satisfies ToolContract diff --git a/apps/studio/pages/api/ai/sql/generate-v4.ts b/apps/studio/pages/api/ai/sql/generate-v4.ts index 204e6ec0141..17164f71eea 100644 --- a/apps/studio/pages/api/ai/sql/generate-v4.ts +++ b/apps/studio/pages/api/ai/sql/generate-v4.ts @@ -27,7 +27,7 @@ import { type AssistantModelId, } from '@/lib/ai/model.utils' import { getTools } from '@/lib/ai/tools' -import { encodeNotebookToolError } from '@/lib/ai/tools/notebook-tools' +import { encodeNotebookToolError } from '@/lib/ai/tools/notebook-tool-error' import { apiWrapper } from '@/lib/api/apiWrapper' import { executeQuery } from '@/lib/api/self-hosted/query' import { getBasePathURL } from '@/lib/helpers'