Skip to content

Commit 09a0f5a

Browse files
committed
fix(agent): always fetch latest custom tool from DB when customToolId is present
1 parent 81dfeb0 commit 09a0f5a

File tree

2 files changed

+318
-5
lines changed

2 files changed

+318
-5
lines changed

apps/sim/executor/handlers/agent/agent-handler.test.ts

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1901,5 +1901,317 @@ describe('AgentBlockHandler', () => {
19011901

19021902
expect(discoveryCalls[0].url).toContain('serverId=mcp-legacy-server')
19031903
})
1904+
1905+
describe('customToolId resolution - DB as source of truth', () => {
1906+
const staleInlineSchema = {
1907+
function: {
1908+
name: 'buttonTemplate',
1909+
description: 'Creates a button template',
1910+
parameters: {
1911+
type: 'object',
1912+
properties: {
1913+
sender_id: { type: 'string', description: 'Sender ID' },
1914+
header_value: { type: 'string', description: 'Header text' },
1915+
body_value: { type: 'string', description: 'Body text' },
1916+
button_array: {
1917+
type: 'array',
1918+
items: { type: 'string' },
1919+
description: 'Button labels',
1920+
},
1921+
},
1922+
required: ['sender_id', 'header_value', 'body_value', 'button_array'],
1923+
},
1924+
},
1925+
}
1926+
1927+
const dbSchema = {
1928+
function: {
1929+
name: 'buttonTemplate',
1930+
description: 'Creates a button template',
1931+
parameters: {
1932+
type: 'object',
1933+
properties: {
1934+
sender_id: { type: 'string', description: 'Sender ID' },
1935+
header_value: { type: 'string', description: 'Header text' },
1936+
body_value: { type: 'string', description: 'Body text' },
1937+
button_array: {
1938+
type: 'array',
1939+
items: { type: 'string' },
1940+
description: 'Button labels',
1941+
},
1942+
channel: { type: 'string', description: 'Channel name' },
1943+
},
1944+
required: ['sender_id', 'header_value', 'body_value', 'button_array', 'channel'],
1945+
},
1946+
},
1947+
}
1948+
1949+
const staleInlineCode =
1950+
'return JSON.stringify({ type: "button", phone: sender_id, header: header_value, body: body_value, buttons: button_array });'
1951+
const dbCode =
1952+
'if (channel === "whatsapp") { return JSON.stringify({ type: "button", phone: sender_id, header: header_value, body: body_value, buttons: button_array }); }'
1953+
1954+
function mockFetchForCustomTool(toolId: string) {
1955+
mockFetch.mockImplementation((url: string) => {
1956+
if (typeof url === 'string' && url.includes('/api/tools/custom')) {
1957+
return Promise.resolve({
1958+
ok: true,
1959+
headers: { get: () => null },
1960+
json: () =>
1961+
Promise.resolve({
1962+
data: [
1963+
{
1964+
id: toolId,
1965+
title: 'buttonTemplate',
1966+
schema: dbSchema,
1967+
code: dbCode,
1968+
},
1969+
],
1970+
}),
1971+
})
1972+
}
1973+
return Promise.resolve({
1974+
ok: true,
1975+
headers: { get: () => null },
1976+
json: () => Promise.resolve({}),
1977+
})
1978+
})
1979+
}
1980+
1981+
function mockFetchFailure() {
1982+
mockFetch.mockImplementation((url: string) => {
1983+
if (typeof url === 'string' && url.includes('/api/tools/custom')) {
1984+
return Promise.resolve({
1985+
ok: false,
1986+
status: 500,
1987+
headers: { get: () => null },
1988+
json: () => Promise.resolve({}),
1989+
})
1990+
}
1991+
return Promise.resolve({
1992+
ok: true,
1993+
headers: { get: () => null },
1994+
json: () => Promise.resolve({}),
1995+
})
1996+
})
1997+
}
1998+
1999+
beforeEach(() => {
2000+
Object.defineProperty(global, 'window', {
2001+
value: undefined,
2002+
writable: true,
2003+
configurable: true,
2004+
})
2005+
})
2006+
2007+
it('should always fetch latest schema from DB when customToolId is present', async () => {
2008+
const toolId = 'custom-tool-123'
2009+
mockFetchForCustomTool(toolId)
2010+
2011+
const inputs = {
2012+
model: 'gpt-4o',
2013+
userPrompt: 'Send a button template',
2014+
apiKey: 'test-api-key',
2015+
tools: [
2016+
{
2017+
type: 'custom-tool',
2018+
customToolId: toolId,
2019+
title: 'buttonTemplate',
2020+
schema: staleInlineSchema,
2021+
code: staleInlineCode,
2022+
usageControl: 'auto' as const,
2023+
},
2024+
],
2025+
}
2026+
2027+
mockGetProviderFromModel.mockReturnValue('openai')
2028+
2029+
await handler.execute(mockContext, mockBlock, inputs)
2030+
2031+
expect(mockExecuteProviderRequest).toHaveBeenCalled()
2032+
const providerCall = mockExecuteProviderRequest.mock.calls[0]
2033+
const tools = providerCall[1].tools
2034+
2035+
expect(tools.length).toBe(1)
2036+
// DB schema wins over stale inline — includes channel param
2037+
expect(tools[0].parameters.required).toContain('channel')
2038+
expect(tools[0].parameters.properties).toHaveProperty('channel')
2039+
})
2040+
2041+
it('should fetch from DB when customToolId has no inline schema', async () => {
2042+
const toolId = 'custom-tool-123'
2043+
mockFetchForCustomTool(toolId)
2044+
2045+
const inputs = {
2046+
model: 'gpt-4o',
2047+
userPrompt: 'Send a button template',
2048+
apiKey: 'test-api-key',
2049+
tools: [
2050+
{
2051+
type: 'custom-tool',
2052+
customToolId: toolId,
2053+
usageControl: 'auto' as const,
2054+
},
2055+
],
2056+
}
2057+
2058+
mockGetProviderFromModel.mockReturnValue('openai')
2059+
2060+
await handler.execute(mockContext, mockBlock, inputs)
2061+
2062+
expect(mockExecuteProviderRequest).toHaveBeenCalled()
2063+
const providerCall = mockExecuteProviderRequest.mock.calls[0]
2064+
const tools = providerCall[1].tools
2065+
2066+
expect(tools.length).toBe(1)
2067+
expect(tools[0].name).toBe('buttonTemplate')
2068+
expect(tools[0].parameters.required).toContain('channel')
2069+
})
2070+
2071+
it('should fall back to inline schema when DB fetch fails and inline exists', async () => {
2072+
mockFetchFailure()
2073+
2074+
const inputs = {
2075+
model: 'gpt-4o',
2076+
userPrompt: 'Send a button template',
2077+
apiKey: 'test-api-key',
2078+
tools: [
2079+
{
2080+
type: 'custom-tool',
2081+
customToolId: 'custom-tool-123',
2082+
title: 'buttonTemplate',
2083+
schema: staleInlineSchema,
2084+
code: staleInlineCode,
2085+
usageControl: 'auto' as const,
2086+
},
2087+
],
2088+
}
2089+
2090+
mockGetProviderFromModel.mockReturnValue('openai')
2091+
2092+
await handler.execute(mockContext, mockBlock, inputs)
2093+
2094+
expect(mockExecuteProviderRequest).toHaveBeenCalled()
2095+
const providerCall = mockExecuteProviderRequest.mock.calls[0]
2096+
const tools = providerCall[1].tools
2097+
2098+
// Falls back to inline schema (4 params, no channel)
2099+
expect(tools.length).toBe(1)
2100+
expect(tools[0].name).toBe('buttonTemplate')
2101+
expect(tools[0].parameters.required).not.toContain('channel')
2102+
})
2103+
2104+
it('should return null when DB fetch fails and no inline schema exists', async () => {
2105+
mockFetchFailure()
2106+
2107+
const inputs = {
2108+
model: 'gpt-4o',
2109+
userPrompt: 'Send a button template',
2110+
apiKey: 'test-api-key',
2111+
tools: [
2112+
{
2113+
type: 'custom-tool',
2114+
customToolId: 'custom-tool-123',
2115+
usageControl: 'auto' as const,
2116+
},
2117+
],
2118+
}
2119+
2120+
mockGetProviderFromModel.mockReturnValue('openai')
2121+
2122+
await handler.execute(mockContext, mockBlock, inputs)
2123+
2124+
expect(mockExecuteProviderRequest).toHaveBeenCalled()
2125+
const providerCall = mockExecuteProviderRequest.mock.calls[0]
2126+
const tools = providerCall[1].tools
2127+
2128+
expect(tools.length).toBe(0)
2129+
})
2130+
2131+
it('should use DB code for executeFunction when customToolId resolves', async () => {
2132+
const toolId = 'custom-tool-123'
2133+
mockFetchForCustomTool(toolId)
2134+
2135+
let capturedTools: any[] = []
2136+
Promise.all = vi.fn().mockImplementation((promises: Promise<any>[]) => {
2137+
const result = originalPromiseAll.call(Promise, promises)
2138+
result.then((tools: any[]) => {
2139+
if (tools?.length) {
2140+
capturedTools = tools.filter((t) => t !== null)
2141+
}
2142+
})
2143+
return result
2144+
})
2145+
2146+
const inputs = {
2147+
model: 'gpt-4o',
2148+
userPrompt: 'Send a button template',
2149+
apiKey: 'test-api-key',
2150+
tools: [
2151+
{
2152+
type: 'custom-tool',
2153+
customToolId: toolId,
2154+
title: 'buttonTemplate',
2155+
schema: staleInlineSchema,
2156+
code: staleInlineCode,
2157+
usageControl: 'auto' as const,
2158+
},
2159+
],
2160+
}
2161+
2162+
mockGetProviderFromModel.mockReturnValue('openai')
2163+
2164+
await handler.execute(mockContext, mockBlock, inputs)
2165+
2166+
expect(capturedTools.length).toBe(1)
2167+
expect(typeof capturedTools[0].executeFunction).toBe('function')
2168+
2169+
await capturedTools[0].executeFunction({ sender_id: '123', channel: 'whatsapp' })
2170+
2171+
// Should use DB code, not stale inline code
2172+
expect(mockExecuteTool).toHaveBeenCalledWith(
2173+
'function_execute',
2174+
expect.objectContaining({
2175+
code: dbCode,
2176+
}),
2177+
false,
2178+
expect.any(Object)
2179+
)
2180+
})
2181+
2182+
it('should not fetch from DB when no customToolId is present', async () => {
2183+
const inputs = {
2184+
model: 'gpt-4o',
2185+
userPrompt: 'Use the tool',
2186+
apiKey: 'test-api-key',
2187+
tools: [
2188+
{
2189+
type: 'custom-tool',
2190+
title: 'inlineTool',
2191+
schema: staleInlineSchema,
2192+
code: staleInlineCode,
2193+
usageControl: 'auto' as const,
2194+
},
2195+
],
2196+
}
2197+
2198+
mockGetProviderFromModel.mockReturnValue('openai')
2199+
2200+
await handler.execute(mockContext, mockBlock, inputs)
2201+
2202+
const customToolFetches = mockFetch.mock.calls.filter(
2203+
(call: any[]) => typeof call[0] === 'string' && call[0].includes('/api/tools/custom')
2204+
)
2205+
expect(customToolFetches.length).toBe(0)
2206+
2207+
expect(mockExecuteProviderRequest).toHaveBeenCalled()
2208+
const providerCall = mockExecuteProviderRequest.mock.calls[0]
2209+
const tools = providerCall[1].tools
2210+
2211+
expect(tools.length).toBe(1)
2212+
expect(tools[0].name).toBe('buttonTemplate')
2213+
expect(tools[0].parameters.required).not.toContain('channel')
2214+
})
2215+
})
19042216
})
19052217
})

apps/sim/executor/handlers/agent/agent-handler.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -272,15 +272,16 @@ export class AgentBlockHandler implements BlockHandler {
272272
let code = tool.code
273273
let title = tool.title
274274

275-
if (tool.customToolId && !schema) {
275+
if (tool.customToolId) {
276276
const resolved = await this.fetchCustomToolById(ctx, tool.customToolId)
277-
if (!resolved) {
277+
if (resolved) {
278+
schema = resolved.schema
279+
code = resolved.code
280+
title = resolved.title
281+
} else if (!schema) {
278282
logger.error(`Custom tool not found: ${tool.customToolId}`)
279283
return null
280284
}
281-
schema = resolved.schema
282-
code = resolved.code
283-
title = resolved.title
284285
}
285286

286287
if (!schema?.function) {

0 commit comments

Comments
 (0)