Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions apps/sim/executor/handlers/pi/local/sim-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
192 changes: 98 additions & 94 deletions apps/sim/executor/handlers/pi/local/sim-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string, unknown>) || {
type: 'object',
properties: {},
},
execute: async (args) => {
const params = mergeToolParameters(preseededParams, args as Record<string, unknown>)
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<string, unknown>
)
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.
Expand All @@ -106,7 +198,7 @@ export async function buildSimToolSpecs(
): Promise<PiToolSpec[]> {
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
Expand All @@ -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<string, unknown>) || {
type: 'object',
properties: {},
},
execute: async (args) => {
const params = mergeToolParameters(preseededParams, args as Record<string, unknown>)
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<string, unknown>
)
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', {
Expand All @@ -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)
)
}
3 changes: 1 addition & 2 deletions apps/sim/providers/custom-block-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Loading
Loading