Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/sim/app/api/mothership/execute/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
chatId,
messageId: providedMessageId,
requestId: providedRequestId,
fileAttachments,
workflowId,
executionId,
} = validation.data.body
Expand Down Expand Up @@ -89,6 +90,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
messageId,
isHosted: true,
workspaceContext: workspaceContextWithMothershipTools,
...(fileAttachments && fileAttachments.length > 0 ? { fileAttachments } : {}),
...(integrationTools.length > 0 ? { integrationTools } : {}),
...(mothershipToolRuntime.tools.length > 0
? { mothershipTools: mothershipToolRuntime.tools }
Expand Down
23 changes: 23 additions & 0 deletions apps/sim/blocks/blocks/mothership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,25 @@ export const MothershipBlock: BlockConfig<MothershipResponse> = {
type: 'short-input',
placeholder: 'e.g., user-123, session-abc, customer-456',
},
{
id: 'attachmentFiles',
title: 'Attachments',
type: 'file-upload',
canonicalParamId: 'files',
placeholder: 'Upload files to attach',
mode: 'basic',
multiple: true,
required: false,
},
{
id: 'fileReferences',
title: 'Attachments',
type: 'short-input',
canonicalParamId: 'files',
placeholder: 'Reference files from previous blocks',
mode: 'advanced',
required: false,
},
],
tools: {
access: [],
Expand All @@ -54,6 +73,10 @@ export const MothershipBlock: BlockConfig<MothershipResponse> = {
type: 'string',
description: 'Mothership chat ID to continue; generated when omitted',
},
files: {
type: 'file',
description: 'Files to send to Mothership as attachments',
},
},
outputs: {
content: { type: 'string', description: 'Generated response content' },
Expand Down
83 changes: 82 additions & 1 deletion apps/sim/executor/handlers/mothership/mothership-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ const {
mockGenerateId,
mockIsExecutionCancelled,
mockIsRedisCancellationEnabled,
mockReadUserFileContent,
} = vi.hoisted(() => ({
mockBuildAuthHeaders: vi.fn(),
mockBuildAPIUrl: vi.fn(),
mockExtractAPIErrorMessage: vi.fn(),
mockGenerateId: vi.fn(),
mockIsExecutionCancelled: vi.fn(),
mockIsRedisCancellationEnabled: vi.fn(),
mockReadUserFileContent: vi.fn(),
}))

vi.mock('@/executor/utils/http', () => ({
Expand All @@ -37,6 +39,10 @@ vi.mock('@/lib/execution/cancellation', () => ({
isRedisCancellationEnabled: mockIsRedisCancellationEnabled,
}))

vi.mock('@/lib/execution/payloads/materialization.server', () => ({
readUserFileContent: mockReadUserFileContent,
}))

function createAbortError(): Error {
const error = new Error('The operation was aborted')
error.name = 'AbortError'
Expand Down Expand Up @@ -78,13 +84,14 @@ describe('MothershipBlockHandler', () => {
mockIsExecutionCancelled.mockReset()
mockIsRedisCancellationEnabled.mockReset()
mockIsRedisCancellationEnabled.mockReturnValue(false)
mockReadUserFileContent.mockReset()

block = {
id: 'mothership-block-1',
metadata: { id: BlockType.MOTHERSHIP, name: 'Mothership' },
position: { x: 0, y: 0 },
config: { tool: BlockType.MOTHERSHIP, params: {} },
inputs: { prompt: 'string', conversationId: 'string' },
inputs: { prompt: 'string', conversationId: 'string', files: 'file[]' },
outputs: {},
enabled: true,
} as SerializedBlock
Expand Down Expand Up @@ -212,6 +219,80 @@ describe('MothershipBlockHandler', () => {
expect(mockGenerateId).toHaveBeenCalledTimes(2)
})

it('embeds attached files for the mothership execute request', async () => {
const fileContent = Buffer.from('hello mothership', 'utf8').toString('base64')
mockGenerateId.mockReturnValueOnce('chat-uuid')
mockGenerateId.mockReturnValueOnce('message-uuid')
mockGenerateId.mockReturnValueOnce('request-uuid')
mockReadUserFileContent.mockResolvedValueOnce(fileContent)

fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
content: 'analyzed',
model: 'mothership',
conversationId: 'chat-uuid',
tokens: {},
toolCalls: [],
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
)
)

const result = await handler.execute(context, block, {
prompt: 'Analyze this file',
files: [
{
name: 'notes.txt',
key: 'workspace/workspace-1/notes.txt',
size: 16,
type: 'text/plain',
},
],
})

expect(result).toMatchObject({
content: 'analyzed',
model: 'mothership',
conversationId: 'chat-uuid',
})
expect(mockReadUserFileContent).toHaveBeenCalledWith(
expect.objectContaining({
id: expect.stringMatching(/^file-/),
key: 'workspace/workspace-1/notes.txt',
name: 'notes.txt',
url: '',
size: 16,
type: 'text/plain',
}),
expect.objectContaining({
encoding: 'base64',
userId: 'user-1',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
requestId: 'request-uuid',
})
)

const [, options] = fetchMock.mock.calls[0] as [string, RequestInit]
const body = JSON.parse(String(options.body))
expect(body.fileAttachments).toEqual([
{
type: 'document',
source: {
type: 'base64',
media_type: 'text/plain',
data: fileContent,
},
filename: 'notes.txt',
},
])
})

it('propagates local aborts to the mothership request', async () => {
const abortController = new AbortController()
context.abortSignal = abortController.signal
Expand Down
58 changes: 58 additions & 0 deletions apps/sim/executor/handlers/mothership/mothership-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,69 @@ import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation'
import { readUserFileContent } from '@/lib/execution/payloads/materialization.server'
import {
createFileContentFromBase64,
type MessageContent,
processSingleFileToUserFile,
type RawFileInput,
} from '@/lib/uploads/utils/file-utils'
import type { BlockOutput } from '@/blocks/types'
import { normalizeFileInput } from '@/blocks/utils'
import { BlockType } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http'
import type { SerializedBlock } from '@/serializer/types'

const logger = createLogger('MothershipBlockHandler')
const CANCELLATION_CHECK_INTERVAL_MS = 500
const MAX_MOTHERSHIP_ATTACHMENT_BYTES = 10 * 1024 * 1024

type MothershipFileAttachment = MessageContent & {
filename?: string
}

async function buildMothershipFileAttachments(
filesInput: unknown,
ctx: ExecutionContext,
requestId: string
): Promise<MothershipFileAttachment[] | undefined> {
const files = normalizeFileInput(filesInput)
if (!files || files.length === 0) {
return undefined
}

if (!ctx.userId) {
throw new Error('Mothership file attachments require an authenticated user.')
}

const attachments: MothershipFileAttachment[] = []
for (const file of files) {
const userFile = processSingleFileToUserFile(file as RawFileInput, requestId, logger)
const base64 = await readUserFileContent(userFile, {
encoding: 'base64',
userId: ctx.userId,
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
executionId: ctx.executionId,
largeValueExecutionIds: ctx.largeValueExecutionIds,
allowLargeValueWorkflowScope: ctx.allowLargeValueWorkflowScope,
requestId,
logger,
maxBytes: MAX_MOTHERSHIP_ATTACHMENT_BYTES,
maxSourceBytes: MAX_MOTHERSHIP_ATTACHMENT_BYTES,
})

const content = createFileContentFromBase64(base64, userFile.type)
if (!content) {
throw new Error(`File type is not supported for Mothership attachments: ${userFile.name}`)
}

attachments.push({ ...content, filename: userFile.name })
}

return attachments
}

/**
* Handler for Mothership blocks that proxy requests to the Mothership AI agent.
Expand Down Expand Up @@ -38,6 +93,7 @@ export class MothershipBlockHandler implements BlockHandler {
const chatId = providedConversationId || generateId()
const messageId = generateId()
const requestId = generateId()
const fileAttachments = await buildMothershipFileAttachments(inputs.files, ctx, requestId)

const url = buildAPIUrl('/api/mothership/execute')
const headers = await buildAuthHeaders(ctx.userId)
Expand All @@ -49,6 +105,7 @@ export class MothershipBlockHandler implements BlockHandler {
chatId,
messageId,
requestId,
...(fileAttachments && { fileAttachments }),
...(ctx.workflowId ? { workflowId: ctx.workflowId } : {}),
...(ctx.executionId ? { executionId: ctx.executionId } : {}),
}
Expand All @@ -60,6 +117,7 @@ export class MothershipBlockHandler implements BlockHandler {
workflowId: ctx.workflowId,
executionId: ctx.executionId,
chatId,
fileAttachmentCount: fileAttachments?.length ?? 0,
})

const abortController = new AbortController()
Expand Down
15 changes: 15 additions & 0 deletions apps/sim/lib/api/contracts/mothership-tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,20 @@ const mothershipExecuteMessageSchema = z.object({
content: z.string(),
})

const mothershipExecuteFileAttachmentSchema = z
.object({
type: z.enum(['text', 'image', 'document', 'audio', 'video']),
source: z
.object({
type: z.literal('base64'),
media_type: z.string().min(1),
data: z.string().min(1),
})
.optional(),
filename: z.string().optional(),
})
.passthrough()

export const mothershipExecuteBodySchema = z.object({
messages: z.array(mothershipExecuteMessageSchema).min(1, 'At least one message is required'),
responseFormat: z.any().optional(),
Expand All @@ -61,6 +75,7 @@ export const mothershipExecuteBodySchema = z.object({
chatId: z.string().optional(),
messageId: z.string().optional(),
requestId: z.string().optional(),
fileAttachments: z.array(mothershipExecuteFileAttachmentSchema).optional(),
workflowId: z.string().optional(),
executionId: z.string().optional(),
})
Expand Down
11 changes: 9 additions & 2 deletions apps/sim/lib/uploads/utils/file-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,13 @@ export function bufferToBase64(buffer: Buffer): string {
* Create message content from file data
*/
export function createFileContent(fileBuffer: Buffer, mimeType: string): MessageContent | null {
return createFileContentFromBase64(bufferToBase64(fileBuffer), mimeType)
}

/**
* Create message content from base64-encoded file data.
*/
export function createFileContentFromBase64(base64: string, mimeType: string): MessageContent | null {
// SVG is XML text — Claude only supports raster image formats (JPEG, PNG, GIF, WebP),
// so send SVGs as an XML document instead
if (mimeType.toLowerCase() === 'image/svg+xml') {
Expand All @@ -155,7 +162,7 @@ export function createFileContent(fileBuffer: Buffer, mimeType: string): Message
source: {
type: 'base64',
media_type: 'text/xml',
data: bufferToBase64(fileBuffer),
data: base64,
},
}
}
Expand All @@ -174,7 +181,7 @@ export function createFileContent(fileBuffer: Buffer, mimeType: string): Message
source: {
type: 'base64',
media_type: mimeType,
data: bufferToBase64(fileBuffer),
data: base64,
},
}
}
Expand Down
Loading