Files
supabase/apps/studio/evals/assistant.eval.ts
T
Matt Rossman 0d8cd9c315 feat(studio): prompt for a higher AI opt-in level instead of "no tool access" (#51411)
## Motivation

When an Assistant tool needs a higher opt-in level than the org has, the
user only sees the model say it has no access with (inconsistent)
written instructions how to fix, instead of Assistant proactively
facilitating the fix.

<img width="400" alt="CleanShot 2026-10-07 at 3 44 29 PM@2x"
src="https://github.com/user-attachments/assets/ba95568a-5db4-4d8c-b4ff-64855f2b87ec"
/>

The majority of recent labelled [Assistant
Issues](https://www.braintrust.dev/app/supabase.io/p/Assistant/topics)
in Braintrust are tools or query results blocked by the org opt-in
level. We've considered making Assistant disabled entirely when data
opt-in is off to eliminate the most common footgun (AI-405), but that's
an intrusive change which could break legitimate use cases.

## Changes



https://github.com/user-attachments/assets/206a2438-630e-4368-9cec-2b04b66c9cd4



A new `update_opt_in_level` tool renders an inline card which prompts
admins to review the opt-in level and highlights the proposed change
(non-admins see a message telling them to contact their admin to change
the setting). Saving approves the call and the turn resumes at the new
level. Results appear in a Braintrust tool span based on
https://github.com/supabase/supabase/pull/45654.

I added 5 evals that simulate the user answering the opt-in card to
verify that the Assistant asks when blocked, continues after the user
accepts, and doesn't invent data after a skip. Tool Usage and
Correctness are at 100% in the [sample
run](https://www.braintrust.dev/app/supabase.io/p/Dev%20(mattrossman%2FAssistant)/experiments/mattrossman%2Fai-158-prompt-users-to-update-settings-instead-of-showing-no-tool-1791483991).

<details>
<summary>📸 Screenshots</summary>

| Admin, pending | Modal, Current and Proposed |
| -- | -- |
| <img width="100%" alt="CleanShot 2026-10-07 at 3 23 37 PM@2x"
src="https://github.com/user-attachments/assets/2fc8ba11-0530-4028-a056-40ca02afd3ef"
/> | <img width="1184" height="1754" alt="CleanShot 2026-10-07 at 3 32
37 PM@2x"
src="https://github.com/user-attachments/assets/73639633-89ac-4d4c-b869-d9301735d5b9"
/> |
| **After saving, turn continues** | **Non-admin** |
| <img width="100%" alt="CleanShot 2026-10-07 at 3 27 14 PM@2x"
src="https://github.com/user-attachments/assets/a91c6760-6dfb-4f51-9ef8-4bdbe1b22495"
/> | <img width="100%" alt="CleanShot 2026-10-07 at 3 15 33 PM@2x"
src="https://github.com/user-attachments/assets/ecb9aa87-26dc-4b69-a086-cb77a28bb860"
/> |

</details>

## Verification

To test in staging, set your org's Assistant opt-in level to Disabled,
ask "What tables do I have?", and review the card. Try selecting the
proposed (or different) opt-in level and saving to continue.

[This
trace](https://www.braintrust.dev/app/supabase.io/p/Dev%20(mattrossman%2FAssistant)/experiments/mattrossman%2Fai-158-prompt-users-to-update-settings-instead-of-showing-no-tool-1791483991?r=90066cd9-4da3-4817-9650-c369d2b42cea)
is a sample from the accept eval case after saving Schema Only, note the
`update_opt_in_level` span.

## Safety considerations

I added a banner to the modal to make it more obvious that this setting
impacts the whole org, not just the current chat or project:

<img width="592" height="78" alt="CleanShot 2026-10-08 at 2 04 44 PM@2x"
src="https://github.com/user-attachments/assets/6b140ed6-65fb-4bc0-b5cc-9c82fe2a67aa"
/>


I leave the current opt-in value selected by default in the form so the
user has to consciously select the proposed value instead of mindlessly
clicking save without understanding implications.

`execute_sql` and `run_notebook` outputs are now stamped with the level
they ran under, and history is sanitized by the least permissive of the
stamped vs current opt-in levels. That way raising the level mid-chat
doesn't unexpectedly expose earlier query rows to the model.

Closes AI-158
2026-10-09 10:39:23 -04:00

116 lines
4.2 KiB
TypeScript
Raw 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 assert from 'node:assert'
import { readUIMessageStream, toUIMessageStream, type UIMessage } from 'ai'
import { Eval } from 'braintrust'
import { dataset } from './dataset'
import { applyOptInDecision, describePriorTurn } from './opt-in-replay'
import {
completenessScorer,
concisenessScorer,
correctnessScorer,
docsFaithfulnessScorer,
goalCompletionScorer,
knowledgeUsageScorer,
safetyScorer,
toolUsageScorer,
urlValidityScorer,
} from './scorer'
import { sqlIdentifierQuotingScorer, sqlSyntaxScorer } from './scorer-wasm'
import { buildTranscript } from './transcript'
import type { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi'
import { generateAssistantResponse } from '@/lib/ai/generate-assistant-response'
import { getModel } from '@/lib/ai/model'
import { DEFAULT_ASSISTANT_BASE_MODEL_ID, getAssistantModelEntry } from '@/lib/ai/model.utils'
import { filterToolsByOptInLevel } from '@/lib/ai/tool-filter'
import { getMockTools } from '@/lib/ai/tools/mock-tools'
assert(process.env.BRAINTRUST_PROJECT_ID, 'BRAINTRUST_PROJECT_ID is not set')
assert(process.env.OPENAI_API_KEY, 'OPENAI_API_KEY is not set')
Eval('Assistant', {
projectId: process.env.BRAINTRUST_PROJECT_ID,
trialCount: process.env.CI ? 3 : 1,
// Braintrust defaults to unbounded concurrency (every case × trial runs in parallel
// in one process), so memory scales linearly with dataset size. Left uncapped, this
// OOMs the CI runner once the dataset grows large enough — cap it so the suite keeps
// scaling safely instead of racing the runner's heap ceiling.
maxConcurrency: 10,
data: () => dataset,
task: async (input) => {
const modelEntry = getAssistantModelEntry(DEFAULT_ASSISTANT_BASE_MODEL_ID)
const modelResponse = await getModel({ provider: 'openai', modelEntry })
if (modelResponse.error) throw modelResponse.error
const { aiOptInLevel, optInDecision } = input
assert(!optInDecision || aiOptInLevel, 'optInDecision needs aiOptInLevel')
const userMessage: UIMessage = {
id: '1',
role: 'user',
parts: [{ type: 'text', text: input.prompt }],
}
const run = async (messages: UIMessage[], level: AiOptInLevel | undefined) => {
const mockTools = await getMockTools(
input.mockTables ? { list_tables: input.mockTables } : undefined,
level
)
return generateAssistantResponse({
...modelResponse.modelParams,
isExplorerEnabled: true,
...(level && { aiOptInLevel: level }),
messages,
tools: level ? filterToolsByOptInLevel(mockTools, level) : mockTools,
})
}
const results = [await run([userMessage], aiOptInLevel)]
let deniedToolCalls: Array<{ toolName: string; input: unknown }> = []
if (optInDecision && aiOptInLevel) {
// Same two requests as production: the approval pauses the turn, then the answered
// approval resumes it at the level the user saved.
let assistantMessage: UIMessage | undefined
for await (const message of readUIMessageStream({
stream: toUIMessageStream({ stream: results[0].stream }),
})) {
assistantMessage = message
}
const resumed =
assistantMessage && applyOptInDecision(assistantMessage, optInDecision, aiOptInLevel)
if (resumed) {
deniedToolCalls = resumed.deniedToolCalls
results.push(await run([userMessage, resumed.message], resumed.level))
}
}
const finishReason = await results[results.length - 1].finishReason
const [first, second] = await Promise.all(
results.map(async (result) => buildTranscript(input.prompt, await result.steps))
)
// Judges score the final request, with the first one and the user's answer as context
const transcript =
second && optInDecision
? { ...second, priorConversation: describePriorTurn(first, optInDecision) }
: first
return {
finishReason,
transcript,
...(deniedToolCalls.length > 0 && { deniedToolCalls }),
}
},
scores: [
toolUsageScorer,
knowledgeUsageScorer,
sqlSyntaxScorer,
sqlIdentifierQuotingScorer,
goalCompletionScorer,
concisenessScorer,
completenessScorer,
docsFaithfulnessScorer,
correctnessScorer,
safetyScorer,
urlValidityScorer,
],
})