mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-30 19:32:46 +08:00
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:
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user