Skip to content
Draft
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
71 changes: 25 additions & 46 deletions apps/sim/executor/handlers/agent/agent-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ vi.mock('@/lib/core/config/feature-flags', () => ({
isEmailVerificationEnabled: false,
isBillingEnabled: false,
isOrganizationsEnabled: false,
isAccessControlEnabled: false,
}))

vi.mock('@/providers/utils', () => ({
Expand Down Expand Up @@ -110,6 +111,12 @@ vi.mock('@sim/db/schema', () => ({
},
}))

const mockGetCustomToolById = vi.fn()

vi.mock('@/lib/workflows/custom-tools/operations', () => ({
getCustomToolById: (...args: unknown[]) => mockGetCustomToolById(...args),
}))

setupGlobalFetchMock()

const mockGetAllBlocks = getAllBlocks as Mock
Expand Down Expand Up @@ -1957,49 +1964,22 @@ describe('AgentBlockHandler', () => {
const staleInlineCode = 'return { title, content };'
const dbCode = 'return { title, content, format };'

function mockFetchForCustomTool(toolId: string) {
mockFetch.mockImplementation((url: string) => {
if (typeof url === 'string' && url.includes('/api/tools/custom')) {
function mockDBForCustomTool(toolId: string) {
mockGetCustomToolById.mockImplementation(({ toolId: id }: { toolId: string }) => {
if (id === toolId) {
return Promise.resolve({
ok: true,
headers: { get: () => null },
json: () =>
Promise.resolve({
data: [
{
id: toolId,
title: 'formatReport',
schema: dbSchema,
code: dbCode,
},
],
}),
id: toolId,
title: 'formatReport',
schema: dbSchema,
code: dbCode,
})
}
return Promise.resolve({
ok: true,
headers: { get: () => null },
json: () => Promise.resolve({}),
})
return Promise.resolve(null)
})
}

function mockFetchFailure() {
mockFetch.mockImplementation((url: string) => {
if (typeof url === 'string' && url.includes('/api/tools/custom')) {
return Promise.resolve({
ok: false,
status: 500,
headers: { get: () => null },
json: () => Promise.resolve({}),
})
}
return Promise.resolve({
ok: true,
headers: { get: () => null },
json: () => Promise.resolve({}),
})
})
function mockDBFailure() {
mockGetCustomToolById.mockRejectedValue(new Error('DB connection failed'))
}

beforeEach(() => {
Expand All @@ -2008,11 +1988,13 @@ describe('AgentBlockHandler', () => {
writable: true,
configurable: true,
})
mockGetCustomToolById.mockReset()
mockContext.userId = 'test-user'
})

it('should always fetch latest schema from DB when customToolId is present', async () => {
const toolId = 'custom-tool-123'
mockFetchForCustomTool(toolId)
mockDBForCustomTool(toolId)

const inputs = {
model: 'gpt-4o',
Expand Down Expand Up @@ -2046,7 +2028,7 @@ describe('AgentBlockHandler', () => {

it('should fetch from DB when customToolId has no inline schema', async () => {
const toolId = 'custom-tool-123'
mockFetchForCustomTool(toolId)
mockDBForCustomTool(toolId)

const inputs = {
model: 'gpt-4o',
Expand Down Expand Up @@ -2075,7 +2057,7 @@ describe('AgentBlockHandler', () => {
})

it('should fall back to inline schema when DB fetch fails and inline exists', async () => {
mockFetchFailure()
mockDBFailure()

const inputs = {
model: 'gpt-4o',
Expand Down Expand Up @@ -2107,7 +2089,7 @@ describe('AgentBlockHandler', () => {
})

it('should return null when DB fetch fails and no inline schema exists', async () => {
mockFetchFailure()
mockDBFailure()

const inputs = {
model: 'gpt-4o',
Expand Down Expand Up @@ -2135,7 +2117,7 @@ describe('AgentBlockHandler', () => {

it('should use DB schema when customToolId resolves', async () => {
const toolId = 'custom-tool-123'
mockFetchForCustomTool(toolId)
mockDBForCustomTool(toolId)

const inputs = {
model: 'gpt-4o',
Expand Down Expand Up @@ -2185,10 +2167,7 @@ describe('AgentBlockHandler', () => {

await handler.execute(mockContext, mockBlock, inputs)

const customToolFetches = mockFetch.mock.calls.filter(
(call: any[]) => typeof call[0] === 'string' && call[0].includes('/api/tools/custom')
)
expect(customToolFetches.length).toBe(0)
expect(mockGetCustomToolById).not.toHaveBeenCalled()

expect(mockExecuteProviderRequest).toHaveBeenCalled()
const providerCall = mockExecuteProviderRequest.mock.calls[0]
Expand Down
40 changes: 10 additions & 30 deletions apps/sim/executor/handlers/agent/agent-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,39 +277,19 @@ export class AgentBlockHandler implements BlockHandler {
ctx: ExecutionContext,
customToolId: string
): Promise<{ schema: any; title: string } | null> {
try {
const headers = await buildAuthHeaders(ctx.userId)
const params: Record<string, string> = {}

if (ctx.workspaceId) {
params.workspaceId = ctx.workspaceId
}
if (ctx.workflowId) {
params.workflowId = ctx.workflowId
}
if (ctx.userId) {
params.userId = ctx.userId
}
if (!ctx.userId) {
logger.error('Cannot fetch custom tool without userId:', { customToolId })
return null
}

const url = buildAPIUrl('/api/tools/custom', params)
const response = await fetch(url.toString(), {
method: 'GET',
headers,
try {
const { getCustomToolById } = await import('@/lib/workflows/custom-tools/operations')
const tool = await getCustomToolById({
toolId: customToolId,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
})

if (!response.ok) {
await response.text().catch(() => {})
logger.error(`Failed to fetch custom tools: ${response.status}`)
return null
}

const data = await response.json()
if (!data.data || !Array.isArray(data.data)) {
logger.error('Invalid custom tools API response')
return null
}

const tool = data.data.find((t: any) => t.id === customToolId)
if (!tool) {
logger.warn(`Custom tool not found by ID: ${customToolId}`)
return null
Expand Down
57 changes: 15 additions & 42 deletions apps/sim/tools/utils.server.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { createLogger } from '@sim/logger'
import { generateInternalToken } from '@/lib/auth/internal'
import {
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { isCustomTool } from '@/executor/constants'
import type { CustomToolDefinition } from '@/hooks/queries/custom-tools'
import { extractErrorMessage } from '@/tools/error-extractors'
Expand Down Expand Up @@ -97,67 +95,42 @@ export async function getToolAsync(
if (builtInTool) return builtInTool

if (isCustomTool(toolId)) {
return fetchCustomToolFromAPI(toolId, context)
return fetchCustomToolFromDB(toolId, context)
}

return undefined
}

async function fetchCustomToolFromAPI(
async function fetchCustomToolFromDB(
customToolId: string,
context: GetToolAsyncContext
): Promise<ToolConfig | undefined> {
const { workflowId, userId, workspaceId } = context
const identifier = customToolId.replace('custom_', '')

try {
const baseUrl = getInternalApiBaseUrl()
const url = new URL('/api/tools/custom', baseUrl)

if (workflowId) {
url.searchParams.append('workflowId', workflowId)
}
if (userId) {
url.searchParams.append('userId', userId)
}
if (workspaceId) {
url.searchParams.append('workspaceId', workspaceId)
}

const headers: Record<string, string> = {}

try {
const internalToken = await generateInternalToken(userId)
headers.Authorization = `Bearer ${internalToken}`
} catch (error) {
logger.warn('Failed to generate internal token for custom tools fetch', { error })
}

const response = await fetch(url.toString(), { headers })

if (!response.ok) {
await response.text().catch(() => {})
logger.error(`Failed to fetch custom tools: ${response.statusText}`)
if (!userId) {
logger.error(`Cannot fetch custom tool without userId: ${identifier}`)
return undefined
}

const result = await response.json()

if (!result.data || !Array.isArray(result.data)) {
logger.error(`Invalid response when fetching custom tools: ${JSON.stringify(result)}`)
return undefined
}
const { getCustomToolById, listCustomTools } = await import(
'@/lib/workflows/custom-tools/operations'
)

const customTool = result.data.find(
(tool: CustomToolDefinition) => tool.id === identifier || tool.title === identifier
) as CustomToolDefinition | undefined
// Try to find by ID first, fall back to searching by title
const customTool =
(await getCustomToolById({ toolId: identifier, userId, workspaceId })) ??
(await listCustomTools({ userId, workspaceId })).find(
(t: { title: string }) => t.title === identifier
)

if (!customTool) {
logger.error(`Custom tool not found: ${identifier}`)
return undefined
}

const toolConfig = createToolConfig(customTool, customToolId)
const toolConfig = createToolConfig(customTool as unknown as CustomToolDefinition, customToolId)

return {
...toolConfig,
Expand All @@ -168,7 +141,7 @@ async function fetchCustomToolFromAPI(
},
}
} catch (error) {
logger.error(`Error fetching custom tool ${identifier} from API:`, error)
logger.error(`Error fetching custom tool ${identifier} from DB:`, error)
return undefined
}
}
Loading