Files
Charis a499db95f9 refactor(studio): migrate execute_sql, deploy_edge_function, rename_chat, and load_knowledge to tool contracts (#51803)
## Summary

Migrates four more assistant tools to the contract + `.server.ts`
pattern used by the notebook tools. Each tool now declares its schemas,
opt-in level, approval and model-visible output once.

## Changes

* Add contracts and server factories for `execute_sql`,
`deploy_edge_function`, `rename_chat`, `load_knowledge`;
`getStudioTools` now composes them.
* Add `execute-sql-output.ts` (output schema, `toModelRows`,
`NO_DATA_PERMISSIONS`) and `knowledge-topics.ts` (topic names + UI
labels, replacing the copy in `Message.Parts.utils.ts`).
* Delete `executeSqlSanitizer` and `ALL_TOOL_SANITIZERS`; history replay
goes through `sanitizeContractPart` only.
* Drop the four tools from `TOOL_CATEGORY_MAP`; the UI and evals import
schemas from the contracts; `rename_chat` input casts now use the
contract type.

## Deviations worth reviewing

* `execute_sql` replay: a bare-array legacy output is stamped `schema`
via a union transform (still hidden from the model, still shown in the
UI). A malformed non-array output is now dropped, where before it passed
through at the data level.
* The "no data permission" message now reaches the model as a JSON
string value instead of a text part. The text the model sees is
unchanged.
* `deploy_edge_function` output is now strictly `{ success: boolean }`
in the UI parse (was optional/passthrough).
2026-10-16 12:40:54 -04:00

139 lines
4.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { untrustedSql } from '@supabase/pg-meta'
import { z, type SafeParseReturnType } from 'zod'
// Splits markdown into alternating [plain, code, plain, code, ...] segments.
// Odd-indexed segments are already inside code spans/fences and should be left alone.
const CODE_SEGMENT_REGEX = /(```[\s\S]*?```|`[^`]*`)/g
// Matches bare placeholder URLs like https://xxx/<project-ref>/... outside markdown link
// syntax. Stops at whitespace, ), or ] to avoid consuming link delimiters. Trailing prose
// punctuation is stripped in the replacement callback below.
const PLACEHOLDER_URL_REGEX = /(?<!\()https?:\/\/[^\s)\]]*<[a-z][a-z0-9]*(?:-[a-z0-9]+)*>[^\s)\]]*/g
/**
* Wraps bare URLs containing <placeholder> patterns in backticks so they render in
* code font, regardless of whether the LLM remembered to wrap them.
*/
export function wrapPlaceholderUrls(markdown: string): string {
if (!markdown.includes('<')) return markdown
const segments = markdown.split(CODE_SEGMENT_REGEX)
return segments
.map((segment, i) => {
if (i % 2 === 1) return segment
return segment.replace(PLACEHOLDER_URL_REGEX, (url) => {
const trailingPunct = url.match(/[.,;:!?'"]+$/)?.[0] ?? ''
const cleanUrl = url.slice(0, url.length - trailingPunct.length)
return `\`${cleanUrl}\`` + trailingPunct
})
})
.join('')
}
// [Joshen] From https://github.com/remarkjs/react-markdown/blob/fda7fa560bec901a6103e195f9b1979dab543b17/lib/index.js#L425
export function defaultUrlTransform(value: string) {
const safeProtocol = /^(https?|ircs?|mailto|xmpp)$/i
const colon = value.indexOf(':')
const questionMark = value.indexOf('?')
const numberSign = value.indexOf('#')
const slash = value.indexOf('/')
if (
// If there is no protocol, it’s relative.
colon === -1 ||
// If the first colon is after a `?`, `#`, or `/`, it’s not a protocol.
(slash !== -1 && colon > slash) ||
(questionMark !== -1 && colon > questionMark) ||
(numberSign !== -1 && colon > numberSign) ||
// It is a protocol, it should be allowed.
safeProtocol.test(value.slice(0, colon))
) {
return value
}
return ''
}
const chartArgsSchema = z
.object({
view: z.enum(['table', 'chart']).optional(),
xKey: z.string().optional(),
xAxis: z.string().optional(),
yKey: z.string().optional(),
yAxis: z.string().optional(),
})
.passthrough()
const chartArgsFieldSchema = z.preprocess((value) => {
if (!value || typeof value !== 'object') return undefined
if (Array.isArray(value)) return value[0]
return value
}, chartArgsSchema.optional())
const executeSqlChartResultSchema = z
.object({
sql: z.string().optional(),
label: z.string().optional(),
isWriteQuery: z.boolean().optional(),
chartConfig: chartArgsFieldSchema,
config: chartArgsFieldSchema,
})
.passthrough()
.transform(({ sql, label, isWriteQuery, chartConfig, config }) => {
const chartArgs = chartConfig ?? config
return {
sql: untrustedSql(sql ?? ''),
label,
isWriteQuery,
view: chartArgs?.view,
xAxis: chartArgs?.xKey ?? chartArgs?.xAxis,
yAxis: chartArgs?.yKey ?? chartArgs?.yAxis,
}
})
export function parseExecuteSqlChartResult(
input: unknown
): SafeParseReturnType<unknown, z.infer<typeof executeSqlChartResultSchema>> {
return executeSqlChartResultSchema.safeParse(input)
}
export const deployEdgeFunctionInputSchema = z
.object({
code: z.string().min(1),
name: z.string().trim().optional(),
slug: z.string().trim().optional(),
functionName: z.string().trim().optional(),
label: z.string().optional(),
})
.passthrough()
.transform((data) => {
const rawName = data.functionName ?? data.name ?? data.slug
const trimmedName = rawName?.trim()
const functionName = trimmedName && trimmedName.length > 0 ? trimmedName : 'my-function'
const rawLabel = data.label ?? rawName
const trimmedLabel = rawLabel?.trim()
const label = trimmedLabel && trimmedLabel.length > 0 ? trimmedLabel : 'Edge Function'
return {
code: data.code,
functionName,
label,
}
})
export const rateMessageResponseSchema = z.object({
category: z.enum([
'sql_generation',
'schema_design',
'rls_policies',
'edge_functions',
'database_optimization',
'debugging',
'general_help',
'other',
]),
})
export type RateMessageResponse = z.infer<typeof rateMessageResponseSchema>