Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

1171 lines
37 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
import {
buildCanonicalIndex,
type CanonicalModeOverrides,
evaluateSubBlockCondition,
isCanonicalPair,
isSubBlockFeatureEnabled,
isSubBlockHidden,
isTriggerModeSubBlock,
resolveCanonicalMode,
type SubBlockCondition,
} from '@/lib/workflows/subblocks/visibility'
import { isCustomBlockType, RESERVED_PARAMS } from '@/blocks/custom/build-config'
import type {
BlockConfig as AppBlockConfig,
SubBlockConfig as BlockSubBlockConfig,
GenerationType,
} from '@/blocks/types'
import { isNonEmpty } from '@/tools/merge-params'
import { getToolMetadata, type ToolMetadata } from '@/tools/metadata'
import { safeAssign } from '@/tools/safe-assign'
import type {
OAuthConfig,
ParameterVisibility,
ToolConfig,
ToolParameterItemSchema,
WorkflowToolExecutionContext,
} from '@/tools/types'
const logger = createLogger('ToolsParams')
type ToolParamDefinition = ToolConfig['params'][string]
// ============================================================================
// Tag/Value Parsing Utilities
// ============================================================================
interface Option {
label: string
value: string
}
interface ComponentCondition {
field: string
value: string | number | boolean | Array<string | number | boolean>
not?: boolean
}
interface UIComponentConfig {
type: string
options?: Option[]
placeholder?: string
password?: boolean
condition?: ComponentCondition
title?: string
value?: unknown
serviceId?: string
selectorKey?: BlockSubBlockConfig['selectorKey']
requiredScopes?: string[]
mimeType?: string
columns?: string[]
min?: number
max?: number
step?: number
integer?: boolean
language?: string
generationType?: string
acceptedTypes?: string[]
multiple?: boolean
multiSelect?: boolean
maxSize?: number
dependsOn?: string[] | { all?: string[]; any?: string[] }
/** Canonical parameter ID if this is part of a canonical group */
canonicalParamId?: string
/** The mode of the source subblock (basic/advanced/both) */
mode?: 'basic' | 'advanced' | 'both' | 'trigger' | 'trigger-advanced'
/** The actual subblock ID this config was derived from */
actualSubBlockId?: string
/** Wand configuration for AI assistance */
wandConfig?: {
enabled: boolean
prompt: string
generationType?: GenerationType
placeholder?: string
maintainHistory?: boolean
}
}
interface SubBlockConfig {
id: string
type: string
title?: string
options?: Option[]
placeholder?: string
password?: boolean
condition?: ComponentCondition
value?: unknown
serviceId?: string
requiredScopes?: string[]
mimeType?: string
columns?: string[]
min?: number
max?: number
step?: number
integer?: boolean
language?: string
generationType?: string
acceptedTypes?: string[]
multiple?: boolean
maxSize?: number
dependsOn?: string[]
}
type ToolInputBlockConfig = Pick<AppBlockConfig, 'type' | 'subBlocks' | 'tools'>
interface SchemaProperty {
type: string
description?: string
items?: ToolParameterItemSchema
properties?: Record<string, SchemaProperty>
required?: string[]
}
export interface ToolSchema {
type: 'object'
properties: Record<string, SchemaProperty>
required: string[]
}
export interface UserToolSchemaOptions {
surface?: 'default' | 'copilot'
/**
* Set when the deployment provides hosted API keys for tools with a
* `hosting` config. For unconditionally hosted tools the key param then
* stays in the schema only as an optional bring-your-own-key override
* instead of a required argument — the executor injects the hosted key
* server-side after validation, and the key value itself is never exposed
* to the model or the mothership. Tools with a conditional
* `hosting.enabled` predicate keep the key required, since injection only
* happens for configurations that satisfy the predicate (mirrors the VFS
* `conditional_hosted_or_byok` auth mode).
*/
hostedKeySupport?: boolean
}
export interface LLMToolSchemaResult {
schema: ToolSchema
enrichedDescription?: string
/**
* Params the model is never allowed to supply, because the tool declares them
* `user-only` or `hidden`. Omitting them from {@link schema} is not enough on
* its own — nothing stops a model from emitting an undeclared key, and the
* merge downstream seeds from the model's args — so the names travel with the
* schema for `prepareToolExecution` to strip.
*/
modelBlockedParams?: string[]
}
export class ToolSchemaEnrichmentError extends Error {
constructor(toolId: string, cause: unknown) {
super(`Failed to enrich schema for tool "${toolId}"`, { cause })
this.name = 'ToolSchemaEnrichmentError'
}
}
export interface ValidationResult {
valid: boolean
missingParams: string[]
}
export interface ToolParameterConfig {
id: string
type: string
required?: boolean // Required for tool execution
visibility?: ParameterVisibility // Controls who can/must provide this parameter
userProvided?: boolean // User filled this parameter
description?: string
default?: unknown
// UI component information from block config
uiComponent?: UIComponentConfig
}
export interface ToolWithParameters {
toolConfig: ToolMetadata
allParameters: ToolParameterConfig[]
userInputParameters: ToolParameterConfig[] // Parameters shown to user
requiredParameters: ToolParameterConfig[] // Must be filled by user or LLM
optionalParameters: ToolParameterConfig[] // Nice to have, shown to user
}
let blockConfigCache: Record<string, ToolInputBlockConfig> | null = null
function getBlockConfigurations(): Record<string, ToolInputBlockConfig> {
if (!blockConfigCache) {
try {
const { getAllBlocks } = require('@/blocks')
const allBlocks = getAllBlocks()
blockConfigCache = {}
allBlocks.forEach((block: AppBlockConfig) => {
blockConfigCache![block.type] = block
})
} catch (error) {
logger.warn('Could not load block configuration:', error)
blockConfigCache = {}
}
}
return blockConfigCache
}
/**
* Gets the correct tool ID for a block operation.
*
* Pass `blockOverride` (a fresh, overlay-aware config) for custom (deploy-as-block)
* blocks — the module `getBlockConfigurations()` cache can miss async-hydrated
* custom blocks, which would return `undefined` here and make "add tool" silently
* no-op.
*/
export function getToolIdForOperation(
blockType: string,
operation?: string,
blockOverride?: Pick<ToolInputBlockConfig, 'tools'>
): string | undefined {
const block = blockOverride ?? getBlockConfigurations()[blockType]
if (!block?.tools?.access) return undefined
if (block.tools.access.length === 1) {
return block.tools.access[0]
}
if (operation && block.tools.config?.tool) {
try {
return block.tools.config.tool({ operation })
} catch (error) {
logger.error('Error selecting tool for operation:', error)
}
}
if (operation && block.tools.access.includes(operation)) {
return operation
}
return block.tools.access[0]
}
function resolveSubBlockForParam(
paramId: string,
subBlocks: BlockSubBlockConfig[],
valuesWithOperation: Record<string, unknown>,
paramType: string
): BlockSubBlockConfig | undefined {
const blockSubBlocks = subBlocks
// First pass: find subblock with matching condition
let fallbackMatch: BlockSubBlockConfig | undefined
for (const sb of blockSubBlocks) {
const matches = sb.id === paramId || sb.canonicalParamId === paramId
if (!matches) continue
// Remember first match as fallback (for condition-based filtering in UI)
if (!fallbackMatch) fallbackMatch = sb
if (
!sb.condition ||
evaluateSubBlockCondition(sb.condition as SubBlockCondition, valuesWithOperation)
) {
return sb
}
}
// Return fallback so its condition can be used for UI filtering
if (fallbackMatch) return fallbackMatch
// Check if boolean param is part of a checkbox-list
if (paramType === 'boolean') {
return blockSubBlocks.find(
(sb) =>
sb.type === 'checkbox-list' &&
Array.isArray(sb.options) &&
(sb.options as Array<{ id?: string }>).some((opt) => opt.id === paramId)
)
}
return undefined
}
/** Map a custom-block field sub-block type to a tool-parameter type. */
function customFieldParamType(subBlockType: string): string {
switch (subBlockType) {
case 'switch':
return 'boolean'
case 'file-upload':
return 'file[]'
case 'code':
return 'json'
default:
return 'string'
}
}
/**
* Gets all parameters for a tool, categorized by their usage
* Also includes UI component information from block configurations
*/
export function getToolParametersConfig(
toolId: string,
blockType?: string,
currentValues?: Record<string, unknown>,
blockConfigOverride?: Pick<ToolInputBlockConfig, 'subBlocks'>
): ToolWithParameters | null {
try {
const toolConfig = getToolMetadata(toolId)
if (!toolConfig) {
logger.warn(`Tool not found: ${toolId}`)
return null
}
// Validate that toolConfig has required properties
if (!toolConfig.params || typeof toolConfig.params !== 'object') {
logger.warn(`Tool ${toolId} has invalid params configuration`)
return null
}
// Custom (deploy-as-block) blocks resolve to `workflow_executor`, but their
// editable inputs are their own per-field sub-blocks — not the generic
// workflowId/inputMapping. Surface those so the tool panel renders the block's
// real fields (and never the workflow-executor fields as "uncovered" params).
// MUST run before the `workflow_executor` branch below. Read subBlocks from the
// fresh, overlay-aware `blockConfigOverride` — the module `getBlockConfigurations`
// cache can miss async-hydrated custom blocks.
if (blockType && isCustomBlockType(blockType)) {
const blockConfig = blockConfigOverride ?? getBlockConfigurations()[blockType]
const fieldSubBlocks = (
(blockConfig?.subBlocks as BlockSubBlockConfig[] | undefined) ?? []
).filter((sb) => !sb.hidden && !RESERVED_PARAMS.has(sb.id))
const parameters: ToolParameterConfig[] = fieldSubBlocks.map((sb) => ({
id: sb.id,
type: customFieldParamType(sb.type),
required: sb.required === true,
visibility: 'user-or-llm',
description: sb.description,
uiComponent: {
type: sb.type,
title: sb.title,
placeholder: sb.placeholder,
language: sb.language,
multiple: sb.multiple,
},
}))
return {
toolConfig,
allParameters: parameters,
userInputParameters: parameters,
requiredParameters: parameters.filter((param) => param.required),
optionalParameters: parameters.filter((param) => !param.required),
}
}
// Special handling for workflow_executor tool
if (toolId === 'workflow_executor') {
const parameters: ToolParameterConfig[] = [
{
id: 'workflowId',
type: 'string',
required: true,
visibility: 'user-only',
description: 'The ID of the workflow to execute',
uiComponent: {
type: 'workflow-selector',
placeholder: 'Select workflow to execute',
selectorKey: 'sim.workflows',
},
},
{
id: 'inputMapping',
type: 'object',
required: false,
visibility: 'user-or-llm',
description: 'Map inputs to the selected workflow',
uiComponent: {
type: 'workflow-input-mapper',
title: 'Workflow Inputs',
condition: {
field: 'workflowId',
value: '',
not: true, // Show when workflowId is not empty
},
dependsOn: ['workflowId'],
},
},
]
return {
toolConfig,
allParameters: parameters,
userInputParameters: parameters.filter(
(param) => param.visibility === 'user-or-llm' || param.visibility === 'user-only'
),
requiredParameters: parameters.filter((param) => param.required),
optionalParameters: parameters.filter(
(param) => param.visibility === 'user-only' && !param.required
),
}
}
// Get block configuration for UI component information
let blockConfig: ToolInputBlockConfig | null = null
if (blockType) {
const blockConfigs = getBlockConfigurations()
blockConfig = blockConfigs[blockType] || null
}
// Build values for condition evaluation
// Operation should come from currentValues if provided, otherwise extract from toolId
const values = currentValues || {}
const valuesWithOperation = { ...values }
if (valuesWithOperation.operation === undefined) {
// Fallback: extract operation from tool ID (e.g., 'slack_message' -> 'message')
const parts = toolId.split('_')
valuesWithOperation.operation =
parts.length >= 3 ? parts.slice(2).join('_') : parts[parts.length - 1]
}
// Convert tool params to our standard format with UI component info
const allParameters: ToolParameterConfig[] = Object.entries(toolConfig.params).map(
([paramId, param]) => {
const toolParam: ToolParameterConfig = {
id: paramId,
type: param.type,
required: param.required ?? false,
visibility: param.visibility ?? (param.required ? 'user-or-llm' : 'user-only'),
description: param.description,
default: param.default,
}
if (blockConfig) {
const subBlock = resolveSubBlockForParam(
paramId,
blockConfig.subBlocks || [],
valuesWithOperation,
param.type
)
if (subBlock) {
if (isSubBlockHidden(subBlock)) {
toolParam.visibility = 'hidden'
}
toolParam.uiComponent = {
type: subBlock.type,
options: subBlock.options as Option[] | undefined,
placeholder: subBlock.placeholder,
password: subBlock.password,
condition: subBlock.condition as ComponentCondition | undefined,
title: subBlock.title,
value: subBlock.value,
serviceId: subBlock.serviceId,
selectorKey: subBlock.selectorKey,
requiredScopes: subBlock.requiredScopes,
mimeType: subBlock.mimeType,
columns: subBlock.columns,
min: subBlock.min,
max: subBlock.max,
step: subBlock.step,
integer: subBlock.integer,
language: subBlock.language,
generationType: subBlock.generationType,
acceptedTypes: subBlock.acceptedTypes ? [subBlock.acceptedTypes] : undefined,
multiple: subBlock.multiple,
maxSize: subBlock.maxSize,
dependsOn: subBlock.dependsOn,
canonicalParamId: subBlock.canonicalParamId,
mode: subBlock.mode,
actualSubBlockId: subBlock.id,
wandConfig: subBlock.wandConfig,
}
}
}
return toolParam
}
)
// Parameters that should be shown to the user for input
const userInputParameters = allParameters.filter(
(param) => param.visibility === 'user-or-llm' || param.visibility === 'user-only'
)
// Parameters that are required (must be filled by user or LLM)
const requiredParameters = allParameters.filter((param) => param.required)
// Parameters that are optional but can be provided by user
const optionalParameters = allParameters.filter(
(param) => param.visibility === 'user-only' && !param.required
)
return {
toolConfig,
allParameters,
userInputParameters,
requiredParameters,
optionalParameters,
}
} catch (error) {
logger.error('Error getting tool parameters config:', error)
return null
}
}
/**
* Creates a tool schema for LLM with user-provided parameters excluded
*/
function buildParameterSchema(
toolId: string,
paramId: string,
param: ToolParamDefinition,
options: UserToolSchemaOptions = {}
): SchemaProperty {
const surface = options.surface ?? 'default'
if (surface === 'copilot' && (param.type === 'file' || param.type === 'file[]')) {
return buildCopilotFileParameterSchema(param)
}
let schemaType = param.type
if (schemaType === 'json' || schemaType === 'any') {
schemaType = 'object'
}
const propertySchema: SchemaProperty = {
type: schemaType,
description: param.description || '',
}
if (param.type === 'array' && param.items) {
propertySchema.items = {
...param.items,
...(param.items.properties && {
properties: { ...param.items.properties },
}),
}
} else if (param.items) {
logger.warn(`items property ignored for non-array param "${paramId}" in tool "${toolId}"`)
}
return propertySchema
}
function buildCopilotFileParameterSchema(param: ToolParamDefinition): SchemaProperty {
const baseDescription =
param.description ||
(param.type === 'file'
? 'A file object for tool execution.'
: 'An array of file objects for tool execution.')
const resolutionDescription =
'For copilot and mothership tool calls, prefer passing canonical workspace file IDs such as "wf_123". The runtime will resolve them into full file objects before tool execution.'
const fileObjectSchema: SchemaProperty = {
type: 'object',
description: `${baseDescription} ${resolutionDescription}`,
properties: {
id: { type: 'string', description: 'Canonical workspace file ID.' },
name: { type: 'string', description: 'File name.' },
url: { type: 'string', description: 'File URL or serve path.' },
size: { type: 'number', description: 'File size in bytes.' },
type: { type: 'string', description: 'MIME type.' },
key: { type: 'string', description: 'Internal storage key.' },
context: { type: 'string', description: 'Optional file context.' },
base64: { type: 'string', description: 'Optional base64-encoded file contents.' },
},
required: ['id', 'name', 'url', 'size', 'type', 'key'],
}
if (param.type === 'file') {
return fileObjectSchema
}
return {
type: 'array',
description: `${baseDescription} ${resolutionDescription}`,
items: {
type: 'object',
description: 'A file object.',
properties: fileObjectSchema.properties,
},
}
}
export function createUserToolSchema(
toolConfig: ToolConfig,
options: UserToolSchemaOptions = {}
): ToolSchema {
const surface = options.surface ?? 'default'
const hostedApiKeyParam =
options.hostedKeySupport && toolConfig.hosting && !toolConfig.hosting.enabled
? toolConfig.hosting.apiKeyParam
: undefined
const schema: ToolSchema = {
type: 'object',
properties: {},
required: [],
}
for (const [paramId, param] of Object.entries(toolConfig.params)) {
if (!param) continue
const visibility = param.visibility ?? 'user-or-llm'
if (visibility === 'hidden') {
continue
}
const propertySchema = buildParameterSchema(toolConfig.id, paramId, param, options)
if (paramId === hostedApiKeyParam) {
propertySchema.description = [
propertySchema.description,
'Optional: Sim provides a hosted key for this tool. Omit this parameter unless intentionally overriding with your own key.',
]
.filter(Boolean)
.join(' ')
}
schema.properties[paramId] = propertySchema
if (param.required && paramId !== hostedApiKeyParam) {
schema.required.push(paramId)
}
}
if (toolConfig.oauth?.required && surface === 'copilot') {
schema.properties.credentialId = {
type: 'string',
description:
'Credential ID to use for this OAuth tool call. Required for Copilot/Superagent execution. Get valid IDs from environment/credentials.json.',
}
schema.required.push('credentialId')
}
return schema
}
export async function createLLMToolSchema(
toolConfig: ToolConfig,
userProvidedParams: Record<string, unknown>,
enrichmentContext: WorkflowToolExecutionContext = {}
): Promise<LLMToolSchemaResult> {
const schema: ToolSchema = {
type: 'object',
properties: {},
required: [],
}
// Derived from the declarations rather than from which branch below skipped a
// param: the loop's `continue`s also skip params the user simply filled in,
// and those are not off-limits to the model.
const modelBlockedParams = Object.entries(toolConfig.params)
.filter(([, param]) => param.visibility === 'user-only' || param.visibility === 'hidden')
.map(([paramId]) => paramId)
for (const [paramId, param] of Object.entries(toolConfig.params)) {
const enrichmentConfig = toolConfig.schemaEnrichment?.[paramId]
const isWorkflowInputMapping =
toolConfig.id === 'workflow_executor' && paramId === 'inputMapping'
if (enrichmentConfig) {
const dependencyValue = userProvidedParams[enrichmentConfig.dependsOn] as string
if (!dependencyValue) {
continue
}
const propertySchema = buildParameterSchema(toolConfig.id, paramId, param)
const enrichedSchema = await enrichmentConfig.enrichSchema(dependencyValue, enrichmentContext)
if (enrichedSchema) {
safeAssign(propertySchema, enrichedSchema as Record<string, unknown>)
schema.properties[paramId] = propertySchema
if (param.required) {
schema.required.push(paramId)
}
}
continue
}
if (!isWorkflowInputMapping) {
if (isNonEmpty(userProvidedParams[paramId])) {
continue
}
if (param.visibility === 'user-only') {
continue
}
if (param.visibility === 'hidden') {
continue
}
}
const propertySchema = buildParameterSchema(toolConfig.id, paramId, param)
if (isWorkflowInputMapping) {
const workflowId = userProvidedParams.workflowId as string
if (workflowId) {
await applyDynamicSchemaForWorkflow(propertySchema, workflowId)
}
}
schema.properties[paramId] = propertySchema
if ((param.visibility === 'user-or-llm' || param.visibility === 'llm-only') && param.required) {
schema.required.push(paramId)
}
}
if (toolConfig.toolEnrichment) {
const dependencyValue = userProvidedParams[toolConfig.toolEnrichment.dependsOn] as string
if (dependencyValue) {
let enriched
try {
enriched = await toolConfig.toolEnrichment.enrichTool(
dependencyValue,
schema,
toolConfig.description,
enrichmentContext
)
} catch (error) {
throw new ToolSchemaEnrichmentError(toolConfig.id, error)
}
if (enriched) {
return {
schema: enriched.parameters as ToolSchema,
enrichedDescription: enriched.description,
modelBlockedParams,
}
}
}
}
return { schema, modelBlockedParams }
}
/**
* Apply dynamic schema enrichment for workflow_executor's inputMapping parameter
*/
async function applyDynamicSchemaForWorkflow(
propertySchema: SchemaProperty,
workflowId: string
): Promise<void> {
try {
const workflowInputFields = await fetchWorkflowInputFields(workflowId)
if (workflowInputFields && workflowInputFields.length > 0) {
propertySchema.type = 'object'
propertySchema.properties = {}
propertySchema.required = []
// Convert workflow input fields to JSON schema properties
for (const field of workflowInputFields) {
propertySchema.properties[field.name] = {
type: field.type || 'string',
description: field.description || `Input field: ${field.name}`,
}
propertySchema.required.push(field.name)
}
// Update description to be more specific
propertySchema.description = `Input values for the workflow. Required fields: ${workflowInputFields.map((f) => f.name).join(', ')}`
}
} catch (error) {
logger.error('Failed to fetch workflow input fields for LLM schema:', error)
}
}
/**
* Fetches workflow input fields from the API.
*/
async function fetchWorkflowInputFields(
workflowId: string
): Promise<Array<{ name: string; type: string; description?: string }>> {
try {
const { buildAuthHeaders, buildAPIUrl } = await import('@/executor/utils/http')
const headers = await buildAuthHeaders()
const url = buildAPIUrl(`/api/workflows/${workflowId}`)
const response = await fetch(url.toString(), { headers })
if (!response.ok) {
throw new Error('Failed to fetch workflow')
}
const { data } = await response.json()
return extractInputFieldsFromBlocks(data?.state?.blocks)
} catch (error) {
logger.error('Error fetching workflow input fields:', error)
return []
}
}
/**
* Creates a complete tool schema for execution with all parameters
*/
export function createExecutionToolSchema(toolConfig: ToolConfig): ToolSchema {
const schema: ToolSchema = {
type: 'object',
properties: {},
required: [],
}
Object.entries(toolConfig.params).forEach(([paramId, param]) => {
const propertySchema: SchemaProperty = {
type: param.type === 'json' ? 'object' : param.type,
description: param.description || '',
}
// Include items property for arrays
if (param.type === 'array' && param.items) {
propertySchema.items = {
...param.items,
...(param.items.properties && {
properties: { ...param.items.properties },
}),
}
} else if (param.items) {
logger.warn(
`items property ignored for non-array param "${paramId}" in tool "${toolConfig.id}"`
)
}
schema.properties[paramId] = propertySchema
if (param.required) {
schema.required.push(paramId)
}
})
return schema
}
interface FilterableToolSchema {
properties?: Record<string, unknown>
required?: string[]
}
/** Filters user-provided parameters from any object-shaped tool schema sent to an LLM. */
export function filterSchemaForLLM<T extends FilterableToolSchema>(
originalSchema: T,
userProvidedParams: Record<string, unknown>
): T {
if (!originalSchema || !originalSchema.properties) {
return originalSchema
}
const filteredProperties = { ...originalSchema.properties }
const filteredRequired = [...(originalSchema.required || [])]
// Remove user-provided parameters from the schema
Object.keys(userProvidedParams).forEach((paramKey) => {
if (isNonEmpty(userProvidedParams[paramKey])) {
delete filteredProperties[paramKey]
const reqIndex = filteredRequired.indexOf(paramKey)
if (reqIndex > -1) {
filteredRequired.splice(reqIndex, 1)
}
}
})
return Object.assign({}, originalSchema, {
properties: filteredProperties,
required: filteredRequired,
})
}
/**
* Validates that all required parameters are provided
*/
export function validateToolParameters(
toolConfig: ToolConfig,
finalParams: Record<string, unknown>
): ValidationResult {
const requiredParams = Object.entries(toolConfig.params)
.filter(([_, param]) => param.required)
.map(([paramId]) => paramId)
const missingParams = requiredParams.filter(
(paramId) =>
finalParams[paramId] === undefined ||
finalParams[paramId] === null ||
finalParams[paramId] === ''
)
return {
valid: missingParams.length === 0,
missingParams,
}
}
/**
* Helper to check if a parameter should be treated as a password field
*/
export function isPasswordParameter(paramId: string): boolean {
const passwordFields = [
'password',
'apiKey',
'token',
'secret',
'key',
'credential',
'accessToken',
'refreshToken',
'botToken',
'authToken',
]
return passwordFields.some((field) => paramId.toLowerCase().includes(field.toLowerCase()))
}
/**
* Formats parameter IDs into human-readable labels
*/
export function formatParameterLabel(paramId: string): string {
// Special cases
if (paramId === 'apiKey') return 'API Key'
if (paramId === 'apiVersion') return 'API Version'
if (paramId === 'accessToken') return 'Access Token'
if (paramId === 'refreshToken') return 'Refresh Token'
if (paramId === 'botToken') return 'Bot Token'
// Handle underscore and hyphen separated words
if (paramId.includes('_') || paramId.includes('-')) {
return paramId
.split(/[-_]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
// Handle single character parameters
if (paramId.length === 1) return paramId.toUpperCase()
// Handle camelCase
if (/[A-Z]/.test(paramId)) {
const result = paramId.replace(/([A-Z])/g, ' $1')
return (
result.charAt(0).toUpperCase() +
result
.slice(1)
.replace(/ Api/g, ' API')
.replace(/ Id/g, ' ID')
.replace(/ Url/g, ' URL')
.replace(/ Uri/g, ' URI')
.replace(/ Ui/g, ' UI')
)
}
// Simple case - just capitalize first letter
return paramId.charAt(0).toUpperCase() + paramId.slice(1)
}
/**
* SubBlock IDs that control tool routing, not user-facing parameters.
* Excluded from tool-input rendering unless they have an explicit paramVisibility set.
*/
const STRUCTURAL_SUBBLOCK_IDS = new Set(['operation'])
/**
* SubBlock types that represent auth/credential inputs handled separately
* by the tool-input OAuth credential selector.
*/
const AUTH_SUBBLOCK_TYPES = new Set(['oauth-input'])
/**
* SubBlock types that should never appear in tool-input context.
*/
const EXCLUDED_SUBBLOCK_TYPES = new Set([
'tool-input',
'skill-input',
'condition-input',
'eval-input',
'webhook-config',
'schedule-info',
'input-format',
'response-format',
'mcp-server-selector',
'mcp-tool-selector',
'mcp-dynamic-args',
'input-mapping',
'variables-input',
'messages-input',
'router-input',
'text',
])
export interface SubBlocksForToolInput {
toolConfig: ToolMetadata
subBlocks: BlockSubBlockConfig[]
oauthConfig?: OAuthConfig
}
/**
* Returns filtered SubBlockConfig[] for rendering in tool-input context.
* Uses subblock definitions as the primary source of UI metadata,
* getting all features (wandConfig, rich conditions, dependsOn, etc.) for free.
*
* For blocks without paramVisibility annotations, falls back to inferring
* visibility from the tool's param definitions.
*/
export function getSubBlocksForToolInput(
toolId: string,
blockType: string,
currentValues?: Record<string, unknown>,
canonicalModeOverrides?: CanonicalModeOverrides,
blockConfigOverride?: Pick<ToolInputBlockConfig, 'subBlocks'>
): SubBlocksForToolInput | null {
try {
const toolConfig = getToolMetadata(toolId)
if (!toolConfig) {
logger.warn(`Tool not found: ${toolId}`)
return null
}
const blockConfigs = getBlockConfigurations()
const blockConfig = blockConfigOverride ?? blockConfigs[blockType]
if (!blockConfig?.subBlocks?.length) {
return null
}
// Custom (deploy-as-block) blocks: render their own editable field sub-blocks
// as `user-or-llm` (the hidden workflowId/inputMapping wiring is filtered by
// RESERVED_PARAMS — `isSubBlockHidden` does NOT honor `hidden: true`, so the
// explicit reserved filter is what keeps them out).
if (blockType && isCustomBlockType(blockType)) {
const fieldSubBlocks = (blockConfig.subBlocks as BlockSubBlockConfig[])
.filter((sb) => !sb.hidden && !RESERVED_PARAMS.has(sb.id))
.map((sb) => ({ ...sb, paramVisibility: 'user-or-llm' as ParameterVisibility }))
return {
toolConfig,
subBlocks: fieldSubBlocks,
oauthConfig: toolConfig.oauth,
}
}
const allSubBlocks = blockConfig.subBlocks as BlockSubBlockConfig[]
const canonicalIndex = buildCanonicalIndex(allSubBlocks)
// Build values for condition evaluation
const values = currentValues || {}
const valuesWithOperation = { ...values }
if (valuesWithOperation.operation === undefined) {
const parts = toolId.split('_')
valuesWithOperation.operation =
parts.length >= 3 ? parts.slice(2).join('_') : parts[parts.length - 1]
}
// Build a map of tool param IDs to their resolved visibility
const toolParamVisibility: Record<string, ParameterVisibility> = {}
for (const [paramId, param] of Object.entries(toolConfig.params || {})) {
toolParamVisibility[paramId] =
param.visibility ?? (param.required ? 'user-or-llm' : 'user-only')
}
// Track which canonical groups we've already included (to avoid duplicates)
const includedCanonicalIds = new Set<string>()
const filtered: BlockSubBlockConfig[] = []
for (const sb of allSubBlocks) {
// Skip excluded types
if (EXCLUDED_SUBBLOCK_TYPES.has(sb.type)) continue
// Skip trigger-mode-only subblocks
if (isTriggerModeSubBlock(sb)) continue
// Hide tool API key fields when running on hosted Sim or when env var is set
if (isSubBlockHidden(sb)) continue
// A field the deployment has switched off is not offerable here either —
// the canvas already hides it, and offering it in tool-input lets an author
// pick a value the executor will refuse (e.g. Python with no sandbox provider).
if (!isSubBlockFeatureEnabled(sb)) continue
// Determine the effective param ID (canonical or subblock id)
const effectiveParamId = sb.canonicalParamId || sb.id
// Resolve paramVisibility: explicit > inferred from tool params > skip
let visibility = sb.paramVisibility
if (!visibility) {
// Infer from structural checks
if (STRUCTURAL_SUBBLOCK_IDS.has(sb.id)) {
visibility = 'hidden'
} else if (AUTH_SUBBLOCK_TYPES.has(sb.type) && sb.canonicalParamId !== 'oauthCredential') {
visibility = 'hidden'
} else if (sb.canonicalParamId === 'oauthCredential') {
visibility = 'user-only'
} else if (
sb.password &&
(sb.id === 'botToken' || sb.id === 'accessToken' || sb.id === 'apiKey')
) {
// Auth tokens without explicit paramVisibility are hidden
// (they're handled by the OAuth credential selector or structurally)
// But only if they don't have a matching tool param
if (!(sb.id in toolParamVisibility)) {
visibility = 'hidden'
} else {
visibility = toolParamVisibility[sb.id] || 'user-or-llm'
}
} else if (effectiveParamId in toolParamVisibility) {
// Fallback: infer from tool param visibility
visibility = toolParamVisibility[effectiveParamId]
} else if (sb.id in toolParamVisibility) {
visibility = toolParamVisibility[sb.id]
} else if (sb.canonicalParamId) {
visibility = 'user-or-llm'
} else {
continue
}
}
// Filter by visibility: exclude hidden and llm-only
if (visibility === 'hidden' || visibility === 'llm-only') continue
if (sb.condition && !sb.reactiveCondition) {
const conditionMet = evaluateSubBlockCondition(
sb.condition as SubBlockCondition,
valuesWithOperation
)
if (!conditionMet) continue
}
// Handle canonical pairs: only include the active mode variant
const canonicalId = canonicalIndex.canonicalIdBySubBlockId[sb.id]
if (canonicalId) {
const group = canonicalIndex.groupsById[canonicalId]
if (group && isCanonicalPair(group)) {
if (includedCanonicalIds.has(canonicalId)) continue
includedCanonicalIds.add(canonicalId)
// Determine active mode
const mode = resolveCanonicalMode(group, valuesWithOperation, canonicalModeOverrides)
if (mode === 'advanced') {
// Find the advanced variant
const advancedSb = allSubBlocks.find((s) => group.advancedIds.includes(s.id))
if (advancedSb) {
filtered.push({ ...advancedSb, paramVisibility: visibility })
}
} else {
// Include basic variant (current sb if it's the basic one)
if (group.basicId === sb.id) {
filtered.push({ ...sb, paramVisibility: visibility })
} else {
const basicSb = allSubBlocks.find((s) => s.id === group.basicId)
if (basicSb) {
filtered.push({ ...basicSb, paramVisibility: visibility })
}
}
}
continue
}
}
// Non-canonical, non-hidden, condition-passing subblock
filtered.push({ ...sb, paramVisibility: visibility })
}
return {
toolConfig,
subBlocks: filtered,
oauthConfig: toolConfig.oauth,
}
} catch (error) {
logger.error('Error getting subblocks for tool input:', error)
return null
}
}