mirror of
https://github.com/supabase/supabase.git
synced 2026-10-16 22:59:11 +03:00
## Summary Each assistant tool's identity and contract is currently written in up to nine places that nothing ties together at compile time. This PR adds a contract/server split and migrates `update_notebook`, the tool with the most copies, as the first proof of the design. - **Contract** (`lib/ai/tools/update-notebook.ts`): isomorphic file with the input and output schemas, approval flag, minimum opt-in level, and `toModelOutput`, the single rule for what the model may see. - **Server** (`update-notebook.server.ts`): the executable tool, built with `approvalGatedToolFromContract`. - **Registry** (`registry.ts`): collects contracts, derives a typed `StudioUIMessage`, and provides `sanitizeContractPart` for history replay. - **Shared types** (`types.ts`): `ToolContract`, `ToolContext`, `ToolFactory`, plus a single `getAuthHeaders` replacing three variants. - **Approval gate** (`approval-gate.ts`): approval-gated tools get `acceptUntrustedSql` / `acceptUntrustedLogsSql` through a gate passed to `execute`, and `*.server.ts` files can't import the raw functions (ESLint). - **Opt-in level**: `update_notebook` is gated by its contract's `minOptInLevel`. `TOOL_CATEGORY_MAP` is now typed against `ToolName` and excludes migrated tools, which exposed `get_active_incidents` missing from the name enum. Removed duplicates: the `update_notebook` sanitizer in `tool-sanitizer.ts`, the client-side `stripNotebookSnapshot`, and the copied update schemas in `Message.utils.ts`. ## Behavior changes - The client no longer strips `previous_content` from `update_notebook` outputs before sending history, so requests carry the previous notebook snapshot. The server still strips it before the model sees it. - On history replay, an `update_notebook` output that doesn't match its schema is dropped instead of passed through minus `previous_content`. ## Testing - New `registry.test.ts` (typed parts, replay sanitizer) and a contract case in `tool-filter.test.ts`. - Studio typecheck, lint ratchet, knip, Prettier and the affected vitest suites pass.
68 lines
2.4 KiB
TypeScript
68 lines
2.4 KiB
TypeScript
import {
|
|
isToolUIPart,
|
|
type UIDataTypes,
|
|
type UIMessage,
|
|
type UIMessagePart,
|
|
type UITools,
|
|
} from 'ai'
|
|
|
|
type UIPart = UIMessagePart<UIDataTypes, UITools>
|
|
|
|
/**
|
|
* Prepares messages for API transmission by cleaning and limiting history
|
|
*/
|
|
export function prepareMessagesForAPI(messages: UIMessage[]): UIMessage[] {
|
|
// [Joshen] Specifically limiting the chat history that get's sent to reduce the
|
|
// size of the context that goes into the model. This should always be an odd number
|
|
// as much as possible so that the first message is always the user's
|
|
const MAX_CHAT_HISTORY = 7
|
|
|
|
const slicedMessages = messages.slice(-MAX_CHAT_HISTORY)
|
|
|
|
// Filter out results from messages before sending to the model
|
|
const cleanedMessages = slicedMessages.map((_message) => {
|
|
const message = _message as UIMessage & { results?: unknown }
|
|
const cleanedMessage = { ...message } as UIMessage & { results?: unknown }
|
|
if (message.role === 'assistant' && message.results) {
|
|
delete cleanedMessage.results
|
|
}
|
|
return cleanedMessage as UIMessage
|
|
})
|
|
|
|
return cleanedMessages
|
|
}
|
|
|
|
/**
|
|
* Approval id when the part is waiting on a human Approve/Deny.
|
|
* Narrows with `isToolUIPart` first, matching the AI SDK `useChat` approval pattern:
|
|
* `state === 'approval-requested' && !approval.isAutomatic`.
|
|
*
|
|
* @see https://ai-sdk.dev/docs/agents/tool-approvals
|
|
*/
|
|
export function getManualApprovalId(part: UIPart): string | undefined {
|
|
if (!isToolUIPart(part) || part.state !== 'approval-requested') return undefined
|
|
if ('isAutomatic' in part.approval && part.approval.isAutomatic === true) return undefined
|
|
return part.approval.id
|
|
}
|
|
|
|
/** True when the part is waiting on a human Approve/Deny, not an automatic policy decision. */
|
|
export function isManualApprovalRequested(part: UIPart): boolean {
|
|
return getManualApprovalId(part) !== undefined
|
|
}
|
|
|
|
/**
|
|
* Returns approval IDs to auto-deny when the model issues multiple approval-required
|
|
* tool calls in the same turn — all but the first, so the model reissues them sequentially.
|
|
*/
|
|
export function getParallelApprovalIdsToReject(messages: UIMessage[]): string[] {
|
|
const lastMessage = messages.findLast((m) => m.role === 'assistant')
|
|
if (!lastMessage) return []
|
|
|
|
const pendingIds: string[] = []
|
|
for (const part of lastMessage.parts ?? []) {
|
|
const id = getManualApprovalId(part)
|
|
if (id) pendingIds.push(id)
|
|
}
|
|
return pendingIds.slice(1)
|
|
}
|