Skip to content

Commit f7a9cbe

Browse files
committed
improvement(tools): support duplicate provider instances
1 parent 011f26d commit f7a9cbe

12 files changed

Lines changed: 539 additions & 42 deletions

apps/sim/providers/custom-block-tool.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,7 @@ describe('transformBlockTool — custom blocks', () => {
4545
)
4646

4747
expect(tool).not.toBeNull()
48-
// Unique per block, name/description from the block (never the source workflow).
49-
expect(tool!.id).toBe('deployed_block_executor_custom_block_test')
48+
expect(tool!.id).toBe('deployed_block_executor')
5049
expect(tool!.name).toBe('The Elder')
5150
// Baked params: block type + assembled (id-keyed) input mapping.
5251
expect(tool!.params.blockType).toBe('custom_block_test')

apps/sim/providers/index.test.ts

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ const {
99
mockGetApiKeyWithBYOK,
1010
mockExecuteRequest,
1111
mockFilterModelSafeWorkspaceFileAttachments,
12+
mockExecuteTool,
1213
mockUploadLargeFilesToProvider,
1314
} = vi.hoisted(() => ({
1415
mockAttachLargeFileRemoteUrls: vi.fn(),
1516
mockGetApiKeyWithBYOK: vi.fn(),
1617
mockExecuteRequest: vi.fn(),
1718
mockFilterModelSafeWorkspaceFileAttachments: vi.fn(async (attachments: unknown[]) => attachments),
19+
mockExecuteTool: vi.fn(async () => ({ success: true, output: {} })),
1820
mockUploadLargeFilesToProvider: vi.fn(),
1921
}))
2022

@@ -39,9 +41,16 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', ()
3941
mockFilterModelSafeWorkspaceFileAttachments(...args),
4042
}))
4143

44+
vi.mock('@/tools', () => ({
45+
executeTool: (...args: unknown[]) => mockExecuteTool(...args),
46+
}))
47+
48+
import type { NormalizedBlockOutput, StreamingExecution } from '@/executor/types'
4249
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
4350
import { executeProviderRequest } from '@/providers'
44-
import type { ProviderResponse } from '@/providers/types'
51+
import { executeProviderTool } from '@/providers/runtime-context'
52+
import type { AgentStreamEvent } from '@/providers/stream-events'
53+
import type { ProviderResponse, ProviderToolConfig } from '@/providers/types'
4554

4655
const HOSTED_RATE_INPUT_COST = 0.340285
4756
const HOSTED_RATE_OUTPUT_COST = 0.0387
@@ -91,6 +100,102 @@ function makeAnthropicResponse(): ProviderResponse {
91100
}
92101
}
93102

