diff --git a/apps/sim/executor/handlers/pi/local/sim-tools.test.ts b/apps/sim/executor/handlers/pi/local/sim-tools.test.ts index 1f21dabc4f1..649d3c9b27e 100644 --- a/apps/sim/executor/handlers/pi/local/sim-tools.test.ts +++ b/apps/sim/executor/handlers/pi/local/sim-tools.test.ts @@ -71,6 +71,43 @@ describe('buildSimToolSpecs', () => { expect(specs[0].name).toMatch(/^[a-zA-Z0-9_-]{1,128}$/) }) + it('aliases duplicate instances while executing each with its canonical id and bound params', async () => { + mockTransformBlockTool + .mockResolvedValueOnce({ + id: 'gmail_send', + name: 'Gmail Send', + description: 'Send an email', + params: { oauthCredential: 'credential-a' }, + parameters: { type: 'object', properties: {} }, + }) + .mockResolvedValueOnce({ + id: 'gmail_send', + name: 'Gmail Send', + description: 'Send an email', + params: { oauthCredential: 'credential-b' }, + parameters: { type: 'object', properties: {} }, + }) + mockExecuteTool.mockResolvedValue({ success: true, output: 'sent' }) + + const specs = await buildSimToolSpecs(executionContext(undefined), [ + { type: 'gmail', operation: 'send', usageControl: 'auto' }, + { type: 'gmail', operation: 'send', usageControl: 'auto' }, + ]) + + expect(specs.map(({ name }) => name)).toEqual(['gmail_send', 'gmail_send__sim_2']) + + await specs[1].execute({ subject: 'Hello' }) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'gmail_send', + expect.objectContaining({ + oauthCredential: 'credential-b', + subject: 'Hello', + }), + expect.any(Object) + ) + }) + it('skips mcp, custom, and usage-none tools without adapting them', async () => { const specs = await buildSimToolSpecs(completeExecutionContext(), [ { type: 'mcp', usageControl: 'auto' }, diff --git a/apps/sim/executor/handlers/pi/local/sim-tools.ts b/apps/sim/executor/handlers/pi/local/sim-tools.ts index 499729d66c6..1088e903a4e 100644 --- a/apps/sim/executor/handlers/pi/local/sim-tools.ts +++ b/apps/sim/executor/handlers/pi/local/sim-tools.ts @@ -16,6 +16,8 @@ import type { PiToolResult, PiToolSpec } from '@/executor/handlers/pi/core/backe import type { ExecutionContext } from '@/executor/types' import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +import { assignProviderToolIdentities } from '@/providers/tool-identity' +import type { ProviderToolConfig } from '@/providers/types' import { transformBlockTool } from '@/providers/utils' import { executeTool } from '@/tools' import { mergeToolParameters } from '@/tools/merge-params' @@ -96,6 +98,96 @@ function projectToolResult( } } +function buildSimToolSpec( + ctx: ExecutionContext, + inputTools: ToolInput[], + provider: ProviderToolConfig, + toolIndex: number +): PiToolSpec { + const toolId = provider.canonicalId ?? provider.id + const preseededParams = provider.params || {} + + return { + name: provider.id, + description: provider.description || '', + parameters: (provider.parameters as Record) || { + type: 'object', + properties: {}, + }, + execute: async (args) => { + const params = mergeToolParameters(preseededParams, args as Record) + const registry = ctx.resolvedSecretTraceRegistry + const sourcePath = ['tools', String(toolIndex), 'params'] as const + const toolCallRegistry = registry?.forkForInputPaths([sourcePath], { + propagated: true, + }) + if (toolCallRegistry && !toolCallRegistry.isComplete()) { + return unavailableToolResult() + } + + if (toolCallRegistry) { + const inputProjection = toolCallRegistry.projectResolvedInputSelection({ + tools: inputTools, + }) + const projectedTool = inputProjection.complete + ? (inputProjection.value.tools as ToolInput[] | undefined)?.[toolIndex] + : undefined + if (!inputProjection.complete || !projectedTool) { + return unavailableToolResult() + } + const projectedParams = mergeToolParameters( + projectedTool.params || {}, + args as Record + ) + toolCallRegistry.recordTransformedInputProjection(params, projectedParams) + if (!toolCallRegistry.isComplete()) return unavailableToolResult() + } + + try { + const result = await executeTool( + toolId, + { + // User-preseeded values win over model arguments, and inputMapping is deep-merged. + ...params, + // Trusted execution context is written last so model arguments cannot override it. + _context: { + workflowId: ctx.workflowId, + workspaceId: ctx.workspaceId, + executionId: ctx.executionId, + userId: ctx.userId, + isDeployedContext: ctx.isDeployedContext, + enforceCredentialAccess: ctx.enforceCredentialAccess, + callChain: ctx.callChain, + }, + }, + { + executionContext: ctx, + resolvedSecretTraceRegistry: toolCallRegistry, + } + ) + const projection = projectToolResult(result, toolCallRegistry?.forkForPropagatedEntries()) + if (projection.safe && registry && toolCallRegistry?.isComplete()) { + registry.mergeToolCallRegistry(toolCallRegistry) + } + return projection.result + } catch (error) { + const projection = projectToolResult( + { + success: false, + output: {}, + error: getErrorMessage(error, 'Tool execution failed'), + }, + toolCallRegistry?.forkForPropagatedEntries() + ) + if (projection.safe && registry && toolCallRegistry?.isComplete()) { + registry.mergeToolCallRegistry(toolCallRegistry) + } + return projection.result + } + }, + } +} + /** * Builds the Sim tool specs exposed to Pi for a local run. Only tools the user * added to the block are included, and `usageControl: 'none'` tools are dropped. @@ -106,7 +198,7 @@ export async function buildSimToolSpecs( ): Promise { if (!Array.isArray(inputTools)) return [] - const specs: PiToolSpec[] = [] + const configuredTools: Array<{ provider: ProviderToolConfig; toolIndex: number }> = [] for (const [toolIndex, tool] of (inputTools as ToolInput[]).entries()) { if ((tool.usageControl || 'auto') === 'none') continue @@ -129,98 +221,7 @@ export async function buildSimToolSpecs( }) if (!provider?.id) continue - - const toolId = provider.id - const preseededParams = provider.params || {} - - specs.push({ - name: toolId, - description: provider.description || '', - parameters: (provider.parameters as Record) || { - type: 'object', - properties: {}, - }, - execute: async (args) => { - const params = mergeToolParameters(preseededParams, args as Record) - const registry = ctx.resolvedSecretTraceRegistry - const sourcePath = ['tools', String(toolIndex), 'params'] as const - const toolCallRegistry = registry?.forkForInputPaths([sourcePath], { - propagated: true, - }) - if (toolCallRegistry && !toolCallRegistry.isComplete()) { - return unavailableToolResult() - } - - if (toolCallRegistry) { - const inputProjection = toolCallRegistry.projectResolvedInputSelection({ - tools: inputTools, - }) - const projectedTool = inputProjection.complete - ? (inputProjection.value.tools as ToolInput[] | undefined)?.[toolIndex] - : undefined - if (!inputProjection.complete || !projectedTool) { - return unavailableToolResult() - } - const projectedParams = mergeToolParameters( - projectedTool.params || {}, - args as Record - ) - toolCallRegistry.recordTransformedInputProjection(params, projectedParams) - if (!toolCallRegistry.isComplete()) return unavailableToolResult() - } - - try { - const result = await executeTool( - toolId, - { - // 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. - ...params, - // 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 - // same way the Agent block's tool calls do. - _context: { - workflowId: ctx.workflowId, - workspaceId: ctx.workspaceId, - executionId: ctx.executionId, - userId: ctx.userId, - isDeployedContext: ctx.isDeployedContext, - enforceCredentialAccess: ctx.enforceCredentialAccess, - callChain: ctx.callChain, - }, - }, - { - executionContext: ctx, - resolvedSecretTraceRegistry: toolCallRegistry, - } - ) - const projection = projectToolResult( - result, - toolCallRegistry?.forkForPropagatedEntries() - ) - if (projection.safe && registry && toolCallRegistry?.isComplete()) { - registry.mergeToolCallRegistry(toolCallRegistry) - } - return projection.result - } catch (error) { - const projection = projectToolResult( - { - success: false, - output: {}, - error: getErrorMessage(error, 'Tool execution failed'), - }, - toolCallRegistry?.forkForPropagatedEntries() - ) - if (projection.safe && registry && toolCallRegistry?.isComplete()) { - registry.mergeToolCallRegistry(toolCallRegistry) - } - return projection.result - } - }, - }) + configuredTools.push({ provider, toolIndex }) } catch (error) { if (error instanceof ToolSchemaEnrichmentError) throw error logger.warn('Failed to adapt Sim tool for Pi', { @@ -230,5 +231,8 @@ export async function buildSimToolSpecs( } } - return specs + assignProviderToolIdentities(configuredTools.map(({ provider }) => provider)) + return configuredTools.map(({ provider, toolIndex }) => + buildSimToolSpec(ctx, inputTools, provider, toolIndex) + ) } diff --git a/apps/sim/providers/custom-block-tool.test.ts b/apps/sim/providers/custom-block-tool.test.ts index b6c97963e5f..fd1f5793545 100644 --- a/apps/sim/providers/custom-block-tool.test.ts +++ b/apps/sim/providers/custom-block-tool.test.ts @@ -45,8 +45,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('deployed_block_executor_custom_block_test') + expect(tool!.id).toBe('deployed_block_executor') expect(tool!.name).toBe('The Elder') // Baked params: block type + assembled (id-keyed) input mapping. expect(tool!.params.blockType).toBe('custom_block_test') diff --git a/apps/sim/providers/index.test.ts b/apps/sim/providers/index.test.ts index 5e1203ed642..349c734e47b 100644 --- a/apps/sim/providers/index.test.ts +++ b/apps/sim/providers/index.test.ts @@ -9,12 +9,14 @@ const { mockGetApiKeyWithBYOK, mockExecuteRequest, mockFilterModelSafeWorkspaceFileAttachments, + mockExecuteTool, mockUploadLargeFilesToProvider, } = vi.hoisted(() => ({ mockAttachLargeFileRemoteUrls: vi.fn(), mockGetApiKeyWithBYOK: vi.fn(), mockExecuteRequest: vi.fn(), mockFilterModelSafeWorkspaceFileAttachments: vi.fn(async (attachments: unknown[]) => attachments), + mockExecuteTool: vi.fn(async () => ({ success: true, output: {} })), mockUploadLargeFilesToProvider: vi.fn(), })) @@ -39,9 +41,16 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () mockFilterModelSafeWorkspaceFileAttachments(...args), })) +vi.mock('@/tools', () => ({ + executeTool: (...args: unknown[]) => mockExecuteTool(...args), +})) + +import type { NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import { executeProviderRequest } from '@/providers' -import type { ProviderResponse } from '@/providers/types' +import { executeProviderTool } from '@/providers/runtime-context' +import type { AgentStreamEvent } from '@/providers/stream-events' +import type { ProviderResponse, ProviderToolConfig } from '@/providers/types' const HOSTED_RATE_INPUT_COST = 0.340285 const HOSTED_RATE_OUTPUT_COST = 0.0387 @@ -91,6 +100,102 @@ function makeAnthropicResponse(): ProviderResponse { } } +function makeProviderTool(id: string, credential: string): ProviderToolConfig { + return { + id, + name: id, + description: id, + params: { oauthCredential: credential }, + parameters: { type: 'object', properties: {}, required: [] }, + } +} + +describe('executeProviderRequest — tool identities', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('sends unique opaque ids and projects provider aliases out of the response', async () => { + const tools = [ + makeProviderTool('gmail_send', 'credential-a'), + makeProviderTool('gmail_send', 'credential-b'), + ] + mockExecuteRequest.mockImplementationOnce(async (request) => { + const alias = request.tools[1].id + expect(request.tools.map((tool: ProviderToolConfig) => tool.id)).toEqual([ + 'gmail_send', + 'gmail_send__sim_2', + ]) + expect(alias).not.toContain('credential-b') + return { + content: 'sent', + model: 'test-model', + toolCalls: [{ name: alias, arguments: {} }], + timing: { + startTime: 'start', + endTime: 'end', + duration: 1, + timeSegments: [{ type: 'tool', name: alias, startTime: 0, endTime: 1, duration: 1 }], + }, + } + }) + + const response = (await executeProviderRequest('anthropic', { + model: 'test-model', + tools, + })) as ProviderResponse + + expect(response.toolCalls?.[0].name).toBe('gmail_send') + expect(response.timing?.timeSegments?.[0].name).toBe('gmail_send') + expect(tools[1].params.oauthCredential).toBe('credential-b') + }) + + it('keeps the alias map active while a streaming provider executes the selected instance', async () => { + const tools = [ + makeProviderTool('gmail_send', 'credential-a'), + makeProviderTool('gmail_send', 'credential-b'), + ] + mockExecuteRequest.mockImplementationOnce(async (request) => { + const selected = request.tools[1] as ProviderToolConfig + const output: NormalizedBlockOutput = { + toolCalls: { list: [], count: 0 }, + providerTiming: { startTime: 'start', endTime: 'end', duration: 0, timeSegments: [] }, + } + return { + streamFormat: 'agent-events-v1', + stream: new ReadableStream({ + async pull(controller) { + await executeProviderTool(selected.id, selected.params) + output.toolCalls = { list: [{ name: selected.id }], count: 1 } + controller.enqueue({ type: 'tool_call_start', id: 'call-1', name: selected.id }) + controller.close() + }, + }), + execution: { success: true, output }, + } + }) + + const response = await executeProviderRequest('anthropic', { + model: 'test-model', + tools, + }) + expect(response).not.toBeInstanceOf(ReadableStream) + expect(response).toHaveProperty('stream') + const streaming = response as StreamingExecution + const reader = (streaming.stream as ReadableStream).getReader() + const event = await reader.read() + await reader.read() + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'gmail_send', + { oauthCredential: 'credential-b' }, + expect.any(Object) + ) + expect(event.value).toEqual({ type: 'tool_call_start', id: 'call-1', name: 'gmail_send' }) + expect(streaming.execution.output.toolCalls?.list[0].name).toBe('gmail_send') + }) +}) + describe('executeProviderRequest — BYOK regression', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/providers/index.ts b/apps/sim/providers/index.ts index 66a58507ef9..d8ecbb04388 100644 --- a/apps/sim/providers/index.ts +++ b/apps/sim/providers/index.ts @@ -22,6 +22,11 @@ import { type ProviderRuntimeContext, runWithProviderRuntimeContext, } from '@/providers/runtime-context' +import { + assignProviderToolIdentities, + projectProviderResponseToolIdentities, + projectStreamingExecutionToolIdentities, +} from '@/providers/tool-identity' import type { ProviderId, ProviderRequest, ProviderResponse } from '@/providers/types' import { generateStructuredOutputInstructions, @@ -218,6 +223,17 @@ export async function executeProviderRequest( const provenanceSafeRequest = await omitUnsafeProviderFileAttachments(sanitizedRequest) const modelSafeRequest = provenanceSafeRequest + const toolIdentities = assignProviderToolIdentities(modelSafeRequest.tools) + const requestRuntimeContext = + toolIdentities.toolIdByWireId.size > 0 + ? { + ...runtimeContext, + toolIdByWireId: new Map([ + ...(runtimeContext?.toolIdByWireId ?? []), + ...toolIdentities.toolIdByWireId, + ]), + } + : runtimeContext if (modelSafeRequest.responseFormat) { const structuredOutputInstructions = generateStructuredOutputInstructions( @@ -230,7 +246,7 @@ export async function executeProviderRequest( } } - const response = await runWithProviderRuntimeContext(runtimeContext, async () => { + const response = await runWithProviderRuntimeContext(requestRuntimeContext, async () => { await attachLargeFileRemoteUrls(modelSafeRequest, providerId) await uploadLargeFilesToProvider(modelSafeRequest, providerId) return provider.executeRequest(modelSafeRequest) @@ -239,6 +255,7 @@ export async function executeProviderRequest( if (isStreamingExecution(response)) { logger.info('Provider returned StreamingExecution', { isBYOK }) applyStreamingCostPolicy(response, resolveModelCostPolicy(sanitizedRequest.model, isBYOK)) + projectStreamingExecutionToolIdentities(response, toolIdentities) return response } @@ -248,6 +265,7 @@ export async function executeProviderRequest( } const costPolicy = resolveModelCostPolicy(response.model, isBYOK) + projectProviderResponseToolIdentities(response, toolIdentities) if (response.tokens) { const { input: promptTokens = 0, output: completionTokens = 0 } = response.tokens diff --git a/apps/sim/providers/runtime-context.test.ts b/apps/sim/providers/runtime-context.test.ts index 00494b67b2a..955514a7caf 100644 --- a/apps/sim/providers/runtime-context.test.ts +++ b/apps/sim/providers/runtime-context.test.ts @@ -108,6 +108,19 @@ describe('provider runtime context', () => { ) }) + it('resolves a provider-only alias before executing the canonical tool', async () => { + await runWithProviderRuntimeContext( + { toolIdByWireId: new Map([['gmail_send__sim_2', 'gmail_send']]) }, + () => executeProviderTool('gmail_send__sim_2', { oauthCredential: 'credential-b' }) + ) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'gmail_send', + { oauthCredential: 'credential-b' }, + expect.any(Object) + ) + }) + it('rebinds a prompt-exposed environment placeholder for the exact tool call', async () => { const sourceRegistry = new ResolvedSecretTraceRegistry([ { diff --git a/apps/sim/providers/runtime-context.ts b/apps/sim/providers/runtime-context.ts index d3d442383da..6d92ad924cb 100644 --- a/apps/sim/providers/runtime-context.ts +++ b/apps/sim/providers/runtime-context.ts @@ -17,6 +17,8 @@ export interface ProviderRuntimeContext { resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry /** Trusted server execution context inherited by model-emitted tool calls. */ executionContext?: ExecutionContext + /** Request-scoped provider wire ids mapped back to canonical tool registry ids. */ + toolIdByWireId?: ReadonlyMap } export type ExecuteProviderToolOptions = ExecuteToolOptions @@ -82,10 +84,11 @@ export async function executeProviderTool( options: ExecuteProviderToolOptions = {} ): Promise { const runtimeContext = providerRuntimeContext.getStore() + const executionToolId = runtimeContext?.toolIdByWireId?.get(toolId) ?? toolId const registry = options.resolvedSecretTraceRegistry ?? runtimeContext?.resolvedSecretTraceRegistry - if (runtimeContext && !registry) { + if (runtimeContext && Object.hasOwn(runtimeContext, 'resolvedSecretTraceRegistry') && !registry) { const response: ToolResponse = { success: false, output: {} } return { rawResponse: response, modelResponse: response } } @@ -102,7 +105,7 @@ export async function executeProviderTool( try { const executionContext = options.executionContext ?? runtimeContext?.executionContext - const result = await executeTool(toolId, params, { + const result = await executeTool(executionToolId, params, { ...options, ...(executionContext ? { executionContext } : {}), resolvedSecretTraceRegistry: toolCallRegistry, diff --git a/apps/sim/providers/tool-identity.test.ts b/apps/sim/providers/tool-identity.test.ts new file mode 100644 index 00000000000..830b4eea71a --- /dev/null +++ b/apps/sim/providers/tool-identity.test.ts @@ -0,0 +1,178 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { NormalizedBlockOutput, StreamingExecution } from '@/executor/types' +import type { AgentStreamEvent } from '@/providers/stream-events' +import { + assignProviderToolIdentities, + projectProviderResponseToolIdentities, + projectStreamingExecutionToolIdentities, +} from '@/providers/tool-identity' +import type { ProviderResponse, ProviderToolConfig } from '@/providers/types' + +function providerTool(id: string, credential: string): ProviderToolConfig { + return { + id, + name: id, + description: id, + params: { oauthCredential: credential }, + parameters: { type: 'object', properties: {}, required: [] }, + } +} + +async function readEvents(stream: ReadableStream): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) return events + events.push(value as AgentStreamEvent) + } +} + +describe('provider tool identities', () => { + it('keeps unique and first-occurrence ids unchanged while aliasing later instances', () => { + const first = providerTool('gmail_send', 'credential-a') + const second = providerTool('gmail_send', 'credential-b') + const unique = providerTool('slack_send_message', 'credential-c') + + const identities = assignProviderToolIdentities([first, second, unique]) + + expect(first.id).toBe('gmail_send') + expect(first).not.toHaveProperty('canonicalId') + expect(second.id).toBe('gmail_send__sim_2') + expect(second.canonicalId).toBe('gmail_send') + expect(second.params.oauthCredential).toBe('credential-b') + expect(unique.id).toBe('slack_send_message') + expect(identities.toolIdByWireId.get(second.id)).toBe('gmail_send') + expect(second.id).not.toContain('credential-b') + }) + + it('deduplicates the same configured instance without collapsing separate instances', () => { + const repeated = providerTool('gmail_send', 'credential-a') + const separate = providerTool('gmail_send', 'credential-a') + const tools = [repeated, repeated, separate] + + assignProviderToolIdentities(tools) + + expect(tools).toHaveLength(2) + expect(tools[0]).toBe(repeated) + expect(tools[1]).toBe(separate) + expect(tools.map((tool) => tool.id)).toEqual(['gmail_send', 'gmail_send__sim_2']) + }) + + it('avoids canonical id collisions and produces stable aliases when applied again', () => { + const tools = [ + providerTool('gmail_send', 'a'), + providerTool('gmail_send__sim_2', 'reserved'), + providerTool('gmail_send', 'b'), + ] + + assignProviderToolIdentities(tools) + const firstPass = tools.map((tool) => tool.id) + assignProviderToolIdentities(tools) + + expect(new Set(firstPass).size).toBe(3) + expect(tools.map((tool) => tool.id)).toEqual(firstPass) + expect(firstPass[2]).toBe('gmail_send__sim_2_2') + }) + + it('bounds generated aliases to the strictest provider name limit', () => { + const longId = `tool_${'a'.repeat(80)}` + const tools = [providerTool(longId, 'a'), providerTool(longId, 'b')] + + assignProviderToolIdentities(tools) + + expect(tools[0].id).toBe(longId) + expect(tools[1].id).toHaveLength(64) + expect(tools[1].id).toMatch(/__sim_2$/) + }) + + it('projects provider response names back to their canonical ids', () => { + const tools = [providerTool('gmail_send', 'a'), providerTool('gmail_send', 'b')] + const identities = assignProviderToolIdentities(tools) + const alias = tools[1].id + const response: ProviderResponse = { + content: 'done', + model: 'test-model', + toolCalls: [{ name: alias, arguments: {} }], + timing: { + startTime: 'start', + endTime: 'end', + duration: 1, + timeSegments: [ + { + type: 'tool', + name: alias, + startTime: 0, + endTime: 1, + duration: 1, + toolCalls: [{ id: 'call-1', name: alias, arguments: {} }], + }, + ], + }, + } + + projectProviderResponseToolIdentities(response, identities) + + expect(response.toolCalls?.[0].name).toBe('gmail_send') + expect(response.timing?.timeSegments?.[0].name).toBe('gmail_send') + expect(response.timing?.timeSegments?.[0].toolCalls?.[0].name).toBe('gmail_send') + }) + + it('projects live events and settled streaming output without changing call ids', async () => { + const tools = [providerTool('gmail_send', 'a'), providerTool('gmail_send', 'b')] + const identities = assignProviderToolIdentities(tools) + const alias = tools[1].id + const output: NormalizedBlockOutput = { + toolCalls: { list: [], count: 0 }, + providerTiming: { + startTime: 'start', + endTime: 'end', + duration: 1, + timeSegments: [], + }, + } + let settleStream: (() => void) | undefined + const canSettle = new Promise((resolve) => { + settleStream = resolve + }) + const response: StreamingExecution = { + streamFormat: 'agent-events-v1', + stream: new ReadableStream({ + async pull(controller) { + await canSettle + controller.enqueue({ type: 'tool_call_start', id: 'call-1', name: alias }) + output.toolCalls = { list: [{ name: alias }], count: 1 } + output.providerTiming?.timeSegments?.push({ + type: 'tool', + name: alias, + startTime: 0, + endTime: 1, + duration: 1, + }) + controller.enqueue({ + type: 'tool_call_end', + id: 'call-1', + name: alias, + status: 'success', + }) + controller.close() + }, + }), + execution: { success: true, output }, + } + + projectStreamingExecutionToolIdentities(response, identities) + settleStream?.() + const events = await readEvents(response.stream) + + expect(events).toEqual([ + { type: 'tool_call_start', id: 'call-1', name: 'gmail_send' }, + { type: 'tool_call_end', id: 'call-1', name: 'gmail_send', status: 'success' }, + ]) + expect(output.toolCalls?.list[0].name).toBe('gmail_send') + expect(output.providerTiming?.timeSegments?.[0].name).toBe('gmail_send') + }) +}) diff --git a/apps/sim/providers/tool-identity.ts b/apps/sim/providers/tool-identity.ts new file mode 100644 index 00000000000..58998fa1fec --- /dev/null +++ b/apps/sim/providers/tool-identity.ts @@ -0,0 +1,159 @@ +import type { NormalizedBlockOutput, StreamingExecution } from '@/executor/types' +import type { AgentStreamEvent } from '@/providers/stream-events' +import type { ProviderResponse, ProviderToolConfig } from '@/providers/types' + +const MAX_PROVIDER_TOOL_ID_LENGTH = 64 +const PROVIDER_ALIAS_MARKER = '__sim_' + +export interface ProviderToolIdentityMap { + /** Provider-only tool id to the canonical registry id used for execution and observability. */ + toolIdByWireId: ReadonlyMap +} + +function buildProviderAlias(toolId: string, occurrence: number, attempt: number): string { + const collisionSuffix = attempt > 0 ? `_${attempt + 1}` : '' + const suffix = `${PROVIDER_ALIAS_MARKER}${occurrence}${collisionSuffix}` + const prefixLength = Math.max(1, MAX_PROVIDER_TOOL_ID_LENGTH - suffix.length) + return `${toolId.slice(0, prefixLength)}${suffix}` +} + +/** + * Gives duplicate configured tools deterministic provider-safe wire ids. + * + * The first occurrence and every already-unique tool keep their existing id for backwards + * compatibility. Later occurrences receive opaque ordinal aliases; resource and credential ids + * never enter the provider-visible name. Tool objects are updated in place so their instance-bound + * params and secret provenance remain attached to the exact object selected by provider adapters. + */ +export function assignProviderToolIdentities( + tools: ProviderToolConfig[] | undefined +): ProviderToolIdentityMap { + if (!tools?.length) return { toolIdByWireId: new Map() } + + const distinctTools = [...new Set(tools)] + if (distinctTools.length !== tools.length) { + tools.splice(0, tools.length, ...distinctTools) + } + + const canonicalIds = tools.map((tool) => tool.canonicalId ?? tool.id) + const reservedIds = new Set(canonicalIds) + const occurrences = new Map() + const usedWireIds = new Set() + const toolIdByWireId = new Map() + + tools.forEach((tool, index) => { + const canonicalId = canonicalIds[index] + const occurrence = (occurrences.get(canonicalId) ?? 0) + 1 + occurrences.set(canonicalId, occurrence) + + let wireId = canonicalId + if (usedWireIds.has(wireId)) { + let attempt = 0 + do { + wireId = buildProviderAlias(canonicalId, occurrence, attempt) + attempt += 1 + } while (reservedIds.has(wireId) || usedWireIds.has(wireId)) + } + + tool.id = wireId + if (wireId !== canonicalId) { + tool.canonicalId = canonicalId + toolIdByWireId.set(wireId, canonicalId) + } + usedWireIds.add(wireId) + }) + + return { toolIdByWireId } +} + +function projectToolId(toolId: string, identities: ProviderToolIdentityMap): string { + return identities.toolIdByWireId.get(toolId) ?? toolId +} + +function projectTiming( + timing: + | { timeSegments?: NonNullable['timeSegments'] } + | undefined, + identities: ProviderToolIdentityMap +): void { + for (const segment of timing?.timeSegments ?? []) { + if (segment.type === 'tool' && segment.name) { + segment.name = projectToolId(segment.name, identities) + } + for (const toolCall of segment.toolCalls ?? []) { + toolCall.name = projectToolId(toolCall.name, identities) + } + } +} + +export function projectProviderResponseToolIdentities( + response: ProviderResponse, + identities: ProviderToolIdentityMap +): void { + if (identities.toolIdByWireId.size === 0) return + + for (const toolCall of response.toolCalls ?? []) { + toolCall.name = projectToolId(toolCall.name, identities) + } + projectTiming(response.timing, identities) +} + +function projectStreamingOutput( + output: NormalizedBlockOutput | undefined, + identities: ProviderToolIdentityMap +): void { + if (!output) return + for (const toolCall of output.toolCalls?.list ?? []) { + toolCall.name = projectToolId(toolCall.name, identities) + } + projectTiming(output.providerTiming, identities) +} + +function projectStreamEvent( + event: AgentStreamEvent, + identities: ProviderToolIdentityMap +): AgentStreamEvent { + if (event.type !== 'tool_call_start' && event.type !== 'tool_call_end') return event + return { ...event, name: projectToolId(event.name, identities) } +} + +/** Keeps provider aliases inside the provider loop, including for live stream events. */ +export function projectStreamingExecutionToolIdentities( + response: StreamingExecution, + identities: ProviderToolIdentityMap +): void { + if (identities.toolIdByWireId.size === 0) return + + projectStreamingOutput(response.execution?.output, identities) + const reader = response.stream.getReader() + const eventStream = response.streamFormat === 'agent-events-v1' + + response.stream = new ReadableStream({ + async pull(controller) { + try { + const { done, value } = await reader.read() + if (done) { + projectStreamingOutput(response.execution?.output, identities) + reader.releaseLock() + controller.close() + return + } + controller.enqueue( + eventStream ? projectStreamEvent(value as AgentStreamEvent, identities) : value + ) + } catch (error) { + projectStreamingOutput(response.execution?.output, identities) + reader.releaseLock() + controller.error(error) + } + }, + async cancel(reason) { + try { + await reader.cancel(reason) + } finally { + projectStreamingOutput(response.execution?.output, identities) + reader.releaseLock() + } + }, + }) +} diff --git a/apps/sim/providers/types.ts b/apps/sim/providers/types.ts index e029f830d2c..1033f87fbf3 100644 --- a/apps/sim/providers/types.ts +++ b/apps/sim/providers/types.ts @@ -119,6 +119,8 @@ export interface ProviderResponse { export type ToolUsageControl = 'auto' | 'force' | 'none' export interface ProviderToolConfig { + /** Canonical registry id when {@link id} is a request-scoped provider wire alias. */ + canonicalId?: string id: string name: string description: string diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index 9deedf19aab..25c2e4b4c7f 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -11,6 +11,8 @@ vi.mock('@/executor/utils/http', () => ({ buildExecutorDelegationHeaders: workflowMetadataMocks.buildExecutorDelegationHeaders, })) +import { assignProviderToolIdentities } from '@/providers/tool-identity' +import type { ProviderToolConfig } from '@/providers/types' import { calculateCost, describeModelLevel, @@ -1264,6 +1266,38 @@ describe('Tool Management', () => { expect(result.toolChoice).toBe('auto') }) + + it('keeps usage control independent for duplicate configured tools', () => { + const providerTools: ProviderToolConfig[] = [ + { + id: 'gmail_send', + name: 'Gmail Send', + description: 'Send an email', + params: { oauthCredential: 'credential-a' }, + parameters: { type: 'object', properties: {}, required: [] }, + usageControl: 'none', + }, + { + id: 'gmail_send', + name: 'Gmail Send', + description: 'Send an email', + params: { oauthCredential: 'credential-b' }, + parameters: { type: 'object', properties: {}, required: [] }, + usageControl: 'force', + }, + ] + assignProviderToolIdentities(providerTools) + const tools = providerTools.map((tool) => ({ function: { name: tool.id } })) + + const result = prepareToolsWithUsageControl(tools, providerTools, mockLogger) + + expect(result.tools).toEqual([{ function: { name: 'gmail_send__sim_2' } }]) + expect(result.forcedTools).toEqual(['gmail_send__sim_2']) + expect(result.toolChoice).toEqual({ + type: 'function', + function: { name: 'gmail_send__sim_2' }, + }) + }) }) }) @@ -1615,7 +1649,7 @@ describe('Provider/Model Blacklist', () => { }) }) -describe('transformBlockTool multi-instance unique IDs', () => { +describe('transformBlockTool table identities', () => { const tableBlockDef = { type: 'table', inputs: {}, @@ -1653,9 +1687,9 @@ describe('transformBlockTool multi-instance unique IDs', () => { { selectedOperation: 'query_rows', getAllBlocks, getTool, canonicalModes, toolIndex } ) - it('appends the table id when stored under the basic selector subblock key', async () => { + it('keeps the canonical id when the table is stored under the basic selector key', async () => { const result = await transformTable({ tableSelector: 'tbl_abc' }) - expect(result?.id).toBe('table_query_rows_tbl_abc') + expect(result?.id).toBe('table_query_rows') }) it('resolves the active table selector before enriching the LLM tool schema', async () => { @@ -1719,7 +1753,7 @@ describe('transformBlockTool multi-instance unique IDs', () => { } ) expect(result).toMatchObject({ - id: 'table_query_rows_tbl_active', + id: 'table_query_rows', description: 'Query rows from tbl_active', params: { tableId: 'tbl_stale', tableSelector: 'tbl_active' }, parameters: { @@ -1731,25 +1765,25 @@ describe('transformBlockTool multi-instance unique IDs', () => { expect(result?.paramsTransform?.(result.params)).toEqual({ tableId: 'tbl_active' }) }) - it('appends the table id resolved from the advanced manual input', async () => { + it('keeps the canonical id for a table resolved from the advanced manual input', async () => { const result = await transformTable( { manualTableId: 'tbl_xyz' }, { '0:tableId': 'advanced' }, 0 ) - expect(result?.id).toBe('table_query_rows_tbl_xyz') + expect(result?.id).toBe('table_query_rows') }) it('resolves an advanced-only manual id via the heuristic when basic is empty and no mode is set', async () => { // No canonicalModes entry: routing through resolveCanonicalMode picks advanced (empty basic), // where the old `?? 'basic'` fallback dropped the advanced-only value. const result = await transformTable({ manualTableId: 'tbl_only' }) - expect(result?.id).toBe('table_query_rows_tbl_only') + expect(result?.id).toBe('table_query_rows') }) - it('appends the canonical table id when already present in params', async () => { + it('keeps the canonical tool id when the table id is already present in params', async () => { const result = await transformTable({ tableId: 'tbl_direct' }) - expect(result?.id).toBe('table_query_rows_tbl_direct') + expect(result?.id).toBe('table_query_rows') }) it('preserves the canonical table id when advanced mode is active', async () => { @@ -1758,7 +1792,7 @@ describe('transformBlockTool multi-instance unique IDs', () => { { '0:tableId': 'advanced' }, 0 ) - expect(result?.id).toBe('table_query_rows_tbl_advanced') + expect(result?.id).toBe('table_query_rows') expect(result?.paramsTransform?.(result.params)).toEqual({ tableId: 'tbl_advanced' }) }) @@ -1778,12 +1812,12 @@ describe('transformBlockTool multi-instance unique IDs', () => { 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') + expect(first?.id).toBe('table_query_rows') + expect(second?.id).toBe('table_query_rows') }) }) -describe('transformBlockTool knowledge-base multi-instance unique IDs', () => { +describe('transformBlockTool knowledge-base identities', () => { const knowledgeBlockDef = { type: 'knowledge', inputs: {}, @@ -1826,23 +1860,23 @@ describe('transformBlockTool knowledge-base multi-instance unique IDs', () => { { selectedOperation: 'search', getAllBlocks, getTool, canonicalModes, toolIndex } ) - it('appends the knowledge base id when stored under the basic selector subblock key', async () => { + it('keeps the canonical id for the basic knowledge base selector', async () => { const result = await transformKb({ knowledgeBaseSelector: 'kb_abc' }) - expect(result?.id).toBe('knowledge_search_kb_abc') + expect(result?.id).toBe('knowledge_search') }) - it('appends the knowledge base id resolved from the advanced manual input', async () => { + it('keeps the canonical id for an advanced knowledge base input', async () => { const result = await transformKb( { manualKnowledgeBaseId: 'kb_xyz' }, { '0:knowledgeBaseId': 'advanced' }, 0 ) - expect(result?.id).toBe('knowledge_search_kb_xyz') + expect(result?.id).toBe('knowledge_search') }) - it('appends the canonical knowledge base id when already present in params', async () => { + it('keeps the canonical tool id when the knowledge base id is already present', async () => { const result = await transformKb({ knowledgeBaseId: 'kb_direct' }) - expect(result?.id).toBe('knowledge_search_kb_direct') + expect(result?.id).toBe('knowledge_search') }) it('falls back to the base tool id when no knowledge base is selected', async () => { @@ -1958,7 +1992,7 @@ describe('workflow executor metadata delegation', () => { }, }) expect(result).toMatchObject({ - id: 'workflow_executor_child-workflow', + id: 'workflow_executor', name: 'Child Workflow', description: 'Child description', }) @@ -2011,7 +2045,7 @@ describe('workflow executor metadata delegation', () => { expect(workflowMetadataMocks.buildExecutorDelegationHeaders).not.toHaveBeenCalled() expect(fetchMock).not.toHaveBeenCalled() expect(result).toMatchObject({ - id: 'workflow_executor_child-workflow', + id: 'workflow_executor', name: 'Workflow Executor', description: 'Execute another workflow', }) diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index ebff63516ff..92be9226251 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -721,8 +721,7 @@ export async function transformBlockTool( return null } return { - // Unique per block so two custom-block tools never collide on the wire. - id: `deployed_block_executor_${block.type}`, + id: customToolConfig.id, // Name/description come from the block itself — never the source workflow's // metadata, which the consumer has no access to. name: blockDef.name, @@ -803,13 +802,10 @@ export async function transformBlockTool( modelBlockedParams, } = await createLLMToolSchema(toolConfig, resolvedResourceParams, enrichmentContext) - let uniqueToolId = toolConfig.id let toolName = toolConfig.name let toolDescription = enrichedDescription || toolConfig.description if (toolId === 'workflow_executor' && resolvedResourceParams.workflowId) { - uniqueToolId = `${toolConfig.id}_${resolvedResourceParams.workflowId}` - const workflowMetadata = await fetchWorkflowMetadata( resolvedResourceParams.workflowId, enrichmentContext @@ -833,10 +829,6 @@ export async function transformBlockTool( toolDescription = mounted.length ? `${toolDescription}\n\nWorkspace secret names available to this code: ${mounted.join(', ')}. Reference one with the exact {{NAME}} syntax. Its value is bound only while the code executes and is not included in the model request. No other secrets are readable.` : `${toolDescription}\n\nThis code has no access to workspace secrets.` - } else if (toolId.startsWith('knowledge_') && resolvedResourceParams.knowledgeBaseId) { - uniqueToolId = `${toolConfig.id}_${resolvedResourceParams.knowledgeBaseId}` - } else if (toolId.startsWith('table_') && resolvedResourceParams.tableId) { - uniqueToolId = `${toolConfig.id}_${resolvedResourceParams.tableId}` } const blockParamsFn = blockDef?.tools?.config?.params as @@ -893,7 +885,7 @@ export async function transformBlockTool( : undefined return { - id: uniqueToolId, + id: toolConfig.id, name: toolName, description: toolDescription, params: userProvidedParams, diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index f982ee9006a..84f13d47bef 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -1596,7 +1596,7 @@ async function executeToolImplementation( try { let tool: ToolConfig | undefined - // Normalize tool ID to strip resource suffixes (e.g., workflow_executor_ -> workflow_executor) + // Preserve direct-call compatibility with legacy resource-suffixed tool ids. const normalizedToolId = normalizeToolId(toolId) if (internalSandboxProfile && normalizedToolId !== 'function_execute') { throw new Error('An internal sandbox profile may only be used with function_execute') @@ -1732,9 +1732,7 @@ async function executeToolImplementation( contextParams.credential = contextParams.oauthCredential } if (contextParams.credential) { - logger.info( - `[${requestId}] Tool ${toolId} needs access token for credential: ${contextParams.credential}` - ) + logger.info(`[${requestId}] Resolving tool access token`, { toolId: normalizedToolId }) try { const workflowId = scope.workflowId const userId = scope.userId diff --git a/apps/sim/tools/normalize.ts b/apps/sim/tools/normalize.ts index c01dceaf33b..50f07f4a2e0 100644 --- a/apps/sim/tools/normalize.ts +++ b/apps/sim/tools/normalize.ts @@ -1,5 +1,7 @@ /** - * Normalizes a tool ID by stripping resource ID suffix (UUID/tableId). + * Normalizes a legacy tool ID by stripping its former resource ID suffix (UUID/tableId). + * New provider requests use request-scoped aliases and resolve them through an explicit map; + * these cases remain for stored logs and callers that still send the historical ids directly. * Workflow tools: 'workflow_executor_' -> 'workflow_executor' * Knowledge tools: 'knowledge_search_' -> 'knowledge_search' * Table tools: 'table_query_rows_' -> 'table_query_rows'