generate-assistant-response.ts204 lines · main
| 1 | import * as ai from 'ai' |
| 2 | import { |
| 3 | convertToModelMessages, |
| 4 | isToolUIPart, |
| 5 | stepCountIs, |
| 6 | type LanguageModel, |
| 7 | type ModelMessage, |
| 8 | type SystemModelMessage, |
| 9 | type ToolSet, |
| 10 | type UIMessage, |
| 11 | } from 'ai' |
| 12 | import { startSpan, traced, withCurrent, wrapAISDK, type Span } from 'braintrust' |
| 13 | import { source } from 'common-tags' |
| 14 | |
| 15 | import type { AssistantEvalInput } from '@/evals/scorer' |
| 16 | import type { AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi' |
| 17 | import { IS_TRACING_ENABLED } from '@/lib/ai/braintrust-logger' |
| 18 | import { CHAT_PROMPT, GENERAL_PROMPT, LIMITATIONS_PROMPT, SECURITY_PROMPT } from '@/lib/ai/prompts' |
| 19 | import { sanitizeMessagePart } from '@/lib/ai/tools/tool-sanitizer' |
| 20 | |
| 21 | const { streamText: tracedStreamText } = wrapAISDK(ai) |
| 22 | |
| 23 | export async function generateAssistantResponse({ |
| 24 | messages: rawMessages, |
| 25 | model, |
| 26 | tools, |
| 27 | aiOptInLevel = 'schema', |
| 28 | getSchemas, |
| 29 | projectRef, |
| 30 | chatId, |
| 31 | chatName, |
| 32 | allowTracing, |
| 33 | userId, |
| 34 | orgId, |
| 35 | planId, |
| 36 | systemProviderOptions, |
| 37 | providerOptions, |
| 38 | requestedModel, |
| 39 | abortSignal, |
| 40 | onSpanCreated, |
| 41 | }: { |
| 42 | messages: UIMessage[] |
| 43 | model: LanguageModel |
| 44 | tools: ToolSet |
| 45 | aiOptInLevel?: AiOptInLevel |
| 46 | getSchemas?: () => Promise<string> |
| 47 | projectRef?: string |
| 48 | chatId?: string |
| 49 | chatName?: string |
| 50 | allowTracing?: boolean |
| 51 | userId?: string |
| 52 | orgId?: number |
| 53 | planId?: string |
| 54 | requestedModel?: string |
| 55 | systemProviderOptions?: Record<string, any> |
| 56 | providerOptions?: Record<string, any> |
| 57 | abortSignal?: AbortSignal |
| 58 | onSpanCreated?: (spanId: string) => void |
| 59 | }) { |
| 60 | const shouldTrace = allowTracing ?? IS_TRACING_ENABLED |
| 61 | |
| 62 | const run = async (span?: Span) => { |
| 63 | // Only returns last 7 messages |
| 64 | // Filters out tools with invalid states |
| 65 | // Filters out tool outputs based on opt-in level |
| 66 | const messages = (rawMessages || []).slice(-7).map((msg) => { |
| 67 | if (msg && msg.role === 'assistant' && 'results' in msg) { |
| 68 | const cleanedMsg = { ...msg } |
| 69 | delete cleanedMsg.results |
| 70 | return cleanedMsg |
| 71 | } |
| 72 | if (msg && msg.role === 'assistant' && msg.parts) { |
| 73 | const cleanedParts = msg.parts |
| 74 | .filter((part) => { |
| 75 | if (isToolUIPart(part)) { |
| 76 | const invalidStates = [ |
| 77 | 'input-streaming', |
| 78 | 'input-available', |
| 79 | 'approval-requested', |
| 80 | 'output-error', |
| 81 | ] |
| 82 | return !invalidStates.includes(part.state) |
| 83 | } |
| 84 | return true |
| 85 | }) |
| 86 | .map((part) => { |
| 87 | return sanitizeMessagePart(part, aiOptInLevel) |
| 88 | }) |
| 89 | return { ...msg, parts: cleanedParts } |
| 90 | } |
| 91 | return msg |
| 92 | }) |
| 93 | |
| 94 | const schemasString = |
| 95 | aiOptInLevel !== 'disabled' && getSchemas |
| 96 | ? shouldTrace |
| 97 | ? await traced(async () => getSchemas(), { name: 'getSchemas', type: 'function' }) |
| 98 | : await getSchemas() |
| 99 | : "You don't have access to any schemas." |
| 100 | |
| 101 | // Important: do not use dynamic content in the system prompt or Bedrock will not cache it |
| 102 | const system = source` |
| 103 | ${GENERAL_PROMPT} |
| 104 | ${CHAT_PROMPT} |
| 105 | ${SECURITY_PROMPT} |
| 106 | ${LIMITATIONS_PROMPT} |
| 107 | |
| 108 | ## Available Knowledge |
| 109 | |
| 110 | Before writing SQL or answering questions about the following topics, call \`load_knowledge\` to load detailed knowledge: |
| 111 | - \`pg_best_practices\` — PostgreSQL best practices. Always load before writing any SQL, even simple queries. |
| 112 | - \`rls\` — Row Level Security policies |
| 113 | - \`edge_functions\` — Briven Edge Functions |
| 114 | - \`realtime\` — Briven Realtime |
| 115 | ` |
| 116 | |
| 117 | const hasProjectContext = |
| 118 | projectRef || chatName || schemasString !== "You don't have access to any schemas." |
| 119 | |
| 120 | const assistantContent = hasProjectContext |
| 121 | ? `The user's current project is ${projectRef || 'unknown'}. Their available schemas are: ${schemasString}. The current chat name is: ${chatName || 'unnamed'}.` |
| 122 | : undefined |
| 123 | |
| 124 | const systemMessage: SystemModelMessage = { |
| 125 | role: 'system', |
| 126 | content: system, |
| 127 | ...(systemProviderOptions && { providerOptions: systemProviderOptions }), |
| 128 | } |
| 129 | |
| 130 | const coreMessages: ModelMessage[] = [ |
| 131 | ...(assistantContent |
| 132 | ? [ |
| 133 | { |
| 134 | role: 'assistant' as const, |
| 135 | content: assistantContent, |
| 136 | }, |
| 137 | ] |
| 138 | : []), |
| 139 | ...(await convertToModelMessages(messages)), |
| 140 | ] |
| 141 | |
| 142 | const streamTextFn = shouldTrace ? tracedStreamText : ai.streamText |
| 143 | |
| 144 | return streamTextFn({ |
| 145 | model, |
| 146 | system: systemMessage, |
| 147 | stopWhen: stepCountIs(5), |
| 148 | messages: coreMessages, |
| 149 | ...(providerOptions && { providerOptions }), |
| 150 | tools, |
| 151 | ...(abortSignal && { abortSignal }), |
| 152 | ...(span && { |
| 153 | onFinish: ({ steps, finishReason }) => { |
| 154 | const metadata: Record<string, unknown> = { |
| 155 | isFinalStep: finishReason === 'stop', |
| 156 | } |
| 157 | for (const step of steps) { |
| 158 | for (const toolCall of step.toolCalls) { |
| 159 | if (toolCall.toolName === 'rename_chat') { |
| 160 | const { newName } = toolCall.input as { newName: string } |
| 161 | metadata.chatName = newName |
| 162 | } |
| 163 | } |
| 164 | } |
| 165 | span.log({ metadata }) |
| 166 | span.end() |
| 167 | }, |
| 168 | }), |
| 169 | } satisfies Parameters<typeof ai.streamText>[0]) |
| 170 | } |
| 171 | |
| 172 | if (shouldTrace) { |
| 173 | // startSpan instead of traced() so we control when the span closes via onFinish. |
| 174 | // Scorers read from child spans (LLM + tool) in the trace rather than a root span output field. |
| 175 | const span = startSpan({ name: 'generateAssistantResponse', type: 'function' }) |
| 176 | onSpanCreated?.(span.id) |
| 177 | |
| 178 | const lastUserMessage = rawMessages.findLast((m) => m.role === 'user') |
| 179 | const lastUserText = lastUserMessage?.parts |
| 180 | ?.filter((p): p is { type: 'text'; text: string } => p.type === 'text') |
| 181 | .map((p) => p.text) |
| 182 | .join('\n') |
| 183 | |
| 184 | span.log({ |
| 185 | input: { prompt: lastUserText ?? '' } satisfies AssistantEvalInput, |
| 186 | metadata: { |
| 187 | projectRef, |
| 188 | chatId, |
| 189 | chatName, |
| 190 | aiOptInLevel, |
| 191 | userId, |
| 192 | orgId, |
| 193 | planId, |
| 194 | requestedModel, |
| 195 | gitBranch: process.env.VERCEL_GIT_COMMIT_REF, |
| 196 | environment: process.env.NEXT_PUBLIC_ENVIRONMENT, |
| 197 | }, |
| 198 | }) |
| 199 | |
| 200 | return withCurrent(span, () => run(span)) |
| 201 | } |
| 202 | |
| 203 | return run() |
| 204 | } |