feat(mothership): add files to mship block (#4584)

* Add files to mship block

* Fixes

* Fix

* fix
This commit is contained in:
Siddharth Ganesan
2026-05-13 16:35:56 -07:00
committed by GitHub
parent 1ed3a4ec6d
commit 2441d5ad6e
6 changed files with 189 additions and 3 deletions
@@ -51,6 +51,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
chatId,
messageId: providedMessageId,
requestId: providedRequestId,
fileAttachments,
workflowId,
executionId,
} = validation.data.body
@@ -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 }
+23
View File
@@ -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: [],
@@ -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' },
@@ -13,6 +13,7 @@ const {
mockGenerateId,
mockIsExecutionCancelled,
mockIsRedisCancellationEnabled,
mockReadUserFileContent,
} = vi.hoisted(() => ({
mockBuildAuthHeaders: vi.fn(),
mockBuildAPIUrl: vi.fn(),
@@ -20,6 +21,7 @@ const {
mockGenerateId: vi.fn(),
mockIsExecutionCancelled: vi.fn(),
mockIsRedisCancellationEnabled: vi.fn(),
mockReadUserFileContent: vi.fn(),
}))
vi.mock('@/executor/utils/http', () => ({
@@ -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'
@@ -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
@@ -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
@@ -2,7 +2,15 @@ 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'
@@ -10,6 +18,53 @@ 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.
@@ -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)
@@ -49,6 +105,7 @@ export class MothershipBlockHandler implements BlockHandler {
chatId,
messageId,
requestId,
...(fileAttachments && { fileAttachments }),
...(ctx.workflowId ? { workflowId: ctx.workflowId } : {}),
...(ctx.executionId ? { executionId: ctx.executionId } : {}),
}
@@ -60,6 +117,7 @@ export class MothershipBlockHandler implements BlockHandler {
workflowId: ctx.workflowId,
executionId: ctx.executionId,
chatId,
fileAttachmentCount: fileAttachments?.length ?? 0,
})
const abortController = new AbortController()
@@ -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(),
@@ -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(),
})
+9 -2
View File
@@ -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') {
@@ -155,7 +162,7 @@ export function createFileContent(fileBuffer: Buffer, mimeType: string): Message
source: {
type: 'base64',
media_type: 'text/xml',
data: bufferToBase64(fileBuffer),
data: base64,
},
}
}
@@ -174,7 +181,7 @@ export function createFileContent(fileBuffer: Buffer, mimeType: string): Message
source: {
type: 'base64',
media_type: mimeType,
data: bufferToBase64(fileBuffer),
data: base64,
},
}
}