From 8a3644c3663fe0846c0d9ce68f163baf96084ae9 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 21 Jul 2026 12:53:30 -0700 Subject: [PATCH 1/3] fix(custom-blocks): render and execute custom blocks as agent tools --- .../components/tool-input/tool-input.tsx | 35 ++++-- apps/sim/blocks/custom/build-config.ts | 26 ++-- .../executor/handlers/agent/agent-handler.ts | 3 + apps/sim/executor/handlers/pi/sim-tools.ts | 3 + .../workflow/custom-block-tool-runner.test.ts | 93 ++++++++++++++ .../workflow/custom-block-tool-runner.ts | 119 ++++++++++++++++++ .../lib/workflows/custom-blocks/operations.ts | 32 +++++ apps/sim/providers/custom-block-tool.test.ts | 75 +++++++++++ apps/sim/providers/utils.ts | 101 +++++++++++++++ apps/sim/tools/index.ts | 22 ++++ apps/sim/tools/normalize.ts | 10 ++ apps/sim/tools/params.test.ts | 56 +++++++++ apps/sim/tools/params.ts | 81 +++++++++++- apps/sim/tools/registry.minimal.ts | 5 + apps/sim/tools/registry.ts | 3 +- .../tools/workflow/custom-block-executor.ts | 42 +++++++ apps/sim/tools/workflow/index.ts | 1 + 17 files changed, 682 insertions(+), 25 deletions(-) create mode 100644 apps/sim/executor/handlers/workflow/custom-block-tool-runner.test.ts create mode 100644 apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts create mode 100644 apps/sim/providers/custom-block-tool.test.ts create mode 100644 apps/sim/tools/workflow/custom-block-executor.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index a64d37b25c7..a4a937a5e27 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -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' @@ -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) @@ -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 = {} @@ -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 @@ -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, @@ -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, @@ -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( @@ -1731,7 +1741,8 @@ export const ToolInput = memo(function ToolInput({ operation: tool.operation, ...tool.params, }, - toolScopedOverrides + toolScopedOverrides, + toolBlock ?? undefined ) : null diff --git a/apps/sim/blocks/custom/build-config.ts b/apps/sim/blocks/custom/build-config.ts index e8f02973d5e..5ff63f41fce 100644 --- a/apps/sim/blocks/custom/build-config.ts +++ b/apps/sim/blocks/custom/build-config.ts @@ -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 { + const mapping: Record = {} + 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) { @@ -161,15 +177,7 @@ export function buildCustomBlockConfig( type: 'code', language: 'json', hidden: true, - value: (params) => { - const mapping: Record = {} - 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, ], diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index 81018c20bf1..a09d70e3f31 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -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' @@ -581,6 +582,8 @@ export class AgentBlockHandler implements BlockHandler { getTool, canonicalModes, toolIndex, + resolveCustomBlockBinding: (blockType: string) => + resolveCustomBlockToolBinding(blockType, ctx.workspaceId), }) if (transformedTool) { diff --git a/apps/sim/executor/handlers/pi/sim-tools.ts b/apps/sim/executor/handlers/pi/sim-tools.ts index 0fb6a3b632e..80d0153bcbc 100644 --- a/apps/sim/executor/handlers/pi/sim-tools.ts +++ b/apps/sim/executor/handlers/pi/sim-tools.ts @@ -9,6 +9,7 @@ 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' @@ -52,6 +53,8 @@ export async function buildSimToolSpecs( getAllBlocks, getTool, getToolAsync, + resolveCustomBlockBinding: (blockType: string) => + resolveCustomBlockToolBinding(blockType, ctx.workspaceId), }) if (!provider?.id) continue diff --git a/apps/sim/executor/handlers/workflow/custom-block-tool-runner.test.ts b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.test.ts new file mode 100644 index 00000000000..ef8086b85ee --- /dev/null +++ b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.test.ts @@ -0,0 +1,93 @@ +/** + * @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 + }, +})) + +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('rejects a missing block type without invoking the handler', async () => { + const res = await runCustomBlockTool({ _context: {} }) + expect(res.success).toBe(false) + expect(mockExecute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts new file mode 100644 index 00000000000..c733013522d --- /dev/null +++ b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts @@ -0,0 +1,119 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler' +import type { ExecutionContext } from '@/executor/types' +import type { SerializedBlock } from '@/serializer/types' +import type { ToolResponse } from '@/tools/types' + +const logger = createLogger('CustomBlockToolRunner') + +/** Server-set execution context propagated to every agent tool call. */ +interface CustomBlockExecutorContext { + workspaceId?: string + userId?: string + workflowId?: string + callChain?: string[] + isDeployedContext?: boolean + billingAttribution?: BillingAttributionSnapshot +} + +interface CustomBlockToolParams { + /** The `custom_block_*` type to run — authority is re-resolved from it server-side. */ + blockType?: string + /** Input values keyed by the source field's stable id (assembled + LLM-filled). */ + inputMapping?: Record | string + _context?: CustomBlockExecutorContext +} + +/** + * Build a minimal top-level `ExecutionContext` for running a custom block as an + * agent tool. Every value comes from the server-set `_context` (LLM-proof) plus a + * fresh executionId. `WorkflowBlockHandler`'s custom-block path re-derives owner + * identity, env, and billing from `getCustomBlockAuthority`, so this only needs the + * fields that path reads — `workspaceId` (org-scopes the authority lookup), + * `metadata` (read unconditionally at `executeCore`), and `callChain` (recursion + * depth guard, inherited so it never resets across hops) — plus the non-optional + * scaffolding. Keep in sync with `WorkflowBlockHandler.executeCore`'s custom branch. + */ +export function buildCustomBlockExecutionContext( + context: CustomBlockExecutorContext +): ExecutionContext { + const executionId = generateId() + return { + workflowId: context.workflowId ?? 'custom-block-tool', + workspaceId: context.workspaceId, + userId: context.userId, + executionId, + isDeployedContext: context.isDeployedContext, + // Inherit the accumulated chain so the handler appends + validates depth; + // resetting to [] would let a self-referential custom block recurse unbounded. + callChain: context.callChain ?? [], + environmentVariables: {}, + blockStates: new Map(), + executedBlocks: new Set(), + blockLogs: [], + decisions: { router: new Map(), condition: new Map() }, + completedLoops: new Set(), + activeExecutionPath: new Set(), + // `WorkflowBlockHandler` reads only `billingAttribution` + `executionMode` on the + // custom-block path; `duration` is the sole required field on the metadata type. + metadata: { + duration: 0, + requestId: generateId(), + executionId, + workflowId: context.workflowId, + workspaceId: context.workspaceId, + userId: context.userId, + billingAttribution: context.billingAttribution, + executionMode: 'sync', + }, + } +} + +/** + * Runs a published custom block (deploy-as-block) as an Agent tool, in-process via + * `WorkflowBlockHandler` — the same invocation boundary the canvas uses — so + * authority (org-scoped owner identity, latest deployment, curated outputs, + * required-input enforcement, cost roll-up) is resolved server-side from the block + * type. No HTTP hop and no body-field trust: the block type + consumer workspace + * come from the server-set `_context`, not the model. + * + * Lives in a server-only module (dynamic-imported by `executeTool`) so the + * client-bundled tool registry never pulls in the executor/db dependency graph. + */ +export async function runCustomBlockTool(params: CustomBlockToolParams): Promise { + if (!params.blockType) { + return { success: false, output: {}, error: 'Missing custom block type' } + } + + const ctx = buildCustomBlockExecutionContext(params._context ?? {}) + const block: SerializedBlock = { + id: generateId(), + position: { x: 0, y: 0 }, + config: { tool: 'workflow_executor', params: {} }, + inputs: {}, + outputs: {}, + metadata: { id: params.blockType }, + enabled: true, + } + + try { + const output = await new WorkflowBlockHandler().execute(ctx, block, { + inputMapping: params.inputMapping, + }) + // Custom blocks never stream (no `onStream` on the synthetic ctx), so the + // handler always returns the projected BlockOutput object (with `cost.total`). + const normalized: Record = + output && typeof output === 'object' && !Array.isArray(output) ? output : { result: output } + return { success: true, output: normalized } + } catch (error) { + // The handler throws a consumer-safe `ChildWorkflowError` on failure. Partial + // child cost rides its trace spans, but the provider tool loop only bills cost + // from successful results, so the error message alone is surfaced here. + const message = getErrorMessage(error, 'Custom block execution failed') + logger.info('Custom block tool execution failed', { blockType: params.blockType, message }) + return { success: false, output: {}, error: message } + } +} diff --git a/apps/sim/lib/workflows/custom-blocks/operations.ts b/apps/sim/lib/workflows/custom-blocks/operations.ts index ad3ff18bd1a..975b479dcba 100644 --- a/apps/sim/lib/workflows/custom-blocks/operations.ts +++ b/apps/sim/lib/workflows/custom-blocks/operations.ts @@ -355,6 +355,38 @@ export async function getCustomBlockAuthority( } } +/** A custom block's agent-tool binding: bound workflow + its input schema surface. */ +export interface CustomBlockToolBinding { + workflowId: string + /** LATEST-deployment Start input fields — the exact inputs the child will accept. */ + inputFields: WorkflowInputField[] + /** Field ids (form keys) the publisher marked required. */ + requiredInputIds: string[] +} + +/** + * Resolve a custom block's agent-tool binding so an Agent can offer it as a tool: + * the authoritative bound workflow (org-scoped to the consumer's workspace, + * owner-derived — the same trust model execution uses via `getCustomBlockAuthority`) + * plus its LATEST-deployment Start input fields and the publisher's required-input + * ids. Returns `null` when the type doesn't resolve for the consumer's org + * (foreign-org, disabled, or missing) so the caller simply omits the tool. Fields + * are keyed by their stable id, matching `assembleCustomBlockInputMapping`. + */ +export async function resolveCustomBlockToolBinding( + type: string, + consumerWorkspaceId: string | undefined +): Promise { + const authority = await getCustomBlockAuthority(type, consumerWorkspaceId) + if (!authority) return null + const inputFields = await deriveInputFields(authority.workflowId) + return { + workflowId: authority.workflowId, + inputFields, + requiredInputIds: authority.requiredInputIds, + } +} + export class CustomBlockValidationError extends Error { constructor(message: string) { super(message) diff --git a/apps/sim/providers/custom-block-tool.test.ts b/apps/sim/providers/custom-block-tool.test.ts new file mode 100644 index 00000000000..4f20474e6b7 --- /dev/null +++ b/apps/sim/providers/custom-block-tool.test.ts @@ -0,0 +1,75 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { transformBlockTool } from '@/providers/utils' + +const mockResolve = vi.fn() + +const options = { + getAllBlocks: () => [ + { + type: 'custom_block_test', + name: 'The Elder', + description: 'Ask the elder', + tools: { access: ['workflow_executor'] }, + subBlocks: [], + }, + ], + getTool: (id: string) => + id === 'custom_block_executor' + ? { id: 'custom_block_executor', description: 'exec' } + : undefined, + resolveCustomBlockBinding: mockResolve, +} + +describe('transformBlockTool — custom blocks', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('builds an id-keyed custom_block_executor tool and omits file[] fields', async () => { + mockResolve.mockResolvedValue({ + workflowId: 'wf-src', + inputFields: [ + { id: 'q', name: 'Question', type: 'string', required: true }, + { id: 'f', name: 'Files', type: 'file[]' }, + ], + requiredInputIds: ['q'], + }) + + const tool = await transformBlockTool( + { type: 'custom_block_test', params: { q: 'hi' } }, + options + ) + + expect(tool).not.toBeNull() + // Unique per block, name/description from the block (never the source workflow). + expect(tool!.id).toBe('custom_block_executor_custom_block_test') + expect(tool!.name).toBe('The Elder') + // Baked params: block type + assembled (id-keyed) input mapping. + expect(tool!.params.blockType).toBe('custom_block_test') + expect(tool!.params.inputMapping).toBe('{"q":"hi"}') + // LLM schema: inputMapping object keyed by field id, file[] omitted, required honored. + const inputMapping = tool!.parameters.properties.inputMapping + expect(Object.keys(inputMapping.properties)).toEqual(['q']) + expect(inputMapping.required).toEqual(['q']) + expect(tool!.parameters.required).toEqual(['inputMapping']) + + expect(mockResolve).toHaveBeenCalledWith('custom_block_test') + }) + + it('returns null (tool not offered) when the binding cannot be resolved', async () => { + mockResolve.mockResolvedValue(null) + const tool = await transformBlockTool({ type: 'custom_block_test', params: {} }, options) + expect(tool).toBeNull() + }) + + it('returns null when no resolver is injected (non-server callers)', async () => { + const tool = await transformBlockTool( + { type: 'custom_block_test', params: {} }, + { ...options, resolveCustomBlockBinding: undefined } + ) + expect(tool).toBeNull() + }) +}) diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 641423746e0..71c6ff213fa 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -13,6 +13,8 @@ import { normalizeStringRecord, normalizeWorkflowVariables, } from '@/lib/core/utils/records' +import type { CustomBlockToolBinding } from '@/lib/workflows/custom-blocks/operations' +import { isFileFieldType, type WorkflowInputField } from '@/lib/workflows/input-format' import { buildCanonicalIndex, type CanonicalGroup, @@ -21,6 +23,7 @@ import { resolveActiveCanonicalValue, scopeCanonicalModesForTool, } from '@/lib/workflows/subblocks/visibility' +import { assembleCustomBlockInputMapping, isCustomBlockType } from '@/blocks/custom/build-config' import { isCustomTool } from '@/executor/constants' import { getComputerUseModels, @@ -518,6 +521,60 @@ function resolveCanonicalResourceParams( return resolved } +/** JSON-schema type for a workflow input field (LLM tool schema). */ +function inputFieldSchemaType(fieldType: string): string { + switch (fieldType) { + case 'number': + return 'number' + case 'boolean': + return 'boolean' + case 'object': + return 'object' + case 'array': + return 'array' + default: + return 'string' + } +} + +/** + * Build the LLM tool schema for a custom block used as an agent tool: a single + * `inputMapping` object whose properties are the block's deployed input fields, + * keyed by the field's stable id (so it lines up with `assembleCustomBlockInputMapping` + * and the child's id→name remap) and marked required per the publisher's overrides. + * `file[]` fields are omitted — the model can't synthesize uploaded-file descriptors. + */ +function buildCustomBlockInputMappingSchema( + blockName: string, + inputFields: WorkflowInputField[], + requiredInputIds: string[] +): ProviderToolConfig['parameters'] { + const requiredSet = new Set(requiredInputIds) + const properties: Record = {} + const requiredFields: string[] = [] + for (const field of inputFields) { + if (isFileFieldType(field.type)) continue + const key = field.id ?? field.name + properties[key] = { + type: inputFieldSchemaType(field.type), + description: field.description ? `${field.name} — ${field.description}` : field.name, + } + if (requiredSet.has(key)) requiredFields.push(key) + } + return { + type: 'object', + properties: { + inputMapping: { + type: 'object', + description: `Input values for ${blockName}`, + properties, + required: requiredFields, + }, + }, + required: requiredFields.length > 0 ? ['inputMapping'] : [], + } +} + /** * Transforms a block tool into a provider tool config with operation selection * @@ -533,6 +590,14 @@ export async function transformBlockTool( getTool: (toolId: string) => any getToolAsync?: (toolId: string) => Promise canonicalModes?: Record + /** + * Server-only resolver for a custom (deploy-as-block) tool's binding (bound + * workflow + input schema), org-scoped to the consumer. Injected as a dependency + * — like `getAllBlocks`/`getTool` — so this client-reachable module never imports + * the DB-backed `operations` module. Omit for non-server callers that can't + * resolve authority; a custom block is then simply not offered as a tool. + */ + resolveCustomBlockBinding?: (blockType: string) => Promise /** * Position of this tool within its parent agent block's `tool-input` array. Canonical-mode * overrides are stored scoped by this index (`${toolIndex}:${canonicalId}`) rather than by @@ -552,6 +617,42 @@ export async function transformBlockTool( return null } + // Custom (deploy-as-block) blocks resolve to the generic `workflow_executor`, but + // as an agent tool they must run through the authority boundary (owner identity, + // latest deployment, curated outputs) — not the plain workflow executor. Route + // them to the dedicated in-process `custom_block_executor` tool, carrying the + // block TYPE (never a source workflow id) so authority is re-resolved server-side. + // Dynamic imports keep the DB/executor dependency graph out of client bundles. + if (isCustomBlockType(block.type)) { + const binding = await options.resolveCustomBlockBinding?.(block.type) + if (!binding) { + logger.warn(`Custom block tool binding not resolved for type: ${block.type}`) + return null + } + const customToolConfig = getTool('custom_block_executor') + if (!customToolConfig) { + logger.warn('custom_block_executor tool not registered') + return null + } + return { + // Unique per block so two custom-block tools never collide on the wire. + id: `custom_block_executor_${block.type}`, + // Name/description come from the block itself — never the source workflow's + // metadata, which the consumer has no access to. + name: blockDef.name, + description: blockDef.description || customToolConfig.description, + params: { + blockType: block.type, + inputMapping: assembleCustomBlockInputMapping(block.params || {}), + }, + parameters: buildCustomBlockInputMappingSchema( + blockDef.name, + binding.inputFields, + binding.requiredInputIds + ), + } + } + let toolId: string | null = null if ((blockDef.tools?.access?.length || 0) > 1) { diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index adb32d63f5e..b9cd260f649 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -1237,6 +1237,28 @@ export async function executeTool( } } + // Custom blocks (deploy-as-block) run in-process through WorkflowBlockHandler. + // The runner is dynamic-imported from a server-only module so the client-bundled + // tool registry never pulls in the executor/db dependency graph (a static or + // dynamic executor import in the tool descriptor itself would break the client + // build — and with it `getTool('workflow_executor')`). + if (normalizedToolId === 'custom_block_executor') { + logger.info(`[${requestId}] Running custom block tool ${toolId}`) + const { runCustomBlockTool } = await import( + '@/executor/handlers/workflow/custom-block-tool-runner' + ) + const result = await runCustomBlockTool(contextParams) + const endTime = new Date() + return { + ...result, + timing: { + startTime: startTimeISO, + endTime: endTime.toISOString(), + duration: endTime.getTime() - startTime.getTime(), + }, + } + } + // Check for direct execution (no HTTP request needed) if (tool.directExecution) { logger.info(`[${requestId}] Using directExecution for ${toolId}`) diff --git a/apps/sim/tools/normalize.ts b/apps/sim/tools/normalize.ts index 9082e79a5c0..1a6b0a32a95 100644 --- a/apps/sim/tools/normalize.ts +++ b/apps/sim/tools/normalize.ts @@ -7,6 +7,16 @@ * Pure string utility — no server dependencies, safe to import in client components. */ export function normalizeToolId(toolId: string): string { + // Check the longer prefix first — `custom_block_executor_` also starts with + // neither `workflow_executor_` nor a knowledge/table op, so ordering is safe, + // but keep it explicit since its suffix (`custom_block_`) is itself prefixed. + if ( + toolId.startsWith('custom_block_executor_') && + toolId.length > 'custom_block_executor_'.length + ) { + return 'custom_block_executor' + } + if (toolId.startsWith('workflow_executor_') && toolId.length > 'workflow_executor_'.length) { return 'workflow_executor' } diff --git a/apps/sim/tools/params.test.ts b/apps/sim/tools/params.test.ts index 5afa84eaf54..734969cd8eb 100644 --- a/apps/sim/tools/params.test.ts +++ b/apps/sim/tools/params.test.ts @@ -5,6 +5,7 @@ import { createUserToolSchema, filterSchemaForLLM, formatParameterLabel, + getSubBlocksForToolInput, getToolParametersConfig, isPasswordParameter, mergeToolParameters, @@ -59,6 +60,15 @@ vi.mock('@/tools/utils', () => ({ if (toolId === 'test_tool') { return mockToolConfig } + if (toolId === 'workflow_executor') { + return { + id: 'workflow_executor', + name: 'Workflow Executor', + description: '', + version: '1.0.0', + params: {}, + } + } return null }), })) @@ -815,3 +825,49 @@ describe('Tool Parameters Utils', () => { }) }) }) + +describe('custom block agent-tool rendering', () => { + // Mirrors buildCustomBlockConfig: hidden workflowId/inputMapping wiring + per-field + // sub-blocks keyed by the source field's stable id. + const customBlockConfig = { + subBlocks: [ + { id: 'workflowId', type: 'short-input', hidden: true }, + { id: 'inputMapping', type: 'code', language: 'json', hidden: true }, + { id: 'field-question', title: 'Question', type: 'short-input', required: true }, + { id: 'field-files', title: 'Attachments', type: 'file-upload', multiple: true }, + ], + } as any + + describe('getToolParametersConfig', () => { + it('surfaces the field sub-blocks, never workflowId/inputMapping', () => { + const result = getToolParametersConfig( + 'workflow_executor', + 'custom_block_abc', + undefined, + customBlockConfig + ) + expect(result).not.toBeNull() + const ids = result!.userInputParameters.map((p) => p.id) + expect(ids).toEqual(['field-question', 'field-files']) + expect(ids).not.toContain('workflowId') + expect(ids).not.toContain('inputMapping') + expect(result!.userInputParameters.every((p) => p.visibility === 'user-or-llm')).toBe(true) + expect(result!.requiredParameters.map((p) => p.id)).toEqual(['field-question']) + }) + }) + + describe('getSubBlocksForToolInput', () => { + it('returns field sub-blocks as user-or-llm and drops reserved/hidden wiring', () => { + const result = getSubBlocksForToolInput( + 'workflow_executor', + 'custom_block_abc', + undefined, + undefined, + customBlockConfig + ) + expect(result).not.toBeNull() + expect(result!.subBlocks.map((sb) => sb.id)).toEqual(['field-question', 'field-files']) + expect(result!.subBlocks.every((sb) => sb.paramVisibility === 'user-or-llm')).toBe(true) + }) + }) +}) diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts index 26e6d67abfa..adffe9b99a0 100644 --- a/apps/sim/tools/params.ts +++ b/apps/sim/tools/params.ts @@ -11,6 +11,7 @@ import { resolveCanonicalMode, type SubBlockCondition, } from '@/lib/workflows/subblocks/visibility' +import { isCustomBlockType, RESERVED_PARAMS } from '@/blocks/custom/build-config' import type { BlockConfig as AppBlockConfig, SubBlockConfig as BlockSubBlockConfig, @@ -194,9 +195,18 @@ function getBlockConfigurations(): Record { /** * Gets the correct tool ID for a block operation. + * + * Pass `blockOverride` (a fresh, overlay-aware config) for custom (deploy-as-block) + * blocks — the module `getBlockConfigurations()` cache can miss async-hydrated + * custom blocks, which would return `undefined` here and make "add tool" silently + * no-op. */ -export function getToolIdForOperation(blockType: string, operation?: string): string | undefined { - const block = getBlockConfigurations()[blockType] +export function getToolIdForOperation( + blockType: string, + operation?: string, + blockOverride?: Pick +): string | undefined { + const block = blockOverride ?? getBlockConfigurations()[blockType] if (!block?.tools?.access) return undefined if (block.tools.access.length === 1) { @@ -260,6 +270,20 @@ function resolveSubBlockForParam( return undefined } +/** Map a custom-block field sub-block type to a tool-parameter type. */ +function customFieldParamType(subBlockType: string): string { + switch (subBlockType) { + case 'switch': + return 'boolean' + case 'file-upload': + return 'file[]' + case 'code': + return 'json' + default: + return 'string' + } +} + /** * Gets all parameters for a tool, categorized by their usage * Also includes UI component information from block configurations @@ -267,7 +291,8 @@ function resolveSubBlockForParam( export function getToolParametersConfig( toolId: string, blockType?: string, - currentValues?: Record + currentValues?: Record, + blockConfigOverride?: Pick ): ToolWithParameters | null { try { const toolConfig = getTool(toolId) @@ -282,6 +307,41 @@ export function getToolParametersConfig( return null } + // Custom (deploy-as-block) blocks resolve to `workflow_executor`, but their + // editable inputs are their own per-field sub-blocks — not the generic + // workflowId/inputMapping. Surface those so the tool panel renders the block's + // real fields (and never the workflow-executor fields as "uncovered" params). + // MUST run before the `workflow_executor` branch below. Read subBlocks from the + // fresh, overlay-aware `blockConfigOverride` — the module `getBlockConfigurations` + // cache can miss async-hydrated custom blocks. + if (blockType && isCustomBlockType(blockType)) { + const blockConfig = blockConfigOverride ?? getBlockConfigurations()[blockType] + const fieldSubBlocks = ( + (blockConfig?.subBlocks as BlockSubBlockConfig[] | undefined) ?? [] + ).filter((sb) => !sb.hidden && !RESERVED_PARAMS.has(sb.id)) + const parameters: ToolParameterConfig[] = fieldSubBlocks.map((sb) => ({ + id: sb.id, + type: customFieldParamType(sb.type), + required: sb.required === true, + visibility: 'user-or-llm', + description: sb.description, + uiComponent: { + type: sb.type, + title: sb.title, + placeholder: sb.placeholder, + language: sb.language, + multiple: sb.multiple, + }, + })) + return { + toolConfig, + allParameters: parameters, + userInputParameters: parameters, + requiredParameters: parameters.filter((param) => param.required), + optionalParameters: parameters.filter((param) => !param.required), + } + } + // Special handling for workflow_executor tool if (toolId === 'workflow_executor') { const parameters: ToolParameterConfig[] = [ @@ -1028,6 +1088,21 @@ export function getSubBlocksForToolInput( return null } + // Custom (deploy-as-block) blocks: render their own editable field sub-blocks + // as `user-or-llm` (the hidden workflowId/inputMapping wiring is filtered by + // RESERVED_PARAMS — `isSubBlockHidden` does NOT honor `hidden: true`, so the + // explicit reserved filter is what keeps them out). + if (blockType && isCustomBlockType(blockType)) { + const fieldSubBlocks = (blockConfig.subBlocks as BlockSubBlockConfig[]) + .filter((sb) => !sb.hidden && !RESERVED_PARAMS.has(sb.id)) + .map((sb) => ({ ...sb, paramVisibility: 'user-or-llm' as ParameterVisibility })) + return { + toolConfig, + subBlocks: fieldSubBlocks, + oauthConfig: toolConfig.oauth, + } + } + const allSubBlocks = blockConfig.subBlocks as BlockSubBlockConfig[] const canonicalIndex = buildCanonicalIndex(allSubBlocks) diff --git a/apps/sim/tools/registry.minimal.ts b/apps/sim/tools/registry.minimal.ts index 31e1e52c36c..4eadc9fbdc1 100644 --- a/apps/sim/tools/registry.minimal.ts +++ b/apps/sim/tools/registry.minimal.ts @@ -72,6 +72,7 @@ import { slackUpdateViewTool, } from '@/tools/slack' import type { ToolConfig } from '@/tools/types' +import { customBlockExecutorTool, workflowExecutorTool } from '@/tools/workflow' /** * Dev-only minimal tool registry. Swapped in for `@/tools/registry` via a @@ -87,6 +88,10 @@ export const tools: Record = { http_request: httpRequestTool, function_execute: functionExecuteTool, guardrails_validate: guardrailsValidateTool, + // Needed so workflow-as-tool and custom (deploy-as-block) tools resolve their + // config in minimal-registry dev mode (both route through `workflow_executor`). + workflow_executor: workflowExecutorTool, + custom_block_executor: customBlockExecutorTool, gmail_send_v2: gmailSendV2Tool, gmail_read_v2: gmailReadV2Tool, gmail_search_v2: gmailSearchV2Tool, diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 61eb2f74dd1..d6c6462efcf 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -4594,7 +4594,7 @@ import { workdayTerminateWorkerTool, workdayUpdateWorkerTool, } from '@/tools/workday' -import { workflowExecutorTool } from '@/tools/workflow' +import { customBlockExecutorTool, workflowExecutorTool } from '@/tools/workflow' import { xCreateBookmarkTool, xCreateTweetTool, @@ -8185,6 +8185,7 @@ export const tools: Record = { google_forms_delete_watch: googleFormsDeleteWatchTool, google_forms_renew_watch: googleFormsRenewWatchTool, workflow_executor: workflowExecutorTool, + custom_block_executor: customBlockExecutorTool, wealthbox_read_contact: wealthboxReadContactTool, wealthbox_write_contact: wealthboxWriteContactTool, wealthbox_read_task: wealthboxReadTaskTool, diff --git a/apps/sim/tools/workflow/custom-block-executor.ts b/apps/sim/tools/workflow/custom-block-executor.ts new file mode 100644 index 00000000000..749cee9f0c3 --- /dev/null +++ b/apps/sim/tools/workflow/custom-block-executor.ts @@ -0,0 +1,42 @@ +import type { ToolConfig } from '@/tools/types' + +interface CustomBlockExecutorParams { + /** The `custom_block_*` type to run — authority is re-resolved from it server-side. */ + blockType: string + /** Input values keyed by the source field's stable id (assembled + LLM-filled). */ + inputMapping?: Record | string +} + +/** + * Tool descriptor for running a published custom block (deploy-as-block) as an + * Agent tool. Execution is handled server-side in `executeTool` (`tools/index.ts`) + * via the `custom-block-tool-runner`, NOT here — so this module (imported by the + * client-bundled tool registry) stays free of the executor/db dependency graph. + * `request` is declared to satisfy the type but is never invoked (the executeTool + * custom-block branch returns first). + */ +export const customBlockExecutorTool: ToolConfig = { + id: 'custom_block_executor', + name: 'Custom Block Executor', + description: 'Execute a published custom block (a workflow packaged as a reusable block).', + version: '1.0.0', + params: { + blockType: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The custom block type to execute', + }, + inputMapping: { + type: 'object', + required: false, + visibility: 'user-or-llm', + description: "Input values for the block's fields, keyed by field id", + }, + }, + request: { + url: () => '', + method: 'POST', + headers: () => ({}), + }, +} diff --git a/apps/sim/tools/workflow/index.ts b/apps/sim/tools/workflow/index.ts index 6330fc9c456..9d51194ca5f 100644 --- a/apps/sim/tools/workflow/index.ts +++ b/apps/sim/tools/workflow/index.ts @@ -1 +1,2 @@ +export { customBlockExecutorTool } from '@/tools/workflow/custom-block-executor' export { workflowExecutorTool } from '@/tools/workflow/executor' From 4ab45cf020a5ca4d90f7fb7b320a2ed9ed392c82 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 21 Jul 2026 13:09:45 -0700 Subject: [PATCH 2/3] fix(custom-blocks): address review findings on agent-tool execution --- apps/sim/executor/handlers/pi/sim-tools.ts | 8 ++++-- .../workflow/custom-block-tool-runner.test.ts | 17 +++++++++++ .../workflow/custom-block-tool-runner.ts | 23 +++++++++++---- .../handlers/workflow/workflow-handler.ts | 2 +- apps/sim/providers/custom-block-tool.test.ts | 28 +++++++++++++++++++ apps/sim/providers/utils.ts | 19 ++++++++++++- apps/sim/tools/index.ts | 3 ++ 7 files changed, 91 insertions(+), 9 deletions(-) diff --git a/apps/sim/executor/handlers/pi/sim-tools.ts b/apps/sim/executor/handlers/pi/sim-tools.ts index 80d0153bcbc..f9cf4b33d27 100644 --- a/apps/sim/executor/handlers/pi/sim-tools.ts +++ b/apps/sim/executor/handlers/pi/sim-tools.ts @@ -16,6 +16,7 @@ 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' @@ -74,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), // 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 diff --git a/apps/sim/executor/handlers/workflow/custom-block-tool-runner.test.ts b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.test.ts index ef8086b85ee..edc83663b1c 100644 --- a/apps/sim/executor/handlers/workflow/custom-block-tool-runner.test.ts +++ b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.test.ts @@ -9,8 +9,11 @@ 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, @@ -85,6 +88,20 @@ describe('runCustomBlockTool', () => { 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) diff --git a/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts index c733013522d..bd9db63949c 100644 --- a/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts +++ b/apps/sim/executor/handlers/workflow/custom-block-tool-runner.ts @@ -2,7 +2,11 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' -import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler' +import { ChildWorkflowError } from '@/executor/errors/child-workflow-error' +import { + aggregateChildCost, + WorkflowBlockHandler, +} from '@/executor/handlers/workflow/workflow-handler' import type { ExecutionContext } from '@/executor/types' import type { SerializedBlock } from '@/serializer/types' import type { ToolResponse } from '@/tools/types' @@ -109,11 +113,20 @@ export async function runCustomBlockTool(params: CustomBlockToolParams): Promise output && typeof output === 'object' && !Array.isArray(output) ? output : { result: output } return { success: true, output: normalized } } catch (error) { - // The handler throws a consumer-safe `ChildWorkflowError` on failure. Partial - // child cost rides its trace spans, but the provider tool loop only bills cost - // from successful results, so the error message alone is surfaced here. + // The handler throws a consumer-safe `ChildWorkflowError` on failure. Its trace + // spans are the only carrier of spend the child already incurred before failing, + // so roll that up onto the failed result too — otherwise a partially-run child is + // recorded as zero-cost. const message = getErrorMessage(error, 'Custom block execution failed') + const failedChildSpans = ChildWorkflowError.isChildWorkflowError(error) + ? error.childTraceSpans + : [] + const childCost = aggregateChildCost(failedChildSpans) logger.info('Custom block tool execution failed', { blockType: params.blockType, message }) - return { success: false, output: {}, error: message } + return { + success: false, + output: childCost > 0 ? { cost: { total: childCost } } : {}, + error: message, + } } } diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index eb1763f8935..dddd1a19010 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -136,7 +136,7 @@ export function remapCustomBlockInputKeys( * breakdowns), minus the base execution charge the parent applies once itself. * A naive top-level `cost.total` sum undercounts when spend sits on nested children. */ -function aggregateChildCost(childTraceSpans: TraceSpan[]): number { +export function aggregateChildCost(childTraceSpans: TraceSpan[]): number { if (childTraceSpans.length === 0) return 0 const summary = calculateCostSummary(childTraceSpans) return Math.max(0, summary.totalCost - summary.baseExecutionCharge) diff --git a/apps/sim/providers/custom-block-tool.test.ts b/apps/sim/providers/custom-block-tool.test.ts index 4f20474e6b7..fec3871da2a 100644 --- a/apps/sim/providers/custom-block-tool.test.ts +++ b/apps/sim/providers/custom-block-tool.test.ts @@ -59,6 +59,34 @@ describe('transformBlockTool — custom blocks', () => { expect(mockResolve).toHaveBeenCalledWith('custom_block_test') }) + it('does not offer the tool when a required file input has no preset value', async () => { + mockResolve.mockResolvedValue({ + workflowId: 'wf-src', + inputFields: [{ id: 'f', name: 'Files', type: 'file[]' }], + requiredInputIds: ['f'], + }) + + const tool = await transformBlockTool({ type: 'custom_block_test', params: {} }, options) + expect(tool).toBeNull() + }) + + it('still offers the tool when a required file input is pre-filled on the block', async () => { + mockResolve.mockResolvedValue({ + workflowId: 'wf-src', + inputFields: [{ id: 'f', name: 'Files', type: 'file[]' }], + requiredInputIds: ['f'], + }) + + const tool = await transformBlockTool( + { type: 'custom_block_test', params: { f: [{ id: 'file-1' }] } }, + options + ) + expect(tool).not.toBeNull() + // The preset value rides the baked mapping; the schema still omits the file field. + expect(tool!.params.inputMapping).toContain('file-1') + expect(Object.keys(tool!.parameters.properties.inputMapping.properties)).toEqual([]) + }) + it('returns null (tool not offered) when the binding cannot be resolved', async () => { mockResolve.mockResolvedValue(null) const tool = await transformBlockTool({ type: 'custom_block_test', params: {} }, options) diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 71c6ff213fa..c04b9bbd4a2 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -634,6 +634,23 @@ export async function transformBlockTool( logger.warn('custom_block_executor tool not registered') return null } + const inputMapping = assembleCustomBlockInputMapping(block.params || {}) + // A `file[]` field is omitted from the model schema (the model can't synthesize + // upload descriptors). If such a field is REQUIRED and the user hasn't + // pre-filled it on the block, no invocation could ever satisfy the child's + // required-input check — so don't offer an unusable tool at all. + const prefilled = JSON.parse(inputMapping) as Record + const requiredIds = new Set(binding.requiredInputIds) + const unfillableFileField = binding.inputFields.find((field) => { + const key = field.id ?? field.name + return isFileFieldType(field.type) && requiredIds.has(key) && !(key in prefilled) + }) + if (unfillableFileField) { + logger.warn( + `Custom block ${block.type} not offered as a tool: required file input "${unfillableFileField.name}" has no preset value and cannot be supplied by the model` + ) + return null + } return { // Unique per block so two custom-block tools never collide on the wire. id: `custom_block_executor_${block.type}`, @@ -643,7 +660,7 @@ export async function transformBlockTool( description: blockDef.description || customToolConfig.description, params: { blockType: block.type, - inputMapping: assembleCustomBlockInputMapping(block.params || {}), + inputMapping, }, parameters: buildCustomBlockInputMappingSchema( blockDef.name, diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index b9cd260f649..6e814b694f2 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -1251,6 +1251,9 @@ export async function executeTool( const endTime = new Date() return { ...result, + // Strip internal `__`-prefixed fields the same way every other tool path does, + // so child-workflow internals never reach the agent's tool result. + output: postProcessToolOutput(normalizedToolId, result.output ?? {}), timing: { startTime: startTimeISO, endTime: endTime.toISOString(), From 2db4e6cf41e457fce966f3a253dfc565f3066fe5 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 21 Jul 2026 13:18:03 -0700 Subject: [PATCH 3/3] fix(custom-blocks): move executor tool id out of the custom-tool namespace --- apps/sim/providers/custom-block-tool.test.ts | 25 ++++++++++++++++--- apps/sim/providers/utils.ts | 8 +++--- apps/sim/tools/index.ts | 2 +- apps/sim/tools/normalize.ts | 13 +++++----- apps/sim/tools/registry.minimal.ts | 2 +- apps/sim/tools/registry.ts | 2 +- .../tools/workflow/custom-block-executor.ts | 6 ++++- 7 files changed, 40 insertions(+), 18 deletions(-) diff --git a/apps/sim/providers/custom-block-tool.test.ts b/apps/sim/providers/custom-block-tool.test.ts index fec3871da2a..b6c97963e5f 100644 --- a/apps/sim/providers/custom-block-tool.test.ts +++ b/apps/sim/providers/custom-block-tool.test.ts @@ -3,6 +3,7 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' import { transformBlockTool } from '@/providers/utils' +import { normalizeToolId } from '@/tools/normalize' const mockResolve = vi.fn() @@ -17,8 +18,8 @@ const options = { }, ], getTool: (id: string) => - id === 'custom_block_executor' - ? { id: 'custom_block_executor', description: 'exec' } + id === 'deployed_block_executor' + ? { id: 'deployed_block_executor', description: 'exec' } : undefined, resolveCustomBlockBinding: mockResolve, } @@ -28,7 +29,7 @@ describe('transformBlockTool — custom blocks', () => { vi.clearAllMocks() }) - it('builds an id-keyed custom_block_executor tool and omits file[] fields', async () => { + it('builds an id-keyed deployed_block_executor tool and omits file[] fields', async () => { mockResolve.mockResolvedValue({ workflowId: 'wf-src', inputFields: [ @@ -45,7 +46,7 @@ describe('transformBlockTool — custom blocks', () => { expect(tool).not.toBeNull() // Unique per block, name/description from the block (never the source workflow). - expect(tool!.id).toBe('custom_block_executor_custom_block_test') + expect(tool!.id).toBe('deployed_block_executor_custom_block_test') expect(tool!.name).toBe('The Elder') // Baked params: block type + assembled (id-keyed) input mapping. expect(tool!.params.blockType).toBe('custom_block_test') @@ -59,6 +60,22 @@ describe('transformBlockTool — custom blocks', () => { expect(mockResolve).toHaveBeenCalledWith('custom_block_test') }) + it('keeps the tool id out of the user-defined custom-tool namespace', async () => { + mockResolve.mockResolvedValue({ + workflowId: 'wf-src', + inputFields: [{ id: 'q', name: 'Question', type: 'string' }], + requiredInputIds: [], + }) + + const tool = await transformBlockTool({ type: 'custom_block_test', params: {} }, options) + + // `custom_` is the custom-tool prefix (`isCustomTool`). Colliding with it makes + // executeTool resolve via the DB custom-tool lookup, skip internal-field + // stripping, and let `disableCustomTools` block deploy-as-block tools. + expect(tool!.id.startsWith('custom_')).toBe(false) + expect(normalizeToolId(tool!.id)).toBe('deployed_block_executor') + }) + it('does not offer the tool when a required file input has no preset value', async () => { mockResolve.mockResolvedValue({ workflowId: 'wf-src', diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index c04b9bbd4a2..ba0ab5629e8 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -620,7 +620,7 @@ export async function transformBlockTool( // Custom (deploy-as-block) blocks resolve to the generic `workflow_executor`, but // as an agent tool they must run through the authority boundary (owner identity, // latest deployment, curated outputs) — not the plain workflow executor. Route - // them to the dedicated in-process `custom_block_executor` tool, carrying the + // them to the dedicated in-process `deployed_block_executor` tool, carrying the // block TYPE (never a source workflow id) so authority is re-resolved server-side. // Dynamic imports keep the DB/executor dependency graph out of client bundles. if (isCustomBlockType(block.type)) { @@ -629,9 +629,9 @@ export async function transformBlockTool( logger.warn(`Custom block tool binding not resolved for type: ${block.type}`) return null } - const customToolConfig = getTool('custom_block_executor') + const customToolConfig = getTool('deployed_block_executor') if (!customToolConfig) { - logger.warn('custom_block_executor tool not registered') + logger.warn('deployed_block_executor tool not registered') return null } const inputMapping = assembleCustomBlockInputMapping(block.params || {}) @@ -653,7 +653,7 @@ export async function transformBlockTool( } return { // Unique per block so two custom-block tools never collide on the wire. - id: `custom_block_executor_${block.type}`, + id: `deployed_block_executor_${block.type}`, // Name/description come from the block itself — never the source workflow's // metadata, which the consumer has no access to. name: blockDef.name, diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 6e814b694f2..f8aee5a6838 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -1242,7 +1242,7 @@ export async function executeTool( // tool registry never pulls in the executor/db dependency graph (a static or // dynamic executor import in the tool descriptor itself would break the client // build — and with it `getTool('workflow_executor')`). - if (normalizedToolId === 'custom_block_executor') { + if (normalizedToolId === 'deployed_block_executor') { logger.info(`[${requestId}] Running custom block tool ${toolId}`) const { runCustomBlockTool } = await import( '@/executor/handlers/workflow/custom-block-tool-runner' diff --git a/apps/sim/tools/normalize.ts b/apps/sim/tools/normalize.ts index 1a6b0a32a95..177c11aa140 100644 --- a/apps/sim/tools/normalize.ts +++ b/apps/sim/tools/normalize.ts @@ -7,14 +7,15 @@ * Pure string utility — no server dependencies, safe to import in client components. */ export function normalizeToolId(toolId: string): string { - // Check the longer prefix first — `custom_block_executor_` also starts with - // neither `workflow_executor_` nor a knowledge/table op, so ordering is safe, - // but keep it explicit since its suffix (`custom_block_`) is itself prefixed. + // Custom (deploy-as-block) tools: 'deployed_block_executor_custom_block_' -> + // 'deployed_block_executor'. Note the id deliberately does NOT start with + // `custom_` — that prefix is the user-defined custom-tool namespace + // (`isCustomTool`), and colliding with it misroutes resolution and permissions. if ( - toolId.startsWith('custom_block_executor_') && - toolId.length > 'custom_block_executor_'.length + toolId.startsWith('deployed_block_executor_') && + toolId.length > 'deployed_block_executor_'.length ) { - return 'custom_block_executor' + return 'deployed_block_executor' } if (toolId.startsWith('workflow_executor_') && toolId.length > 'workflow_executor_'.length) { diff --git a/apps/sim/tools/registry.minimal.ts b/apps/sim/tools/registry.minimal.ts index 4eadc9fbdc1..bacfae7e566 100644 --- a/apps/sim/tools/registry.minimal.ts +++ b/apps/sim/tools/registry.minimal.ts @@ -91,7 +91,7 @@ export const tools: Record = { // Needed so workflow-as-tool and custom (deploy-as-block) tools resolve their // config in minimal-registry dev mode (both route through `workflow_executor`). workflow_executor: workflowExecutorTool, - custom_block_executor: customBlockExecutorTool, + deployed_block_executor: customBlockExecutorTool, gmail_send_v2: gmailSendV2Tool, gmail_read_v2: gmailReadV2Tool, gmail_search_v2: gmailSearchV2Tool, diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index d6c6462efcf..fd41bad5da1 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -8185,7 +8185,7 @@ export const tools: Record = { google_forms_delete_watch: googleFormsDeleteWatchTool, google_forms_renew_watch: googleFormsRenewWatchTool, workflow_executor: workflowExecutorTool, - custom_block_executor: customBlockExecutorTool, + deployed_block_executor: customBlockExecutorTool, wealthbox_read_contact: wealthboxReadContactTool, wealthbox_write_contact: wealthboxWriteContactTool, wealthbox_read_task: wealthboxReadTaskTool, diff --git a/apps/sim/tools/workflow/custom-block-executor.ts b/apps/sim/tools/workflow/custom-block-executor.ts index 749cee9f0c3..37e19f27fc9 100644 --- a/apps/sim/tools/workflow/custom-block-executor.ts +++ b/apps/sim/tools/workflow/custom-block-executor.ts @@ -16,7 +16,11 @@ interface CustomBlockExecutorParams { * custom-block branch returns first). */ export const customBlockExecutorTool: ToolConfig = { - id: 'custom_block_executor', + // NOT `custom_block_executor`: `custom_` is the user-defined custom-tool namespace + // (`isCustomTool`), so that id would make `executeTool` resolve this through the + // DB custom-tool lookup, skip `postProcessToolOutput`'s internal-field stripping, + // and let `disableCustomTools` workspaces block deploy-as-block tools. + id: 'deployed_block_executor', name: 'Custom Block Executor', description: 'Execute a published custom block (a workflow packaged as a reusable block).', version: '1.0.0',