Files
supabase/apps/studio/pages/api/ai/code/complete.ts
Matt Rossman d143571586 feat(assistant): trace-level scorers + server-side tool execution with needsApproval (#45654)
## Motivation

When Assistant runs a potentially destructive tool like `execute_sql`,
it stops the LLM request and prompts for client-side approval and
execution of the tool. After approval, a second request kicks off under
a separate trace. This has made scoring and
[Topics](https://www.braintrust.dev/blog/topics) classification
challenging, as the generated `output` is split across stateless
requests. The [span-level
scoring](https://www.braintrust.dev/docs/evaluate/custom-code#score-spans)
approach we've used thusfar (after the LLM call, we massage the result
into an `output` payload that's stuck onto the root span) has been
cumbersome and led to invalid scores / topics where only part of the
assistant response is considered. It's also inefficient, as we're
duplicating potentially large info (like the `search_docs` output) that
already exists within the trace.

An alternative to scoring spans is to [score
traces](https://www.braintrust.dev/docs/evaluate/custom-code#score-traces).
Braintrust [best
practices](https://www.braintrust.dev/docs/evaluate/score-online#best-practices)
advise:

> Use span scope for evaluating individual operations or outputs. Use
trace scope for evaluating multi-turn conversations, overall workflow
completion, or when your scorer needs access to the full execution
context.

We've also received [direct
guidance](https://supabase.slack.com/archives/C05QYJBLX89/p1777925770927149?thread_ts=1777905716.911979&cid=C05QYJBLX89)
from their team to use this approach.

## Changes

Migrates eval scorers from custom `AssistantEvalOutput` shape to
trace-level scoring via `trace.getThread()` / `trace.getSpans()`, with
thread parsing that scores the full latest Assistant turn and passes
prior conversation separately where relevant.

Moves `execute_sql` and `deploy_edge_function` from client-side
execution after approval to AI SDK `needsApproval` + server-side
`execute()`. SQL results returned to the model are gated by AI opt-in
level, so row data is only included with `schema_and_log_and_data`;
otherwise the tool returns the no-data-permissions sentinel.

Adds `metadata.isFinalStep` to disambiguate multiple LLM requests within
an "assistant" turn due to tool call requests/responses. For online
evals, this means we should configure automations to only score traces
with `metadata.isFinalStep = true` to ensure we're judging the complete
generated response.

Other minor kaizen changes:
- Renamed `promptProviderOptions` to `systemProviderOptions` to clarify
that this is associated with the "system" message and disambiguate from
the root `providerOptions`
- Adds `evals/trace-utils.ts` to handle Zod validation of the `unknown`
span shapes from Braintrust, to more easily access typed inputs/output
on tool spans.
- Bumps AI SDK floor version `^6.0.116` → `^6.0.174`
- Tweaked the "Conciseness" scorer to not unfairly dock points for the
new `[called tool_name]` labels in serialized assistant response

## Verification

In the studio staging build, I asked Assistant to create a todos table
with 3 sample todos. I manually approved the `execute_sql` call and saw
Assistant generate text before & after the call.

In Braintrust I verified two traces were produced (see [filtered
logs](https://www.braintrust.dev/app/supabase.io/p/Assistant/logs?v=Staging&tvt=trace&search={%22filter%22:[{%22text%22:%22metadata.environment%2520%253D%2520%27staging%27%22,%22label%22:%22metadata.environment%2520%253D%2520%27staging%27%22,%22originType%22:%22btql%22},{%22text%22:%22%2560Chat%2520ID%2560%2520%253D%2520%25221cb2ac45-e5e7-458c-9da4-3bf6863b8842%2522%22,%22label%22:%22Chat%2520ID%2520equals%25201cb2ac45-e5e7-458c-9da4-3bf6863b8842%22,%22originType%22:%22form%22}]})),
the first with `metadata.isFinalStep = false` and the second with
`metadata.isFinalStep = true`.

In the Braintrust staging scorers, I ran the preview Completeness scorer
on the second trace and verified it sees the complete Assistant response
including markers for tool calls ([link to
trace](https://www.braintrust.dev/app/supabase.io/p/Assistant%20(Staging%20Scorers)/trace?object_type=project_logs&object_id=b5214b62-ad1e-4929-9d5b-40b1daebe948&r=0ed0a4f8-8aff-4a34-bb1d-1df1d88a5070&s=ff9015f8-6bf7-4ab3-83a9-ca4e69e27e82))

<img width="1193" height="960" alt="CleanShot 2026-05-07 at 11 27 10@2x"
src="https://github.com/user-attachments/assets/509d4858-c3a1-4068-986d-3aa4d5617d1a"
/>

I also tested the `deploy_edge_function` workflow and verified it still
prompts for permission and warns on deployment of existing functions.

**References**
- https://www.braintrust.dev/docs/evaluate/custom-code#score-traces
-
https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling#tool-execution-approval

Supercedes https://github.com/supabase/supabase/pull/45556 and
https://github.com/supabase/supabase/pull/45339

Closes AI-473

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

* **New Features**
* Tool actions (SQL execution, edge-function deploy) now require
explicit user Approve/Deny before proceeding.

* **Improvements**
* Assistant pauses for approval responses before sending follow-ups,
giving clearer control over risky actions.
  * Deploy/replace flows show confirmation and clearer replace warnings.
* Evaluation/scoring updated to use richer trace data for more accurate
assistant performance signals.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-12 15:24:21 -04:00

312 lines
9.9 KiB
TypeScript

import pgMeta, { getEntityDefinitionsSql } from '@supabase/pg-meta'
import { generateText, ModelMessage, stepCountIs, tool } from 'ai'
import { IS_PLATFORM } from 'common'
import { source } from 'common-tags'
import { NextApiRequest, NextApiResponse } from 'next'
import z from 'zod'
import { executeSql } from '@/data/sql/execute-sql-query'
import { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi'
import { getOrgAIDetails } from '@/lib/ai/ai-details'
import { getModel } from '@/lib/ai/model'
import { DEFAULT_COMPLETION_MODEL } from '@/lib/ai/model.utils'
import {
COMPLETION_PROMPT,
EDGE_FUNCTION_PROMPT,
PG_BEST_PRACTICES,
SECURITY_PROMPT,
SQL_COMPLETION_INSTRUCTIONS,
} from '@/lib/ai/prompts'
import apiWrapper from '@/lib/api/apiWrapper'
import { executeQuery } from '@/lib/api/self-hosted/query'
export const maxDuration = 60
const pgMetaSchemasList = pgMeta.schemas.list()
type Schemas = z.infer<(typeof pgMetaSchemasList)['zod']>
type EntityDefinitionRow = { data: { definitions: Array<{ id: number; sql: string }> } }
type SqlFetchParams = {
projectRef: string
connectionString: string | null | undefined
headers: Record<string, string>
}
type SchemaListResult =
| { error: true }
| { error: false; queriedSchemas: string[]; otherSchemas: string[] }
type SchemaDDLResult = { error: true } | { error: false; sqlDefinitions: string[] }
async function fetchSchemas(
includeSchema: boolean,
{ projectRef, connectionString, headers }: SqlFetchParams
): Promise<{ schemas: Schemas; error: boolean }> {
if (!includeSchema) return { schemas: [], error: false }
try {
const { result } = await executeSql<Schemas>(
{ projectRef, connectionString, sql: pgMetaSchemasList.sql },
undefined,
headers,
IS_PLATFORM ? undefined : executeQuery
)
return { schemas: result, error: false }
} catch {
return { schemas: [], error: true }
}
}
async function fetchSchemaDDL(
schemas: string[],
{ projectRef, connectionString, headers }: SqlFetchParams
): Promise<SchemaDDLResult> {
if (schemas.length === 0) return { error: false, sqlDefinitions: [] }
try {
const { result } = await executeSql<EntityDefinitionRow[]>(
{ projectRef, connectionString, sql: getEntityDefinitionsSql({ schemas }) },
undefined,
headers,
IS_PLATFORM ? undefined : executeQuery
)
const definitions = result?.[0]?.data?.definitions ?? []
return {
error: false,
sqlDefinitions: definitions.map((d) => d.sql),
}
} catch {
return { error: true }
}
}
function buildDatabaseSchemaSection({
includeSchema,
schemaListResult,
schemaDDLResult,
}: {
includeSchema: boolean
schemaListResult: SchemaListResult
schemaDDLResult: SchemaDDLResult
}): string {
if (!includeSchema) {
return 'Schema context is unavailable — data opt-in is not enabled for this project.'
}
const lines: string[] = []
if (schemaListResult.error) {
lines.push(
"Unable to fetch list of available database schemas. Assume `public` schema, infer others from the user's existing code."
)
} else {
lines.push(`Queried schemas: ${schemaListResult.queriedSchemas.join(', ')}`)
if (schemaListResult.otherSchemas.length > 0)
lines.push(
`Other available schemas (use getSchemaDefinitions tool): ${schemaListResult.otherSchemas.join(', ')}`
)
}
if (schemaDDLResult.error) {
lines.push('Failed to fetch table definitions due to a database error.')
} else {
const defsText =
schemaDDLResult.sqlDefinitions.length > 0
? schemaDDLResult.sqlDefinitions.join('\n\n')
: 'No table definitions found.'
lines.push(`\n${defsText}`)
}
return lines.join('\n')
}
const requestBodySchema = z.object({
completionMetadata: z.object({
textBeforeCursor: z.string(),
textAfterCursor: z.string(),
prompt: z.string(),
selection: z.string(),
}),
projectRef: z.string(),
connectionString: z.string().nullish(),
orgSlug: z.string().optional(),
language: z.string().optional(),
})
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
return res.status(405).json({ error: `Method ${req.method} Not Allowed` })
}
try {
let body: unknown
try {
body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body
} catch {
return res.status(400).json({ error: 'Malformed JSON' })
}
const { data, error: parseError } = requestBodySchema.safeParse(body)
if (parseError) {
return res.status(400).json({ error: 'Invalid request body', issues: parseError.issues })
}
const { completionMetadata, projectRef, connectionString, orgSlug, language } = data
const { textBeforeCursor, textAfterCursor, prompt, selection } = completionMetadata
const authorization = req.headers.authorization
let aiOptInLevel: AiOptInLevel = IS_PLATFORM ? 'disabled' : 'schema'
if (IS_PLATFORM && orgSlug && authorization && projectRef) {
const { aiOptInLevel: orgAIOptInLevel } = await getOrgAIDetails({
orgSlug,
authorization,
})
aiOptInLevel = orgAIOptInLevel
}
const {
modelParams,
error: modelError,
systemProviderOptions,
} = await getModel({
provider: 'openai',
modelEntry: DEFAULT_COMPLETION_MODEL,
})
if (modelError) {
return res.status(500).json({ error: modelError.message })
}
const headers = {
'Content-Type': 'application/json',
...(authorization && { Authorization: authorization }),
}
const includeSchema = aiOptInLevel !== 'disabled'
// Fetch schema list first so we can determine which schemas to load DDL for.
// These are best-effort — if they fail, we proceed without DDL context.
const { schemas, error: schemaListError } = await fetchSchemas(includeSchema, {
projectRef,
connectionString,
headers,
})
// Always include public; also eagerly include any non-public schema whose name
// appears as `name.` in the cursor context. Checking against the real schema list
// avoids fetching DDL for table aliases or other false matches. This is robust to
// incomplete SQL (the user may be mid-typing, so a full parser would fail here).
const cursorContext = textBeforeCursor + selection + textAfterCursor
const lowerContext = cursorContext.toLowerCase()
const schemasToFetch = includeSchema
? [
'public',
...schemas
.filter((s) => {
const lower = s.name.toLowerCase()
return (
s.name !== 'public' &&
(lowerContext.includes(lower + '.') || lowerContext.includes(`"${lower}".`))
)
})
.map((s) => s.name),
]
: []
const schemaDDLResult = await fetchSchemaDDL(schemasToFetch, {
projectRef,
connectionString,
headers,
})
// Reshape the fetched schemas and candidates into a discriminated union over error states
const fetchedSchemaSet = new Set(schemasToFetch)
const schemaListResult: SchemaListResult = schemaListError
? { error: true }
: {
error: false,
queriedSchemas: schemasToFetch,
otherSchemas: schemas.filter((s) => !fetchedSchemaSet.has(s.name)).map((s) => s.name),
}
// Important: do not use dynamic content in the system prompt or Bedrock will not cache it
const system = source`
${COMPLETION_PROMPT}
${language === 'sql' ? SQL_COMPLETION_INSTRUCTIONS : ''}
${language === 'sql' ? PG_BEST_PRACTICES : EDGE_FUNCTION_PROMPT}
${SECURITY_PROMPT}
`
const userMessage = source`
## Database Schema
${buildDatabaseSchemaSection({ includeSchema, schemaListResult, schemaDDLResult })}
## Code
\`\`\`${language ?? ''}
${textBeforeCursor}<selection>${selection}</selection>${textAfterCursor}
\`\`\`
## Instruction
${prompt}
`
// Note: these must be of type `CoreMessage` to prevent AI SDK from stripping `providerOptions`
// https://github.com/vercel/ai/blob/81ef2511311e8af34d75e37fc8204a82e775e8c3/packages/ai/core/prompt/standardize-prompt.ts#L83-L88
const coreMessages: ModelMessage[] = [
{
role: 'system',
content: system,
...(systemProviderOptions && { providerOptions: systemProviderOptions }),
},
{
role: 'user',
content: userMessage,
},
]
const { text } = await generateText({
...modelParams,
stopWhen: stepCountIs(5),
messages: coreMessages,
tools:
includeSchema && !schemaListResult.error
? {
getSchemaDefinitions: tool({
description: 'Get table and column definitions for one or more schemas',
inputSchema: z.object({
schemas: z
.array(z.string())
.describe('The schema names to get the definitions for'),
}),
execute: async ({ schemas: maybeSchemas }) => {
const validSchemas = maybeSchemas.filter((name) =>
schemas.some((s) => s.name === name)
)
const result = await fetchSchemaDDL(validSchemas, {
projectRef,
connectionString,
headers,
})
if (result.error)
return 'Failed to fetch schema definitions due to a database error.'
if (result.sqlDefinitions.length === 0) return 'No table definitions found.'
return result.sqlDefinitions.join('\n\n')
},
}),
}
: undefined,
})
return res.status(200).json(text)
} catch (error) {
console.error('Completion error:', error)
return res.status(500).json({ error: 'Failed to generate completion' })
}
}
const wrapper = (req: NextApiRequest, res: NextApiResponse) =>
apiWrapper(req, res, handler, { withAuth: true })
export default wrapper