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 46e7ff7c15e..fcce1d78567 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 @@ -512,11 +512,11 @@ export const ToolInput = memo(function ToolInput({ // Uses canonical resolution so the active field (basic vs advanced) is respected. const toolCredentialId = useMemo(() => { const allBlocks = getAllBlocks() - for (const tool of selectedTools) { + for (const [toolIndex, tool] of selectedTools.entries()) { const blockConfig = allBlocks.find((b: { type: string }) => b.type === tool.type) if (!blockConfig?.subBlocks) continue const toolCanonical = buildCanonicalIndex(blockConfig.subBlocks) - const scopedOverrides = scopeCanonicalModesForTool(canonicalModeOverrides, tool.type) + const scopedOverrides = scopeCanonicalModesForTool(canonicalModeOverrides, toolIndex) const reactiveSubBlock = blockConfig.subBlocks.find( (sb: { reactiveCondition?: unknown }) => sb.reactiveCondition ) @@ -1680,7 +1680,7 @@ export const ToolInput = memo(function ToolInput({ }) : null - const toolScopedOverrides = scopeCanonicalModesForTool(canonicalModeOverrides, tool.type) + const toolScopedOverrides = scopeCanonicalModesForTool(canonicalModeOverrides, toolIndex) const subBlocksResult: SubBlocksForToolInput | null = !isCustomTool && !isMcpTool && currentToolId @@ -2072,7 +2072,7 @@ export const ToolInput = memo(function ToolInput({ const nextMode = canonicalMode === 'advanced' ? 'basic' : 'advanced' collaborativeSetBlockCanonicalMode( blockId, - `${tool.type}:${canonicalId}`, + `${toolIndex}:${canonicalId}`, nextMode ) }, diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index c9bb5291347..f81130a6536 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -65,6 +65,10 @@ export class AgentBlockHandler implements BlockHandler { block: SerializedBlock, inputs: AgentInputs ): Promise { + const toolIndexByRef = new Map( + (inputs.tools || []).map((tool, index) => [tool, index] as const) + ) + const filteredTools = await this.filterUnavailableMcpTools(ctx, inputs.tools || []) const filteredInputs = { ...inputs, tools: filteredTools } @@ -79,7 +83,8 @@ export class AgentBlockHandler implements BlockHandler { const formattedTools = await this.formatTools( ctx, filteredInputs.tools || [], - block.canonicalModes + block.canonicalModes, + toolIndexByRef ) const skillInputs = filteredInputs.skills ?? [] @@ -204,31 +209,38 @@ 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 async formatTools( ctx: ExecutionContext, inputTools: ToolInput[], - canonicalModes?: Record + canonicalModes?: Record, + toolIndexByRef?: Map ): Promise { if (!Array.isArray(inputTools)) return [] - const filtered = inputTools.filter((tool) => { - const usageControl = tool.usageControl || 'auto' - return usageControl !== 'none' - }) + const filtered = inputTools + .map((tool, localIndex) => ({ tool, toolIndex: toolIndexByRef?.get(tool) ?? localIndex })) + .filter(({ tool }) => (tool.usageControl || 'auto') !== 'none') const mcpTools: ToolInput[] = [] - const otherTools: ToolInput[] = [] + const otherTools: Array<{ tool: ToolInput; toolIndex: number }> = [] - for (const tool of filtered) { - if (tool.type === 'mcp') { - mcpTools.push(tool) + for (const entry of filtered) { + if (entry.tool.type === 'mcp') { + mcpTools.push(entry.tool) } else { - otherTools.push(tool) + otherTools.push(entry) } } const otherResults = await Promise.all( - otherTools.map(async (tool) => { + otherTools.map(async ({ tool, toolIndex }) => { try { if (tool.type && tool.type !== 'custom-tool') { await validateBlockType(ctx.userId, ctx.workspaceId, tool.type, ctx) @@ -236,7 +248,7 @@ export class AgentBlockHandler implements BlockHandler { if (tool.type === 'custom-tool' && (tool.schema || tool.customToolId)) { return await this.createCustomTool(ctx, tool) } - return this.transformBlockTool(ctx, tool, canonicalModes) + return this.transformBlockTool(ctx, tool, canonicalModes, toolIndex) } catch (error) { logger.error(`[AgentHandler] Error creating tool:`, { tool, error }) return null @@ -553,7 +565,8 @@ export class AgentBlockHandler implements BlockHandler { private async transformBlockTool( ctx: ExecutionContext, tool: ToolInput, - canonicalModes?: Record + canonicalModes?: Record, + toolIndex?: number ) { const transformedTool = await transformBlockTool(tool, { selectedOperation: tool.operation, @@ -566,6 +579,7 @@ export class AgentBlockHandler implements BlockHandler { }), getTool, canonicalModes, + toolIndex, }) if (transformedTool) { diff --git a/apps/sim/lib/workflows/search-replace/indexer.ts b/apps/sim/lib/workflows/search-replace/indexer.ts index 2a412fedd6c..8d40fb09d5c 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.ts @@ -675,11 +675,14 @@ function isVisibleToolParameter(param: ToolParameterConfig, values: Record blockConfigs?: WorkflowSearchIndexerOptions['blockConfigs'] @@ -723,7 +726,7 @@ export function getToolInputParamConfigs({ if (!toolId) return genericFallback() - const scopedCanonicalModes = scopeCanonicalModesForTool(parentCanonicalModes, tool.type) + const scopedCanonicalModes = scopeCanonicalModesForTool(parentCanonicalModes, toolIndex) const blockConfig = tool.type !== 'custom-tool' && tool.type !== 'mcp' ? (blockConfigs?.[tool.type] ?? getBlock(tool.type)) @@ -977,6 +980,7 @@ function addToolInputMatches({ const params = getToolInputParamConfigs({ tool, + toolIndex, parentCanonicalModes, credentialTypeById, blockConfigs, diff --git a/apps/sim/lib/workflows/subblocks/visibility.test.ts b/apps/sim/lib/workflows/subblocks/visibility.test.ts index b55bfad5f54..e538f42d069 100644 --- a/apps/sim/lib/workflows/subblocks/visibility.test.ts +++ b/apps/sim/lib/workflows/subblocks/visibility.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { evaluateSubBlockCondition } from './visibility' +import { evaluateSubBlockCondition, scopeCanonicalModesForTool } from './visibility' describe('evaluateSubBlockCondition', () => { describe('simple value matching', () => { @@ -178,3 +178,39 @@ describe('evaluateSubBlockCondition', () => { }) }) }) + +describe('scopeCanonicalModesForTool', () => { + it.concurrent('returns undefined when there are no overrides', () => { + expect(scopeCanonicalModesForTool(undefined, 0)).toBeUndefined() + }) + + it.concurrent('returns undefined when toolIndex is undefined', () => { + expect(scopeCanonicalModesForTool({ '0:tableId': 'advanced' }, undefined)).toBeUndefined() + }) + + it.concurrent('strips the toolIndex prefix for the matching tool instance', () => { + const overrides = { '0:tableId': 'advanced', '1:tableId': 'basic' } + expect(scopeCanonicalModesForTool(overrides, 0)).toEqual({ tableId: 'advanced' }) + expect(scopeCanonicalModesForTool(overrides, 1)).toEqual({ tableId: 'basic' }) + }) + + it.concurrent( + 'keeps two same-type tool instances independent (regression: two Table tools on one Agent block used to share a mode)', + () => { + const overrides = { '0:tableId': 'advanced', '1:tableId': 'basic' } + // Both tools have type "table" and canonicalId "tableId" - only toolIndex disambiguates them. + expect(scopeCanonicalModesForTool(overrides, 0)).toEqual({ tableId: 'advanced' }) + expect(scopeCanonicalModesForTool(overrides, 1)).toEqual({ tableId: 'basic' }) + } + ) + + it.concurrent('returns undefined when no keys match the given toolIndex prefix', () => { + expect(scopeCanonicalModesForTool({ '1:tableId': 'advanced' }, 0)).toBeUndefined() + }) + + it.concurrent('ignores falsy override values', () => { + expect( + scopeCanonicalModesForTool({ '0:tableId': undefined as unknown as 'advanced' }, 0) + ).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/workflows/subblocks/visibility.ts b/apps/sim/lib/workflows/subblocks/visibility.ts index 7cce5d9575e..cf784d24b56 100644 --- a/apps/sim/lib/workflows/subblocks/visibility.ts +++ b/apps/sim/lib/workflows/subblocks/visibility.ts @@ -260,18 +260,20 @@ export function resolveActiveCanonicalValue( } /** - * Strip the `${toolType}:` prefix from canonical-mode override keys, returning the overrides for a + * Strip the `${toolIndex}:` prefix from canonical-mode override keys, returning the overrides for a * nested tool keyed by bare `canonicalId`. An agent block stores its nested tools' modes scoped as - * `${toolType}:${canonicalId}` (to avoid cross-tool collisions when tools share a `canonicalParamId`), - * so this is the canonical un-scoping primitive. Returns `undefined` when there are no overrides, no - * `toolType`, or no matching keys. + * `${toolIndex}:${canonicalId}` — keyed by the tool's position in the `tool-input` array, not its + * `type` — so that two tool entries of the SAME type (e.g. two Table tools on one Agent block) get + * independent canonical modes instead of colliding on a shared `${toolType}:${canonicalId}` key. This + * is the canonical un-scoping primitive. Returns `undefined` when there are no overrides, no + * `toolIndex`, or no matching keys. */ export function scopeCanonicalModesForTool( overrides: CanonicalModeOverrides | undefined, - toolType: string | undefined + toolIndex: number | undefined ): CanonicalModeOverrides | undefined { - if (!overrides || !toolType) return undefined - const prefix = `${toolType}:` + if (!overrides || toolIndex === undefined) return undefined + const prefix = `${toolIndex}:` let scoped: CanonicalModeOverrides | undefined for (const [key, value] of Object.entries(overrides)) { if (key.startsWith(prefix) && value) { diff --git a/apps/sim/lib/workspaces/fork/mapping/dependent-reconfigs.test.ts b/apps/sim/lib/workspaces/fork/mapping/dependent-reconfigs.test.ts index 9b9927cb489..ee39dc19092 100644 --- a/apps/sim/lib/workspaces/fork/mapping/dependent-reconfigs.test.ts +++ b/apps/sim/lib/workspaces/fork/mapping/dependent-reconfigs.test.ts @@ -414,7 +414,7 @@ describe('collectForkDependentReconfigs', () => { return undefined as unknown as BlockConfig }) // Agent block with a nested gmail tool; the dormant basic credential holds a stale id while the - // tool-scoped `gmail:credential` override (when present) marks advanced as active. + // tool-scoped `0:credential` override (when present) marks advanced as active. const agentState = (canonicalModes?: Record) => ({ blocks: { @@ -449,7 +449,7 @@ describe('collectForkDependentReconfigs', () => { // Scoped override present -> anchors on the ACTIVE advanced member (today's heuristic missed it). const withOverride = collectForkDependentReconfigs( [replaceItem], - new Map([['wf-src', agentState({ 'gmail:credential': 'advanced' })]]), + new Map([['wf-src', agentState({ '0:credential': 'advanced' })]]), resolve ) expect(withOverride).toHaveLength(1) @@ -472,6 +472,97 @@ describe('collectForkDependentReconfigs', () => { }) }) + it('regression: two same-type nested tools resolve their canonical-mode override independently', () => { + vi.mocked(getBlock).mockImplementation((type) => { + if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }]) + if (type === 'gmail') + return blockWith([ + { + id: 'credential', + title: 'Credential', + type: 'oauth-input', + canonicalParamId: 'credential', + mode: 'basic', + }, + { + id: 'manualCredential', + title: 'Credential ID', + type: 'short-input', + canonicalParamId: 'credential', + mode: 'advanced', + }, + { + id: 'folder', + title: 'Label', + type: 'folder-selector', + dependsOn: ['credential'], + selectorKey: 'gmail.labels', + required: true, + }, + ]) + return undefined as unknown as BlockConfig + }) + const states = new Map([ + [ + 'wf-src', + { + blocks: { + 'block-1': { + id: 'block-1', + type: 'agent', + name: 'Block', + // Tool #0 is scoped to advanced (active id: cred-0-active); tool #1 is scoped to + // basic (active id: cred-1-basic). Both are the SAME tool type ("gmail") and the + // SAME canonicalId ("credential"), so only the per-instance index can tell them + // apart - before the fix both shared the single `gmail:credential` key. + data: { canonicalModes: { '0:credential': 'advanced', '1:credential': 'basic' } }, + subBlocks: { + tools: { + value: [ + { + type: 'gmail', + title: 'Gmail 1', + params: { + credential: 'cred-0-stale', + manualCredential: 'cred-0-active', + folder: 'INBOX', + }, + }, + { + type: 'gmail', + title: 'Gmail 2', + params: { + credential: 'cred-1-basic', + manualCredential: 'cred-1-stale', + folder: 'SENT', + }, + }, + ], + }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + } as unknown as WorkflowState, + ], + ]) + + const result = collectForkDependentReconfigs([replaceItem], states, resolve) + + expect(result).toHaveLength(2) + expect(result[0]).toMatchObject({ + parentSourceId: 'cred-0-active', + subBlockKey: 'tools[0].folder', + }) + expect(result[1]).toMatchObject({ + parentSourceId: 'cred-1-basic', + subBlockKey: 'tools[1].folder', + }) + }) + it('offers a nested tool selector even when the source left it empty', () => { vi.mocked(getBlock).mockImplementation((type) => { if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }]) diff --git a/apps/sim/lib/workspaces/fork/mapping/dependent-reconfigs.ts b/apps/sim/lib/workspaces/fork/mapping/dependent-reconfigs.ts index 9b20a3dfaac..29d7a7b968e 100644 --- a/apps/sim/lib/workspaces/fork/mapping/dependent-reconfigs.ts +++ b/apps/sim/lib/workspaces/fork/mapping/dependent-reconfigs.ts @@ -289,7 +289,7 @@ export function collectForkDependentReconfigs( contextSubBlocks: toolContextSubBlocks, blockName: block.name, targetWorkflowId: item.targetWorkflowId, - canonicalModes: scopeCanonicalModesForTool(block.data?.canonicalModes, tool.type), + canonicalModes: scopeCanonicalModesForTool(block.data?.canonicalModes, toolIndex), resolveTargetBlockId: resolveBlockId, makeSubBlockKey: (id) => `${toolInputKey}[${toolIndex}].${id}`, makeTitle: (dependent) => `${toolLabel}: ${dependent.title ?? dependent.id ?? ''}`, diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index 41f76b6c3f6..0182491436e 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -1564,11 +1564,12 @@ describe('transformBlockTool multi-instance unique IDs', () => { const transformTable = ( params: Record, - canonicalModes?: Record + canonicalModes?: Record, + toolIndex?: number ) => transformBlockTool( { type: 'table', operation: 'query_rows', params }, - { selectedOperation: 'query_rows', getAllBlocks, getTool, canonicalModes } + { selectedOperation: 'query_rows', getAllBlocks, getTool, canonicalModes, toolIndex } ) it('appends the table id when stored under the basic selector subblock key', async () => { @@ -1579,7 +1580,8 @@ describe('transformBlockTool multi-instance unique IDs', () => { it('appends the table id resolved from the advanced manual input', async () => { const result = await transformTable( { manualTableId: 'tbl_xyz' }, - { 'table:tableId': 'advanced' } + { '0:tableId': 'advanced' }, + 0 ) expect(result?.id).toBe('table_query_rows_tbl_xyz') }) @@ -1600,6 +1602,21 @@ describe('transformBlockTool multi-instance unique IDs', () => { const result = await transformTable({}) expect(result?.id).toBe('table_query_rows') }) + + it('regression: two Table tool instances on one Agent block resolve their canonical mode independently', async () => { + // Both tools are type "table" with canonicalId "tableId" and BOTH basic + advanced values + // populated, so only the explicit per-instance mode determines which one wins. Before the fix, + // canonicalModes was keyed by `${toolType}:${canonicalId}` (shared across every "table" tool), + // so toggling tool #0 to advanced also flipped tool #1's resolved value. + const sharedParams = { tableSelector: 'tbl_basic', manualTableId: 'tbl_advanced' } + const canonicalModes = { '0:tableId': 'advanced', '1:tableId': 'basic' } + + const first = await transformTable(sharedParams, canonicalModes, 0) + const second = await transformTable(sharedParams, canonicalModes, 1) + + expect(first?.id).toBe('table_query_rows_tbl_advanced') + expect(second?.id).toBe('table_query_rows_tbl_basic') + }) }) describe('transformBlockTool knowledge-base multi-instance unique IDs', () => { @@ -1637,11 +1654,12 @@ describe('transformBlockTool knowledge-base multi-instance unique IDs', () => { const transformKb = ( params: Record, - canonicalModes?: Record + canonicalModes?: Record, + toolIndex?: number ) => transformBlockTool( { type: 'knowledge', operation: 'search', params }, - { selectedOperation: 'search', getAllBlocks, getTool, canonicalModes } + { selectedOperation: 'search', getAllBlocks, getTool, canonicalModes, toolIndex } ) it('appends the knowledge base id when stored under the basic selector subblock key', async () => { @@ -1652,7 +1670,8 @@ describe('transformBlockTool knowledge-base multi-instance unique IDs', () => { it('appends the knowledge base id resolved from the advanced manual input', async () => { const result = await transformKb( { manualKnowledgeBaseId: 'kb_xyz' }, - { 'knowledge:knowledgeBaseId': 'advanced' } + { '0:knowledgeBaseId': 'advanced' }, + 0 ) expect(result?.id).toBe('knowledge_search_kb_xyz') }) diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 40ff390cadf..c5f0b69742d 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -14,8 +14,10 @@ import { import { buildCanonicalIndex, type CanonicalGroup, + type CanonicalModeOverrides, isCanonicalPair, resolveActiveCanonicalValue, + scopeCanonicalModesForTool, } from '@/lib/workflows/subblocks/visibility' import { isCustomTool } from '@/executor/constants' import { @@ -490,8 +492,7 @@ export function extractAndParseJSON(content: string): any { function resolveCanonicalResourceParams( params: Record, canonicalGroups: CanonicalGroup[], - blockType: string, - canonicalModes?: Record + scopedCanonicalModes?: CanonicalModeOverrides ): Record { if (canonicalGroups.length === 0) return params const resolved = { ...params } @@ -500,7 +501,7 @@ function resolveCanonicalResourceParams( if (existing !== undefined && existing !== null && existing !== '') continue // Route through the canonical SOT: an explicit scoped override wins, else the value heuristic - // no `?? 'basic'` (which ignored an advanced-only value when basic was empty). - const explicitMode = canonicalModes?.[`${blockType}:${group.canonicalId}`] + const explicitMode = scopedCanonicalModes?.[group.canonicalId] const chosen = resolveActiveCanonicalValue( group, params, @@ -526,9 +527,18 @@ export async function transformBlockTool( getTool: (toolId: string) => any getToolAsync?: (toolId: string) => Promise canonicalModes?: Record + /** + * 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 + * `block.type`, so that two tool entries of the same type (e.g. two Table tools) don't share + * a canonical-mode override. Omit for tools with no such array position (e.g. Pi local tools). + */ + toolIndex?: number } ): Promise { - const { selectedOperation, getAllBlocks, getTool, getToolAsync, canonicalModes } = options + const { selectedOperation, getAllBlocks, getTool, getToolAsync, canonicalModes, toolIndex } = + options + const scopedCanonicalModes = scopeCanonicalModesForTool(canonicalModes, toolIndex) const blockDef = getAllBlocks().find((b: any) => b.type === block.type) if (!blockDef) { @@ -594,8 +604,7 @@ export async function transformBlockTool( const resolvedResourceParams = resolveCanonicalResourceParams( userProvidedParams, canonicalGroups, - block.type, - canonicalModes + scopedCanonicalModes ) let uniqueToolId = toolConfig.id @@ -634,7 +643,7 @@ export async function transformBlockTool( for (const group of canonicalGroups) { // Route through the canonical SOT: an explicit scoped override wins, else the value // heuristic - no `?? 'basic'` (which dropped an advanced-only value when basic was empty). - const explicitMode = canonicalModes?.[`${block.type}:${group.canonicalId}`] + const explicitMode = scopedCanonicalModes?.[group.canonicalId] const chosen = resolveActiveCanonicalValue( group, result,