diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index b557a9b373e..c92eacd9b5e 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -7995,6 +7995,11 @@ "enum": ["auto", "force", "none"], "description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`." }, + "usageControlExpression": { + "type": "string", + "maxLength": 2048, + "description": "Variable-capable tool mode value used when the matching canonical mode is `advanced`. It must resolve to `auto`, `force`, or `none` at execution time." + }, "params": { "type": "object", "propertyNames": { @@ -8041,6 +8046,11 @@ "type": "string", "enum": ["auto", "force", "none"], "description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`." + }, + "usageControlExpression": { + "type": "string", + "maxLength": 2048, + "description": "Variable-capable tool mode value used when the matching canonical mode is `advanced`. It must resolve to `auto`, `force`, or `none` at execution time." } }, "required": ["type", "customToolId"], @@ -8109,6 +8119,11 @@ "type": "string", "enum": ["auto", "force", "none"], "description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`." + }, + "usageControlExpression": { + "type": "string", + "maxLength": 2048, + "description": "Variable-capable tool mode value used when the matching canonical mode is `advanced`. It must resolve to `auto`, `force`, or `none` at execution time." } }, "required": ["type", "schema", "code"], @@ -8174,6 +8189,11 @@ "type": "string", "enum": ["auto", "force", "none"], "description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`." + }, + "usageControlExpression": { + "type": "string", + "maxLength": 2048, + "description": "Variable-capable tool mode value used when the matching canonical mode is `advanced`. It must resolve to `auto`, `force`, or `none` at execution time." } }, "required": ["type", "params"], @@ -8282,6 +8302,11 @@ "type": "string", "enum": ["auto", "force", "none"], "description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`." + }, + "usageControlExpression": { + "type": "string", + "maxLength": 2048, + "description": "Variable-capable tool mode value used when the matching canonical mode is `advanced`. It must resolve to `auto`, `force`, or `none` at execution time." } }, "required": ["type", "params"], diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/usage-control.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/usage-control.tsx new file mode 100644 index 00000000000..c710e863a72 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/usage-control.tsx @@ -0,0 +1,115 @@ +import { Button, ChipCombobox, cn, Label, Tooltip } from '@sim/emcn' +import { ArrowLeftRight } from '@sim/emcn/icons' +import type { CanonicalMode } from '@/lib/workflows/subblocks/visibility' +import type { StoredTool } from '@/lib/workflows/tool-input/types' +import { ShortInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input' + +interface ToolUsageControlProps { + blockId: string + aggregateSubBlockId: string + toolIndex: number + tool: StoredTool + mode: CanonicalMode + supportsForce: boolean + disabled: boolean + onFixedChange: (value: NonNullable) => void + onExpressionChange: (value: string) => void + onModeToggle: () => void +} + +const MODE_OPTIONS = [ + { + value: 'auto', + label: 'Auto', + suffixElement: (model decides), + }, + { + value: 'force', + label: 'Force', + suffixElement: (always use), + }, + { + value: 'none', + label: 'None', + suffixElement: (disable tool), + }, +] as const + +export function ToolUsageControl({ + blockId, + aggregateSubBlockId, + toolIndex, + tool, + mode, + supportsForce, + disabled, + onFixedChange, + onExpressionChange, + onModeToggle, +}: ToolUsageControlProps) { + const toggleLabel = mode === 'advanced' ? 'Switch to selector' : 'Switch to variable' + + return ( +
+
+ + + + + + {toggleLabel} + +
+ {mode === 'advanced' ? ( + + ) : ( + ({ + ...option, + disabled: option.value === 'force' && !supportsForce, + suffixElement: + option.value === 'force' && !supportsForce ? ( + (not supported by model) + ) : ( + option.suffixElement + ), + onSelect: () => onFixedChange(option.value), + }))} + value={tool.usageControl ?? 'auto'} + disabled={disabled} + aria-label='Permission Mode' + /> + )} +
+ ) +} 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 8c8d2ac3875..7d496c08a0a 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 @@ -2,17 +2,19 @@ import type React from 'react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Badge, + Button, Combobox, type ComboboxOption, type ComboboxOptionGroup, cn, + FieldDivider, Popover, PopoverContent, PopoverItem, PopoverTrigger, Tooltip, } from '@sim/emcn' -import { ArrowLeft, ChevronRight, Server, Wrench, X } from '@sim/emcn/icons' +import { ArrowLeft, ChevronRight, Pencil, Server, Wrench, X } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { useParams } from 'next/navigation' import { McpIcon, WorkflowIcon } from '@/components/icons' @@ -32,6 +34,11 @@ import { } from '@/lib/permission-groups/operation-access' import { resolveStoredToolName } from '@/lib/workflows/subblocks/display' import { buildToolSubBlockId } from '@/lib/workflows/tool-input/synthetic-subblocks' +import { + buildAgentToolUsageControlCanonicalKey, + getAgentToolUsageControlMode, + resolveAgentToolUsageControl, +} from '@/lib/workflows/tool-input/usage-control' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { McpServerFormModal } from '@/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' @@ -40,6 +47,7 @@ import { CustomToolModal, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal' import { ToolSubBlockRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer' +import { ToolUsageControl } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/usage-control' import { clearDependentToolParams } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/param-dependents' import type { StoredTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types' import { @@ -382,7 +390,6 @@ export const ToolInput = memo(function ToolInput({ const [editingToolIndex, setEditingToolIndex] = useState(null) const [draggedIndex, setDraggedIndex] = useState(null) const [dragOverIndex, setDragOverIndex] = useState(null) - const [usageControlPopoverIndex, setUsageControlPopoverIndex] = useState(null) const [mcpRemovePopoverIndex, setMcpRemovePopoverIndex] = useState(null) const [mcpServerDrilldown, setMcpServerDrilldown] = useState(null) @@ -837,6 +844,7 @@ export const ToolInput = memo(function ToolInput({ type: 'custom-tool', customToolId: customTool.id, usageControl: existingTool.usageControl || 'auto', + usageControlExpression: existingTool.usageControlExpression, isExpanded: existingTool.isExpanded, } : { @@ -993,23 +1001,36 @@ export const ToolInput = memo(function ToolInput({ [isPreview, disabled, selectedTools, getToolIdForOperation, blockId, setStoreValue] ) - const handleUsageControlChange = useCallback( - (toolIndex: number, usageControl: string) => { - if (isPreview || disabled) return + const handleUsageControlChange = ( + toolIndex: number, + usageControl: NonNullable + ) => { + if (isPreview || disabled) return - setStoreValue( - selectedTools.map((tool, index) => - index === toolIndex - ? { - ...tool, - usageControl: usageControl as 'auto' | 'force' | 'none', - } - : tool - ) + setStoreValue( + selectedTools.map((tool, index) => + index === toolIndex + ? { + ...tool, + usageControl, + } + : tool ) - }, - [isPreview, disabled, selectedTools, setStoreValue] - ) + ) + } + + const handleUsageControlExpressionChange = ( + toolIndex: number, + usageControlExpression: string + ) => { + if (isPreview || disabled) return + + setStoreValue( + selectedTools.map((tool, index) => + index === toolIndex ? { ...tool, usageControlExpression } : tool + ) + ) + } const [localExpanded, setLocalExpanded] = useState>({}) @@ -1502,6 +1523,13 @@ export const ToolInput = memo(function ToolInput({ toolIndex, tool.type ) + const toolUsageControlMode = getAgentToolUsageControlMode( + toolIndex, + canonicalModeOverrides + ) + const isToolDisabled = + supportsToolControl && + resolveAgentToolUsageControl(tool, toolIndex, canonicalModeOverrides) === 'none' const subBlocksResult: SubBlocksForToolInput | null = !isCustomTool && !isMcpFamily && currentToolId @@ -1562,12 +1590,14 @@ export const ToolInput = memo(function ToolInput({ const hasOperations = !isCustomTool && !isMcpFamily && hasMultipleOperations(toolBlock ?? undefined) - const hasToolBody = hasOperations || displaySubBlocks.length > 0 + const showToolControl = supportsToolControl && !(isMcpTool && isMcpToolUnavailable(tool)) + const hasToolBody = showToolControl || hasOperations || displaySubBlocks.length > 0 const isSearchExpanded = activeSearchTarget?.subBlockId === subBlockId && activeSearchTarget.valuePath[0] === toolIndex && - activeSearchTarget.valuePath[1] === 'params' + (activeSearchTarget.valuePath[1] === 'params' || + activeSearchTarget.valuePath[1] === 'usageControlExpression') const isExpandedForDisplay = hasToolBody ? isPreview || disabled ? isSearchExpanded || (localExpanded[toolIndex] ?? !!tool.isExpanded) @@ -1594,23 +1624,24 @@ export const ToolInput = memo(function ToolInput({
{ - if (isCustomTool) { - handleEditCustomTool(toolIndex) - } else if (hasToolBody) { + if (hasToolBody) { toggleToolExpansion(toolIndex) + } else if (isCustomTool) { + handleEditCustomTool(toolIndex) } }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { - if (isCustomTool) { - handleEditCustomTool(toolIndex) - } else if (hasToolBody) { + if (hasToolBody) { toggleToolExpansion(toolIndex) + } else if (isCustomTool) { + handleEditCustomTool(toolIndex) } } }} @@ -1687,65 +1718,19 @@ export const ToolInput = memo(function ToolInput({ )}
- {supportsToolControl && !(isMcpTool && isMcpToolUnavailable(tool)) && ( - setUsageControlPopoverIndex(open ? toolIndex : null)} - colorScheme='inverted' + {isCustomTool && hasToolBody && ( + - - e.stopPropagation()} - className='gap-0.5' - border - > - { - handleUsageControlChange(toolIndex, 'auto') - setUsageControlPopoverIndex(null) - }} - > - Auto (model decides) - - { - handleUsageControlChange(toolIndex, 'force') - setUsageControlPopoverIndex(null) - }} - > - Force{' '} - - {supportsForce ? '(always use)' : '(not supported by model)'} - - - { - handleUsageControlChange(toolIndex, 'none') - setUsageControlPopoverIndex(null) - }} - > - None - - - + + )} {isMcpTool && selectedTools.filter( @@ -1815,8 +1800,39 @@ export const ToolInput = memo(function ToolInput({
- {!isCustomTool && isExpandedForDisplay && ( + {isExpandedForDisplay && (
+ {showToolControl && ( + <> + + handleUsageControlChange(toolIndex, usageControl) + } + onExpressionChange={(usageControlExpression) => + handleUsageControlExpressionChange(toolIndex, usageControlExpression) + } + onModeToggle={() => { + const nextMode = + toolUsageControlMode === 'advanced' ? 'basic' : 'advanced' + collaborativeSetBlockCanonicalMode( + blockId, + buildAgentToolUsageControlCanonicalKey(toolIndex), + nextMode + ) + }} + /> + {(hasOperations || displaySubBlocks.length > 0) && ( + + )} + + )} {isAdvancedMcpServer && ( { + if (displaySubBlocks.length === 0) return null + const renderSubBlock = (sb: BlockSubBlockConfig): React.ReactNode => { const effectiveParamId = sb.id const canonicalId = toolCanonicalIndex?.canonicalIdBySubBlockId[sb.id] diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index 58e8c8c98a6..1d4baba49fd 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -1092,6 +1092,203 @@ describe('AgentBlockHandler', () => { expect(toolIds).not.toContain('transformed_tool_2') }) + it('uses the resolved canonical tool mode expression before filtering tools', async () => { + const inputs = { + model: 'gpt-4o', + userPrompt: 'Use the enabled tools.', + apiKey: 'test-api-key', + tools: [ + { + id: 'tool_1', + type: 'tool-type-1', + operation: 'operation1', + usageControl: 'force' as const, + usageControlExpression: 'none', + }, + { + id: 'tool_2', + type: 'tool-type-2', + operation: 'operation2', + usageControl: 'none' as const, + usageControlExpression: ' Force ', + }, + ], + } + const block = { + ...mockBlock, + canonicalModes: { + '0:agentToolUsageControl': 'advanced' as const, + '1:agentToolUsageControl': 'advanced' as const, + }, + } + + mockGetProviderFromModel.mockReturnValue('openai') + + await handler.execute(mockContext, block, inputs) + + expect(mockExecuteProviderRequest.mock.calls[0][1].tools).toEqual([ + expect.objectContaining({ id: 'transformed_tool_2', usageControl: 'force' }), + ]) + }) + + it.each([ + ['unsupported word', 'sometimes'], + ['empty string', ''], + ['whitespace', ' \n\t '], + ['missing value', undefined], + ['null', null], + ['number', 0], + ['boolean', true], + ['empty array', []], + ['array containing a valid mode', ['force']], + ['object containing a valid mode', { mode: 'force' }], + ['quoted mode', '"force"'], + ['unresolved reference', ''], + ['multiple modes', 'force\nnone'], + ['unicode lookalike', 'FORCE'], + ['invisible prefix', '\u200bforce'], + ['oversized resolved value', 'force'.repeat(1024)], + ])('rejects %s before provider or tool work', async (_label, usageControlExpression) => { + const inputs = { + model: 'gpt-4o', + userPrompt: 'Use the tool.', + apiKey: 'test-api-key', + tools: [ + { + id: 'tool_1', + type: 'tool-type-1', + operation: 'operation1', + usageControl: 'auto' as const, + usageControlExpression, + }, + ], + } + const block = { + ...mockBlock, + canonicalModes: { '0:agentToolUsageControl': 'advanced' as const }, + } + + await expect(handler.execute(mockContext, block, inputs)).rejects.toThrow( + 'Tool 1 mode must resolve to Auto, Force, or None' + ) + expect(mockExecuteProviderRequest).not.toHaveBeenCalled() + expect(mockTransformBlockTool).not.toHaveBeenCalled() + expect(mockReadAvailableCustomToolByIdOrTitleAsExecutor).not.toHaveBeenCalled() + expect(mockDiscoverMcpServerToolsAsExecutor).not.toHaveBeenCalled() + }) + + it('settles a secret-derived permission without sending its expression to the provider', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'QA_TOOL_MODE', plaintext: 'force', encryptedValue: 'encrypted-mode' }, + ]) + const path = ['tools', '0', 'usageControlExpression'] as const + registry.recordResolvedAtInputPath('QA_TOOL_MODE', 'force', path) + registry.recordResolvedInputProjection(path, 'force', '{{QA_TOOL_MODE}}') + mockContext.resolvedSecretTraceRegistry = registry + const inputs = { + model: 'gpt-4o', + userPrompt: 'Use the tool.', + apiKey: 'test-api-key', + tools: [{ id: 'tool_1', type: 'tool-type-1', usageControlExpression: 'force' }], + } + + await handler.execute( + mockContext, + { ...mockBlock, canonicalModes: { '0:agentToolUsageControl': 'advanced' } }, + inputs + ) + + const providerTools = mockExecuteProviderRequest.mock.calls[0][1].tools + expect(providerTools).toEqual([expect.objectContaining({ usageControl: 'force' })]) + expect(providerTools[0]).not.toHaveProperty('usageControlExpression') + expect(JSON.stringify(providerTools)).not.toContain('QA_TOOL_MODE') + expect(inputs.tools[0].usageControlExpression).toBe('{{QA_TOOL_MODE}}') + expect(mockContext.resolvedSecretTraceRegistry?.getActiveMatches()).toEqual([]) + }) + + it.each(['mcp', 'mcp-server-advanced'])( + 'skips discovery for a disabled %s tool', + async (type) => { + mockDiscoverMcpServerToolsAsExecutor.mockRejectedValue(new Error('MCP unavailable')) + await handler.execute( + mockContext, + { ...mockBlock, canonicalModes: { '0:agentToolUsageControl': 'advanced' } }, + { + model: 'gpt-4o', + userPrompt: 'Reply without tools.', + apiKey: 'test-api-key', + tools: [ + { type, params: { serverId: 'unavailable-server' }, usageControlExpression: 'none' }, + ], + } + ) + expect(mockDiscoverMcpServerToolsAsExecutor).not.toHaveBeenCalled() + expect(mockExecuteProviderRequest.mock.calls[0][1].tools).toEqual([]) + } + ) + + it('ignores an invalid inactive expression in selector mode', async () => { + const inputs = { + model: 'gpt-4o', + userPrompt: 'Use the enabled tool.', + apiKey: 'test-api-key', + tools: [ + { + id: 'tool_1', + type: 'tool-type-1', + operation: 'operation1', + usageControl: 'force' as const, + usageControlExpression: { invalid: true }, + }, + ], + } + + await handler.execute(mockContext, mockBlock, inputs) + + expect(mockExecuteProviderRequest.mock.calls[0][1].tools).toEqual([ + expect.objectContaining({ id: 'transformed_tool_1', usageControl: 'force' }), + ]) + }) + + it('keeps original tool inputs intact after a provider error and resolves the next run afresh', async () => { + const inputs = { + model: 'gpt-4o', + userPrompt: 'Use the enabled tool.', + apiKey: 'test-api-key', + tools: [ + { + id: 'tool_1', + type: 'tool-type-1', + operation: 'operation1', + usageControl: 'none' as const, + usageControlExpression: 'force', + }, + ], + } + const originalInputs = structuredClone(inputs) + const block = { + ...mockBlock, + canonicalModes: { '0:agentToolUsageControl': 'advanced' as const }, + } + mockExecuteProviderRequest.mockRejectedValueOnce(new Error('Provider unavailable')) + + await expect(handler.execute(mockContext, block, inputs)).rejects.toThrow( + 'Provider unavailable' + ) + expect(inputs).toEqual(originalInputs) + expect(mockExecuteProviderRequest.mock.calls[0][1].tools).toEqual([ + expect.objectContaining({ usageControl: 'force' }), + ]) + + await handler.execute(mockContext, block, { + ...inputs, + tools: [{ ...inputs.tools[0], usageControlExpression: 'none' }], + }) + + expect(mockExecuteProviderRequest.mock.calls[1][1].tools).toEqual([]) + expect(inputs).toEqual(originalInputs) + }) + it('should include usageControl property in transformed tools', async () => { const inputs = { model: 'gpt-4o', diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index a78105e8aba..476a881e7f7 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -35,6 +35,10 @@ import { import { selectModelBoundFileInputPaths } from '@/lib/uploads/utils/model-input' import { hydrateUserFilesWithBase64 } from '@/lib/uploads/utils/user-file-base64.server' import { resolveCustomBlockToolBinding } from '@/lib/workflows/custom-blocks/operations' +import { + getAgentToolUsageControlMode, + resolveAgentToolUsageControl, +} from '@/lib/workflows/tool-input/usage-control' import { getAllBlocks, getBlock } from '@/blocks' import { assembleCustomBlockInputMapping, isCustomBlockType } from '@/blocks/custom/build-config' import type { BlockOutput } from '@/blocks/types' @@ -226,9 +230,6 @@ export class AgentBlockHandler implements BlockHandler { AGENT_RAW_PROVIDER_ERROR_INPUT_PATHS ) ctx.errorResolvedSecretTraceRegistry = providerErrorRegistry - const toolIndexByRef = new Map( - (inputs.tools || []).map((tool, index) => [tool, index] as const) - ) const privateAgentSelectorInputPaths: ResolvedSecretInputPath[] = [] let responseFormatModelInputPaths: ResolvedSecretInputPath[] = [] let privateAgentSelectorsSettled = false @@ -244,6 +245,10 @@ export class AgentBlockHandler implements BlockHandler { } try { + const tools = this.resolveToolUsageControls(inputs.tools || [], block.canonicalModes) + const toolIndexByRef = new Map( + tools.map((tool, index) => [tool, index] as const) + ) const privateAgentSelectors = this.getPrivateAgentSelectorInputPaths(ctx, inputs, []) privateAgentSelectorInputPaths.push(...privateAgentSelectors.inputPaths) if (!privateAgentSelectors.complete) { @@ -263,8 +268,7 @@ export class AgentBlockHandler implements BlockHandler { } ) responseFormatModelInputPaths = responseFormatProjection.inputPaths - const filteredTools = inputs.tools || [] - const filteredInputs = { ...inputs, tools: filteredTools } + const filteredInputs = { ...inputs, tools } this.assertInputPathsDoNotResolveSecrets( ctx, this.getMessageStructuralInputPaths(filteredInputs), @@ -294,7 +298,7 @@ export class AgentBlockHandler implements BlockHandler { ...modelInputProjection.value, responseFormat: responseFormatProjection.value, } - const projectedToolInputs = this.projectToolInputsForProvenance(ctx, inputs.tools || []) + const projectedToolInputs = this.projectToolInputsForProvenance(ctx, tools) await this.validateToolPermissions(ctx, filteredInputs.tools || []) @@ -456,6 +460,24 @@ export class AgentBlockHandler implements BlockHandler { } } + private resolveToolUsageControls( + tools: ToolInput[], + canonicalModes?: Record + ): ToolInput[] { + return tools.map((tool, toolIndex) => { + if (getAgentToolUsageControlMode(toolIndex, canonicalModes) === 'basic') return tool + + const usageControl = resolveAgentToolUsageControl(tool, toolIndex, canonicalModes) + if (!usageControl) { + throw new Error( + `Tool ${toolIndex + 1} mode must resolve to Auto, Force, or None before the Agent can run.` + ) + } + + return { ...tool, usageControl } + }) + } + /** * Derives the compact routing signals for sim-auto resolution from the * block's resolved inputs. Excerpts only — the resolver truncates further @@ -619,13 +641,6 @@ export class AgentBlockHandler implements BlockHandler { } } - /** - * `canonicalModes` overrides are keyed by each tool's position in the ORIGINAL, unfiltered - * tools array (matching what the editor wrote), not by `tool.type` - so two tool entries of - * the same type (e.g. two Table tools) resolve independently. `toolIndexByRef` preserves that - * original position across the mcp-availability filter and the mcp/other split below, both of - * which would otherwise renumber tools by their post-filter position. - */ private projectToolInputsForProvenance( ctx: ExecutionContext, inputTools: ToolInput[] @@ -645,6 +660,10 @@ export class AgentBlockHandler implements BlockHandler { return projection.value.tools as ToolInput[] } + /** + * Preserve original tool indexes through disabled-tool filtering and MCP grouping + * so canonical modes and secret provenance stay attached to the configured tool. + */ private async formatTools( ctx: ExecutionContext, inputTools: ToolInput[], @@ -1651,6 +1670,9 @@ export class AgentBlockHandler implements BlockHandler { if (inputs.tools?.[toolIndex]?.customToolId) { candidatePaths.push(['tools', String(toolIndex), 'customToolId']) } + if (inputs.tools?.[toolIndex]?.usageControlExpression) { + candidatePaths.push(['tools', String(toolIndex), 'usageControlExpression']) + } } for (let skillIndex = 0; skillIndex < (inputs.skills?.length ?? 0); skillIndex++) { if (inputs.skills?.[skillIndex]?.skillId) { diff --git a/apps/sim/executor/handlers/agent/types.ts b/apps/sim/executor/handlers/agent/types.ts index 6ba968f9904..d9b5d134120 100644 --- a/apps/sim/executor/handlers/agent/types.ts +++ b/apps/sim/executor/handlers/agent/types.ts @@ -65,6 +65,8 @@ export interface ToolInput { params?: Record timeout?: number usageControl?: 'auto' | 'force' | 'none' + /** Resolved value from the variable-capable tool mode input. */ + usageControlExpression?: unknown operation?: string /** Database ID for custom tools (new reference format) */ customToolId?: string diff --git a/apps/sim/executor/variables/resolver.test.ts b/apps/sim/executor/variables/resolver.test.ts index f4243461260..4274c37b581 100644 --- a/apps/sim/executor/variables/resolver.test.ts +++ b/apps/sim/executor/variables/resolver.test.ts @@ -2108,6 +2108,13 @@ describe('VariableResolver agent model levels', () => { reasoningEffort: '', verbosity: '', thinkingLevel: '{{THINKING}}', + tools: [ + { + type: 'search', + usageControl: 'auto', + usageControlExpression: '', + }, + ], }) const workflow: SerializedWorkflow = { version: '1', @@ -2138,6 +2145,7 @@ describe('VariableResolver agent model levels', () => { expect(result.reasoningEffort).toBe('high') expect(result.verbosity).toBe('low') expect(result.thinkingLevel).toBe('medium') + expect(result.tools[0].usageControlExpression).toBe('low') expect(result.model).toBe('gpt-5') }) }) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/workflow-agent-tools.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/workflow-agent-tools.test.ts index 2fb11b55462..ff66c4435cb 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/workflow-agent-tools.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/workflow-agent-tools.test.ts @@ -11,6 +11,28 @@ import { import { MAX_MCP_TOOL_NAME_BYTES } from '@/lib/mcp/constants' describe('v2AgentToolInputSchema', () => { + it.each([ + { type: 'search', operation: 'search' }, + { type: 'custom-tool', customToolId: 'cst_123' }, + { + type: 'custom-tool', + schema: { type: 'function', function: { name: 'probe', parameters: { type: 'object' } } }, + code: 'return true', + }, + { type: 'mcp', params: { serverId: 'mcp_123', toolName: 'probe' } }, + { type: 'mcp-server-advanced', params: { serverId: 'mcp_123' } }, + ])('enforces expression type and size consistently for $type', (tool) => { + for (const value of ['', 'none', '', 'a'.repeat(2048)]) { + const entry = { ...tool, usageControlExpression: value } + expect(v2AgentToolInputSchema.parse([entry])).toEqual([entry]) + } + for (const value of [null, true, 0, ['auto'], { mode: 'auto' }, 'a'.repeat(2049)]) { + expect( + v2AgentToolInputSchema.safeParse([{ ...tool, usageControlExpression: value }]).success + ).toBe(false) + } + }) + it('accepts literal tool-name policies with a runtime server reference', () => { const tools = [ { @@ -44,6 +66,7 @@ describe('v2AgentToolInputSchema', () => { type: 'cloudwatch', operation: 'describe_alarm_history', usageControl: 'auto', + usageControlExpression: '', params: { region: 'us-east-1' }, }, { diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 2cadb32693d..7bf93b7b7d4 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -2906,6 +2906,13 @@ const v2AgentToolUsageControlSchema = z 'When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`.' ) +const v2AgentToolUsageControlExpressionSchema = z + .string() + .max(2048, 'Agent tool mode expression must be at most 2048 characters') + .describe( + 'Variable-capable tool mode value used when the matching canonical mode is `advanced`. It must resolve to `auto`, `force`, or `none` at execution time.' + ) + const v2AgentToolParamsSchema = z .record(z.string(), z.unknown().describe('One tool parameter value.')) .describe( @@ -2937,6 +2944,7 @@ export const v2AgentIntegrationToolSchema = z 'Operation ID from Get Block. Required when the block exposes multiple operations; it may differ from the tool ID.' ), usageControl: v2AgentToolUsageControlSchema.optional(), + usageControlExpression: v2AgentToolUsageControlExpressionSchema.optional(), params: v2AgentToolParamsSchema.optional(), }) .catchall( @@ -2969,6 +2977,7 @@ const v2AgentCustomToolReferenceSchema = z .max(255, 'Agent customToolId must be at most 255 characters') .describe('Custom tool ID from List Custom Tools.'), usageControl: v2AgentToolUsageControlSchema.optional(), + usageControlExpression: v2AgentToolUsageControlExpressionSchema.optional(), }) .catchall( z @@ -3005,6 +3014,7 @@ const v2AgentInlineCustomToolSchema = z .describe('Inline OpenAI-style function declaration.'), code: z.string().describe('Inline tool implementation executed by the Function runtime.'), usageControl: v2AgentToolUsageControlSchema.optional(), + usageControlExpression: v2AgentToolUsageControlExpressionSchema.optional(), }) .catchall( z @@ -3065,6 +3075,7 @@ export const v2AgentMcpToolSchema = z 'MCP server and tool identity plus any tool arguments fixed by the workflow author.' ), usageControl: v2AgentToolUsageControlSchema.optional(), + usageControlExpression: v2AgentToolUsageControlExpressionSchema.optional(), }) .catchall( z.unknown().describe('Forward-compatible MCP tool metadata preserved by the workflow editor.') @@ -3103,6 +3114,7 @@ export const v2AgentMcpServerAdvancedSchema = z 'Executable server or connection identity for authorized operation discovery and execution.' ), usageControl: v2AgentToolUsageControlSchema.optional(), + usageControlExpression: v2AgentToolUsageControlExpressionSchema.optional(), }) .catchall( z.unknown().describe('Forward-compatible MCP server metadata preserved by the workflow editor.') diff --git a/apps/sim/lib/workflows/editing/builders.test.ts b/apps/sim/lib/workflows/editing/builders.test.ts index d618b7a2d7c..7bfc9cce562 100644 --- a/apps/sim/lib/workflows/editing/builders.test.ts +++ b/apps/sim/lib/workflows/editing/builders.test.ts @@ -2,15 +2,19 @@ * @vitest-environment node */ import { describe, expect, it, vi } from 'vitest' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { applyBlockRetry, applyTriggerConfigToBlockSubblocks, createBlockFromParams, filterDisallowedTools, normalizeSubblockValue, + normalizeTools, resolveBlockRetryUpdate, + updateCanonicalModesForInputs, } from '@/lib/workflows/editing/builders' import type { SkippedItem } from '@/lib/workflows/editing/types' +import { getBlock } from '@/blocks/registry' const { mockIsIntegrationDeploymentAvailable } = vi.hoisted(() => ({ mockIsIntegrationDeploymentAvailable: vi.fn(() => true), @@ -26,7 +30,10 @@ const agentBlockConfig = { outputs: { content: { type: 'string', description: 'Default content output' }, }, - subBlocks: [{ id: 'responseFormat', type: 'response-format' }], + subBlocks: [ + { id: 'responseFormat', type: 'response-format' }, + { id: 'tools', type: 'tool-input' }, + ], } const conditionBlockConfig = { @@ -115,6 +122,24 @@ describe('createBlockFromParams', () => { expect(block.outputs.answer.type).toBe('string') }) + it('selects variable Tool Mode when an agent tool supplies an expression', () => { + const block = createBlockFromParams('b-agent', { + type: 'agent', + name: 'Agent', + inputs: { + tools: [ + { + type: 'custom-tool', + customToolId: 'custom-1', + usageControlExpression: '', + }, + ], + }, + }) + + expect(block.data.canonicalModes['0:agentToolUsageControl']).toBe('advanced') + }) + it('preserves configured subblock types and normalizes condition branch ids', () => { const block = createBlockFromParams('condition-1', { type: 'condition', @@ -177,7 +202,113 @@ describe('createBlockFromParams', () => { }) }) +describe('retaining agent permission modes across tool-array edits', () => { + const tool = (id: string, expression = 'none') => ({ + type: 'custom-tool', + customToolId: id, + usageControl: 'force', + usageControlExpression: expression, + }) + + function editTools( + previous: ReturnType[], + next: ReturnType[], + modes: Record + ) { + const block = createBlockFromParams('agent', { + type: 'agent', + name: 'Agent', + inputs: { tools: previous }, + }) + const previousTools = block.subBlocks.tools.value + block.data.canonicalModes = modes + block.subBlocks.tools.value = normalizeTools(next) + updateCanonicalModesForInputs(block, ['tools'], getBlock('agent')!, previousTools) + return block.data.canonicalModes + } + + it('does not let a replacement inherit a removed tool mode', () => { + expect( + editTools([tool('removed')], [tool('new')], { '0:agentToolUsageControl': 'advanced' }) + ).toEqual({}) + }) + + it('matches repeated callable identities by their retained configuration', () => { + const first = tool('repeated', 'none') + const second = tool('repeated', 'auto') + expect( + editTools([first, second], [second, first], { '1:agentToolUsageControl': 'advanced' }) + ).toEqual({ '0:agentToolUsageControl': 'advanced' }) + }) + + it('keeps different modes for identical tools on an unchanged round trip', () => { + const repeated = [tool('repeated'), tool('repeated')] + const modes = { '1:agentToolUsageControl': 'advanced' as const } + expect(editTools(repeated, structuredClone(repeated), modes)).toEqual(modes) + }) + + it('rejects an ambiguous duplicate removal instead of enabling the fixed fallback', () => { + expect(() => + editTools([tool('repeated'), tool('repeated')], [tool('repeated')], { + '1:agentToolUsageControl': 'advanced', + }) + ).toThrow('ambiguous canonical modes') + }) + + it('clears removed tool modes without clearing unrelated canonical settings', () => { + expect( + editTools([tool('removed')], [], { + '0:agentToolUsageControl': 'advanced', + model: 'advanced', + }) + ).toEqual({ model: 'advanced' }) + }) + + it('preserves every mode when reversing the maximum API tool count', () => { + const previous = Array.from({ length: 100 }, (_, index) => tool(`custom-${index}`)) + const modes: Record = {} + const expected: Record = {} + for (let index = 0; index < previous.length; index += 2) { + modes[`${index}:agentToolUsageControl`] = 'advanced' + expected[`${previous.length - index - 1}:agentToolUsageControl`] = 'advanced' + } + expect(editTools(previous, [...previous].reverse(), modes)).toEqual(expected) + }) +}) + describe('filterDisallowedTools', () => { + it('assigns canonical tool modes after removing disallowed tools', () => { + const block = createBlockFromParams( + 'agent-1', + { + type: 'agent', + name: 'Agent', + inputs: { + tools: [ + { + type: 'mcp', + params: { serverId: 'server-1', toolName: 'search' }, + usageControl: 'auto', + }, + { + type: 'custom-tool', + customToolId: 'custom-1', + usageControlExpression: '', + }, + ], + }, + }, + undefined, + undefined, + { ...DEFAULT_PERMISSION_GROUP_CONFIG, disableMcpTools: true } + ) + + expect(block.subBlocks.tools.value).toEqual([ + expect.objectContaining({ customToolId: 'custom-1' }), + ]) + expect(block.data.canonicalModes).toEqual({ '0:agentToolUsageControl': 'advanced' }) + }) + it('removes unavailable integration tools even without a permission group', () => { mockIsIntegrationDeploymentAvailable.mockImplementation((type: string) => type !== 'slack') const skippedItems: Parameters[3] = [] @@ -194,6 +325,29 @@ describe('filterDisallowedTools', () => { }) }) +describe('normalizeTools', () => { + it('preserves a custom tool variable-backed usage mode', () => { + expect( + normalizeTools([ + { + type: 'custom-tool', + customToolId: 'custom-1', + usageControl: 'auto', + usageControlExpression: '', + }, + ]) + ).toEqual([ + { + type: 'custom-tool', + customToolId: 'custom-1', + usageControl: 'auto', + usageControlExpression: '', + isExpanded: true, + }, + ]) + }) +}) + describe('normalizeSubblockValue', () => { it.each(['tagFilters', 'documentTags', 'conditions', 'routes'])( 'serializes %s to a JSON string the subblock component can parse', diff --git a/apps/sim/lib/workflows/editing/builders.ts b/apps/sim/lib/workflows/editing/builders.ts index 340b050f2a2..710ccddee1b 100644 --- a/apps/sim/lib/workflows/editing/builders.ts +++ b/apps/sim/lib/workflows/editing/builders.ts @@ -26,6 +26,10 @@ import { buildDefaultCanonicalModes, isCanonicalPair, } from '@/lib/workflows/subblocks/visibility' +import { + AGENT_TOOL_USAGE_CONTROL_CANONICAL_ID, + buildAgentToolUsageControlCanonicalKey, +} from '@/lib/workflows/tool-input/usage-control' import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' import { getBlock } from '@/blocks/registry' import type { BlockConfig } from '@/blocks/types' @@ -324,6 +328,10 @@ export function updateCanonicalModesForInputs( block.data?.canonicalModes ?? {}, collectExplicitToolCanonicalModes(tools) ) + tools.forEach((_, index) => { + const key = buildAgentToolUsageControlCanonicalKey(index) + if (canonicalModes[key] === 'basic') delete canonicalModes[key] + }) block.data = { ...block.data, canonicalModes } } } @@ -334,6 +342,13 @@ function collectExplicitToolCanonicalModes(tools: unknown[]) { tools.forEach((tool, index) => { if (!isRecordLike(tool)) return const choices: Record = {} + const hasFixedPermission = tool.usageControl !== undefined + const hasPermissionExpression = tool.usageControlExpression !== undefined + if (!hasPermissionExpression || !hasFixedPermission) { + choices[AGENT_TOOL_USAGE_CONTROL_CANONICAL_ID] = hasPermissionExpression + ? 'advanced' + : 'basic' + } const config = typeof tool.type === 'string' ? getBlock(tool.type) : undefined if (config && isRecordLike(tool.params)) { const params = tool.params @@ -363,7 +378,9 @@ export function normalizeTools(tools: any[]): any[] { return { type: tool.type, customToolId: tool.customToolId, - usageControl: tool.usageControl || 'auto', + usageControl: + tool.usageControl || (tool.usageControlExpression === undefined ? 'auto' : undefined), + usageControlExpression: tool.usageControlExpression, isExpanded: tool.isExpanded ?? true, } } diff --git a/apps/sim/lib/workflows/editing/operations.test.ts b/apps/sim/lib/workflows/editing/operations.test.ts index 43f5956e769..368fa3236d1 100644 --- a/apps/sim/lib/workflows/editing/operations.test.ts +++ b/apps/sim/lib/workflows/editing/operations.test.ts @@ -402,6 +402,166 @@ describe('handleEditOperation dependent inputs', () => { projectId: 'PROJECT-NEW', }) }) + + it.each(['removal', 'reorder', 'insertion', 'edited reorder'] as const)( + 'preserves a surviving tool permission mode after %s', + (mutation) => { + const retainedTool = { + type: 'custom-tool', + customToolId: 'custom-retained', + usageControl: 'force', + usageControlExpression: 'none', + } + const workflow = { + blocks: { + agent: { + id: 'agent', + type: 'agent', + name: 'Agent', + position: { x: 0, y: 0 }, + enabled: true, + subBlocks: { + tools: { + id: 'tools', + type: 'tool-input', + value: [ + { type: 'custom-tool', customToolId: 'custom-removed', usageControl: 'auto' }, + retainedTool, + ], + }, + }, + outputs: {}, + data: { canonicalModes: { '1:agentToolUsageControl': 'advanced' as const } }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + } + + const originalTools = workflow.blocks.agent.subBlocks.tools.value + const nextTools = + mutation === 'removal' + ? [retainedTool] + : mutation === 'insertion' + ? [ + { type: 'custom-tool', customToolId: 'custom-new', usageControl: 'auto' }, + ...originalTools, + ] + : [ + mutation === 'edited reorder' + ? { ...retainedTool, usageControlExpression: '' } + : retainedTool, + originalTools[0], + ] + + const { state } = applyOperationsToWorkflowState(workflow, [ + { + operation_type: 'edit', + block_id: 'agent', + params: { inputs: { tools: structuredClone(nextTools) } }, + }, + ]) + + expect(state.blocks.agent.data.canonicalModes).toEqual({ + [`${mutation === 'insertion' ? 2 : 0}:agentToolUsageControl`]: 'advanced', + }) + } + ) + + it('switches nested Agent Tool Mode based on the canonical field supplied', () => { + const workflow = { + blocks: { + 'agent-1': { + id: 'agent-1', + type: 'agent', + name: 'Agent 1', + position: { x: 0, y: 0 }, + enabled: true, + subBlocks: { + tools: { + id: 'tools', + type: 'tool-input', + value: [ + { + type: 'custom-tool', + customToolId: 'custom-1', + usageControl: 'auto', + usageControlExpression: '', + }, + ], + }, + }, + outputs: {}, + data: {}, + }, + }, + edges: [], + loops: {}, + parallels: {}, + } + + const basicRoundTrip = applyOperationsToWorkflowState(workflow, [ + { + operation_type: 'edit', + block_id: 'agent-1', + params: { + inputs: { + tools: [ + { + type: 'custom-tool', + customToolId: 'custom-1', + usageControl: 'auto', + usageControlExpression: '', + }, + ], + }, + }, + }, + ]).state + + expect(basicRoundTrip.blocks['agent-1'].data.canonicalModes).not.toHaveProperty( + '0:agentToolUsageControl' + ) + + const advanced = applyOperationsToWorkflowState(basicRoundTrip, [ + { + operation_type: 'edit', + block_id: 'agent-1', + params: { + inputs: { + tools: [ + { + type: 'custom-tool', + customToolId: 'custom-1', + usageControlExpression: '', + }, + ], + }, + }, + }, + ]).state + + expect(advanced.blocks['agent-1'].data.canonicalModes).toMatchObject({ + '0:agentToolUsageControl': 'advanced', + }) + + const basic = applyOperationsToWorkflowState(advanced, [ + { + operation_type: 'edit', + block_id: 'agent-1', + params: { + inputs: { + tools: [{ type: 'custom-tool', customToolId: 'custom-1', usageControl: 'force' }], + }, + }, + }, + ]).state + + expect(basic.blocks['agent-1'].data.canonicalModes).not.toHaveProperty( + '0:agentToolUsageControl' + ) + }) }) function makeParallelWorkflow() { diff --git a/apps/sim/lib/workflows/operations/import-export.test.ts b/apps/sim/lib/workflows/operations/import-export.test.ts index 26fa1968ad8..a4e8bc1afdf 100644 --- a/apps/sim/lib/workflows/operations/import-export.test.ts +++ b/apps/sim/lib/workflows/operations/import-export.test.ts @@ -10,6 +10,7 @@ vi.mock('@/blocks/registry-maps', async () => { const { partialBlockRegistry } = await import('@sim/testing/mocks/block-registry.mock') return partialBlockRegistry( await import('@/blocks/blocks/knowledge'), + await import('@/blocks/blocks/agent'), await import('@/blocks/blocks/start_trigger') ) }) @@ -18,6 +19,8 @@ vi.mock('@/lib/api/client/request', () => ({ requestJson: vi.fn().mockResolvedValue({}), })) +import { requestJson } from '@/lib/api/client/request' +import type { WorkflowStateContractInput } from '@/lib/api/contracts/workflows' import { extractWorkflowName, parseWorkflowJson, @@ -151,6 +154,46 @@ describe('workflow import/export parsing', () => { }) }) +it('preserves variable permissions and the dormant selector through import', async () => { + const state = createLegacyState() + const tool = { type: 'function', usageControl: 'none', usageControlExpression: '' } + const tools = { id: 'tools', type: 'tool-input', value: [tool] } + const canonicalModes = { '0:agentToolUsageControl': 'advanced' } + const content = JSON.stringify({ + state: { + ...state, + blocks: { + ...state.blocks, + agent: { + ...state.blocks['start-1'], + id: 'agent', + name: 'Agent', + type: 'agent', + subBlocks: { tools }, + data: { canonicalModes }, + }, + }, + }, + }) + const createWorkflow = vi.fn().mockResolvedValue({ id: 'imported-workflow' }) + await expect( + persistImportedWorkflow({ + content, + filename: 'workflow.json', + workspaceId: 'ws-1', + createWorkflow, + }) + ).resolves.toMatchObject({ workflowId: 'imported-workflow' }) + const written = vi.mocked(requestJson).mock.calls.at(-1)?.[1].body as WorkflowStateContractInput + expect(Object.values(written.blocks)).toContainEqual( + expect.objectContaining({ + type: 'agent', + subBlocks: expect.objectContaining({ tools }), + data: expect.objectContaining({ canonicalModes: expect.objectContaining(canonicalModes) }), + }) + ) +}) + describe('persistImportedWorkflow description handling', () => { function buildContent(description?: string) { const state = createLegacyState() diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts index 667b67963d5..fd4f29ab814 100644 --- a/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts +++ b/apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts @@ -163,6 +163,42 @@ describe('sanitizeForCopilot server-only block inputs', () => { }) }) +describe('sanitizeForCopilot Agent tool modes', () => { + it('preserves fixed and variable-backed usage modes while removing UI state', () => { + const result = sanitizeForCopilot( + makeSingleBlockWorkflow('agent-1', { + type: 'agent', + name: 'Agent 1', + enabled: true, + subBlocks: { + tools: { + id: 'tools', + type: 'tool-input', + value: [ + { + type: 'custom-tool', + customToolId: 'custom-1', + usageControl: 'auto', + usageControlExpression: '', + isExpanded: true, + }, + ], + }, + }, + }) + ) + + expect(result.blocks['agent-1'].inputs?.tools).toEqual([ + { + type: 'custom-tool', + customToolId: 'custom-1', + usageControl: 'auto', + usageControlExpression: '', + }, + ]) + }) +}) + describe('sanitizeForCopilot product-gated block inputs', () => { it('retains a persisted Function sandbox selection for model-visible read access', () => { const state = makeSingleBlockWorkflow('function-1', { diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts index 5878ea23889..297a4c3af6c 100644 --- a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts +++ b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts @@ -189,6 +189,7 @@ interface ToolInput { title?: string toolId?: string usageControl?: string + usageControlExpression?: string isExpanded?: boolean [key: string]: unknown } @@ -198,6 +199,7 @@ interface SanitizedTool { type: string customToolId?: string usageControl?: string + usageControlExpression?: string title?: string toolId?: string schema?: { @@ -224,6 +226,7 @@ function sanitizeTools(tools: ToolInput[]): SanitizedTool[] { type: tool.type, customToolId: tool.customToolId, usageControl: tool.usageControl, + usageControlExpression: tool.usageControlExpression, } } @@ -233,6 +236,7 @@ function sanitizeTools(tools: ToolInput[]): SanitizedTool[] { title: tool.title, toolId: tool.toolId, usageControl: tool.usageControl, + usageControlExpression: tool.usageControlExpression, } // Include schema for inline format (legacy format) diff --git a/apps/sim/lib/workflows/search-replace/indexer.test.ts b/apps/sim/lib/workflows/search-replace/indexer.test.ts index 43e247c32f9..e2581a05a3c 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.test.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.test.ts @@ -1698,6 +1698,67 @@ describe('indexWorkflowSearchMatches', () => { expect(matches.some((match) => match.valuePath.includes('schema'))).toBe(false) }) + it('indexes only the active variable-capable Agent tool mode value', () => { + const workflow = createSearchReplaceWorkflowFixture() + workflow.blocks['tool-input-1'] = { + id: 'tool-input-1', + type: 'custom', + name: 'Tool Input Block', + position: { x: 0, y: 0 }, + enabled: true, + outputs: {}, + data: { canonicalModes: { '0:agentToolUsageControl': 'advanced' } }, + subBlocks: { + tools: { + id: 'tools', + type: 'tool-input', + value: [ + { + type: 'native', + usageControl: 'auto', + usageControlExpression: '', + }, + ], + }, + }, + } + const blockConfigs = { + ...SEARCH_REPLACE_BLOCK_CONFIGS, + custom: { subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' as const }] }, + native: { name: 'Native', subBlocks: [] }, + } + + const advancedMatches = indexWorkflowSearchMatches({ + workflow, + query: 'route', + mode: 'all', + blockConfigs, + }).filter((match) => match.blockId === 'tool-input-1') + + expect(advancedMatches.map((match) => match.kind)).toEqual(['text', 'workflow-reference']) + expect(advancedMatches).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + fieldTitle: 'Permission Mode', + valuePath: [0, 'usageControlExpression'], + searchText: '', + }), + ]) + ) + + workflow.blocks['tool-input-1'].data = { + canonicalModes: { '0:agentToolUsageControl': 'basic' }, + } + const basicMatches = indexWorkflowSearchMatches({ + workflow, + query: 'route', + mode: 'all', + blockConfigs, + }).filter((match) => match.blockId === 'tool-input-1') + + expect(basicMatches).toHaveLength(0) + }) + it('indexes canonical MCP and custom-tool names over mutated stored titles', () => { const workflow = createSearchReplaceWorkflowFixture() workflow.blocks['tool-input-1'] = { diff --git a/apps/sim/lib/workflows/search-replace/indexer.ts b/apps/sim/lib/workflows/search-replace/indexer.ts index 575d2e96db6..128972236fd 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.ts @@ -47,6 +47,7 @@ import { } from '@/lib/workflows/subblocks/visibility' import { isSyntheticToolSubBlockId } from '@/lib/workflows/tool-input/synthetic-subblocks' import { type ParsedStoredTool, parseStoredToolInputValue } from '@/lib/workflows/tool-input/types' +import { getAgentToolUsageControlMode } from '@/lib/workflows/tool-input/usage-control' import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' import { isReference } from '@/executor/constants' @@ -954,24 +955,35 @@ function addToolInputMatches({ }) } - const params = getToolInputParamConfigs({ - tool, - toolIndex, - parentCanonicalModes, - credentialTypeById, - blockConfigs, - }) + const params: Array = + getToolInputParamConfigs({ + tool, + toolIndex, + parentCanonicalModes, + credentialTypeById, + blockConfigs, + }).map((param) => ({ ...param, valuePath: [toolIndex, 'params', param.paramId] })) + + if (getAgentToolUsageControlMode(toolIndex, parentCanonicalModes) === 'advanced') { + params.unshift({ + paramId: 'usageControlExpression', + config: { id: 'usageControlExpression', title: 'Permission Mode', type: 'short-input' }, + value: tool.usageControlExpression, + valuePath: [toolIndex, 'usageControlExpression'], + authoritative: false, + }) + } for (const { paramId, config, value: paramValue, + valuePath: basePath, selectorContext, dependentValuePaths, } of params) { const subBlockType = config.type const structuredResourceKind = getResourceKindForSubBlock(config) - const basePath: WorkflowSearchValuePath = [toolIndex, 'params', paramId] const nestedDependentValuePaths = dependentValuePaths?.map((path) => [toolIndex, ...path]) if (mode !== 'resource' && !structuredResourceKind) { diff --git a/apps/sim/lib/workflows/tool-input/types.ts b/apps/sim/lib/workflows/tool-input/types.ts index caa3d655b7e..e7ba7fda9ee 100644 --- a/apps/sim/lib/workflows/tool-input/types.ts +++ b/apps/sim/lib/workflows/tool-input/types.ts @@ -30,6 +30,7 @@ export interface StoredTool { code?: string operation?: string usageControl?: 'auto' | 'force' | 'none' + usageControlExpression?: string } export interface ParsedStoredTool extends Omit { @@ -67,6 +68,10 @@ export function parseStoredToolInputValue(value: unknown): ParsedStoredTool[] { record.usageControl === 'none' ? record.usageControl : undefined, + usageControlExpression: + typeof record.usageControlExpression === 'string' + ? record.usageControlExpression + : undefined, isExpanded: typeof record.isExpanded === 'boolean' ? record.isExpanded : undefined, schema: isRecordLike(record.schema) ? (record.schema as StoredToolSchema) : undefined, }, diff --git a/apps/sim/lib/workflows/tool-input/usage-control.test.ts b/apps/sim/lib/workflows/tool-input/usage-control.test.ts new file mode 100644 index 00000000000..13897b563ed --- /dev/null +++ b/apps/sim/lib/workflows/tool-input/usage-control.test.ts @@ -0,0 +1,65 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { parseStoredToolInputValue } from '@/lib/workflows/tool-input/types' +import { + buildAgentToolUsageControlCanonicalKey, + getAgentToolUsageControlMode, + resolveAgentToolUsageControl, +} from '@/lib/workflows/tool-input/usage-control' + +describe('agent tool usage control', () => { + it('defaults legacy tools to Auto in basic mode', () => { + expect(resolveAgentToolUsageControl({}, 0)).toBe('auto') + }) + + it('uses the fixed selection in basic mode', () => { + expect( + resolveAgentToolUsageControl({ usageControl: 'force', usageControlExpression: 'none' }, 2, { + [buildAgentToolUsageControlCanonicalKey(2)]: 'basic', + }) + ).toBe('force') + }) + + it('uses and normalizes the resolved expression in advanced mode', () => { + expect( + resolveAgentToolUsageControl({ usageControl: 'auto', usageControlExpression: ' Force ' }, 1, { + [buildAgentToolUsageControlCanonicalKey(1)]: 'advanced', + }) + ).toBe('force') + }) + + it('rejects an empty or unsupported advanced value', () => { + const overrides = { [buildAgentToolUsageControlCanonicalKey(0)]: 'advanced' } as const + + expect( + resolveAgentToolUsageControl({ usageControlExpression: '' }, 0, overrides) + ).toBeUndefined() + expect( + resolveAgentToolUsageControl({ usageControlExpression: 'sometimes' }, 0, overrides) + ).toBeUndefined() + }) + + it('scopes canonical mode independently by tool index', () => { + const overrides = { [buildAgentToolUsageControlCanonicalKey(1)]: 'advanced' } as const + + expect(getAgentToolUsageControlMode(0, overrides)).toBe('basic') + expect(getAgentToolUsageControlMode(1, overrides)).toBe('advanced') + }) + + it('preserves the variable-capable value when parsing a stored tool input', () => { + expect( + parseStoredToolInputValue([ + { + type: 'search', + usageControl: 'auto', + usageControlExpression: '', + }, + ])[0] + ).toMatchObject({ + usageControl: 'auto', + usageControlExpression: '', + }) + }) +}) diff --git a/apps/sim/lib/workflows/tool-input/usage-control.ts b/apps/sim/lib/workflows/tool-input/usage-control.ts new file mode 100644 index 00000000000..4a7dc8493d4 --- /dev/null +++ b/apps/sim/lib/workflows/tool-input/usage-control.ts @@ -0,0 +1,42 @@ +import type { CanonicalMode, CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility' +import type { ToolUsageControl } from '@/providers/types' + +export const AGENT_TOOL_USAGE_CONTROL_CANONICAL_ID = 'agentToolUsageControl' +export const DEFAULT_AGENT_TOOL_USAGE_CONTROL = 'auto' + +interface AgentToolUsageControlInput { + usageControl?: unknown + usageControlExpression?: unknown +} + +export function buildAgentToolUsageControlCanonicalKey(toolIndex: number): string { + return `${toolIndex}:${AGENT_TOOL_USAGE_CONTROL_CANONICAL_ID}` +} + +export function getAgentToolUsageControlMode( + toolIndex: number, + overrides?: CanonicalModeOverrides +): CanonicalMode { + return overrides?.[buildAgentToolUsageControlCanonicalKey(toolIndex)] === 'advanced' + ? 'advanced' + : 'basic' +} + +export function resolveAgentToolUsageControl( + tool: AgentToolUsageControlInput, + toolIndex: number, + overrides?: CanonicalModeOverrides +): ToolUsageControl | undefined { + const mode = getAgentToolUsageControlMode(toolIndex, overrides) + const rawValue = + mode === 'advanced' + ? tool.usageControlExpression + : (tool.usageControl ?? DEFAULT_AGENT_TOOL_USAGE_CONTROL) + + if (typeof rawValue !== 'string') return undefined + + const normalized = rawValue.trim().toLowerCase() + return normalized === 'auto' || normalized === 'force' || normalized === 'none' + ? normalized + : undefined +} diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 64ae6b86cec..8ff7ff0ac3a 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -395,6 +395,7 @@ type ApplyWorkflowOperationsBodyRef3 = { type: string operation?: string usageControl?: 'auto' | 'force' | 'none' + usageControlExpression?: string params?: Record } @@ -403,6 +404,7 @@ type ApplyWorkflowOperationsBodyRef4 = type: 'custom-tool' customToolId: string usageControl?: 'auto' | 'force' | 'none' + usageControlExpression?: string } | { type: 'custom-tool' @@ -416,6 +418,7 @@ type ApplyWorkflowOperationsBodyRef4 = } code: string usageControl?: 'auto' | 'force' | 'none' + usageControlExpression?: string } type ApplyWorkflowOperationsBodyRef5 = { @@ -425,6 +428,7 @@ type ApplyWorkflowOperationsBodyRef5 = { toolName: string } & Record usageControl?: 'auto' | 'force' | 'none' + usageControlExpression?: string } type ApplyWorkflowOperationsBodyRef6 = { @@ -445,6 +449,7 @@ type ApplyWorkflowOperationsBodyRef6 = { serverId: string } usageControl?: 'auto' | 'force' | 'none' + usageControlExpression?: string } export type ApplyWorkflowOperationsBody = {