Files
simstudioai--sim/apps/sim/lib/copilot/chat/payload.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

404 lines
14 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { LRUCache } from 'lru-cache'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { isPaid } from '@/lib/billing/plan-helpers'
import { getBlockVisibilityForCopilot, visibilitySignature } from '@/lib/copilot/block-visibility'
import type { VfsSnapshotV1 } from '@/lib/copilot/generated/vfs-snapshot-v1'
import {
filterExposedIntegrationTools,
getExposedIntegrationTools,
} from '@/lib/copilot/integration-tools'
import { getToolEntry } from '@/lib/copilot/tool-executor/router'
import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions'
import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils'
import type { BlockVisibilityState } from '@/lib/core/config/block-visibility'
import { isE2BDocEnabled, isHosted } from '@/lib/core/config/env-flags'
import { buildUserSkillTool } from '@/lib/mothership/skills'
import { trackChatUpload } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { stripVersionSuffix } from '@/tools/utils'
const logger = createLogger('CopilotChatPayload')
const INTEGRATION_TOOL_SCHEMA_CACHE_TTL_MS = 5_000
const INTEGRATION_TOOL_SCHEMA_CACHE_MAX_ENTRIES = 500
interface BuildPayloadParams {
message: string
workflowId?: string
workflowName?: string
workspaceId?: string
userId: string
userMessageId: string
mode: string
model: string
provider?: string
contexts?: Array<{ type: string; content: string; tag?: string; path?: string }>
fileAttachments?: Array<{ id: string; key: string; size: number; [key: string]: unknown }>
commands?: string[]
chatId?: string
prefetch?: boolean
implicitFeedback?: string
workspaceContext?: string
vfs?: VfsSnapshotV1
userPermission?: string
/** Plan/flag-gated org capabilities (e.g. "custom-blocks") the mothership gates tools/prompts on. */
entitlements?: string[]
userTimezone?: string
userMetadata?: {
name?: string
email?: string
timezone?: string
}
includeMothershipTools?: boolean
}
export interface ToolSchema {
name: string
description: string
input_schema: Record<string, unknown>
defer_loading?: boolean
executeLocally?: boolean
params?: Record<string, unknown>
/** Canonical integration service/folder (e.g. "slack"), for server-side grouping. */
service?: string
oauth?: { required: boolean; provider: string }
}
interface BuildIntegrationToolSchemasOptions {
schemaSurface?: 'default' | 'copilot'
}
interface IntegrationToolSchemaCacheEntry {
promise: Promise<ToolSchema[]>
}
const integrationToolSchemaCache = new LRUCache<string, IntegrationToolSchemaCacheEntry>({
max: INTEGRATION_TOOL_SCHEMA_CACHE_MAX_ENTRIES,
ttl: INTEGRATION_TOOL_SCHEMA_CACHE_TTL_MS,
})
function getIntegrationToolSchemaCacheKey(
userId: string,
workspaceId: string | undefined,
schemaSurface: string,
visSignature: string
): string {
// The visibility signature keys the entry to the viewer's gated projection —
// two users in one workspace with different preview reveals must not share.
return JSON.stringify([userId, workspaceId ?? null, schemaSurface, visSignature])
}
function cloneToolSchemas(toolSchemas: ToolSchema[]): ToolSchema[] {
return toolSchemas.map((tool) => {
const cloned: ToolSchema = {
...tool,
input_schema: { ...tool.input_schema },
}
if (tool.params) cloned.params = { ...tool.params }
if (tool.oauth) cloned.oauth = { ...tool.oauth }
return cloned
})
}
export function clearIntegrationToolSchemaCacheForTests(): void {
integrationToolSchemaCache.clear()
}
/**
* Build deferred integration tool schemas from the Sim tool registry.
* Shared by the interactive chat payload builder and the non-interactive
* block execution route so both paths send the same tool definitions to Go.
*
* When `workspaceId` is provided the user's workspace permission config is
* loaded once and used to skip any tool whose owning block is not in the
* workspace's `allowedIntegrations` allowlist.
*/
export async function buildIntegrationToolSchemas(
userId: string,
messageId?: string,
options: BuildIntegrationToolSchemasOptions = { schemaSurface: 'copilot' },
workspaceId?: string
): Promise<ToolSchema[]> {
const schemaSurface = options.schemaSurface ?? 'copilot'
const vis = await getBlockVisibilityForCopilot(userId, workspaceId)
const cacheKey = getIntegrationToolSchemaCacheKey(
userId,
workspaceId,
schemaSurface,
visibilitySignature(vis)
)
const cached = integrationToolSchemaCache.get(cacheKey)
if (cached) {
return cloneToolSchemas(await cached.promise)
}
const promise = buildIntegrationToolSchemasUncached(
userId,
messageId,
{ schemaSurface },
workspaceId,
vis
).catch((error) => {
integrationToolSchemaCache.delete(cacheKey)
throw error
})
integrationToolSchemaCache.set(cacheKey, {
promise,
})
return cloneToolSchemas(await promise)
}
async function buildIntegrationToolSchemasUncached(
userId: string,
messageId: string | undefined,
options: Required<BuildIntegrationToolSchemasOptions>,
workspaceId?: string,
vis: BlockVisibilityState | null = null
): Promise<ToolSchema[]> {
const reqLogger = logger.withMetadata({ messageId })
const integrationTools: ToolSchema[] = []
try {
const { createUserToolSchema } = await import('@/tools/params')
let shouldAppendEmailTagline = false
try {
const subscription = await getHighestPrioritySubscription(userId)
shouldAppendEmailTagline = !subscription || !isPaid(subscription.plan)
} catch (error) {
reqLogger.warn('Failed to load subscription for copilot tool descriptions', {
userId,
error: toError(error).message,
})
}
let allowedIntegrations: Set<string> | null = null
let toolIdToBlockType: Map<string, string> | null = null
if (workspaceId) {
try {
const [{ getUserPermissionConfig }, { getAllBlocks }] = await Promise.all([
import('@/ee/access-control/utils/permission-check'),
import('@/blocks/registry'),
])
const permissionConfig = await getUserPermissionConfig(userId, workspaceId)
if (permissionConfig?.allowedIntegrations) {
allowedIntegrations = new Set(
permissionConfig.allowedIntegrations.map((i) => i.toLowerCase())
)
toolIdToBlockType = new Map()
for (const blockConfig of getAllBlocks()) {
const access = blockConfig.tools?.access
if (!access) continue
for (const toolId of access) {
toolIdToBlockType.set(stripVersionSuffix(toolId), blockConfig.type.toLowerCase())
}
}
}
} catch (error) {
reqLogger.warn('Failed to load permission config for tool schema filter', {
userId,
workspaceId,
error: toError(error).message,
})
}
}
const exposedTools = filterExposedIntegrationTools(getExposedIntegrationTools(), vis)
for (const { toolId, config: toolConfig, service } of exposedTools) {
try {
if (allowedIntegrations && toolIdToBlockType) {
const owningBlock = toolIdToBlockType.get(stripVersionSuffix(toolId))
if (owningBlock && !allowedIntegrations.has(owningBlock)) {
continue
}
}
const userSchema = createUserToolSchema(toolConfig, {
surface: options.schemaSurface,
})
const catalogEntry = getToolEntry(toolId)
integrationTools.push({
name: toolId,
service,
description: getCopilotToolDescription(toolConfig, {
isHosted,
fallbackName: toolId,
appendEmailTagline: shouldAppendEmailTagline,
}),
input_schema: { ...userSchema },
defer_loading: true,
executeLocally:
catalogEntry?.clientExecutable === true || catalogEntry?.route === 'client',
...(toolConfig.oauth?.required && {
oauth: {
required: true,
provider: toolConfig.oauth.provider,
},
}),
})
} catch (toolError) {
logger.warn(
messageId
? `Failed to build schema for tool, skipping [messageId:${messageId}]`
: 'Failed to build schema for tool, skipping',
{
toolId,
error: toError(toolError).message,
}
)
}
}
} catch (error) {
logger.warn(
messageId
? `Failed to build tool schemas [messageId:${messageId}]`
: 'Failed to build tool schemas',
{
error: toError(error).message,
}
)
}
return integrationTools
}
/**
* Build the request payload for the copilot backend.
*/
export async function buildCopilotRequestPayload(
params: BuildPayloadParams,
options: {
selectedModel: string
}
): Promise<Record<string, unknown>> {
const {
message,
workflowId,
userId,
userMessageId,
mode,
provider,
contexts,
fileAttachments,
commands,
chatId,
prefetch,
implicitFeedback,
} = params
const selectedModel = options.selectedModel
const effectiveMode = mode === 'agent' ? 'build' : mode
const transportMode = effectiveMode === 'build' ? 'agent' : effectiveMode
// Track uploaded files in the DB and build context tags instead of base64 inlining
const uploadContexts: Array<{ type: string; content: string; tag?: string; path?: string }> = []
if (chatId && params.workspaceId && fileAttachments && fileAttachments.length > 0) {
for (const f of fileAttachments) {
const filename = (f.filename ?? f.name ?? 'file') as string
const mediaType = (f.media_type ?? f.mimeType ?? 'application/octet-stream') as string
try {
const { displayName } = await trackChatUpload(
params.workspaceId,
userId,
chatId,
f.key,
filename,
mediaType,
f.size
)
// Encode the read path per the percent-encoded VFS convention (matches
// files/ and the uploads glob output). The materialize_file `fileName`
// arg stays the raw display name — the upload resolver accepts both.
let encodedUploadName = displayName
try {
encodedUploadName = encodeVfsSegment(displayName)
} catch {
encodedUploadName = displayName
}
const lines = [
`File "${displayName}" (${mediaType}, ${f.size} bytes) uploaded.`,
`Read with: read("uploads/${encodedUploadName}")`,
`To save permanently: materialize_file(fileName: "${displayName}")`,
]
if (displayName.endsWith('.json')) {
lines.push(
`To import as a workflow: materialize_file(fileName: "${displayName}", operation: "import")`
)
}
uploadContexts.push({
type: 'uploaded_file',
content: lines.join('\n'),
})
} catch (err) {
logger.warn('Failed to track chat upload', {
filename,
chatId,
error: toError(err).message,
})
}
}
}
const allContexts = [...(contexts ?? []), ...uploadContexts]
let integrationTools: ToolSchema[] = []
const mothershipTools: ToolSchema[] = []
const payloadLogger = logger.withMetadata({ messageId: userMessageId })
if (effectiveMode === 'build') {
integrationTools = await buildIntegrationToolSchemas(
userId,
userMessageId,
{ schemaSurface: 'copilot' },
params.workspaceId
)
if (params.includeMothershipTools && params.workspaceId) {
// Expose all workspace user-created skills via the single load_user_skill
// tool. Available to every user; content is fetched sim-side when the
// model calls it.
try {
const userSkillTool = await buildUserSkillTool(params.workspaceId)
if (userSkillTool) mothershipTools.push(userSkillTool)
} catch (error) {
logger.warn('Failed to build load_user_skill tool', {
error: toError(error).message,
})
}
}
}
return {
message,
...(workflowId ? { workflowId } : {}),
...(params.workflowName ? { workflowName: params.workflowName } : {}),
...(params.workspaceId ? { workspaceId: params.workspaceId } : {}),
userId,
...(selectedModel ? { model: selectedModel } : {}),
...(provider ? { provider } : {}),
mode: transportMode,
messageId: userMessageId,
...(allContexts.length > 0 ? { context: allContexts } : {}),
...(chatId ? { chatId } : {}),
...(typeof prefetch === 'boolean' ? { prefetch } : {}),
...(implicitFeedback ? { implicitFeedback } : {}),
...(integrationTools.length > 0 ? { integrationTools } : {}),
...(mothershipTools.length > 0 ? { mothershipTools } : {}),
...(commands && commands.length > 0 ? { commands } : {}),
...(params.workspaceContext ? { workspaceContext: params.workspaceContext } : {}),
...(params.vfs ? { vfs: params.vfs } : {}),
...(params.userPermission ? { userPermission: params.userPermission } : {}),
...(params.entitlements?.length ? { entitlements: params.entitlements } : {}),
...(params.userTimezone ? { userTimezone: params.userTimezone } : {}),
...(params.userMetadata &&
(params.userMetadata.name || params.userMetadata.email || params.userMetadata.timezone)
? { userMetadata: params.userMetadata }
: {}),
// Tell the copilot file subagent which document toolchain to write. Emitted
// only in Python mode so the JS path sends no new field (Go defaults to js).
...(isE2BDocEnabled ? { docCompiler: 'python' } : {}),
isHosted,
}
}