improvement(tools): support duplicate provider instances (#7064)

* improvement(tools): support duplicate provider instances

* fix(tools): cover duplicate Pi tool instances
This commit is contained in:
Waleed
2026-08-24 23:30:34 -07:00
committed by GitHub
parent 011f26d9f3
commit 174c77384a
14 changed files with 680 additions and 136 deletions
@@ -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' },
@@ -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<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.
@@ -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
@@ -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', {
@@ -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)
)
}
+1 -2
View File
@@ -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')
+106 -1
View File
@@ -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<AgentStreamEvent>({
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<AgentStreamEvent>).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()
+19 -1
View File
@@ -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
@@ -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([
{
+5 -2
View File
@@ -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<string, string>
}
export type ExecuteProviderToolOptions = ExecuteToolOptions
@@ -82,10 +84,11 @@ export async function executeProviderTool(
options: ExecuteProviderToolOptions = {}
): Promise<ProviderToolExecutionResult> {
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,
+178
View File
@@ -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<AgentStreamEvent[]> {
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<void>((resolve) => {
settleStream = resolve
})
const response: StreamingExecution = {
streamFormat: 'agent-events-v1',
stream: new ReadableStream<AgentStreamEvent>({
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')
})
})
+159
View File
@@ -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<string, string>
}
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<string, number>()
const usedWireIds = new Set<string>()
const toolIdByWireId = new Map<string, string>()
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<NormalizedBlockOutput['providerTiming']>['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()
}
},
})
}
+2
View File
@@ -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
+55 -21
View File
@@ -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',
})
+2 -10
View File
@@ -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,
+2 -4
View File
@@ -1596,7 +1596,7 @@ async function executeToolImplementation(
try {
let tool: ToolConfig | undefined
// Normalize tool ID to strip resource suffixes (e.g., workflow_executor_<uuid> -> 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
+3 -1
View File
@@ -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_<uuid>' -> 'workflow_executor'
* Knowledge tools: 'knowledge_search_<uuid>' -> 'knowledge_search'
* Table tools: 'table_query_rows_<tableId>' -> 'table_query_rows'