Skip to content

Commit fce1024

Browse files
committed
Mothership block
1 parent ae080f1 commit fce1024

14 files changed

Lines changed: 13525 additions & 38 deletions

File tree

apps/sim/app/api/billing/update-cost/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const UpdateCostSchema = z.object({
1818
model: z.string().min(1, 'Model is required'),
1919
inputTokens: z.number().min(0).default(0),
2020
outputTokens: z.number().min(0).default(0),
21-
source: z.enum(['copilot', 'workspace-chat', 'mcp_copilot']).default('copilot'),
21+
source: z.enum(['copilot', 'workspace-chat', 'mcp_copilot', 'mothership_block']).default('copilot'),
2222
})
2323

2424
/**
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { z } from 'zod'
4+
import { checkInternalAuth } from '@/lib/auth/hybrid'
5+
import { buildIntegrationToolSchemas } from '@/lib/copilot/chat-payload'
6+
import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator'
7+
import { generateWorkspaceContext } from '@/lib/copilot/workspace-context'
8+
9+
const logger = createLogger('MothershipExecuteAPI')
10+
11+
const MessageSchema = z.object({
12+
role: z.enum(['system', 'user', 'assistant']),
13+
content: z.string(),
14+
})
15+
16+
const ExecuteRequestSchema = z.object({
17+
messages: z.array(MessageSchema).min(1, 'At least one message is required'),
18+
responseFormat: z.any().optional(),
19+
workspaceId: z.string().min(1, 'workspaceId is required'),
20+
userId: z.string().min(1, 'userId is required'),
21+
chatId: z.string().optional(),
22+
})
23+
24+
/**
25+
* POST /api/mothership/execute
26+
*
27+
* Non-streaming endpoint for Mothership block execution within workflows.
28+
* Called by the executor via internal JWT auth, not by the browser directly.
29+
* Consumes the Go SSE stream internally and returns a single JSON response.
30+
*/
31+
export async function POST(req: NextRequest) {
32+
try {
33+
const auth = await checkInternalAuth(req, { requireWorkflowId: false })
34+
if (!auth.success) {
35+
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
36+
}
37+
38+
const body = await req.json()
39+
const { messages, responseFormat, workspaceId, userId, chatId } =
40+
ExecuteRequestSchema.parse(body)
41+
42+
const effectiveChatId = chatId || crypto.randomUUID()
43+
const [workspaceContext, integrationTools] = await Promise.all([
44+
generateWorkspaceContext(workspaceId, userId),
45+
buildIntegrationToolSchemas(),
46+
])
47+
48+
const requestPayload: Record<string, unknown> = {
49+
messages,
50+
responseFormat,
51+
userId,
52+
chatId: effectiveChatId,
53+
mode: 'agent',
54+
messageId: crypto.randomUUID(),
55+
isHosted: true,
56+
workspaceContext,
57+
...(integrationTools.length > 0 ? { integrationTools } : {}),
58+
}
59+
60+
const result = await orchestrateCopilotStream(requestPayload, {
61+
userId,
62+
workspaceId,
63+
chatId: effectiveChatId,
64+
goRoute: '/api/mothership/execute',
65+
autoExecuteTools: true,
66+
interactive: false,
67+
})
68+
69+
if (!result.success) {
70+
logger.error('Mothership execute failed', {
71+
error: result.error,
72+
errors: result.errors,
73+
})
74+
return NextResponse.json(
75+
{
76+
error: result.error || 'Mothership execution failed',
77+
content: result.content || '',
78+
},
79+
{ status: 500 }
80+
)
81+
}
82+
83+
return NextResponse.json({
84+
content: result.content,
85+
model: 'mothership',
86+
tokens: {},
87+
toolCalls: result.toolCalls,
88+
})
89+
} catch (error) {
90+
if (error instanceof z.ZodError) {
91+
return NextResponse.json(
92+
{ error: 'Invalid request data', details: error.errors },
93+
{ status: 400 }
94+
)
95+
}
96+
97+
logger.error('Mothership execute error', {
98+
error: error instanceof Error ? error.message : 'Unknown error',
99+
})
100+
101+
return NextResponse.json(
102+
{ error: error instanceof Error ? error.message : 'Internal server error' },
103+
{ status: 500 }
104+
)
105+
}
106+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { Rocket } from 'lucide-react'
2+
import type { BlockConfig } from '@/blocks/types'
3+
import type { ToolResponse } from '@/tools/types'
4+
5+
interface MothershipResponse extends ToolResponse {
6+
output: {
7+
content: string
8+
model: string
9+
tokens?: {
10+
prompt?: number
11+
completion?: number
12+
total?: number
13+
}
14+
}
15+
}
16+
17+
export const MothershipBlock: BlockConfig<MothershipResponse> = {
18+
type: 'mothership',
19+
name: 'Mothership',
20+
description: 'Query the Mothership AI agent',
21+
longDescription:
22+
'The Mothership block sends messages to the Mothership AI agent, which has access to subagents, integration tools, memory, and workspace context. Use it to perform complex multi-step reasoning, cross-service queries, or any task that benefits from the full Mothership intelligence within a workflow.',
23+
bestPractices: `
24+
- Use for tasks that require multi-step reasoning, tool use, or cross-service coordination.
25+
- Response Format should be a valid JSON Schema. When present, structured fields are returned at root level (e.g. <mothership1.field>). Without it, the block returns content, model, and tokens.
26+
- The Mothership picks its own model and tools internally — you only provide messages and an optional response format.
27+
`,
28+
category: 'blocks',
29+
bgColor: '#802FDE',
30+
icon: Rocket,
31+
subBlocks: [
32+
{
33+
id: 'messages',
34+
title: 'Messages',
35+
type: 'messages-input',
36+
placeholder: 'Enter messages...',
37+
},
38+
{
39+
id: 'responseFormat',
40+
title: 'Response Format',
41+
type: 'code',
42+
placeholder: 'Enter JSON schema...',
43+
language: 'json',
44+
mode: 'advanced',
45+
},
46+
{
47+
id: 'memoryType',
48+
title: 'Memory',
49+
type: 'dropdown',
50+
placeholder: 'Select memory...',
51+
options: [
52+
{ label: 'None', id: 'none' },
53+
{ label: 'Conversation', id: 'conversation' },
54+
],
55+
mode: 'advanced',
56+
},
57+
{
58+
id: 'conversationId',
59+
title: 'Conversation ID',
60+
type: 'short-input',
61+
placeholder: 'e.g., user-123, session-abc',
62+
required: {
63+
field: 'memoryType',
64+
value: ['conversation'],
65+
},
66+
condition: {
67+
field: 'memoryType',
68+
value: ['conversation'],
69+
},
70+
},
71+
],
72+
tools: {
73+
access: [],
74+
},
75+
inputs: {
76+
messages: {
77+
type: 'json',
78+
description:
79+
'Array of message objects with role and content: [{ role: "system", content: "..." }, { role: "user", content: "..." }]',
80+
},
81+
responseFormat: {
82+
type: 'json',
83+
description: 'JSON response format schema for structured output',
84+
},
85+
memoryType: {
86+
type: 'string',
87+
description: 'Type of memory: none (default) or conversation',
88+
},
89+
conversationId: {
90+
type: 'string',
91+
description: 'Persistent conversation ID for memory across executions',
92+
},
93+
},
94+
outputs: {
95+
content: { type: 'string', description: 'Generated response content' },
96+
model: { type: 'string', description: 'Model used for generation' },
97+
tokens: { type: 'json', description: 'Token usage statistics' },
98+
},
99+
}

apps/sim/blocks/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ import { MailchimpBlock } from '@/blocks/blocks/mailchimp'
8282
import { MailgunBlock } from '@/blocks/blocks/mailgun'
8383
import { ManualTriggerBlock } from '@/blocks/blocks/manual_trigger'
8484
import { McpBlock } from '@/blocks/blocks/mcp'
85+
import { MothershipBlock } from '@/blocks/blocks/mothership'
8586
import { Mem0Block } from '@/blocks/blocks/mem0'
8687
import { MemoryBlock } from '@/blocks/blocks/memory'
8788
import { MicrosoftDataverseBlock } from '@/blocks/blocks/microsoft_dataverse'
@@ -285,6 +286,7 @@ export const registry: Record<string, BlockConfig> = {
285286
mistral_parse_v2: MistralParseV2Block,
286287
mistral_parse_v3: MistralParseV3Block,
287288
mongodb: MongoDBBlock,
289+
mothership: MothershipBlock,
288290
mysql: MySQLBlock,
289291
neo4j: Neo4jBlock,
290292
note: NoteBlock,

apps/sim/executor/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export enum BlockType {
2525

2626
FUNCTION = 'function',
2727
AGENT = 'agent',
28+
MOTHERSHIP = 'mothership',
2829
API = 'api',
2930
EVALUATOR = 'evaluator',
3031
VARIABLES = 'variables',
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { createLogger } from '@sim/logger'
2+
import type { BlockOutput } from '@/blocks/types'
3+
import { BlockType } from '@/executor/constants'
4+
import type { BlockHandler, ExecutionContext } from '@/executor/types'
5+
import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http'
6+
import type { SerializedBlock } from '@/serializer/types'
7+
8+
const logger = createLogger('MothershipBlockHandler')
9+
10+
/**
11+
* Handler for Mothership blocks that proxy requests to the Mothership AI agent.
12+
*
13+
* Unlike the Agent block (which calls LLM providers directly), the Mothership
14+
* block delegates to the full Mothership infrastructure: main agent, subagents,
15+
* integration tools, memory, and workspace context.
16+
*/
17+
export class MothershipBlockHandler implements BlockHandler {
18+
canHandle(block: SerializedBlock): boolean {
19+
return block.metadata?.id === BlockType.MOTHERSHIP
20+
}
21+
22+
async execute(
23+
ctx: ExecutionContext,
24+
block: SerializedBlock,
25+
inputs: Record<string, any>
26+
): Promise<BlockOutput> {
27+
const messages = this.resolveMessages(inputs)
28+
const responseFormat = this.parseResponseFormat(inputs.responseFormat)
29+
30+
const memoryType = inputs.memoryType || 'none'
31+
const chatId =
32+
memoryType === 'conversation' && inputs.conversationId
33+
? inputs.conversationId
34+
: crypto.randomUUID()
35+
36+
const url = buildAPIUrl('/api/mothership/execute')
37+
const headers = await buildAuthHeaders()
38+
39+
const body: Record<string, unknown> = {
40+
messages,
41+
workspaceId: ctx.workspaceId || '',
42+
userId: ctx.userId || '',
43+
chatId,
44+
}
45+
if (responseFormat) {
46+
body.responseFormat = responseFormat
47+
}
48+
49+
logger.info('Executing Mothership block', {
50+
blockId: block.id,
51+
messageCount: messages.length,
52+
hasResponseFormat: !!responseFormat,
53+
memoryType,
54+
hasConversationId: memoryType === 'conversation',
55+
})
56+
57+
const response = await fetch(url.toString(), {
58+
method: 'POST',
59+
headers,
60+
body: JSON.stringify(body),
61+
})
62+
63+
if (!response.ok) {
64+
const errorMsg = await extractAPIErrorMessage(response)
65+
throw new Error(`Mothership execution failed: ${errorMsg}`)
66+
}
67+
68+
const result = await response.json()
69+
70+
if (responseFormat && result.content) {
71+
return this.processStructuredResponse(result)
72+
}
73+
74+
return {
75+
content: result.content || '',
76+
model: result.model || 'mothership',
77+
tokens: result.tokens || {},
78+
}
79+
}
80+
81+
private resolveMessages(
82+
inputs: Record<string, any>
83+
): Array<{ role: string; content: string }> {
84+
const raw = inputs.messages
85+
if (!raw) {
86+
throw new Error('Messages input is required for the Mothership block')
87+
}
88+
89+
let messages: unknown[]
90+
if (typeof raw === 'string') {
91+
try {
92+
messages = JSON.parse(raw)
93+
} catch {
94+
throw new Error('Messages must be a valid JSON array')
95+
}
96+
} else if (Array.isArray(raw)) {
97+
messages = raw
98+
} else {
99+
throw new Error('Messages must be an array of {role, content} objects')
100+
}
101+
102+
return messages.map((msg: any, i: number) => {
103+
if (!msg.role || typeof msg.content !== 'string') {
104+
throw new Error(
105+
`Message at index ${i} must have "role" (string) and "content" (string)`
106+
)
107+
}
108+
return { role: String(msg.role), content: msg.content }
109+
})
110+
}
111+
112+
private parseResponseFormat(responseFormat?: string | object): any {
113+
if (!responseFormat || responseFormat === '') return undefined
114+
115+
if (typeof responseFormat === 'object') return responseFormat
116+
117+
if (typeof responseFormat === 'string') {
118+
const trimmed = responseFormat.trim()
119+
if (!trimmed) return undefined
120+
if (trimmed.startsWith('<') || trimmed.startsWith('{{')) return undefined
121+
try {
122+
return JSON.parse(trimmed)
123+
} catch {
124+
logger.warn('Failed to parse responseFormat as JSON', {
125+
preview: trimmed.slice(0, 100),
126+
})
127+
return undefined
128+
}
129+
}
130+
131+
return undefined
132+
}
133+
134+
private processStructuredResponse(result: any): BlockOutput {
135+
const content = result.content
136+
try {
137+
const parsed = JSON.parse(content.trim())
138+
return {
139+
...parsed,
140+
model: result.model || 'mothership',
141+
tokens: result.tokens || {},
142+
}
143+
} catch {
144+
logger.warn('Failed to parse structured response, returning raw content')
145+
return {
146+
content,
147+
model: result.model || 'mothership',
148+
tokens: result.tokens || {},
149+
}
150+
}
151+
}
152+
}

0 commit comments

Comments
 (0)