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
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
useActiveSearchTarget,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider'
import { getAllBlocks, getBlock } from '@/blocks'
import { isCustomBlockType } from '@/blocks/custom/build-config'
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
import { getTileIconColorClass } from '@/blocks/icon-color'
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
Expand Down Expand Up @@ -761,7 +762,9 @@ export const ToolInput = memo(function ToolInput({
if (hasMultipleOperations(blockType)) {
return false
}
if (blockType === 'workflow' || blockType === 'knowledge') {
// Custom blocks all share toolId `workflow_executor`, so dedup-by-toolId would
// block a second (distinct) custom block — allow multiple like workflow/knowledge.
if (blockType === 'workflow' || blockType === 'knowledge' || isCustomBlockType(blockType)) {
return false
}
return selectedTools.some((tool) => tool.toolId === toolId)
Expand Down Expand Up @@ -799,12 +802,12 @@ export const ToolInput = memo(function ToolInput({
const operationOptions = hasOperations ? getOperationOptions(toolBlock.type) : []
const defaultOperation = operationOptions.length > 0 ? operationOptions[0].id : undefined

const toolId = getToolIdForOperation(toolBlock.type, defaultOperation)
const toolId = getToolIdForOperation(toolBlock.type, defaultOperation, toolBlock)
if (!toolId) return

if (isToolAlreadySelected(toolId, toolBlock.type)) return

const toolParams = getToolParametersConfig(toolId, toolBlock.type)
const toolParams = getToolParametersConfig(toolId, toolBlock.type, undefined, toolBlock)
if (!toolParams) return

const initialParams: Record<string, string> = {}
Expand Down Expand Up @@ -1002,7 +1005,7 @@ export const ToolInput = memo(function ToolInput({

const tool = selectedTools[toolIndex]

const newToolId = getToolIdForOperation(tool.type, operation)
const newToolId = getToolIdForOperation(tool.type, operation, getBlock(tool.type))

if (!newToolId) {
return
Expand Down Expand Up @@ -1589,7 +1592,7 @@ export const ToolInput = memo(function ToolInput({
groups.push({
section: 'Built-in Tools',
items: builtInTools.map((block) => {
const toolId = getToolIdForOperation(block.type, undefined)
const toolId = getToolIdForOperation(block.type, undefined, block)
const alreadySelected = toolId ? isToolAlreadySelected(toolId, block.type) : false
return {
label: block.name,
Expand All @@ -1606,7 +1609,7 @@ export const ToolInput = memo(function ToolInput({
groups.push({
section: 'Integrations',
items: integrations.map((block) => {
const toolId = getToolIdForOperation(block.type, undefined)
const toolId = getToolIdForOperation(block.type, undefined, block)
const alreadySelected = toolId ? isToolAlreadySelected(toolId, block.type) : false
return {
label: block.name,
Expand Down Expand Up @@ -1705,15 +1708,22 @@ export const ToolInput = memo(function ToolInput({

const currentToolId =
!isCustomTool && !isMcpTool
? getToolIdForOperation(tool.type, tool.operation) || tool.toolId || ''
? getToolIdForOperation(tool.type, tool.operation, toolBlock ?? undefined) ||
tool.toolId ||
''
: tool.toolId || ''

const toolParams =
!isCustomTool && !isMcpTool && currentToolId
? getToolParametersConfig(currentToolId, tool.type, {
operation: tool.operation,
...tool.params,
})
? getToolParametersConfig(
currentToolId,
tool.type,
{
operation: tool.operation,
...tool.params,
},
toolBlock ?? undefined
)
: null

const toolScopedOverrides = scopeCanonicalModesForTool(
Expand All @@ -1731,7 +1741,8 @@ export const ToolInput = memo(function ToolInput({
operation: tool.operation,
...tool.params,
},
toolScopedOverrides
toolScopedOverrides,
toolBlock ?? undefined
)
: null

Expand Down
26 changes: 17 additions & 9 deletions apps/sim/blocks/custom/build-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,22 @@ export function isReservedOutputName(name: string): boolean {
return RESERVED_OUTPUT_NAMES.has(name.trim().toLowerCase())
}

/**
* Collect a custom block's per-field param values into the child `inputMapping`
* JSON string: every non-reserved, non-empty param keyed by the source field's
* stable id. Shared by the hidden `inputMapping` sub-block (canvas serialization)
* and the agent-tool transform, so both paths assemble the mapping identically.
*/
export function assembleCustomBlockInputMapping(params: Record<string, unknown>): string {
const mapping: Record<string, unknown> = {}
for (const [key, val] of Object.entries(params)) {
if (RESERVED_PARAMS.has(key)) continue
if (val === undefined || val === '') continue
mapping[key] = val
}
return JSON.stringify(mapping)
}

/** Map a Start input field type to the editor sub-block type used to collect it. */
function subBlockTypeForField(fieldType: string): SubBlockType {
switch (fieldType) {
Expand Down Expand Up @@ -161,15 +177,7 @@ export function buildCustomBlockConfig(
type: 'code',
language: 'json',
hidden: true,
value: (params) => {
const mapping: Record<string, unknown> = {}
for (const [key, val] of Object.entries(params)) {
if (RESERVED_PARAMS.has(key)) continue
if (val === undefined || val === '') continue
mapping[key] = val
}
return JSON.stringify(mapping)
},
value: (params) => assembleCustomBlockInputMapping(params),
},
...fieldSubBlocks,
],
Expand Down
3 changes: 3 additions & 0 deletions apps/sim/executor/handlers/agent/agent-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { normalizeStringRecord, normalizeWorkflowVariables } from '@/lib/core/ut
import { createMcpToolId } from '@/lib/mcp/utils'
import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils'
import { hydrateUserFilesWithBase64 } from '@/lib/uploads/utils/user-file-base64.server'
import { resolveCustomBlockToolBinding } from '@/lib/workflows/custom-blocks/operations'
import { getCustomToolById } from '@/lib/workflows/custom-tools/operations'
import { getAllBlocks } from '@/blocks'
import type { BlockOutput } from '@/blocks/types'
Expand Down Expand Up @@ -581,6 +582,8 @@ export class AgentBlockHandler implements BlockHandler {
getTool,
canonicalModes,
toolIndex,
resolveCustomBlockBinding: (blockType: string) =>
resolveCustomBlockToolBinding(blockType, ctx.workspaceId),
})

if (transformedTool) {
Expand Down
11 changes: 9 additions & 2 deletions apps/sim/executor/handlers/pi/sim-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@

import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { resolveCustomBlockToolBinding } from '@/lib/workflows/custom-blocks/operations'
import { getAllBlocks } from '@/blocks/registry'
import type { ToolInput } from '@/executor/handlers/agent/types'
import type { PiToolResult, PiToolSpec } from '@/executor/handlers/pi/backend'
import type { ExecutionContext } from '@/executor/types'
import { transformBlockTool } from '@/providers/utils'
import { executeTool } from '@/tools'
import { mergeToolParameters } from '@/tools/params'
import type { ToolResponse } from '@/tools/types'
import { getTool } from '@/tools/utils'
import { getToolAsync } from '@/tools/utils.server'
Expand Down Expand Up @@ -52,6 +54,8 @@ export async function buildSimToolSpecs(
getAllBlocks,
getTool,
getToolAsync,
resolveCustomBlockBinding: (blockType: string) =>
resolveCustomBlockToolBinding(blockType, ctx.workspaceId),
Comment thread
TheodoreSpeaks marked this conversation as resolved.
})

if (!provider?.id) continue
Expand All @@ -71,8 +75,11 @@ export async function buildSimToolSpecs(
const result = await executeTool(
toolId,
{
...preseededParams,
...args,
// Same merge the Agent block's tool calls use: user-preseeded values
// win over LLM args, and `inputMapping` is deep-merged rather than
// replaced — a partial mapping from the model must not drop the
// user-filled fields baked onto the block.
...mergeToolParameters(preseededParams, args as Record<string, unknown>),
// Trusted execution context, spread last so an LLM-supplied
// `_context` arg can't override it. executeTool reads this directly
// for OAuth-credential resolution and internal-route identity, the
Expand Down
110 changes: 110 additions & 0 deletions apps/sim/executor/handlers/workflow/custom-block-tool-runner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockExecute } = vi.hoisted(() => ({ mockExecute: vi.fn() }))

vi.mock('@/executor/handlers/workflow/workflow-handler', () => ({
WorkflowBlockHandler: class {
execute = mockExecute
},
aggregateChildCost: (spans: Array<{ cost?: { total?: number } }>) =>
spans.reduce((sum, span) => sum + (span?.cost?.total ?? 0), 0),
}))

import { ChildWorkflowError } from '@/executor/errors/child-workflow-error'
import {
buildCustomBlockExecutionContext,
runCustomBlockTool,
} from '@/executor/handlers/workflow/custom-block-tool-runner'

describe('buildCustomBlockExecutionContext', () => {
it('carries consumer identity, inherits the call chain, and is fully scaffolded', () => {
const ctx = buildCustomBlockExecutionContext({
workspaceId: 'ws-consumer',
userId: 'u-consumer',
workflowId: 'wf-parent',
callChain: ['wf-parent'],
billingAttribution: { actorUserId: 'u-consumer', workspaceId: 'ws-consumer' } as any,
})

expect(ctx.workspaceId).toBe('ws-consumer')
expect(ctx.userId).toBe('u-consumer')
// Inherited (not reset) so the handler's depth guard keeps bounding recursion.
expect(ctx.callChain).toEqual(['wf-parent'])
// metadata must be a real object — the handler reads it unconditionally.
expect(ctx.metadata).toBeTypeOf('object')
expect(ctx.metadata.billingAttribution).toEqual({
actorUserId: 'u-consumer',
workspaceId: 'ws-consumer',
})
expect(ctx.metadata.executionMode).toBe('sync')
// Non-optional scaffolding present.
expect(ctx.blockStates).toBeInstanceOf(Map)
expect(ctx.executedBlocks).toBeInstanceOf(Set)
expect(ctx.completedLoops).toBeInstanceOf(Set)
expect(ctx.activeExecutionPath).toBeInstanceOf(Set)
expect(ctx.decisions.router).toBeInstanceOf(Map)
expect(ctx.decisions.condition).toBeInstanceOf(Map)
expect(Array.isArray(ctx.blockLogs)).toBe(true)
expect(ctx.executionId).toBeTruthy()
})

it('defaults the call chain to [] when none is provided', () => {
expect(buildCustomBlockExecutionContext({}).callChain).toEqual([])
})
})

describe('runCustomBlockTool', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('runs the handler with the synthetic ctx and returns its projected output', async () => {
mockExecute.mockResolvedValue({ success: true, result: { answer: 'hi' }, cost: { total: 0.5 } })

const res = await runCustomBlockTool({
blockType: 'custom_block_abc',
inputMapping: '{"field-question":"hi"}',
_context: { workspaceId: 'ws-consumer', userId: 'u-consumer' },
})

expect(res.success).toBe(true)
expect(res.output.cost).toEqual({ total: 0.5 })

const [ctxArg, blockArg, inputsArg] = mockExecute.mock.calls[0]
expect(ctxArg.workspaceId).toBe('ws-consumer')
expect(blockArg.metadata.id).toBe('custom_block_abc')
expect(inputsArg).toEqual({ inputMapping: '{"field-question":"hi"}' })
})

it('surfaces a handler failure as a clean tool error', async () => {
mockExecute.mockRejectedValue(new Error('This block’s workflow is not deployed.'))

const res = await runCustomBlockTool({ blockType: 'custom_block_abc', _context: {} })

expect(res.success).toBe(false)
expect(res.error).toContain('not deployed')
})

it('rolls up already-incurred child cost when the run fails', async () => {
const err: any = new Error('child blew up')
err.name = 'ChildWorkflowError'
err.childTraceSpans = [{ id: 's1', name: 'child', type: 'agent', cost: { total: 0.25 } }]
Object.setPrototypeOf(err, ChildWorkflowError.prototype)
mockExecute.mockRejectedValue(err)

const res = await runCustomBlockTool({ blockType: 'custom_block_abc', _context: {} })

expect(res.success).toBe(false)
// Partial spend must not be recorded as zero-cost.
expect((res.output as any).cost.total).toBeGreaterThan(0)
})

it('rejects a missing block type without invoking the handler', async () => {
const res = await runCustomBlockTool({ _context: {} })
expect(res.success).toBe(false)
expect(mockExecute).not.toHaveBeenCalled()
})
})
Loading
Loading