103+
function makeProviderTool(id: string, credential: string): ProviderToolConfig {
104+
return {
105+
id,
106+
name: id,
107+
description: id,
108+
params: { oauthCredential: credential },
109+
parameters: { type: 'object', properties: {}, required: [] },
110+
}
111+
}
112+
113+
describe('executeProviderRequest — tool identities', () => {
114+
beforeEach(() => {
115+
vi.clearAllMocks()
116+
})
117+
118+
it('sends unique opaque ids and projects provider aliases out of the response', async () => {
119+
const tools = [
120+
makeProviderTool('gmail_send', 'credential-a'),
121+
makeProviderTool('gmail_send', 'credential-b'),
122+
]
123+
mockExecuteRequest.mockImplementationOnce(async (request) => {
124+
const alias = request.tools[1].id
125+
expect(request.tools.map((tool: ProviderToolConfig) => tool.id)).toEqual([
126+
'gmail_send',
127+
'gmail_send__sim_2',
128+
])
129+
expect(alias).not.toContain('credential-b')
130+
return {
131+
content: 'sent',
132+
model: 'test-model',
133+
toolCalls: [{ name: alias, arguments: {} }],
134+
timing: {
135+
startTime: 'start',
136+
endTime: 'end',
137+
duration: 1,
138+
timeSegments: [{ type: 'tool', name: alias, startTime: 0, endTime: 1, duration: 1 }],
139+
},
140+
}
141+
})
142+
143+
const response = (await executeProviderRequest('anthropic', {
144+
model: 'test-model',
145+
tools,
146+
})) as ProviderResponse
147+
148+
expect(response.toolCalls?.[0].name).toBe('gmail_send')
149+
expect(response.timing?.timeSegments?.[0].name).toBe('gmail_send')
150+
expect(tools[1].params.oauthCredential).toBe('credential-b')
151+
})
152+
153+
it('keeps the alias map active while a streaming provider executes the selected instance', async () => {
154+
const tools = [
155+
makeProviderTool('gmail_send', 'credential-a'),
156+
makeProviderTool('gmail_send', 'credential-b'),
157+
]
158+
mockExecuteRequest.mockImplementationOnce(async (request) => {
159+
const selected = request.tools[1] as ProviderToolConfig
160+
const output: NormalizedBlockOutput = {
161+
toolCalls: { list: [], count: 0 },
162+
providerTiming: { startTime: 'start', endTime: 'end', duration: 0, timeSegments: [] },
163+
}
164+
return {
165+
streamFormat: 'agent-events-v1',
166+
stream: new ReadableStream<AgentStreamEvent>({
167+
async pull(controller) {
168+
await executeProviderTool(selected.id, selected.params)
169+
output.toolCalls = { list: [{ name: selected.id }], count: 1 }
170+
controller.enqueue({ type: 'tool_call_start', id: 'call-1', name: selected.id })
171+
controller.close()
172+
},
173+
}),
174+
execution: { success: true, output },
175+
}
176+
})
177+
178+
const response = await executeProviderRequest('anthropic', {
179+
model: 'test-model',
180+
tools,
181+
})
182+
expect(response).not.toBeInstanceOf(ReadableStream)
183+
expect(response).toHaveProperty('stream')
184+
const streaming = response as StreamingExecution
185+
const reader = (streaming.stream as ReadableStream<AgentStreamEvent>).getReader()
186+
const event = await reader.read()
187+
await reader.read()
188+
189+
expect(mockExecuteTool).toHaveBeenCalledWith(
190+
'gmail_send',
191+
{ oauthCredential: 'credential-b' },
192+
expect.any(Object)
193+
)
194+
expect(event.value).toEqual({ type: 'tool_call_start', id: 'call-1', name: 'gmail_send' })
195+
expect(streaming.execution.output.toolCalls?.list[0].name).toBe('gmail_send')
196+
})
197+
})
198+
94199
describe('executeProviderRequest — BYOK regression', () => {
95200
beforeEach(() => {
96201
vi.clearAllMocks()

apps/sim/providers/index.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ import {
2222
type ProviderRuntimeContext,
2323
runWithProviderRuntimeContext,
2424
} from '@/providers/runtime-context'
25+
import {
26+
assignProviderToolIdentities,
27+
projectProviderResponseToolIdentities,
28+
projectStreamingExecutionToolIdentities,
29+
} from '@/providers/tool-identity'
2530
import type { ProviderId, ProviderRequest, ProviderResponse } from '@/providers/types'
2631
import {
2732
generateStructuredOutputInstructions,
@@ -218,6 +223,17 @@ export async function executeProviderRequest(
218223

219224
const provenanceSafeRequest = await omitUnsafeProviderFileAttachments(sanitizedRequest)
220225
const modelSafeRequest = provenanceSafeRequest
226+
const toolIdentities = assignProviderToolIdentities(modelSafeRequest.tools)
227+
const requestRuntimeContext =
228+
toolIdentities.toolIdByWireId.size > 0
229+
? {
230+
...runtimeContext,
231+
toolIdByWireId: new Map([
232+
...(runtimeContext?.toolIdByWireId ?? []),
233+
...toolIdentities.toolIdByWireId,
234+
]),
235+
}
236+
: runtimeContext
221237

222238
if (modelSafeRequest.responseFormat) {
223239
const structuredOutputInstructions = generateStructuredOutputInstructions(
@@ -230,7 +246,7 @@ export async function executeProviderRequest(
230246
}
231247
}
232248

233-
const response = await runWithProviderRuntimeContext(runtimeContext, async () => {
249+
const response = await runWithProviderRuntimeContext(requestRuntimeContext, async () => {
234250
await attachLargeFileRemoteUrls(modelSafeRequest, providerId)
235251
await uploadLargeFilesToProvider(modelSafeRequest, providerId)
236252
return provider.executeRequest(modelSafeRequest)
@@ -239,6 +255,7 @@ export async function executeProviderRequest(
239255
if (isStreamingExecution(response)) {
240256
logger.info('Provider returned StreamingExecution', { isBYOK })
241257
applyStreamingCostPolicy(response, resolveModelCostPolicy(sanitizedRequest.model, isBYOK))
258+
projectStreamingExecutionToolIdentities(response, toolIdentities)
242259
return response
243260
}
244261

@@ -248,6 +265,7 @@ export async function executeProviderRequest(
248265
}
249266

250267
const costPolicy = resolveModelCostPolicy(response.model, isBYOK)
268+
projectProviderResponseToolIdentities(response, toolIdentities)
251269

252270
if (response.tokens) {
253271
const { input: promptTokens = 0, output: completionTokens = 0 } = response.tokens

apps/sim/providers/runtime-context.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,19 @@ describe('provider runtime context', () => {
108108
)
109109
})
110110

111+
it('resolves a provider-only alias before executing the canonical tool', async () => {
112+
await runWithProviderRuntimeContext(
113+
{ toolIdByWireId: new Map([['gmail_send__sim_2', 'gmail_send']]) },
114+
() => executeProviderTool('gmail_send__sim_2', { oauthCredential: 'credential-b' })
115+
)
116+
117+
expect(mockExecuteTool).toHaveBeenCalledWith(
118+
'gmail_send',
119+
{ oauthCredential: 'credential-b' },
120+
expect.any(Object)
121+
)
122+
})
123+
111124
it('rebinds a prompt-exposed environment placeholder for the exact tool call', async () => {
112125
const sourceRegistry = new ResolvedSecretTraceRegistry([
113126
{

apps/sim/providers/runtime-context.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export interface ProviderRuntimeContext {
1717
resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry
1818
/** Trusted server execution context inherited by model-emitted tool calls. */
1919
executionContext?: ExecutionContext
20+
/** Request-scoped provider wire ids mapped back to canonical tool registry ids. */
21+
toolIdByWireId?: ReadonlyMap<string, string>
2022
}
2123

2224
export type ExecuteProviderToolOptions = ExecuteToolOptions
@@ -82,10 +84,11 @@ export async function executeProviderTool(
8284
options: ExecuteProviderToolOptions = {}
8385
): Promise<ProviderToolExecutionResult> {
8486
const runtimeContext = providerRuntimeContext.getStore()
87+
const executionToolId = runtimeContext?.toolIdByWireId?.get(toolId) ?? toolId
8588
const registry =
8689
options.resolvedSecretTraceRegistry ?? runtimeContext?.resolvedSecretTraceRegistry
8790

88-
if (runtimeContext && !registry) {
91+
if (runtimeContext && Object.hasOwn(runtimeContext, 'resolvedSecretTraceRegistry') && !registry) {
8992
const response: ToolResponse = { success: false, output: {} }
9093
return { rawResponse: response, modelResponse: response }
9194
}
@@ -102,7 +105,7 @@ export async function executeProviderTool(
102105

103106
try {
104107
const executionContext = options.executionContext ?? runtimeContext?.executionContext
105-
const result = await executeTool(toolId, params, {
108+
const result = await executeTool(executionToolId, params, {
106109
...options,
107110
...(executionContext ? { executionContext } : {}),
108111
resolvedSecretTraceRegistry: toolCallRegistry,

0 commit comments

Comments
 (0)